diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1b4f6..c743f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ToCleanString()` for `IPAddresses` mapped IPv4 to IPv6 - `ToSubnetMask()` to generated IPv4 subnet masks from CIDR notation - `ObfuscateIpAddress()` to mask sensidive parts of an IP address (e.g. for listing in logs) +- `ArchiveHelper` to create zip or tar.gz archives from a list of `ArchiveFile`s (net8.0/net10.0) ### Changed diff --git a/src/AMWD.Common/Compression/ArchiveFile.cs b/src/AMWD.Common/Compression/ArchiveFile.cs new file mode 100644 index 0000000..cad4990 --- /dev/null +++ b/src/AMWD.Common/Compression/ArchiveFile.cs @@ -0,0 +1,71 @@ +#if NET8_0_OR_GREATER + +using System; +using System.IO; + +namespace AMWD.Common.Compression +{ + /// + /// A file to be included in an archive. + /// + public class ArchiveFile + { + /// + /// Gets or sets the file name (including relative path) within the archive. + /// + public string FileName { get; set; } + + /// + /// Gets or sets the binary content. + /// + public byte[] Content { get; set; } + + /// + /// Gets or sets the Unix file permissions (only applicable for tar). + /// + public UnixFileMode Permissions { get; set; } + + /// + /// Sets the Unix file permissions from an octal string (e.g. "0755"). + /// + /// The octal file permission. + public void SetPermissions(string oct) + { + int permissions = Convert.ToInt32(oct, 8); + SetPermissions(permissions); + } + + /// + /// Sets the Unix file permissions from a decimal integer (e.g. 493 for octal 0755). + /// + /// The decimal file permission. + public void SetPermissions(int dec) + { + // Keep only permission and special bits (octal 07777) + int bits = dec & 0xFFF; + + UnixFileMode result = 0; + + if ((bits & 0b100000000) != 0) result |= UnixFileMode.UserRead; // 0400 + if ((bits & 0b010000000) != 0) result |= UnixFileMode.UserWrite; // 0200 + if ((bits & 0b001000000) != 0) result |= UnixFileMode.UserExecute; // 0100 + + if ((bits & 0b000100000) != 0) result |= UnixFileMode.GroupRead; // 0040 + if ((bits & 0b000010000) != 0) result |= UnixFileMode.GroupWrite; // 0020 + if ((bits & 0b000001000) != 0) result |= UnixFileMode.GroupExecute; // 0010 + + if ((bits & 0b000000100) != 0) result |= UnixFileMode.OtherRead; // 0004 + if ((bits & 0b000000010) != 0) result |= UnixFileMode.OtherWrite; // 0002 + if ((bits & 0b000000001) != 0) result |= UnixFileMode.OtherExecute; // 0001 + + // Special bits + if ((bits & 0b100000000000) != 0) result |= UnixFileMode.SetUser; // 4000 + if ((bits & 0b010000000000) != 0) result |= UnixFileMode.SetGroup; // 2000 + if ((bits & 0b001000000000) != 0) result |= UnixFileMode.StickyBit; // 1000 + + Permissions = result; + } + } +} + +#endif diff --git a/src/AMWD.Common/Compression/ArchiveHelper.cs b/src/AMWD.Common/Compression/ArchiveHelper.cs new file mode 100644 index 0000000..b416ff6 --- /dev/null +++ b/src/AMWD.Common/Compression/ArchiveHelper.cs @@ -0,0 +1,91 @@ +#if NET8_0_OR_GREATER + +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +namespace AMWD.Common.Compression +{ + /// + /// Provides helper methods for creating zip and tar.gz archives from collections of files. + /// + /// + /// All methods in this class are static and thread-safe. The returned archive data is provided as a + /// byte array in memory, suitable for saving to disk or transmitting over a network. This class does not perform any + /// validation on file contents or names; callers are responsible for ensuring that input files are valid and + /// appropriate for archiving. + /// + public static class ArchiveHelper + { + /// + /// Creates a ZIP archive containing the specified files and returns its contents as a byte array. + /// + /// + /// The resulting ZIP archive will contain one entry for each file in the collection, using the + /// file's specified name and content. The method uses optimal compression for all entries. + /// + /// A read-only collection of files to include in the ZIP archive. Each file must specify a file name and its content. + /// A cancellation token that can be used to cancel the asynchronous operation. The default value is none. + /// A byte array containing the contents of the created ZIP archive. The array will be empty if no files are provided. + public static async Task CreateZip(IReadOnlyCollection files, CancellationToken cancellationToken = default) + { + using var ms = new MemoryStream(); + + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: false)) + { + foreach (var file in files) + { + var entry = zip.CreateEntry(file.FileName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(file.Content, cancellationToken); + } + } + + await ms.FlushAsync(cancellationToken); + return ms.ToArray(); + } + + /// + /// Creates a GZip-compressed tar archive containing the specified files, using the given tar entry format. + /// + /// The returned archive is written to memory and may consume significant resources for large file + /// collections. The order of files in the archive matches the order in the input collection. + /// The collection of files to include in the archive. Each file must specify a file name and its content. Cannot be + /// null or contain null elements. + /// The tar entry format to use for the archive. Defaults to Pax if not specified. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A byte array containing the GZip-compressed tar archive with the specified files. + public static async Task CreateTarGz(IReadOnlyCollection files, TarEntryFormat format = TarEntryFormat.Pax, CancellationToken cancellationToken = default) + { + using var ms = new MemoryStream(); + + using (var gzip = new GZipStream(ms, CompressionLevel.Optimal, leaveOpen: false)) + using (var tar = new TarWriter(gzip, format, leaveOpen: false)) + { + foreach (var file in files) + { + PosixTarEntry entry = format switch + { + TarEntryFormat.Gnu => new GnuTarEntry(TarEntryType.RegularFile, file.FileName), + TarEntryFormat.Ustar => new UstarTarEntry(TarEntryType.RegularFile, file.FileName), + _ => new PaxTarEntry(TarEntryType.RegularFile, file.FileName) + }; + + entry.Mode = file.Permissions; + + using var stream = new MemoryStream(file.Content); + entry.DataStream = stream; + await tar.WriteEntryAsync(entry, cancellationToken); + } + } + + await ms.FlushAsync(cancellationToken); + return ms.ToArray(); + } + } +} + +#endif diff --git a/test/AMWD.Common.Tests/Compression/ArchiveFileTest.cs b/test/AMWD.Common.Tests/Compression/ArchiveFileTest.cs new file mode 100644 index 0000000..5ba2387 --- /dev/null +++ b/test/AMWD.Common.Tests/Compression/ArchiveFileTest.cs @@ -0,0 +1,75 @@ +using System.IO; +using AMWD.Common.Compression; + +namespace AMWD.Common.Tests.Compression +{ + [TestClass] + public class ArchiveFileTest + { + [TestMethod] + public void ShouldSetCorrectPermissionsFromOctal() + { + // Arrange + var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + var file = new ArchiveFile(); + + // Act + file.SetPermissions("0755"); + + // Assert + Assert.AreEqual(expected, file.Permissions); + } + + [TestMethod] + public void ShouldSetCorrectPermissionsFromDecimal() + { + // Arrange + var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + var file = new ArchiveFile(); + + // Act + file.SetPermissions(493); // decimal for octal 0755 + + // Assert + Assert.AreEqual(expected, file.Permissions); + } + + [TestMethod] + public void ShouldSetPermissionsWithSpecialFlags() + { + // Arrange + var expected = UnixFileMode.SetUser + | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + var file = new ArchiveFile(); + + // Act + file.SetPermissions("4755"); // setuid + 0755 + + // Assert + Assert.AreEqual(expected, file.Permissions); + } + + [TestMethod] + public void ShouldIgnoreHighBytePermissions() + { + // Arrange + int input = (1 << 20) | 493; + var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + var file = new ArchiveFile(); + + // Act + file.SetPermissions(input); + + // Assert + Assert.AreEqual(expected, file.Permissions); + } + } +} diff --git a/test/AMWD.Common.Tests/Compression/ArchiveHelperTest.cs b/test/AMWD.Common.Tests/Compression/ArchiveHelperTest.cs new file mode 100644 index 0000000..f3dcc50 --- /dev/null +++ b/test/AMWD.Common.Tests/Compression/ArchiveHelperTest.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AMWD.Common.Compression; + +namespace AMWD.Common.Tests.Compression +{ + [TestClass] + public class ArchiveHelperTest + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task ShouldCreateZipFile() + { + // arrange + var files = new List + { + new() { FileName = "folder/hello.txt", Content = Encoding.UTF8.GetBytes("Hello World") }, + new() { FileName = "readme.md", Content = Encoding.UTF8.GetBytes("# Readme") } + }; + + // act + byte[] zipBytes = await ArchiveHelper.CreateZip(files, TestContext.CancellationToken); + + // assert + using var ms = new MemoryStream(zipBytes); + using var zip = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: false); + + Assert.HasCount(2, zip.Entries); + + var entry1 = zip.GetEntry("folder/hello.txt"); + Assert.IsNotNull(entry1); + + using (var s = entry1.Open()) + using (var sr = new MemoryStream()) + { + await s.CopyToAsync(sr, TestContext.CancellationToken); + CollectionAssert.AreEqual(files[0].Content, sr.ToArray()); + } + + var entry2 = zip.GetEntry("readme.md"); + Assert.IsNotNull(entry2); + + using (var s = entry2.Open()) + using (var sr = new MemoryStream()) + { + await s.CopyToAsync(sr, TestContext.CancellationToken); + CollectionAssert.AreEqual(files[1].Content, sr.ToArray()); + } + } + + [TestMethod] + public async Task ShouldCreateTarGzFile() + { + // arrange + var file1 = new ArchiveFile + { + FileName = "a.txt", + Content = Encoding.UTF8.GetBytes("Alpha"), + }; + file1.SetPermissions("0755"); // rwxr-xr-x + + var file2 = new ArchiveFile + { + FileName = "b.bin", + Content = [1, 2, 3, 4, 5] + }; + file2.SetPermissions("2750"); // setgid + rwxr-x--- + + var files = new List { file1, file2 }; + + // act + byte[] tarGz = await ArchiveHelper.CreateTarGz(files, cancellationToken: TestContext.CancellationToken); + + // decompress gzip to raw tar bytes + byte[] tarBytes; + using (var msIn = new MemoryStream(tarGz)) + using (var gzip = new GZipStream(msIn, CompressionMode.Decompress)) + using (var msOut = new MemoryStream()) + { + await gzip.CopyToAsync(msOut, TestContext.CancellationToken); + tarBytes = msOut.ToArray(); + } + + var entries = ParseTarEntries(tarBytes); + + // assert + Assert.HasCount(4, entries); // 2 files + 2 headers + + var entry1 = entries.FirstOrDefault(e => e.Name == "a.txt"); + Assert.IsNotNull(entry1); + + CollectionAssert.AreEqual(file1.Content, entry1.Data); + Assert.AreEqual(Convert.ToInt32("0755", 8), entry1.Mode); + + var entry2 = entries.FirstOrDefault(e => e.Name == "b.bin"); + Assert.IsNotNull(entry2); + + CollectionAssert.AreEqual(file2.Content, entry2.Data); + Assert.AreEqual(Convert.ToInt32("2750", 8), entry2.Mode); + } + + private sealed class TarEntryInfo + { + public string Name { get; set; } + + public int Mode { get; set; } + + public byte[] Data { get; set; } + } + + /// + /// Minimal TAR parser for test purposes: reads header blocks (512 bytes) and extracts + /// name, mode and data for regular files. Stops on two consecutive zero blocks or empty name. + /// + private static List ParseTarEntries(byte[] tar) + { + var result = new List(); + int offset = 0; + int zeroBlockCount = 0; + + while (offset + 512 <= tar.Length) + { + // read header block + byte[] header = new ArraySegment(tar, offset, 512).ToArray(); + + // check for zero block + if (IsAllZero(header)) + { + zeroBlockCount++; + if (zeroBlockCount >= 2) break; + offset += 512; + continue; + } + zeroBlockCount = 0; + + string name = ReadNullTerminatedAscii(header, 0, 100); + if (string.IsNullOrEmpty(name)) break; + + string modeStr = ReadNullTerminatedAscii(header, 100, 8).Trim(); + int mode = 0; + if (!string.IsNullOrEmpty(modeStr)) + { + mode = Convert.ToInt32(modeStr, 8); + } + + string sizeStr = ReadNullTerminatedAscii(header, 124, 12).Trim(); + long size = 0; + if (!string.IsNullOrEmpty(sizeStr)) + { + size = Convert.ToInt64(sizeStr, 8); + } + + offset += 512; + + byte[] data = new byte[size > 0 ? size : 0]; + if (size > 0) + { + Array.Copy(tar, offset, data, 0, size); + } + + result.Add(new TarEntryInfo + { + Name = name, + Mode = mode, + Data = data + }); + + // advance to next header (data is stored in 512-byte blocks) + long dataBlockCount = (size + 511) / 512; + offset += (int)(dataBlockCount * 512); + } + + return result; + } + + private static bool IsAllZero(byte[] block) + { + foreach (byte b in block) if (b != 0) return false; + return true; + } + + private static string ReadNullTerminatedAscii(byte[] src, int start, int length) + { + int end = start; + int last = start + length; + while (end < last && src[end] != 0) end++; + return Encoding.ASCII.GetString(src, start, end - start); + } + } +}