From f51456b2f294f69ac3debad2b6afa86bac3b1585 Mon Sep 17 00:00:00 2001 From: MarkS Date: Mon, 12 Jan 2026 10:46:51 -0700 Subject: [PATCH] tools: add Zippy test utility --- tools/Zippy.Tests/ZipServiceTests.cs | 145 +++++++++++++++ tools/Zippy.Tests/Zippy.Tests.csproj | 20 +++ tools/Zippy/Cli.cs | 90 ++++++++++ tools/Zippy/Program.cs | 4 + tools/Zippy/ZipService.cs | 260 +++++++++++++++++++++++++++ tools/Zippy/Zippy.csproj | 15 ++ 6 files changed, 534 insertions(+) create mode 100644 tools/Zippy.Tests/ZipServiceTests.cs create mode 100644 tools/Zippy.Tests/Zippy.Tests.csproj create mode 100644 tools/Zippy/Cli.cs create mode 100644 tools/Zippy/Program.cs create mode 100644 tools/Zippy/ZipService.cs create mode 100644 tools/Zippy/Zippy.csproj diff --git a/tools/Zippy.Tests/ZipServiceTests.cs b/tools/Zippy.Tests/ZipServiceTests.cs new file mode 100644 index 00000000000..e64da0107e3 --- /dev/null +++ b/tools/Zippy.Tests/ZipServiceTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.IO; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using ICSharpCode.SharpZipLib.Zip; +using NUnit.Framework; +using Zippy; + +namespace Zippy.Tests; + +[TestFixture] +public sealed class ZipServiceTests +{ + [Test] + public void CreateZip_FlattensFilePathsAndRootsDirectories() + { + // Suppose the command is run as `zippy zip out.zip my-file.txt foo/some-file.txt baz/some-dir` in a directory containing: + // - my-file.txt + // - foo/some-file.txt + // - baz/some-dir/my-file.txt + // - baz/some-dir/sub/nested.txt + // We should create a zip file with expected entries. + string tempRoot = Directory.CreateTempSubdirectory("zippy-test-").FullName; + try + { + string myFile = Path.Join(tempRoot, "my-file.txt"); + File.WriteAllText(myFile, "a"); + + string fooDir = Path.Join(tempRoot, "foo"); + Directory.CreateDirectory(fooDir); + string fooSomeFile = Path.Join(fooDir, "some-file.txt"); + File.WriteAllText(fooSomeFile, "b"); + + string bazSomeDir = Path.Join(tempRoot, "baz", "some-dir"); + Directory.CreateDirectory(Path.Join(bazSomeDir, "sub")); + File.WriteAllText(Path.Join(bazSomeDir, "inner.txt"), "c"); + File.WriteAllText(Path.Join(bazSomeDir, "sub", "nested.txt"), "d"); + + string zipPath = Path.Join(tempRoot, "out.zip"); + + var service = new ZipService(); + service.CreateZip(zipPath, new List { myFile, fooSomeFile, bazSomeDir }); + + using FileStream stream = File.OpenRead(zipPath); + using var zip = new ZipFile(stream); + List entryNames = zip.Cast().Select(e => e.Name).ToList(); + + Assert.That(entryNames, Does.Contain("my-file.txt")); + Assert.That(entryNames, Does.Contain("some-file.txt")); + Assert.That(entryNames, Does.Contain("some-dir/inner.txt")); + Assert.That(entryNames, Does.Contain("some-dir/sub/nested.txt")); + + Assert.That(entryNames, Does.Not.Contain("foo/some-file.txt")); + Assert.That(entryNames, Does.Not.Contain("baz/some-dir/inner.txt")); + } + finally + { + Directory.Delete(tempRoot, true); + } + } + + [Test] + public void CreateZip_WhenZipAlreadyExists_Throws() + { + string tempRoot = Directory.CreateTempSubdirectory("zippy-test-").FullName; + try + { + string zipPath = Path.Join(tempRoot, "out.zip"); + File.WriteAllText(zipPath, "already exists"); + + string inputFile = Path.Join(tempRoot, "a.txt"); + File.WriteAllText(inputFile, "a"); + + var service = new ZipService(); + Assert.Throws(() => service.CreateZip(zipPath, new List { inputFile })); + } + finally + { + Directory.Delete(tempRoot, true); + } + } + + [Test] + public void CreateZip_WhenDuplicateEntryNames_Throws() + { + string tempRoot = Directory.CreateTempSubdirectory("zippy-test-").FullName; + try + { + string dir1 = Path.Join(tempRoot, "one"); + string dir2 = Path.Join(tempRoot, "two"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + + string file1 = Path.Join(dir1, "dup.txt"); + string file2 = Path.Join(dir2, "dup.txt"); + File.WriteAllText(file1, "a"); + File.WriteAllText(file2, "b"); + + string zipPath = Path.Join(tempRoot, "out.zip"); + + var service = new ZipService(); + Assert.Throws(() => service.CreateZip(zipPath, new List { file1, file2 })); + } + finally + { + Directory.Delete(tempRoot, true); + } + } + + [Test] + public void ExtractZipToCurrentDirectory_WhenFileExists_ThrowsAndDoesNotOverwrite() + { + string tempRoot = Directory.CreateTempSubdirectory("zippy-test-").FullName; + string zipRoot = Directory.CreateTempSubdirectory("zippy-zip-").FullName; + + string originalCwd = Directory.GetCurrentDirectory(); + try + { + string existingPath = Path.Join(tempRoot, "a.txt"); + File.WriteAllText(existingPath, "original"); + + string inputFile = Path.Join(zipRoot, "a.txt"); + File.WriteAllText(inputFile, "new"); + string zipPath = Path.Join(zipRoot, "out.zip"); + + var service = new ZipService(); + service.CreateZip(zipPath, new List { inputFile }); + + Directory.SetCurrentDirectory(tempRoot); + + Assert.Throws(() => service.ExtractZipToCurrentDirectory(zipPath)); + Assert.That(File.ReadAllText(existingPath), Is.EqualTo("original")); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + Directory.Delete(tempRoot, true); + Directory.Delete(zipRoot, true); + } + } +} diff --git a/tools/Zippy.Tests/Zippy.Tests.csproj b/tools/Zippy.Tests/Zippy.Tests.csproj new file mode 100644 index 00000000000..3b601a29dd7 --- /dev/null +++ b/tools/Zippy.Tests/Zippy.Tests.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + false + enable + + + + + + + + + + + + + + diff --git a/tools/Zippy/Cli.cs b/tools/Zippy/Cli.cs new file mode 100644 index 00000000000..912b4132640 --- /dev/null +++ b/tools/Zippy/Cli.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.CommandLine.Parsing; + +namespace Zippy; + +/// +/// Contains the System.CommandLine wiring for Zippy. +/// This is separate from so it can be unit tested. +/// +/// The purpose of the Zippy program is to provide a helper to test the functionality of SharpZipLib. +/// +public static class Cli +{ + public static Parser BuildParser() + { + var rootCommand = new RootCommand("Zippy: zip and unzip utility") { TreatUnmatchedTokensAsErrors = true }; + + var unzipCommand = new Command("unzip", "Extract a zip file into the current working directory"); + var unzipZipPathArgument = new Argument("zipFile", description: "The zip file to extract") + { + Arity = ArgumentArity.ExactlyOne, + }; + unzipCommand.AddArgument(unzipZipPathArgument); + + unzipCommand.SetHandler( + (string zipFilePath) => + { + var zipService = new ZipService(); + zipService.ExtractZipToCurrentDirectory(zipFilePath); + }, + unzipZipPathArgument + ); + + var zipCommand = new Command("zip", "Create a zip file from files and directories"); + var zipZipPathArgument = new Argument("zipFile", description: "The zip file to create") + { + Arity = ArgumentArity.ExactlyOne, + }; + var zipInputsArgument = new Argument>( + "inputs", + description: "Files and directories to include in the zip (added as root items)" + ) + { + Arity = ArgumentArity.OneOrMore, + }; + zipCommand.AddArgument(zipZipPathArgument); + zipCommand.AddArgument(zipInputsArgument); + + zipCommand.SetHandler( + (string zipFilePath, List inputs) => + { + var zipService = new ZipService(); + zipService.CreateZip(zipFilePath, inputs); + }, + zipZipPathArgument, + zipInputsArgument + ); + + rootCommand.AddCommand(unzipCommand); + rootCommand.AddCommand(zipCommand); + + // System.CommandLine's default exception handler prints a stack trace. + // Override it so we only print a concise message and return exit code 1. + return new CommandLineBuilder(rootCommand) + .UseDefaults() + .UseExceptionHandler( + (Exception ex, InvocationContext context) => + { + context.Console.Error.WriteLine(ex.Message); + context.ExitCode = 1; + }, + 1 + ) + .Build(); + } + + public static Task RunAsync(string[] args, IConsole console) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(console); + + Parser parser = BuildParser(); + return parser.InvokeAsync(args, console); + } +} diff --git a/tools/Zippy/Program.cs b/tools/Zippy/Program.cs new file mode 100644 index 00000000000..9f3693aeab9 --- /dev/null +++ b/tools/Zippy/Program.cs @@ -0,0 +1,4 @@ +using System.CommandLine.IO; +using Zippy; + +return await Cli.RunAsync(args, new SystemConsole()); diff --git a/tools/Zippy/ZipService.cs b/tools/Zippy/ZipService.cs new file mode 100644 index 00000000000..9601ca0622a --- /dev/null +++ b/tools/Zippy/ZipService.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ICSharpCode.SharpZipLib.Core; +using ICSharpCode.SharpZipLib.Zip; + +namespace Zippy; + +/// +/// Implements zip and unzip operations for the Zippy CLI. +/// +public sealed class ZipService +{ + public void CreateZip(string zipFilePath, IReadOnlyList inputPaths) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(zipFilePath); + ArgumentNullException.ThrowIfNull(inputPaths); + + if (inputPaths.Count == 0) + { + throw new ArgumentException("At least one input path is required.", nameof(inputPaths)); + } + + if (File.Exists(zipFilePath) || Directory.Exists(zipFilePath)) + { + throw new IOException($"The zip file already exists: {zipFilePath}"); + } + + Dictionary plannedEntries = PlanZipEntries(inputPaths); + + string? outputDirectory = Path.GetDirectoryName(Path.GetFullPath(zipFilePath)); + if (!string.IsNullOrWhiteSpace(outputDirectory) && !Directory.Exists(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + using FileStream outputStream = new FileStream( + zipFilePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None + ); + using var zipOutputStream = new ZipOutputStream(outputStream); + zipOutputStream.IsStreamOwner = false; + + foreach (KeyValuePair entry in plannedEntries) + { + if (entry.Value.Kind == InputSourceKind.DirectoryMarker) + { + var dirEntry = new ZipEntry(entry.Key) { DateTime = DateTime.Now }; + zipOutputStream.PutNextEntry(dirEntry); + zipOutputStream.CloseEntry(); + continue; + } + + if (entry.Value.Kind != InputSourceKind.File) + { + throw new InvalidOperationException($"Unexpected entry kind: {entry.Value.Kind}"); + } + + var fileEntry = new ZipEntry(entry.Key) { DateTime = File.GetLastWriteTime(entry.Value.FilePath) }; + + zipOutputStream.PutNextEntry(fileEntry); + using FileStream inputStream = new FileStream( + entry.Value.FilePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + StreamUtils.Copy(inputStream, zipOutputStream, new byte[8192]); + zipOutputStream.CloseEntry(); + } + + zipOutputStream.Finish(); + } + + public void ExtractZipToCurrentDirectory(string zipFilePath) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(zipFilePath); + + if (!File.Exists(zipFilePath)) + { + throw new FileNotFoundException("The zip file was not found.", zipFilePath); + } + + string destinationRoot = Directory.GetCurrentDirectory(); + string destinationRootFullPath = EnsureTrailingSeparator(Path.GetFullPath(destinationRoot)); + + using FileStream zipStream = new FileStream(zipFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var zipFile = new ZipFile(zipStream); + + IReadOnlyList planned = PlanExtraction(zipFile, destinationRootFullPath); + ValidateNoExtractionConflicts(planned); + + foreach (PlannedExtraction extraction in planned) + { + string destinationDirectory = Path.GetDirectoryName(extraction.DestinationPath) ?? destinationRootFullPath; + if (!string.IsNullOrWhiteSpace(destinationDirectory) && !Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + using Stream input = zipFile.GetInputStream(extraction.Entry); + using FileStream output = new FileStream( + extraction.DestinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None + ); + input.CopyTo(output); + } + } + + private static Dictionary PlanZipEntries(IReadOnlyList inputPaths) + { + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string inputPath in inputPaths) + { + if (string.IsNullOrWhiteSpace(inputPath)) + { + throw new ArgumentException("Input path items cannot be empty.", nameof(inputPaths)); + } + + string fullPath = Path.GetFullPath(inputPath); + if (File.Exists(fullPath)) + { + string fileName = Path.GetFileName(fullPath); + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException($"Unable to determine file name for: {inputPath}", nameof(inputPaths)); + } + AddUnique(entries, NormalizeEntryName(fileName), InputSource.ForFile(fullPath)); + } + else if (Directory.Exists(fullPath)) + { + string dirName = Path.GetFileName( + fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + ); + if (string.IsNullOrWhiteSpace(dirName)) + { + throw new ArgumentException( + $"Unable to determine directory name for: {inputPath}", + nameof(inputPaths) + ); + } + + AddUnique(entries, NormalizeEntryName(dirName) + "/", InputSource.ForDirectoryMarker()); + + foreach (string file in Directory.EnumerateFiles(fullPath, "*", SearchOption.AllDirectories)) + { + string relativePath = Path.GetRelativePath(fullPath, file); + string entryName = NormalizeEntryName(Path.Combine(dirName, relativePath)); + AddUnique(entries, entryName, InputSource.ForFile(file)); + } + } + else + { + throw new FileNotFoundException("Input path does not exist.", inputPath); + } + } + + return entries; + } + + private static void AddUnique(Dictionary entries, string entryName, InputSource source) + { + if (entries.ContainsKey(entryName)) + { + throw new ArgumentException($"Duplicate zip entry name: {entryName}"); + } + + entries.Add(entryName, source); + } + + private static string NormalizeEntryName(string path) + { + string normalized = path.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + while (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized.Substring(2); + } + + normalized = normalized.TrimStart('/'); + return normalized; + } + + private static IReadOnlyList PlanExtraction(ZipFile zipFile, string destinationRootFullPath) + { + var planned = new List(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile || string.IsNullOrWhiteSpace(entry.Name)) + { + continue; + } + + string destinationPath = GetSafeExtractionPath(destinationRootFullPath, entry.Name); + planned.Add(new PlannedExtraction(entry, destinationPath)); + } + + return planned; + } + + private static void ValidateNoExtractionConflicts(IReadOnlyList planned) + { + foreach (PlannedExtraction extraction in planned) + { + if (File.Exists(extraction.DestinationPath) || Directory.Exists(extraction.DestinationPath)) + { + throw new IOException($"Refusing to overwrite existing path: {extraction.DestinationPath}"); + } + + string? directoryPath = Path.GetDirectoryName(extraction.DestinationPath); + if (!string.IsNullOrWhiteSpace(directoryPath) && File.Exists(directoryPath)) + { + throw new IOException($"Cannot create directory because a file exists: {directoryPath}"); + } + } + } + + private static string GetSafeExtractionPath(string destinationRootFullPath, string entryName) + { + string sanitizedEntryName = entryName.Replace('\\', '/'); + string combined = Path.GetFullPath(Path.Join(destinationRootFullPath, sanitizedEntryName)); + if (!combined.StartsWith(destinationRootFullPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"Zip entry is outside the destination directory: {entryName}"); + } + + return combined; + } + + private static string EnsureTrailingSeparator(string path) + { + if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar)) + { + return path; + } + + return path + Path.DirectorySeparatorChar; + } + + private readonly record struct PlannedExtraction(ZipEntry Entry, string DestinationPath); + + private enum InputSourceKind + { + File, + DirectoryMarker, + } + + private readonly record struct InputSource(InputSourceKind Kind, string FilePath) + { + public static InputSource ForFile(string filePath) => new(InputSourceKind.File, filePath); + + public static InputSource ForDirectoryMarker() => new(InputSourceKind.DirectoryMarker, string.Empty); + } +} diff --git a/tools/Zippy/Zippy.csproj b/tools/Zippy/Zippy.csproj new file mode 100644 index 00000000000..75cd9c45e5a --- /dev/null +++ b/tools/Zippy/Zippy.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + +