Added zip and tar.gz archives
This commit is contained in:
@@ -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
|
- `ToCleanString()` for `IPAddresses` mapped IPv4 to IPv6
|
||||||
- `ToSubnetMask()` to generated IPv4 subnet masks from CIDR notation
|
- `ToSubnetMask()` to generated IPv4 subnet masks from CIDR notation
|
||||||
- `ObfuscateIpAddress()` to mask sensidive parts of an IP address (e.g. for listing in logs)
|
- `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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
71
src/AMWD.Common/Compression/ArchiveFile.cs
Normal file
71
src/AMWD.Common/Compression/ArchiveFile.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#if NET8_0_OR_GREATER
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace AMWD.Common.Compression
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A file to be included in an archive.
|
||||||
|
/// </summary>
|
||||||
|
public class ArchiveFile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file name (including relative path) within the archive.
|
||||||
|
/// </summary>
|
||||||
|
public string FileName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the binary content.
|
||||||
|
/// </summary>
|
||||||
|
public byte[] Content { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the Unix file permissions (only applicable for tar).
|
||||||
|
/// </summary>
|
||||||
|
public UnixFileMode Permissions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the Unix file permissions from an octal string (e.g. "0755").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="oct">The octal file permission.</param>
|
||||||
|
public void SetPermissions(string oct)
|
||||||
|
{
|
||||||
|
int permissions = Convert.ToInt32(oct, 8);
|
||||||
|
SetPermissions(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the Unix file permissions from a decimal integer (e.g. 493 for octal 0755).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dec">The decimal file permission.</param>
|
||||||
|
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
|
||||||
91
src/AMWD.Common/Compression/ArchiveHelper.cs
Normal file
91
src/AMWD.Common/Compression/ArchiveHelper.cs
Normal file
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides helper methods for creating zip and tar.gz archives from collections of files.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ArchiveHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a ZIP archive containing the specified files and returns its contents as a byte array.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="files">A read-only collection of files to include in the ZIP archive. Each file must specify a file name and its content.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation. The default value is none.</param>
|
||||||
|
/// <returns>A byte array containing the contents of the created ZIP archive. The array will be empty if no files are provided.</returns>
|
||||||
|
public static async Task<byte[]> CreateZip(IReadOnlyCollection<ArchiveFile> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a GZip-compressed tar archive containing the specified files, using the given tar entry format.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>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.</remarks>
|
||||||
|
/// <param name="files">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.</param>
|
||||||
|
/// <param name="format">The tar entry format to use for the archive. Defaults to Pax if not specified.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A byte array containing the GZip-compressed tar archive with the specified files.</returns>
|
||||||
|
public static async Task<byte[]> CreateTarGz(IReadOnlyCollection<ArchiveFile> 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
|
||||||
75
test/AMWD.Common.Tests/Compression/ArchiveFileTest.cs
Normal file
75
test/AMWD.Common.Tests/Compression/ArchiveFileTest.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
test/AMWD.Common.Tests/Compression/ArchiveHelperTest.cs
Normal file
196
test/AMWD.Common.Tests/Compression/ArchiveHelperTest.cs
Normal file
@@ -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<ArchiveFile>
|
||||||
|
{
|
||||||
|
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<ArchiveFile> { 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static List<TarEntryInfo> ParseTarEntries(byte[] tar)
|
||||||
|
{
|
||||||
|
var result = new List<TarEntryInfo>();
|
||||||
|
int offset = 0;
|
||||||
|
int zeroBlockCount = 0;
|
||||||
|
|
||||||
|
while (offset + 512 <= tar.Length)
|
||||||
|
{
|
||||||
|
// read header block
|
||||||
|
byte[] header = new ArraySegment<byte>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user