Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace Persistence.Tests.MsappPacking;

[TestClass]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "MSTEST0049:Flow TestContext.CancellationToken to async operations", Justification = "<Pending>")]
public class MsappPackingServiceTests : TestBase
{
private const string AlmTestApp_asManyEntitiesAsPossible = "AlmTestApp-asManyEntitiesAsPossible.msapp";
Expand Down Expand Up @@ -127,10 +128,12 @@ static MsappArchive OpenMsappWithHeader(string headerFileName)
}

[TestMethod]
public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
[DataRow(false)]
[DataRow(true)]
public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries(bool enableLoadFromYaml)
{
// Arrange
var testDir = CreateTestOutputFolder(ensureEmpty: true);
var testDir = CreateTestCaseOutputFolder($"enableLoadFromYaml{enableLoadFromYaml}", ensureEmpty: true);
var unpackedDir = Path.Combine(testDir, "unpacked");
var repackedMsappPath = Path.Combine(testDir, "repacked.msapp");
var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible);
Expand All @@ -139,7 +142,8 @@ public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
// Act: unpack then pack
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);
service.PackFromMsappReferenceFile(msaprPath, repackedMsappPath, TestPackingClient);
await service.PackFromMsappReferenceFileAsync(msaprPath, repackedMsappPath, TestPackingClient
, enableLoadFromYaml: enableLoadFromYaml);

// Assert: output file exists
File.Exists(repackedMsappPath).Should().BeTrue("the repacked .msapp file should be created");
Expand All @@ -165,7 +169,7 @@ public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
.Which.DeserializeAsJson<PackedJson>(MsappSerialization.PackedJsonSerializeOptions);
packedJson.PackedStructureVersion.Should().Be(PackedJson.CurrentPackedStructureVersion);
packedJson.LastPackedDateTimeUtc.Should().NotBeNull();
packedJson.LoadConfiguration.LoadFromYaml.Should().BeTrue("PaYamlSourceCode was unpacked");
packedJson.LoadConfiguration.LoadFromYaml.Should().Be(enableLoadFromYaml);
packedJson.PackingClient.Should().BeEquivalentTo(TestPackingClient);
}

Expand All @@ -186,8 +190,8 @@ public async Task PackFromMsappReferenceFile_ThrowsWhenOutputExists_AndOverwrite
File.WriteAllText(outputMsappPath, "existing content");

// Act & Assert
FluentActions.Invoking(() => service.PackFromMsappReferenceFile(msaprPath, outputMsappPath, TestPackingClient, overwriteOutput: false))
.Should().Throw<MsappPackException>()
await FluentActions.Invoking(() => service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient, overwriteOutput: false))
.Should().ThrowAsync<MsappPackException>()
.WithMessage($"*'{outputMsappPath}'*");
}

Expand All @@ -208,7 +212,7 @@ public async Task PackFromMsappReferenceFile_Overwrites_WhenOverwriteIsTrue()
File.WriteAllText(outputMsappPath, "existing content");

// Act: should not throw
service.PackFromMsappReferenceFile(msaprPath, outputMsappPath, TestPackingClient, overwriteOutput: true);
await service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient, overwriteOutput: true);

// Assert: the file was overwritten with a valid msapp
using var msapp = MsappArchiveFactory.Default.Open(outputMsappPath);
Expand Down Expand Up @@ -239,7 +243,7 @@ public async Task PackFromMsappReferenceFile_PreservesNonAsciiSrcFileNames()

// Act
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);
service.PackFromMsappReferenceFile(msaprPath, repackedMsappPath, TestPackingClient);
await service.PackFromMsappReferenceFileAsync(msaprPath, repackedMsappPath, TestPackingClient);

// Assert: each non-ASCII entry name is preserved verbatim in the packed msapp
using var repackedMsapp = MsappArchiveFactory.Default.Open(repackedMsappPath);
Expand Down Expand Up @@ -269,7 +273,7 @@ public async Task PackFromMsappReferenceFile_IgnoresNonPaYamlFileInSrc()
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);

// Act: should not throw; behavior is Ignore
service.PackFromMsappReferenceFile(msaprPath, outputMsappPath, TestPackingClient);
await service.PackFromMsappReferenceFileAsync(msaprPath, outputMsappPath, TestPackingClient);

// Assert: notes.txt is NOT present in the output msapp
using var repackedMsapp = MsappArchiveFactory.Default.Open(outputMsappPath);
Expand Down
18 changes: 15 additions & 3 deletions src/Persistence/Compression/PaArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,26 +244,38 @@ public IEnumerable<PaArchiveEntry> GetEntriesInDirectory(PaArchivePath directory
}
}

[Obsolete($"Use {nameof(AddEntryFromJsonAsync)} instead.")]
public void AddEntryFromJson<T>(string fullName, T value, JsonSerializerOptions serializerOptions)
{
var entry = CreateEntry(fullName);
using var stream = entry.Open();
JsonSerializer.Serialize(stream, value, serializerOptions);
}

public void AddEntryFrom(string fullName, PaArchiveEntry sourceEntry) => AddEntryFrom(PaArchivePath.ParseArgument(fullName), sourceEntry);
public async Task AddEntryFromJsonAsync<T>(string fullName, T value, JsonSerializerOptions serializerOptions, CancellationToken cancellationToken = default)
{
var entry = CreateEntry(fullName);
using var stream = entry.Open();
await JsonSerializer.SerializeAsync(stream, value, serializerOptions, cancellationToken).ConfigureAwait(false);
}

public async Task AddEntryFromAsync(string fullName, PaArchiveEntry sourceEntry, CancellationToken cancellationToken = default)
{
await AddEntryFromAsync(PaArchivePath.ParseArgument(fullName), sourceEntry, cancellationToken).ConfigureAwait(false);
}

public void AddEntryFrom(PaArchivePath entryPath, PaArchiveEntry sourceEntry)
public async Task AddEntryFromAsync(PaArchivePath entryPath, PaArchiveEntry sourceEntry, CancellationToken cancellationToken = default)
{
if (sourceEntry.PaArchive.InnerZipArchive == InnerZipArchive)
{
throw new ArgumentException($"The {nameof(sourceEntry)} can not be from the same archive instance.", nameof(sourceEntry));
}

cancellationToken.ThrowIfCancellationRequested();
var newEntry = CreateEntry(entryPath);
using var srcStream = sourceEntry.Open();
using var destStream = newEntry.Open();
srcStream.CopyTo(destStream);
await srcStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
}

#region IDisposable
Expand Down
15 changes: 15 additions & 0 deletions src/Persistence/Compression/PaArchiveEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ internal PaArchiveEntry(PaArchive paArchive, ZipArchiveEntry zipEntry, PaArchive
/// <returns>A Stream that represents the contents of the entry.</returns>
public Stream Open() => ZipEntry.Open();

/// <summary>
/// Asynchronously opens the entry.
/// See additional docs for <see cref="ZipArchiveEntry.Open"/>.
/// </summary>
/// <returns>A Stream that represents the contents of the entry.</returns>
public async Task<Stream> OpenAsync(CancellationToken cancellationToken = default)
{
#if NET10_0_OR_GREATER
return await ZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
#else
cancellationToken.ThrowIfCancellationRequested();
return Open();
#endif
}

/// <summary>
/// Deletes the entry from the <see cref="PaArchive"/>.
/// </summary>
Expand Down
23 changes: 16 additions & 7 deletions src/Persistence/Compression/PaArchiveExtensions.ExtractAsync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.Compression;
public static partial class PaArchiveExtensions
{
#if NET10_0_OR_GREATER
public static async Task ExtractToFileAsync(this PaArchiveEntry source, string destinationFileName, bool overwrite = false)
public static async Task ExtractToFileAsync(this PaArchiveEntry source, string destinationFileName, bool overwrite = false, CancellationToken cancellationToken = default)
{
// .net 10 supports ExtractToFileAsync
await source.ZipEntry.ExtractToFileAsync(destinationFileName, overwrite).ConfigureAwait(false);
await source.ZipEntry.ExtractToFileAsync(destinationFileName, overwrite, cancellationToken).ConfigureAwait(false);
}
#else
public static ValueTask ExtractToFileAsync(this PaArchiveEntry source, string destinationFileName, bool overwrite = false)
public static ValueTask ExtractToFileAsync(this PaArchiveEntry source, string destinationFileName, bool overwrite = false, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
source.ZipEntry.ExtractToFile(destinationFileName, overwrite);
return ValueTask.CompletedTask;
}
Expand All @@ -42,14 +43,18 @@ public static async Task ExtractToDirectoryAsync(this PaArchive source, string d
/// <remarks>
/// This method is protected against ZipSlip attacks.
/// </remarks>
public static async Task ExtractToDirectoryAsync(this IEnumerable<PaArchiveEntry> entries, string destinationDirectoryName, bool overwrite = false)
public static async Task ExtractToDirectoryAsync(
this IEnumerable<PaArchiveEntry> entries,
string destinationDirectoryName,
bool overwrite = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(destinationDirectoryName);

foreach (var entry in entries)
{
await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwrite).ConfigureAwait(false);
await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwrite, cancellationToken).ConfigureAwait(false);
}
}

Expand All @@ -61,7 +66,11 @@ public static async Task ExtractToDirectoryAsync(this IEnumerable<PaArchiveEntry
/// <remarks>
/// This method is protected against ZipSlip attacks.
/// </remarks>
public static async Task ExtractRelativeToDirectoryAsync(this PaArchiveEntry source, string destinationDirectoryName, bool overwrite = false)
public static async Task ExtractRelativeToDirectoryAsync(
this PaArchiveEntry source,
string destinationDirectoryName,
bool overwrite = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(destinationDirectoryName);
Expand All @@ -70,6 +79,6 @@ public static async Task ExtractRelativeToDirectoryAsync(this PaArchiveEntry sou

// PaArchiveEntry's should always only ever represent a file
Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!);
await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite).ConfigureAwait(false);
await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, cancellationToken).ConfigureAwait(false);
}
}
68 changes: 53 additions & 15 deletions src/Persistence/Compression/PaArchiveExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,70 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.Compression;

public static partial class PaArchiveExtensions
{
/// <summary>
/// Reads the contents of the entry as JSON and deserializes it to an instance of type <typeparamref name="T"/> using <see cref="JsonSerializer"/>.
/// </summary>
/// <returns>The deserialized object.</returns>
/// <exception cref="PersistenceLibraryException">The json represents a value of null, or an exception occured during deserialization.</exception>
public static T DeserializeAsJson<T>(this PaArchiveEntry entry, JsonSerializerOptions serializerOptions)
where T : notnull
{
// TODO: Create a new exception 'PaArchiveException' set of exceptions to target archive-only type errors.
// For now though, calling code currently expects PersistenceLibraryException so we'll keep it.
// maybe 'PaArchiveException' can inherit from PersistenceLibraryException, and we can use it for more specific error codes and to avoid confusion with other types of PersistenceLibraryExceptions that are not archive related.
using var entryStream = entry.Open();

try
{
return JsonSerializer.Deserialize<T>(entryStream, serializerOptions)
?? throw CreatePersistenceExceptionForNullJsonEntry(entry);
}
catch (JsonException ex)
{
throw CreatePersistenceExceptionFrom<T>(ex, entry);
}
}

/// <summary>
/// Asynchronously reads the contents of the entry as JSON and deserializes it to an instance of type <typeparamref name="T"/> using <see cref="JsonSerializer"/>.
/// </summary>
/// <returns>The deserialized object.</returns>
/// <exception cref="PersistenceLibraryException">The json represents a value of null, or an exception occured during deserialization.</exception>
public static async Task<T> DeserializeAsJsonAsync<T>(this PaArchiveEntry entry, JsonSerializerOptions serializerOptions, CancellationToken cancellationToken = default)
where T : notnull
{
using var entryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false);

try
{
return JsonSerializer.Deserialize<T>(entry.Open(), serializerOptions)
?? throw new PersistenceLibraryException(PersistenceErrorCode.PaArchiveEntryDeserializedToJsonNull, "Deserialization of json file resulted in null object.")
{
MsappEntryFullPath = entry.FullName,
};
return await JsonSerializer.DeserializeAsync<T>(entryStream, serializerOptions, cancellationToken).ConfigureAwait(false)
?? throw CreatePersistenceExceptionForNullJsonEntry(entry);
}
catch (JsonException ex)
{
throw new PersistenceLibraryException(PersistenceErrorCode.PaArchiveEntryDeserializedToJsonFailed, $"Failed to deserialize json file to an instance of {typeof(T).Name}.", ex)
{
MsappEntryFullPath = entry.FullName,
LineNumber = ex.LineNumber,
Column = ex.BytePositionInLine,
JsonPath = ex.Path,
};
throw CreatePersistenceExceptionFrom<T>(ex, entry);
}
}

private static PersistenceLibraryException CreatePersistenceExceptionForNullJsonEntry(PaArchiveEntry entry)
{
return new(PersistenceErrorCode.PaArchiveEntryDeserializedToJsonNull, "Deserialization of json file resulted in null object.")
{
MsappEntryFullPath = entry.FullName,
};
}

private static PersistenceLibraryException CreatePersistenceExceptionFrom<T>(JsonException ex, PaArchiveEntry entry) where T : notnull
{
// TODO: Create a new exception 'PaArchiveException' set of exceptions to target archive-only type errors.
// For now though, calling code currently expects PersistenceLibraryException so we'll keep it.
// maybe 'PaArchiveException' can inherit from PersistenceLibraryException, and we can use it for more specific error codes and to avoid confusion with other types of PersistenceLibraryExceptions that are not archive related.
return new(PersistenceErrorCode.PaArchiveEntryDeserializedToJsonFailed, $"Failed to deserialize json file to an instance of {typeof(T).Name}.", ex)
{
MsappEntryFullPath = entry.FullName,
LineNumber = ex.LineNumber,
Column = ex.BytePositionInLine,
JsonPath = ex.Path,
};
}

/// <summary>
/// Uses <see cref="SHA256"/> to compute a hash of the contents of the entry.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion src/Persistence/MsApp/MsAppServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.IO.Compression;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;

namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsApp;
Expand All @@ -16,6 +17,6 @@ public static class MsAppServiceCollectionExtensions
/// <param name="services">the services collection instance.</param>
public static void AddMsappArchiveFactory(this IServiceCollection services)
{
services.AddSingleton<IMsappArchiveFactory, MsappArchiveFactory>();
services.TryAddSingleton<IMsappArchiveFactory, MsappArchiveFactory>();
}
}
Loading
Loading