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);
+ }
+ }
+}