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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.IO.Compression;
using Microsoft.Extensions.Logging;
using Microsoft.PowerPlatform.PowerApps.Persistence.Compression;

namespace Persistence.Tests.Compression;

[TestClass]
public class PaArchiveExtensionsExtractTests : TestBase
{
[TestMethod]
[DataRow("file.txt")]
[DataRow("subdir/file.txt")]
[DataRow("a/b/c/file.txt")]
public void TryComputeAndValidateExtractToPath_WithValidPath_ReturnsTrue(string entryPathStr)
{
var destDir = CreateTestOutputFolder();
var entryPath = new PaArchivePath(entryPathStr);

PaArchiveExtensions.TryComputeAndValidateExtractToPathRelativeToDirectory(destDir, entryPath, out var validFullPath)
.Should().BeTrue();
validFullPath.Should().NotBeNull();
validFullPath.Should().StartWith(Path.GetFullPath(destDir) + Path.DirectorySeparatorChar);
validFullPath.Should().EndWithEquivalentOf(entryPath.FullName);
}

[TestMethod]
[DataRow(@"foo\..\..\bar\file.txt")]
public void TryComputeAndValidateExtractToPath_WithPathTraversal_ReturnsFalse(string inputEntryPath)
{
// This simulates an entry path that bypassed PaArchivePath validation (e.g. via a crafted zip),
// resulting in a path that traverses outside the destination directory.
#pragma warning disable CS0618 // TestOnly_CreateInvalidPath is intentionally obsolete for test use only
var maliciousPath = PaArchivePath.TestOnly_CreateInvalidPath(inputEntryPath);
#pragma warning restore CS0618

var destDir = CreateTestOutputFolder();

PaArchiveExtensions.TryComputeAndValidateExtractToPathRelativeToDirectory(destDir, maliciousPath, out var validFullPath)
.Should().BeFalse();
validFullPath.Should().BeNull();
}

[TestMethod]
[DataRow(@"foo\..\..\bar\file.txt")]
public void ComputeAndValidateExtractToPath_WithPathTraversal_ThrowsAndLogsError(string inputEntryPath)
{
// Arrange: create a PaArchive with a logger and an entry whose NormalizedPath bypasses
// PaArchivePath validation, simulating a crafted zip that slipped through.
using var stream = new MemoryStream();
var capturingLogger = new CapturingLogger<PaArchive>();
using var paArchive = new PaArchive(stream, ZipArchiveMode.Create, leaveOpen: true, logger: capturingLogger);

#pragma warning disable CS0618 // TestOnly methods are intentionally obsolete for test use only
var maliciousPath = PaArchivePath.TestOnly_CreateInvalidPath(inputEntryPath);
#pragma warning restore CS0618

// Create foux zip entry archive for the bad entry
var zipEntry = paArchive.InnerZipArchive.CreateEntry(inputEntryPath);
var entry = new PaArchiveEntry(paArchive, zipEntry, maliciousPath, skipValidation: true);

var destDir = CreateTestOutputFolder();

// Act & Assert: extracting should throw IOException
FluentActions.Invoking(() => PaArchiveExtensions.ComputeAndValidateExtractToPathRelativeToDirectory(entry, destDir))
.Should().Throw<IOException>()
.WithMessage($"Extracting {nameof(PaArchiveEntry)} would have resulted in a file outside the specified destination directory.");

// Assert: the log entry was recorded with Error level
capturingLogger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error)
.Which.Message.Should().Match($"Extracting {nameof(PaArchiveEntry)} would have resulted in a file outside the specified destination directory.*'{inputEntryPath}'*");
}
}
28 changes: 14 additions & 14 deletions src/Persistence.Tests/MsappPacking/MsappPackingServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class MsappPackingServiceTests : TestBase
};

[TestMethod]
public void UnpackToDirectoryWithDefaultConfig()
public async Task UnpackToDirectoryWithDefaultConfig()
{
// Arrange
var testDir = CreateTestOutputFolder(ensureEmpty: true);
Expand All @@ -36,7 +36,7 @@ public void UnpackToDirectoryWithDefaultConfig()
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

// Act: unpack with default config (only PaYamlSourceCode is unpacked to disk)
service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);

Directory.Exists(unpackedDir).Should().BeTrue("service should have created the output folder if it didn't already exist");

Expand Down Expand Up @@ -127,7 +127,7 @@ static MsappArchive OpenMsappWithHeader(string headerFileName)
}

[TestMethod]
public void PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
public async Task PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
{
// Arrange
var testDir = CreateTestOutputFolder(ensureEmpty: true);
Expand All @@ -137,7 +137,7 @@ public void PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

// Act: unpack then pack
service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);
service.PackFromMsappReferenceFile(msaprPath, repackedMsappPath, TestPackingClient);

Expand Down Expand Up @@ -170,7 +170,7 @@ public void PackFromMsappReferenceFile_RoundTrip_ProducesSameEntries()
}

[TestMethod]
public void PackFromMsappReferenceFile_ThrowsWhenOutputExists_AndOverwriteIsFalse()
public async Task PackFromMsappReferenceFile_ThrowsWhenOutputExists_AndOverwriteIsFalse()
{
// Arrange
var testDir = CreateTestOutputFolder(ensureEmpty: true);
Expand All @@ -179,7 +179,7 @@ public void PackFromMsappReferenceFile_ThrowsWhenOutputExists_AndOverwriteIsFals
var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible);
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);

// Create a file at the output path to simulate a conflict
Expand All @@ -192,7 +192,7 @@ public void PackFromMsappReferenceFile_ThrowsWhenOutputExists_AndOverwriteIsFals
}

[TestMethod]
public void PackFromMsappReferenceFile_Overwrites_WhenOverwriteIsTrue()
public async Task PackFromMsappReferenceFile_Overwrites_WhenOverwriteIsTrue()
{
// Arrange
var testDir = CreateTestOutputFolder(ensureEmpty: true);
Expand All @@ -201,7 +201,7 @@ public void PackFromMsappReferenceFile_Overwrites_WhenOverwriteIsTrue()
var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible);
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);

// Create a file at the output path
Expand All @@ -216,7 +216,7 @@ public void PackFromMsappReferenceFile_Overwrites_WhenOverwriteIsTrue()
}

[TestMethod]
public void PackFromMsappReferenceFile_PreservesNonAsciiSrcFileNames()
public async Task PackFromMsappReferenceFile_PreservesNonAsciiSrcFileNames()
{
// Arrange: unpack, then add pa.yaml files with non-ASCII names
var testDir = CreateTestOutputFolder(ensureEmpty: true);
Expand All @@ -225,7 +225,7 @@ public void PackFromMsappReferenceFile_PreservesNonAsciiSrcFileNames()
var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible);
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);

var srcDir = Path.Combine(unpackedDir, MsappLayoutConstants.DirectoryNames.Src);
var nonAsciiFileNames = new[]
Expand All @@ -252,7 +252,7 @@ public void PackFromMsappReferenceFile_PreservesNonAsciiSrcFileNames()
}

[TestMethod]
public void PackFromMsappReferenceFile_IgnoresNonPaYamlFileInSrc()
public async Task PackFromMsappReferenceFile_IgnoresNonPaYamlFileInSrc()
{
// Arrange
var testDir = CreateTestOutputFolder(ensureEmpty: true);
Expand All @@ -261,7 +261,7 @@ public void PackFromMsappReferenceFile_IgnoresNonPaYamlFileInSrc()
var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible);
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);

// Add a non-.pa.yaml file to the Src folder
File.WriteAllText(Path.Combine(unpackedDir, "Src", "notes.txt"), "This is not a pa.yaml file");
Expand All @@ -277,15 +277,15 @@ public void PackFromMsappReferenceFile_IgnoresNonPaYamlFileInSrc()
}

[TestMethod]
public void BuildPackInstructions_ProducesCorrectInstructions()
public async Task BuildPackInstructions_ProducesCorrectInstructions()
{
// Arrange: unpack to a temp dir, then verify BuildPackInstructions output
var testDir = CreateTestOutputFolder(ensureEmpty: true);
var unpackedDir = Path.Combine(testDir, "unpacked");
var msappPath = Path.Combine("_TestData", "AlmApps", AlmTestApp_asManyEntitiesAsPossible);
var service = new MsappPackingService(MsappArchiveFactory.Default, MsappReferenceArchiveFactory.Default);

service.UnpackToDirectory(msappPath, unpackedDir);
await service.UnpackToDirectoryAsync(msappPath, unpackedDir);
var msaprPath = Path.Combine(unpackedDir, AlmTestAppMsaprName);

using var msaprArchive = MsappReferenceArchiveFactory.Default.Open(msaprPath);
Expand Down
40 changes: 40 additions & 0 deletions src/Persistence.Tests/TestAssemblyInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics;

namespace Persistence.Tests;

/// <summary>
/// MSTest assembly-level initializer. Runs once before any tests in this assembly.
/// </summary>
[TestClass]
public static class TestAssemblyInitializer
{
[AssemblyInitialize]
public static void AssemblyInitialize(TestContext _)
{
// Replace the default TraceListener (which shows a dialog or aborts) with one that
// throws an exception, so that Debug.Assert failures are surfaced as test failures.
Trace.Listeners.Clear();
Trace.Listeners.Add(new ThrowingTraceListener());
}

/// <summary>
/// A <see cref="TraceListener"/> that converts <see cref="Debug.Assert(bool)"/> failures into
/// <see cref="InvalidOperationException"/>s, making them visible as test failures.
/// </summary>
private sealed class ThrowingTraceListener : TraceListener
{
public override void Write(string? message) { }
public override void WriteLine(string? message) { }

public override void Fail(string? message, string? detailMessage)
{
var fullMessage = string.IsNullOrEmpty(detailMessage)
? $"Debug.Assert failed: {message}"
: $"Debug.Assert failed: {message}{Environment.NewLine}{detailMessage}";
throw new InvalidOperationException(fullMessage);
}
}
}
2 changes: 2 additions & 0 deletions src/Persistence/Compression/PaArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public PaArchive(

internal ZipArchive InnerZipArchive => _innerZipArchive;

internal ILogger<PaArchive>? InnerLogger => _logger;

public ZipArchiveMode Mode => _innerZipArchive.Mode;

protected bool IsDisposed => _isDisposed;
Expand Down
12 changes: 6 additions & 6 deletions src/Persistence/Compression/PaArchiveEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.Compression;

public class PaArchiveEntry
{
internal PaArchiveEntry(PaArchive paArchive, ZipArchiveEntry zipEntry, PaArchivePath normalizedPath)
internal PaArchiveEntry(PaArchive paArchive, ZipArchiveEntry zipEntry, PaArchivePath normalizedPath, bool skipValidation = false)
{
Debug.Assert(zipEntry.Archive == paArchive.InnerZipArchive, "The underlying zip archives do not match.");
Debug.Assert(
PaArchivePath.TryParse(zipEntry.FullName, out var reparsedPath, out _) && normalizedPath.Equals(reparsedPath),
Debug.Assert(skipValidation || zipEntry.Archive == paArchive.InnerZipArchive, "The underlying zip archives do not match.");
Debug.Assert(skipValidation ||
(PaArchivePath.TryParse(zipEntry.FullName, out var reparsedPath, out _) && normalizedPath.Equals(reparsedPath)),
$"normalizedPath '{normalizedPath}' does not match the path parsed from zipEntry.FullName '{zipEntry.FullName}'.");
Debug.Assert(!normalizedPath.IsRoot, "PaArchiveEntry should never be created with a root path.");
Debug.Assert(!normalizedPath.IsDirectory, "PaArchiveEntry should never be created with a directory path.");
Debug.Assert(skipValidation || !normalizedPath.IsRoot, "PaArchiveEntry should never be created with a root path.");
Debug.Assert(skipValidation || !normalizedPath.IsDirectory, "PaArchiveEntry should never be created with a directory path.");

PaArchive = paArchive;
ZipEntry = zipEntry;
Expand Down
Loading