From 0ee365d49d3f7853a2684e877c0c751c188e7a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:22:45 +0000 Subject: [PATCH 1/3] Initial plan From a5b9f03ecd14e64c80c5b241a4f202004d837be5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:25:40 +0000 Subject: [PATCH 2/3] Fix URL-decoding of zip entry names during module extraction Agent-Logs-Url: https://github.com/JustinGrote/ModuleFast/sessions/d72bd0f4-f6fb-4703-ad8d-33d3fb81f9cb Co-authored-by: JustinGrote <15258962+JustinGrote@users.noreply.github.com> --- ModuleFast.psm1 | 19 ++++++++++++++++++- ModuleFast.tests.ps1 | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 51baf96..610a1b2 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1023,7 +1023,24 @@ function Install-ModuleFastHelper { #We are going to extract these straight out of memory, so we don't need to write the nupkg to disk $zip = [IO.Compression.ZipArchive]::new($stream, 'Read') - [IO.Compression.ZipFileExtensions]::ExtractToDirectory($zip, $installPath) + # NuGet packages may URL-encode file names (e.g. spaces as %20), so we must decode each + # entry's FullName before using it as a file path. See: + # https://github.com/NuGet/NuGet.Client/blob/d2887cd591059fd675d397b48c79cdc30ee2b6ba/src/NuGet.Core/NuGet.Packaging/PackageArchiveReader.cs#L317 + foreach ($entry in $zip.Entries) { + $decodedEntryName = [Uri]::UnescapeDataString($entry.FullName) + $destPath = Join-Path $installPath $decodedEntryName + if ($decodedEntryName.EndsWith('/') -or $decodedEntryName.EndsWith('\')) { + # Directory entry — ensure the directory exists + New-Item -ItemType Directory -Path $destPath -Force | Out-Null + } else { + # File entry — ensure parent directory exists, then extract + $destDir = [Path]::GetDirectoryName($destPath) + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true) + } + } $manifestPath = Join-Path $installPath "$($context.Module.Name).psd1" diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 75e7ccc..840605e 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -82,6 +82,40 @@ InModuleScope 'ModuleFast' { $manifest.RootModule | Should -Be 'coreclr\PrtgAPI.PowerShell.dll' } } + + Describe 'URL-decoding zip entry names during extraction' { + It 'Decodes URL-encoded file names (e.g. spaces as %20) when extracting a zip archive' { + # Build an in-memory ZIP that has a percent-encoded entry name + $memStream = [System.IO.MemoryStream]::new() + $zipWrite = [System.IO.Compression.ZipArchive]::new($memStream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + $entry = $zipWrite.CreateEntry('subfolder/file%20with%20spaces.txt') + $writer = [System.IO.StreamWriter]::new($entry.Open()) + $writer.Write('hello') + $writer.Dispose() + $zipWrite.Dispose() + $memStream.Position = 0 + + # Replicate the extraction logic from Install-ModuleFast (URL-decode each entry name) + $extractPath = Join-Path $TestDrive ([System.Guid]::NewGuid()) + New-Item -ItemType Directory -Path $extractPath -Force | Out-Null + $zipRead = [System.IO.Compression.ZipArchive]::new($memStream, [System.IO.Compression.ZipArchiveMode]::Read) + foreach ($zipEntry in $zipRead.Entries) { + $decodedEntryName = [Uri]::UnescapeDataString($zipEntry.FullName) + $destPath = Join-Path $extractPath $decodedEntryName + $destDir = [System.IO.Path]::GetDirectoryName($destPath) + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($zipEntry, $destPath, $true) + } + $zipRead.Dispose() + $memStream.Dispose() + + # The extracted file should use the decoded name, not the percent-encoded one + Test-Path (Join-Path $extractPath 'subfolder' 'file with spaces.txt') | Should -BeTrue + Test-Path (Join-Path $extractPath 'subfolder' 'file%20with%20spaces.txt') | Should -BeFalse + } + } } Describe 'Get-ModuleFastPlan' -Tag 'E2E' { From dc2e4e917b107900fcba85f2a0891bb4740e8144 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:26:41 +0000 Subject: [PATCH 3/3] Address review feedback: add comment on backslash check and expand test to cover directory entries Agent-Logs-Url: https://github.com/JustinGrote/ModuleFast/sessions/d72bd0f4-f6fb-4703-ad8d-33d3fb81f9cb Co-authored-by: JustinGrote <15258962+JustinGrote@users.noreply.github.com> --- ModuleFast.psm1 | 1 + ModuleFast.tests.ps1 | 27 +++++++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 610a1b2..770733f 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1029,6 +1029,7 @@ function Install-ModuleFastHelper { foreach ($entry in $zip.Entries) { $decodedEntryName = [Uri]::UnescapeDataString($entry.FullName) $destPath = Join-Path $installPath $decodedEntryName + # ZIP spec uses '/' but some tools emit '\'; treat both as directory entries if ($decodedEntryName.EndsWith('/') -or $decodedEntryName.EndsWith('\')) { # Directory entry — ensure the directory exists New-Item -ItemType Directory -Path $destPath -Force | Out-Null diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 840605e..7ec3fdc 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -85,10 +85,13 @@ InModuleScope 'ModuleFast' { Describe 'URL-decoding zip entry names during extraction' { It 'Decodes URL-encoded file names (e.g. spaces as %20) when extracting a zip archive' { - # Build an in-memory ZIP that has a percent-encoded entry name + # Build an in-memory ZIP that has a percent-encoded entry name and an explicit directory entry $memStream = [System.IO.MemoryStream]::new() $zipWrite = [System.IO.Compression.ZipArchive]::new($memStream, [System.IO.Compression.ZipArchiveMode]::Create, $true) - $entry = $zipWrite.CreateEntry('subfolder/file%20with%20spaces.txt') + # Explicit directory entry with percent-encoded name + $zipWrite.CreateEntry('sub%20folder/') | Out-Null + # File entry inside the percent-encoded directory + $entry = $zipWrite.CreateEntry('sub%20folder/file%20with%20spaces.txt') $writer = [System.IO.StreamWriter]::new($entry.Open()) $writer.Write('hello') $writer.Dispose() @@ -102,18 +105,26 @@ InModuleScope 'ModuleFast' { foreach ($zipEntry in $zipRead.Entries) { $decodedEntryName = [Uri]::UnescapeDataString($zipEntry.FullName) $destPath = Join-Path $extractPath $decodedEntryName - $destDir = [System.IO.Path]::GetDirectoryName($destPath) - if (-not (Test-Path $destDir)) { - New-Item -ItemType Directory -Path $destDir -Force | Out-Null + if ($decodedEntryName.EndsWith('/') -or $decodedEntryName.EndsWith('\')) { + New-Item -ItemType Directory -Path $destPath -Force | Out-Null + } else { + $destDir = [System.IO.Path]::GetDirectoryName($destPath) + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($zipEntry, $destPath, $true) } - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($zipEntry, $destPath, $true) } $zipRead.Dispose() $memStream.Dispose() + # The decoded directory should exist + Test-Path (Join-Path $extractPath 'sub folder') | Should -BeTrue + Test-Path (Join-Path $extractPath 'sub%20folder') | Should -BeFalse + # The extracted file should use the decoded name, not the percent-encoded one - Test-Path (Join-Path $extractPath 'subfolder' 'file with spaces.txt') | Should -BeTrue - Test-Path (Join-Path $extractPath 'subfolder' 'file%20with%20spaces.txt') | Should -BeFalse + Test-Path (Join-Path $extractPath 'sub folder' 'file with spaces.txt') | Should -BeTrue + Test-Path (Join-Path $extractPath 'sub folder' 'file%20with%20spaces.txt') | Should -BeFalse } } }