diff --git a/AMWD.Common/Packing/Ar/ArFileInfo.cs b/AMWD.Common/Packing/Ar/ArFileInfo.cs
new file mode 100644
index 0000000..fdd05be
--- /dev/null
+++ b/AMWD.Common/Packing/Ar/ArFileInfo.cs
@@ -0,0 +1,53 @@
+using System;
+
+//[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("UnitTests")]
+
+namespace AMWD.Common.Packing.Ar
+{
+ ///
+ /// Represents the file information saved in the archive.
+ ///
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public class ArFileInfo
+ {
+ ///
+ /// Gets or sets the file name.
+ ///
+ public string FileName { get; set; }
+
+ ///
+ /// Gets or sets the file size in bytes.
+ ///
+ public long FileSize { get; set; }
+
+ ///
+ /// Gets or sets the timestamp of the last modification.
+ ///
+ public DateTime ModifyTime { get; set; }
+
+ ///
+ /// Gets or sets the user id.
+ ///
+ public int UserId { get; set; }
+
+ ///
+ /// Gets or sets the group id.
+ ///
+ public int GroupId { get; set; }
+
+ ///
+ /// Gets or sets the access mode in decimal (not octal!).
+ ///
+ ///
+ /// To see the octal representation use Convert.ToString(Mode, 8).
+ ///
+ public int Mode { get; set; }
+ }
+
+ internal class ArFileInfoExtended : ArFileInfo
+ {
+ public long HeaderPosition { get; set; }
+
+ public long DataPosition { get; set; }
+ }
+}
diff --git a/AMWD.Common/Packing/Ar/ArReader.cs b/AMWD.Common/Packing/Ar/ArReader.cs
new file mode 100644
index 0000000..e59f1e3
--- /dev/null
+++ b/AMWD.Common/Packing/Ar/ArReader.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace AMWD.Common.Packing.Ar
+{
+ ///
+ /// Reads UNIX ar (archive) files in the GNU format.
+ ///
+ public class ArReader
+ {
+ // Source: http://en.wikipedia.org/wiki/Ar_%28Unix%29
+
+ private readonly Stream inStream;
+ private readonly List files = new();
+ private readonly long streamStartPosition;
+
+ private static readonly DateTime unixEpoch = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stream to read the archive from.
+ public ArReader(Stream inStream)
+ {
+ if (!inStream.CanRead || !inStream.CanSeek)
+ throw new ArgumentException("Stream not readable or seekable", nameof(inStream));
+
+ streamStartPosition = inStream.Position;
+ this.inStream = inStream;
+
+ Initialize();
+ }
+
+ ///
+ /// Returns a list with all filenames of the archive.
+ ///
+ public IEnumerable GetFileList()
+ {
+ return files.Select(fi => fi.FileName).ToList();
+ }
+
+ ///
+ /// Returns the file info of a specific file in the archive.
+ ///
+ /// The name of the specific file.
+ public ArFileInfo GetFileInfo(string fileName)
+ {
+ return files
+ .Where(fi => fi.FileName == fileName)
+ .Select(fi => new ArFileInfo
+ {
+ FileName = fi.FileName,
+ FileSize = fi.FileSize,
+ GroupId = fi.GroupId,
+ Mode = fi.Mode,
+ ModifyTime = fi.ModifyTime,
+ UserId = fi.UserId
+ })
+ .FirstOrDefault();
+ }
+
+ ///
+ /// Reads a file from the archive into a stream.
+ ///
+ /// The file name in the archive.
+ /// The output stream.
+ public void ReadFile(string fileName, Stream outStream)
+ {
+ if (!outStream.CanWrite)
+ throw new ArgumentException("Stream not writable", nameof(outStream));
+
+ var info = files.Where(fi => fi.FileName == fileName).FirstOrDefault();
+ if (info == null)
+ return;
+
+ long bytesToRead = info.FileSize;
+ byte[] buffer = new byte[1024 * 1024];
+
+ inStream.Seek(info.DataPosition, SeekOrigin.Begin);
+ while (bytesToRead > 0)
+ {
+ int readCount = (int)Math.Min(bytesToRead, buffer.Length);
+ inStream.Read(buffer, 0, readCount);
+ outStream.Write(buffer, 0, readCount);
+
+ bytesToRead -= readCount;
+ }
+ inStream.Seek(streamStartPosition, SeekOrigin.Begin);
+ }
+
+ ///
+ /// Reads a fie from the archive and saves it to disk.
+ ///
+ /// The file name in the archive.
+ /// The destination path on disk.
+ public void ReadFile(string fileName, string destinationPath)
+ {
+ var info = files.Where(fi => fi.FileName == fileName).FirstOrDefault();
+ if (info == null)
+ return;
+
+ using (var fs = File.OpenWrite(destinationPath))
+ {
+ ReadFile(fileName, fs);
+ }
+ File.SetLastWriteTimeUtc(destinationPath, info.ModifyTime);
+ }
+
+ private void Initialize()
+ {
+ // Reset stream
+ inStream.Seek(streamStartPosition, SeekOrigin.Begin);
+
+ // Read header
+ string header = ReadAsciiString(8);
+ if (header != "!\n")
+ throw new FormatException("The file stream is no archive");
+
+ // Create file list
+ while (inStream.Position < inStream.Length)
+ {
+ var info = ReadFileHeader();
+ files.Add(info);
+
+ // Move stream behind file content
+ inStream.Seek(info.FileSize, SeekOrigin.Current);
+
+ // Align to even offsets (padded with LF bytes)
+ if (inStream.Position % 2 != 0)
+ inStream.Seek(1, SeekOrigin.Current);
+ }
+
+ // Reset stream
+ inStream.Seek(streamStartPosition, SeekOrigin.Begin);
+ }
+
+ private string ReadAsciiString(int byteCount)
+ {
+ byte[] buffer = new byte[byteCount];
+ inStream.Read(buffer, 0, byteCount);
+ return Encoding.ASCII.GetString(buffer);
+ }
+
+ private ArFileInfoExtended ReadFileHeader()
+ {
+ long startPosition = inStream.Position;
+
+ string fileName = ReadAsciiString(16).Trim();
+
+ int.TryParse(ReadAsciiString(12).Trim(), out int unixTimestamp);
+ int.TryParse(ReadAsciiString(6).Trim(), out int userId);
+ int.TryParse(ReadAsciiString(6).Trim(), out int groupId);
+ int mode = Convert.ToInt32(ReadAsciiString(8).Trim(), 8);
+
+ long.TryParse(ReadAsciiString(10).Trim(), out long fileSize);
+
+ // file magic
+ byte[] magic = new byte[2];
+ inStream.Read(magic, 0, magic.Length);
+
+ if (magic[0] != 0x60 || magic[1] != 0x0A)
+ throw new FormatException("Invalid file magic");
+
+ return new ArFileInfoExtended
+ {
+ HeaderPosition = startPosition,
+ DataPosition = inStream.Position,
+ FileName = fileName,
+ ModifyTime = unixEpoch.AddSeconds(unixTimestamp),
+ UserId = userId,
+ GroupId = groupId,
+ Mode = mode,
+ FileSize = fileSize
+ };
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Ar/ArWriter.cs b/AMWD.Common/Packing/Ar/ArWriter.cs
new file mode 100644
index 0000000..321149c
--- /dev/null
+++ b/AMWD.Common/Packing/Ar/ArWriter.cs
@@ -0,0 +1,147 @@
+using System;
+using System.IO;
+using System.Text;
+
+namespace AMWD.Common.Packing.Ar
+{
+ ///
+ /// Writes UNIX ar (archive) files in the GNU format.
+ ///
+ ///
+ /// Copied from
+ ///
+ public class ArWriter
+ {
+ // Source: http://en.wikipedia.org/wiki/Ar_%28Unix%29
+
+ private readonly Stream outStream;
+ private static readonly DateTime unixEpoch = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Initialises a new instance of the class.
+ ///
+ /// The stream to write the archive to.
+ public ArWriter(Stream outStream)
+ {
+ if (!outStream.CanWrite)
+ throw new ArgumentException("Stream not writable", nameof(outStream));
+
+ this.outStream = outStream;
+ Initialize();
+ }
+
+ ///
+ /// Writes a file from disk to the archive.
+ ///
+ /// The name of the file to copy.
+ /// The user ID of the file in the archive.
+ /// The group ID of the file in the archive.
+ /// The mode of the file in the archive (decimal).
+ public void WriteFile(string fileName, int userId = 0, int groupId = 0, int mode = 33188 /* 0100644 */)
+ {
+ var fi = new FileInfo(fileName);
+
+ using var fs = File.OpenRead(fileName);
+ WriteFile(fs, fi.Name, fi.LastWriteTimeUtc, userId, groupId, mode);
+ }
+
+ ///
+ /// Writes a file from a Stream to the archive.
+ ///
+ /// The stream to read the file contents from.
+ /// The name of the file in the archive.
+ /// The last modification time of the file in the archive.
+ /// The user ID of the file in the archive.
+ /// The group ID of the file in the archive.
+ /// The mode of the file in the archive (decimal).
+ public void WriteFile(Stream stream, string fileName, DateTime modifyTime, int userId = 0, int groupId = 0, int mode = 33188 /* 0100644 */)
+ {
+ // Write file header
+ WriteFileHeader(fileName, modifyTime, userId, groupId, mode, stream.Length);
+
+ // Write file contents
+ stream.CopyTo(outStream);
+
+ // Align to even offsets, pad with LF bytes
+ if ((outStream.Position % 2) != 0)
+ {
+ byte[] bytes = new byte[] { 0x0A };
+ outStream.Write(bytes, 0, 1);
+ }
+ }
+
+ ///
+ /// Writes the archive header.
+ ///
+ private void Initialize()
+ {
+ WriteAsciiString("!\n");
+ }
+
+ ///
+ /// Writes a file header.
+ ///
+ private void WriteFileHeader(string fileName, DateTime modifyTime, int userId, int groupId, int mode, long fileSize)
+ {
+ // File name
+ if (fileName.Length > 16)
+ throw new ArgumentException("Long file names are not supported.");
+
+ WriteAsciiString(fileName.PadRight(16, ' '));
+
+ // File modification timestamp
+ int unixTime = (int)(modifyTime - unixEpoch).TotalSeconds;
+ WriteAsciiString(unixTime.ToString().PadRight(12, ' '));
+
+ // User ID
+ if (userId >= 0)
+ {
+ WriteAsciiString(userId.ToString().PadRight(6, ' '));
+ }
+ else
+ {
+ WriteAsciiString(" ");
+ }
+
+ // Group ID
+ if (groupId >= 0)
+ {
+ WriteAsciiString(groupId.ToString().PadRight(6, ' '));
+ }
+ else
+ {
+ WriteAsciiString(" ");
+ }
+
+ // File mode
+ if (mode >= 0)
+ {
+ WriteAsciiString(Convert.ToString(mode, 8).PadRight(8, ' '));
+ }
+ else
+ {
+ WriteAsciiString(" ");
+ }
+
+ // File size in bytes
+ if (fileSize < 0 || 10000000000 <= fileSize)
+ throw new ArgumentOutOfRangeException("Invalid file size."); // above 9.32 GB
+
+ WriteAsciiString(fileSize.ToString().PadRight(10, ' '));
+
+ // File magic
+ byte[] bytes = new byte[] { 0x60, 0x0A };
+ outStream.Write(bytes, 0, 2);
+ }
+
+ ///
+ /// Writes a string using ASCII encoding.
+ ///
+ /// The string to write to the output stream.
+ private void WriteAsciiString(string str)
+ {
+ byte[] bytes = Encoding.ASCII.GetBytes(str);
+ outStream.Write(bytes, 0, bytes.Length);
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Interfaces/IArchiveDataWriter.cs b/AMWD.Common/Packing/Tar/Interfaces/IArchiveDataWriter.cs
new file mode 100644
index 0000000..d621ed3
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Interfaces/IArchiveDataWriter.cs
@@ -0,0 +1,26 @@
+namespace AMWD.Common.Packing.Tar.Interfaces
+{
+ ///
+ /// Interface of a archive writer.
+ ///
+ public interface IArchiveDataWriter
+ {
+ ///
+ /// Write bytes of data from to corresponding archive.
+ ///
+ /// The data storage.
+ /// How many bytes to be written to the corresponding archive.
+ int Write(byte[] buffer, int count);
+
+ ///
+ /// Gets a value indicating whether the writer can write.
+ ///
+ bool CanWrite { get; }
+ }
+
+ ///
+ /// The writer delegate.
+ ///
+ /// The writer.
+ public delegate void WriteDataDelegate(IArchiveDataWriter writer);
+}
diff --git a/AMWD.Common/Packing/Tar/Interfaces/ITarHeader.cs b/AMWD.Common/Packing/Tar/Interfaces/ITarHeader.cs
new file mode 100644
index 0000000..f4a474f
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Interfaces/ITarHeader.cs
@@ -0,0 +1,125 @@
+using System;
+using AMWD.Common.Packing.Tar.Utils;
+
+namespace AMWD.Common.Packing.Tar.Interfaces
+{
+ ///
+ /// See "struct star_header" in
+ ///
+ public interface ITarHeader
+ {
+ ///
+ /// The name field is the file name of the file, with directory names (if any) preceding the file name,
+ /// separated by slashes.
+ ///
+ ///
+ /// name
+ ///
+ /// Byte offset: 0
+ ///
+ string FileName { get; set; }
+
+ ///
+ /// The mode field provides nine bits specifying file permissions and three bits to specify
+ /// the Set UID, Set GID, and Save Text (sticky) modes.
+ /// When special permissions are required to create a file with a given mode,
+ /// and the user restoring files from the archive does not hold such permissions,
+ /// the mode bit(s) specifying those special permissions are ignored.
+ /// Modes which are not supported by the operating system restoring files from the archive will be ignored.
+ /// Unsupported modes should be faked up when creating or updating an archive; e.g.,
+ /// the group permission could be copied from the other permission.
+ ///
+ ///
+ /// mode
+ ///
+ /// Byte offset: 100
+ ///
+ int Mode { get; set; }
+
+ ///
+ /// The uid field is the numeric user ID of the file owners.
+ /// If the operating system does not support numeric user ID, this field should be ignored.
+ ///
+ ///
+ /// uid
+ ///
+ /// Byte offset: 108
+ ///
+ int UserId { get; set; }
+
+ ///
+ /// The gid fields is the numeric group ID of the file owners.
+ /// If the operating system does not support numeric group ID, this field should be ignored.
+ ///
+ ///
+ /// gid
+ ///
+ /// Byte offset: 116
+ ///
+ int GroupId { get; set; }
+
+ ///
+ /// The size field is the size of the file in bytes;
+ /// linked files are archived with this field specified as zero.
+ ///
+ ///
+ /// size
+ ///
+ /// Byte offset: 124
+ ///
+ long SizeInBytes { get; set; }
+
+ ///
+ /// mtime
+ /// byte offset: 136
+ /// The mtime field represents the data modification time of the file at the time it was archived.
+ /// It represents the integer number of seconds since January 1, 1970, 00:00 Coordinated Universal Time.
+ ///
+ ///
+ /// mtime
+ ///
+ /// Byte offset: 136
+ ///
+ DateTime LastModification { get; set; }
+
+ ///
+ /// The typeflag field specifies the type of file archived.
+ /// If a particular implementation does not recognize or permit the specified type,
+ /// the file will be extracted as if it were a regular file.
+ /// As this action occurs, tar issues a warning to the standard error.
+ ///
+ ///
+ /// typeflag
+ ///
+ /// Byte offset: 156
+ ///
+ EntryType EntryType { get; set; }
+
+ ///
+ /// The uname field will contain the ASCII representation of the owner of the file.
+ /// If found, the user ID is used rather than the value in the uid field.
+ ///
+ ///
+ /// uname
+ ///
+ /// Byte offset: 265
+ ///
+ string UserName { get; set; }
+
+ ///
+ /// The gname field will contain the ASCII representation of the group of the file.
+ /// If found, the group ID is used rather than the values in the gid field.
+ ///
+ ///
+ /// gname
+ ///
+ /// Byte offset: 297
+ ///
+ string GroupName { get; set; }
+
+ ///
+ /// The size of this header.
+ ///
+ int HeaderSize { get; }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/TarReader.cs b/AMWD.Common/Packing/Tar/TarReader.cs
new file mode 100644
index 0000000..edb5bac
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/TarReader.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using AMWD.Common.Packing.Tar.Interfaces;
+using AMWD.Common.Packing.Tar.Utils;
+
+namespace AMWD.Common.Packing.Tar
+{
+ ///
+ /// Extract contents of a tar file represented by a stream for the TarReader constructor
+ ///
+ ///
+ /// https://github.com/ygoe/DotnetMakeDeb/blob/v1.1.0/DotnetMakeDeb/Tar/TarReader.cs
+ ///
+ public class TarReader
+ {
+ private readonly byte[] dataBuffer = new byte[512];
+ private readonly UsTarHeader header;
+ private readonly Stream inStream;
+ private long remainingBytesInFile;
+
+ ///
+ /// Constructs TarReader object to read data from `tarredData` stream
+ ///
+ /// A stream to read tar archive from
+ public TarReader(Stream tarredData)
+ {
+ inStream = tarredData;
+ header = new UsTarHeader();
+ }
+
+ public ITarHeader FileInfo
+ {
+ get { return header; }
+ }
+
+ ///
+ /// Read all files from an archive to a directory. It creates some child directories to
+ /// reproduce a file structure from the archive.
+ ///
+ /// The out directory.
+ ///
+ /// CAUTION! This method is not safe. It's not tar-bomb proof.
+ /// {see http://en.wikipedia.org/wiki/Tar_(file_format) }
+ /// If you are not sure about the source of an archive you extracting,
+ /// then use MoveNext and Read and handle paths like ".." and "../.." according
+ /// to your business logic.
+ public void ReadToEnd(string destDirectory)
+ {
+ while (MoveNext(false))
+ {
+ string fileNameFromArchive = FileInfo.FileName;
+ string totalPath = destDirectory + Path.DirectorySeparatorChar + fileNameFromArchive;
+ if (UsTarHeader.IsPathSeparator(fileNameFromArchive[fileNameFromArchive.Length - 1]) || FileInfo.EntryType == EntryType.Directory)
+ {
+ // Record is a directory
+ Directory.CreateDirectory(totalPath);
+ continue;
+ }
+ // If record is a file
+ string fileName = Path.GetFileName(totalPath);
+ string directory = totalPath.Remove(totalPath.Length - fileName.Length);
+ Directory.CreateDirectory(directory);
+ using (FileStream file = File.Create(totalPath))
+ {
+ Read(file);
+ }
+ }
+ }
+
+ ///
+ /// Read data from a current file to a Stream.
+ ///
+ /// A stream to read data to
+ ///
+ ///
+ public void Read(Stream dataDestanation)
+ {
+ Debug.WriteLine("tar stream position Read in: " + inStream.Position);
+ int readBytes;
+ byte[] read;
+ while ((readBytes = Read(out read)) != -1)
+ {
+ Debug.WriteLine("tar stream position Read while(...) : " + inStream.Position);
+ dataDestanation.Write(read, 0, readBytes);
+ }
+ Debug.WriteLine("tar stream position Read out: " + inStream.Position);
+ }
+
+ protected int Read(out byte[] buffer)
+ {
+ if (remainingBytesInFile == 0)
+ {
+ buffer = null;
+ return -1;
+ }
+ int align512 = -1;
+ long toRead = remainingBytesInFile - 512;
+
+ if (toRead > 0)
+ toRead = 512;
+ else
+ {
+ align512 = 512 - (int)remainingBytesInFile;
+ toRead = remainingBytesInFile;
+ }
+
+
+ int bytesRead = inStream.Read(dataBuffer, 0, (int)toRead);
+ remainingBytesInFile -= bytesRead;
+
+ if (inStream.CanSeek && align512 > 0)
+ {
+ inStream.Seek(align512, SeekOrigin.Current);
+ }
+ else
+ while (align512 > 0)
+ {
+ inStream.ReadByte();
+ --align512;
+ }
+
+ buffer = dataBuffer;
+ return bytesRead;
+ }
+
+ ///
+ /// Check if all bytes in buffer are zeroes
+ ///
+ /// buffer to check
+ /// true if all bytes are zeroes, otherwise false
+ private static bool IsEmpty(IEnumerable buffer)
+ {
+ foreach (byte b in buffer)
+ {
+ if (b != 0) return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Move internal pointer to a next file in archive.
+ ///
+ /// Should be true if you want to read a header only, otherwise false
+ /// false on End Of File otherwise true
+ ///
+ /// Example:
+ /// while(MoveNext())
+ /// {
+ /// Read(dataDestStream);
+ /// }
+ ///
+ public bool MoveNext(bool skipData)
+ {
+ Debug.WriteLine("tar stream position MoveNext in: " + inStream.Position);
+ if (remainingBytesInFile > 0)
+ {
+ if (!skipData)
+ {
+ throw new TarException(
+ "You are trying to change file while not all the data from the previous one was read. If you do want to skip files use skipData parameter set to true.");
+ }
+ // Skip to the end of file.
+ if (inStream.CanSeek)
+ {
+ long remainer = (remainingBytesInFile % 512);
+ inStream.Seek(remainingBytesInFile + (512 - (remainer == 0 ? 512 : remainer)), SeekOrigin.Current);
+ }
+ else
+ {
+ byte[] buffer;
+ while (Read(out buffer) != -1)
+ {
+ }
+ }
+ }
+
+ byte[] bytes = header.GetBytes();
+
+ int headerRead = inStream.Read(bytes, 0, header.HeaderSize);
+ if (headerRead < 512)
+ {
+ throw new TarException("Can not read header");
+ }
+
+ if (IsEmpty(bytes))
+ {
+ headerRead = inStream.Read(bytes, 0, header.HeaderSize);
+ if (headerRead == 512 && IsEmpty(bytes))
+ {
+ Debug.WriteLine("tar stream position MoveNext out(false): " + inStream.Position);
+ return false;
+ }
+ throw new TarException("Broken archive");
+ }
+
+ if (header.UpdateHeaderFromBytes())
+ {
+ throw new TarException("Checksum check failed");
+ }
+
+ remainingBytesInFile = header.SizeInBytes;
+
+ Debug.WriteLine("tar stream position MoveNext out(true): " + inStream.Position);
+ return true;
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/TarWriter.cs b/AMWD.Common/Packing/Tar/TarWriter.cs
new file mode 100644
index 0000000..740864b
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/TarWriter.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using AMWD.Common.Packing.Tar.Interfaces;
+using AMWD.Common.Packing.Tar.Utils;
+
+namespace AMWD.Common.Packing.Tar
+{
+ // https://github.com/ygoe/DotnetMakeDeb/blob/v1.1.0/DotnetMakeDeb/Tar/TarWriter.cs
+ public class TarWriter : LegacyTarWriter
+ {
+ public TarWriter(Stream outStream) : base(outStream)
+ {
+ }
+
+ protected override void WriteHeader(string name, DateTime lastModificationTime, long count, int userId, int groupId, int mode, EntryType entryType)
+ {
+ var tarHeader = new UsTarHeader()
+ {
+ FileName = name,
+ Mode = mode,
+ UserId = userId,
+ GroupId = groupId,
+ SizeInBytes = count,
+ LastModification = lastModificationTime,
+ EntryType = entryType,
+ UserName = Convert.ToString(userId, 8),
+ GroupName = Convert.ToString(groupId, 8)
+ };
+ OutStream.Write(tarHeader.GetHeaderValue(), 0, tarHeader.HeaderSize);
+ }
+
+ protected virtual void WriteHeader(string name, DateTime lastModificationTime, long count, string userName, string groupName, int mode, EntryType entryType)
+ {
+ WriteHeader(
+ name: name,
+ lastModificationTime: lastModificationTime,
+ count: count,
+ userId: userName.GetHashCode(),
+ groupId: groupName.GetHashCode(),
+ mode: mode,
+ entryType: entryType);
+ }
+
+ public virtual void Write(string name, long dataSizeInBytes, string userName, string groupName, int mode, DateTime lastModificationTime, WriteDataDelegate writeDelegate)
+ {
+ var writer = new DataWriter(OutStream, dataSizeInBytes);
+ WriteHeader(name, lastModificationTime, dataSizeInBytes, userName, groupName, mode, EntryType.File);
+ while (writer.CanWrite)
+ {
+ writeDelegate(writer);
+ }
+ AlignTo512(dataSizeInBytes, false);
+ }
+
+ public void Write(Stream data, long dataSizeInBytes, string fileName, string userId, string groupId, int mode,
+ DateTime lastModificationTime)
+ {
+ WriteHeader(fileName, lastModificationTime, dataSizeInBytes, userId, groupId, mode, EntryType.File);
+ WriteContent(dataSizeInBytes, data);
+ AlignTo512(dataSizeInBytes, false);
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Utils/DataWriter.cs b/AMWD.Common/Packing/Tar/Utils/DataWriter.cs
new file mode 100644
index 0000000..10a1b23
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Utils/DataWriter.cs
@@ -0,0 +1,45 @@
+using System.IO;
+using AMWD.Common.Packing.Tar.Interfaces;
+
+namespace AMWD.Common.Packing.Tar.Utils
+{
+ internal class DataWriter : IArchiveDataWriter
+ {
+ private readonly long size;
+ private long remainingBytes;
+ private readonly Stream stream;
+
+ public DataWriter(Stream data, long dataSizeInBytes)
+ {
+ size = dataSizeInBytes;
+ remainingBytes = size;
+ stream = data;
+ }
+
+ public bool CanWrite { get; private set; } = true;
+
+ public int Write(byte[] buffer, int count)
+ {
+ if (remainingBytes == 0)
+ {
+ CanWrite = false;
+ return -1;
+ }
+
+ int bytesToWrite;
+ if (remainingBytes - count < 0)
+ {
+ bytesToWrite = (int)remainingBytes;
+ }
+ else
+ {
+ bytesToWrite = count;
+ }
+
+ stream.Write(buffer, 0, bytesToWrite);
+ remainingBytes -= bytesToWrite;
+
+ return bytesToWrite;
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Utils/EntryType.cs b/AMWD.Common/Packing/Tar/Utils/EntryType.cs
new file mode 100644
index 0000000..2eadbec
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Utils/EntryType.cs
@@ -0,0 +1,73 @@
+namespace AMWD.Common.Packing.Tar.Utils
+{
+ ///
+ /// See "Values used in typeflag field." in
+ ///
+ public enum EntryType : byte
+ {
+ ///
+ /// AREGTYPE, regular file
+ ///
+ File = 0,
+
+ ///
+ /// REGTYPE, regular file
+ ///
+ FileObsolete = 0x30,
+
+ ///
+ /// LNKTYPE, link
+ ///
+ HardLink = 0x31,
+
+ ///
+ /// SYMTYPE, reserved
+ ///
+ SymLink = 0x32,
+
+ ///
+ /// CHRTYPE, character special
+ ///
+ CharDevice = 0x33,
+
+ ///
+ /// BLKTYPE, block special
+ ///
+ BlockDevice = 0x34,
+
+ ///
+ /// DIRTYPE, directory
+ ///
+ Directory = 0x35,
+
+ ///
+ /// FIFOTYPE, FIFO special
+ ///
+ Fifo = 0x36,
+
+ ///
+ /// CONTTYPE, reserved
+ ///
+ Content = 0x37,
+
+ ///
+ /// XHDTYPE, Extended header referring to the next file in the archive
+ ///
+ ExtendedHeader = 0x78,
+
+ ///
+ /// XGLTYPE, Global extended header
+ ///
+ GlobalExtendedHeader = 0x67,
+
+ ///
+ /// GNUTYPE_LONGLINK, Identifies the *next* file on the tape as having a long linkname.
+ ///
+ LongLink = 0x4b,
+
+ ///
+ /// GNUTYPE_LONGNAME, Identifies the *next* file on the tape as having a long name.
+ ///
+ LongName = 0x4c
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Utils/LegacyTarWriter.cs b/AMWD.Common/Packing/Tar/Utils/LegacyTarWriter.cs
new file mode 100644
index 0000000..ad249aa
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Utils/LegacyTarWriter.cs
@@ -0,0 +1,258 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using AMWD.Common.Packing.Tar.Interfaces;
+
+namespace AMWD.Common.Packing.Tar.Utils
+{
+ ///
+ /// Implements a legacy TAR writer.
+ ///
+ ///
+ /// Copied from
+ ///
+ public class LegacyTarWriter : IDisposable
+ {
+ private readonly Stream outStream;
+ protected byte[] buffer = new byte[1024];
+ private bool isClosed;
+ public bool ReadOnZero = true;
+
+ ///
+ /// Writes tar (see GNU tar) archive to a stream
+ ///
+ /// stream to write archive to
+ public LegacyTarWriter(Stream outStream)
+ {
+ this.outStream = outStream;
+ }
+
+ protected virtual Stream OutStream
+ {
+ get { return outStream; }
+ }
+
+ #region IDisposable Members
+
+ public void Dispose()
+ {
+ Close();
+ }
+
+ #endregion IDisposable Members
+
+ public void WriteDirectoryEntry(string path, int userId, int groupId, int mode)
+ {
+ if (string.IsNullOrEmpty(path))
+ throw new ArgumentNullException("path");
+ if (path[path.Length - 1] != '/')
+ {
+ path += '/';
+ }
+ DateTime lastWriteTime;
+ if (Directory.Exists(path))
+ {
+ lastWriteTime = Directory.GetLastWriteTime(path);
+ }
+ else
+ {
+ lastWriteTime = DateTime.Now;
+ }
+
+ // handle long path names (> 99 characters)
+ if (path.Length > 99)
+ {
+ WriteLongName(
+ name: path,
+ userId: userId,
+ groupId: groupId,
+ mode: mode,
+ lastModificationTime: lastWriteTime);
+
+ // shorten the path name so it can be written properly
+ path = path.Substring(0, 99);
+ }
+
+ WriteHeader(path, lastWriteTime, 0, userId, groupId, mode, EntryType.Directory);
+ }
+
+ public void WriteDirectory(string directory, bool doRecursive)
+ {
+ if (string.IsNullOrEmpty(directory))
+ throw new ArgumentNullException("directory");
+
+ WriteDirectoryEntry(directory, 0, 0, 0755);
+
+ string[] files = Directory.GetFiles(directory);
+ foreach (var fileName in files)
+ {
+ Write(fileName);
+ }
+
+ string[] directories = Directory.GetDirectories(directory);
+ foreach (var dirName in directories)
+ {
+ WriteDirectoryEntry(dirName, 0, 0, 0755);
+ if (doRecursive)
+ {
+ WriteDirectory(dirName, true);
+ }
+ }
+ }
+
+ public void Write(string fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ throw new ArgumentNullException("fileName");
+ using (FileStream file = File.OpenRead(fileName))
+ {
+ Write(file, file.Length, fileName, 61, 61, 511, File.GetLastWriteTime(file.Name));
+ }
+ }
+
+ public void Write(FileStream file)
+ {
+ string path = Path.GetFullPath(file.Name).Replace(Path.GetPathRoot(file.Name), string.Empty);
+ path = path.Replace(Path.DirectorySeparatorChar, '/');
+ Write(file, file.Length, path, 61, 61, 511, File.GetLastWriteTime(file.Name));
+ }
+
+ public void Write(Stream data, long dataSizeInBytes, string name)
+ {
+ Write(data, dataSizeInBytes, name, 61, 61, 511, DateTime.Now);
+ }
+
+ public virtual void Write(string name, long dataSizeInBytes, int userId, int groupId, int mode, DateTime lastModificationTime, WriteDataDelegate writeDelegate)
+ {
+ IArchiveDataWriter writer = new DataWriter(OutStream, dataSizeInBytes);
+ WriteHeader(name, lastModificationTime, dataSizeInBytes, userId, groupId, mode, EntryType.File);
+ while (writer.CanWrite)
+ {
+ writeDelegate(writer);
+ }
+ AlignTo512(dataSizeInBytes, false);
+ }
+
+ public virtual void Write(Stream data, long dataSizeInBytes, string name, int userId, int groupId, int mode,
+ DateTime lastModificationTime)
+ {
+ if (isClosed)
+ throw new TarException("Can not write to the closed writer");
+
+ // handle long file names (> 99 characters)
+ if (name.Length > 99)
+ {
+ WriteLongName(
+ name: name,
+ userId: userId,
+ groupId: groupId,
+ mode: mode,
+ lastModificationTime: lastModificationTime);
+
+ // shorten the file name so it can be written properly
+ name = name.Substring(0, 99);
+ }
+
+ WriteHeader(name, lastModificationTime, dataSizeInBytes, userId, groupId, mode, EntryType.File);
+ WriteContent(dataSizeInBytes, data);
+ AlignTo512(dataSizeInBytes, false);
+ }
+
+ ///
+ /// Handle long file or path names (> 99 characters).
+ /// Write header and content, which its content contain the long (complete) file/path name.
+ /// This handling method is adapted from https://github.com/qmfrederik/dotnet-packaging/pull/50/files#diff-f64c58cc18e8e445cee6ffed7a0d765cdb442c0ef21a3ed80bd20514057967b1
+ ///
+ /// File name or path name.
+ /// User ID.
+ /// Group ID.
+ /// Mode.
+ /// Last modification time.
+ private void WriteLongName(string name, int userId, int groupId, int mode, DateTime lastModificationTime)
+ {
+ // must include a trailing \0
+ var nameLength = Encoding.UTF8.GetByteCount(name);
+ byte[] entryName = new byte[nameLength + 1];
+
+ Encoding.UTF8.GetBytes(name, 0, name.Length, entryName, 0);
+
+ // add a "././@LongLink" pseudo-entry which contains the full name
+ using (var nameStream = new MemoryStream(entryName))
+ {
+ WriteHeader("././@LongLink", lastModificationTime, entryName.Length, userId, groupId, mode, EntryType.LongName);
+ WriteContent(entryName.Length, nameStream);
+ AlignTo512(entryName.Length, false);
+ }
+ }
+
+ protected void WriteContent(long count, Stream data)
+ {
+ while (count > 0 && count > buffer.Length)
+ {
+ int bytesRead = data.Read(buffer, 0, buffer.Length);
+ if (bytesRead < 0)
+ throw new IOException("LegacyTarWriter unable to read from provided stream");
+ if (bytesRead == 0)
+ {
+ if (ReadOnZero)
+ Thread.Sleep(100);
+ else
+ break;
+ }
+ OutStream.Write(buffer, 0, bytesRead);
+ count -= bytesRead;
+ }
+ if (count > 0)
+ {
+ int bytesRead = data.Read(buffer, 0, (int)count);
+ if (bytesRead < 0)
+ throw new IOException("LegacyTarWriter unable to read from provided stream");
+ if (bytesRead == 0)
+ {
+ while (count > 0)
+ {
+ OutStream.WriteByte(0);
+ --count;
+ }
+ }
+ else
+ OutStream.Write(buffer, 0, bytesRead);
+ }
+ }
+
+ protected virtual void WriteHeader(string name, DateTime lastModificationTime, long count, int userId, int groupId, int mode, EntryType entryType)
+ {
+ var header = new TarHeader
+ {
+ FileName = name,
+ LastModification = lastModificationTime,
+ SizeInBytes = count,
+ UserId = userId,
+ GroupId = groupId,
+ Mode = mode,
+ EntryType = entryType
+ };
+ OutStream.Write(header.GetHeaderValue(), 0, header.HeaderSize);
+ }
+
+ public void AlignTo512(long size, bool acceptZero)
+ {
+ size = size % 512;
+ if (size == 0 && !acceptZero) return;
+ while (size < 512)
+ {
+ OutStream.WriteByte(0);
+ size++;
+ }
+ }
+
+ public virtual void Close()
+ {
+ if (isClosed) return;
+ AlignTo512(0, true);
+ AlignTo512(0, true);
+ isClosed = true;
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Utils/TarException.cs b/AMWD.Common/Packing/Tar/Utils/TarException.cs
new file mode 100644
index 0000000..c3dbf70
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Utils/TarException.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace AMWD.Common.Packing.Tar.Utils
+{
+ public class TarException : Exception
+ {
+ public TarException(string message) : base(message)
+ {
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Utils/TarHeader.cs b/AMWD.Common/Packing/Tar/Utils/TarHeader.cs
new file mode 100644
index 0000000..01ccbc1
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Utils/TarHeader.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Net;
+using System.Text;
+using AMWD.Common.Packing.Tar.Interfaces;
+
+namespace AMWD.Common.Packing.Tar.Utils
+{
+ internal class TarHeader : ITarHeader
+ {
+ private readonly byte[] buffer = new byte[512];
+ private long headerChecksum;
+
+ public TarHeader()
+ {
+ // Default values
+ Mode = 511; // 0777 dec
+ UserId = 61; // 101 dec
+ GroupId = 61; // 101 dec
+ }
+
+ private string fileName;
+ protected readonly DateTime TheEpoch = new DateTime(1970, 1, 1, 0, 0, 0);
+ public EntryType EntryType { get; set; }
+ private static byte[] spaces = Encoding.ASCII.GetBytes(" ");
+
+ public virtual string FileName
+ {
+ get
+ {
+ return fileName.Replace("\0", string.Empty);
+ }
+ set
+ {
+ if (value.Length > 100)
+ {
+ throw new TarException("A file name can not be more than 100 chars long");
+ }
+ fileName = value;
+ }
+ }
+ public int Mode { get; set; }
+
+ public string ModeString
+ {
+ get { return Convert.ToString(Mode, 8).PadLeft(7, '0'); }
+ }
+
+ public int UserId { get; set; }
+ public virtual string UserName
+ {
+ get { return UserId.ToString(); }
+ set { UserId = Int32.Parse(value); }
+ }
+
+ public string UserIdString
+ {
+ get { return Convert.ToString(UserId, 8).PadLeft(7, '0'); }
+ }
+
+ public int GroupId { get; set; }
+ public virtual string GroupName
+ {
+ get { return GroupId.ToString(); }
+ set { GroupId = Int32.Parse(value); }
+ }
+
+ public string GroupIdString
+ {
+ get { return Convert.ToString(GroupId, 8).PadLeft(7, '0'); }
+ }
+
+ public long SizeInBytes { get; set; }
+
+ public string SizeString
+ {
+ get { return Convert.ToString(SizeInBytes, 8).PadLeft(11, '0'); }
+ }
+
+ public DateTime LastModification { get; set; }
+
+ public string LastModificationString
+ {
+ get
+ {
+ return Convert.ToString((long)(LastModification - TheEpoch).TotalSeconds, 8).PadLeft(11, '0');
+ }
+ }
+
+ public string HeaderChecksumString
+ {
+ get { return Convert.ToString(headerChecksum, 8).PadLeft(6, '0'); }
+ }
+
+ public virtual int HeaderSize
+ {
+ get { return 512; }
+ }
+
+ public byte[] GetBytes()
+ {
+ return buffer;
+ }
+
+ public virtual bool UpdateHeaderFromBytes()
+ {
+ FileName = Encoding.ASCII.GetString(buffer, 0, 100);
+ // thanks to Shasha Alperocivh. Trimming nulls.
+ Mode = Convert.ToInt32(Encoding.ASCII.GetString(buffer, 100, 7).Trim(), 8);
+ UserId = Convert.ToInt32(Encoding.ASCII.GetString(buffer, 108, 7).Trim(), 8);
+ GroupId = Convert.ToInt32(Encoding.ASCII.GetString(buffer, 116, 7).Trim(), 8);
+
+ EntryType = (EntryType)buffer[156];
+
+ if ((buffer[124] & 0x80) == 0x80) // if size in binary
+ {
+ long sizeBigEndian = BitConverter.ToInt64(buffer, 0x80);
+ SizeInBytes = IPAddress.NetworkToHostOrder(sizeBigEndian);
+ }
+ else
+ {
+ SizeInBytes = Convert.ToInt64(Encoding.ASCII.GetString(buffer, 124, 11), 8);
+ }
+ long unixTimeStamp = Convert.ToInt64(Encoding.ASCII.GetString(buffer, 136, 11), 8);
+ LastModification = TheEpoch.AddSeconds(unixTimeStamp);
+
+ var storedChecksum = Convert.ToInt32(Encoding.ASCII.GetString(buffer, 148, 6));
+ RecalculateChecksum(buffer);
+ if (storedChecksum == headerChecksum)
+ {
+ return true;
+ }
+
+ RecalculateAltChecksum(buffer);
+ return storedChecksum == headerChecksum;
+ }
+
+ private void RecalculateAltChecksum(byte[] buf)
+ {
+ spaces.CopyTo(buf, 148);
+ headerChecksum = 0;
+ foreach (byte b in buf)
+ {
+ if ((b & 0x80) == 0x80)
+ {
+ headerChecksum -= b ^ 0x80;
+ }
+ else
+ {
+ headerChecksum += b;
+ }
+ }
+ }
+
+ public virtual byte[] GetHeaderValue()
+ {
+ // Clean old values
+ Array.Clear(buffer, 0, buffer.Length);
+
+ if (string.IsNullOrEmpty(FileName)) throw new TarException("FileName can not be empty.");
+ if (FileName.Length >= 100) throw new TarException("FileName is too long. It must be less than 100 bytes.");
+
+ // Fill header
+ Encoding.ASCII.GetBytes(FileName.PadRight(100, '\0')).CopyTo(buffer, 0);
+ Encoding.ASCII.GetBytes(ModeString).CopyTo(buffer, 100);
+ Encoding.ASCII.GetBytes(UserIdString).CopyTo(buffer, 108);
+ Encoding.ASCII.GetBytes(GroupIdString).CopyTo(buffer, 116);
+ Encoding.ASCII.GetBytes(SizeString).CopyTo(buffer, 124);
+ Encoding.ASCII.GetBytes(LastModificationString).CopyTo(buffer, 136);
+
+ // buffer[156] = 20;
+ buffer[156] = ((byte)EntryType);
+
+ RecalculateChecksum(buffer);
+
+ // Write checksum
+ Encoding.ASCII.GetBytes(HeaderChecksumString).CopyTo(buffer, 148);
+
+ return buffer;
+ }
+
+ protected virtual void RecalculateChecksum(byte[] buf)
+ {
+ // Set default value for checksum. That is 8 spaces.
+ spaces.CopyTo(buf, 148);
+
+ // Calculate checksum
+ headerChecksum = 0;
+ foreach (byte b in buf)
+ {
+ headerChecksum += b;
+ }
+ }
+ }
+}
diff --git a/AMWD.Common/Packing/Tar/Utils/UsTarHeader.cs b/AMWD.Common/Packing/Tar/Utils/UsTarHeader.cs
new file mode 100644
index 0000000..f9d530c
--- /dev/null
+++ b/AMWD.Common/Packing/Tar/Utils/UsTarHeader.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Net;
+using System.Text;
+
+namespace AMWD.Common.Packing.Tar.Utils
+{
+ ///
+ /// UsTar header implementation.
+ ///
+ internal class UsTarHeader : TarHeader
+ {
+ private const string magic = "ustar";
+ private const string version = " ";
+ private string groupName;
+
+ private string namePrefix = string.Empty;
+ private string userName;
+
+ public override string UserName
+ {
+ get
+ {
+ return userName.Replace("\0", string.Empty);
+ }
+ set
+ {
+ if (value.Length > 32)
+ {
+ throw new TarException("user name can not be longer than 32 chars");
+ }
+ userName = value;
+ }
+ }
+
+ public override string GroupName
+ {
+ get
+ {
+ return groupName.Replace("\0", string.Empty);
+ }
+ set
+ {
+ if (value.Length > 32)
+ {
+ throw new TarException("group name can not be longer than 32 chars");
+ }
+ groupName = value;
+ }
+ }
+
+ public override string FileName
+ {
+ get
+ {
+ return namePrefix.Replace("\0", string.Empty) + base.FileName.Replace("\0", string.Empty);
+ }
+ set
+ {
+ if (value.Length > 100)
+ {
+ if (value.Length > 255)
+ {
+ throw new TarException("UsTar fileName can not be longer thatn 255 chars");
+ }
+ int position = value.Length - 100;
+
+ // Find first path separator in the remaining 100 chars of the file name
+ while (!IsPathSeparator(value[position]))
+ {
+ ++position;
+ if (position == value.Length)
+ {
+ break;
+ }
+ }
+ if (position == value.Length)
+ position = value.Length - 100;
+ namePrefix = value.Substring(0, position);
+ base.FileName = value.Substring(position, value.Length - position);
+ }
+ else
+ {
+ base.FileName = value;
+ }
+ }
+ }
+
+ public override bool UpdateHeaderFromBytes()
+ {
+ byte[] bytes = GetBytes();
+ UserName = Encoding.ASCII.GetString(bytes, 0x109, 32);
+ GroupName = Encoding.ASCII.GetString(bytes, 0x129, 32);
+ namePrefix = Encoding.ASCII.GetString(bytes, 347, 157);
+ return base.UpdateHeaderFromBytes();
+ }
+
+ internal static bool IsPathSeparator(char ch)
+ {
+ return (ch == '\\' || ch == '/' || ch == '|'); // All the path separators I ever met.
+ }
+
+ public override byte[] GetHeaderValue()
+ {
+ byte[] header = base.GetHeaderValue();
+
+ Encoding.ASCII.GetBytes(magic).CopyTo(header, 0x101); // Mark header as ustar
+ Encoding.ASCII.GetBytes(version).CopyTo(header, 0x106);
+ Encoding.ASCII.GetBytes(UserName).CopyTo(header, 0x109);
+ Encoding.ASCII.GetBytes(GroupName).CopyTo(header, 0x129);
+ Encoding.ASCII.GetBytes(namePrefix).CopyTo(header, 347);
+
+ if (SizeInBytes >= 0x1FFFFFFFF)
+ {
+ byte[] bytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(SizeInBytes));
+ SetMarker(AlignTo12(bytes)).CopyTo(header, 124);
+ }
+
+ RecalculateChecksum(header);
+ Encoding.ASCII.GetBytes(HeaderChecksumString).CopyTo(header, 148);
+ return header;
+ }
+
+ private static byte[] SetMarker(byte[] bytes)
+ {
+ bytes[0] |= 0x80;
+ return bytes;
+ }
+
+ private static byte[] AlignTo12(byte[] bytes)
+ {
+ var retVal = new byte[12];
+ bytes.CopyTo(retVal, 12 - bytes.Length);
+ return retVal;
+ }
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ab18bf..d95ecb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added `directory.build.props` and `directory.build.targets`
+- Added `ArReader` and `ArWriter` for Unix archives
+- Added `TarReader` and `TarWriter` for TAR archives
## [v1.10.0](https://git.am-wd.de/AM.WD/common/compare/v1.9.0...v1.10.0) - 2022-09-18
diff --git a/Directory.Build.props b/Directory.Build.props
index 6383836..e32b6ca 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- {semvertag:main}
+ {semvertag:main}{!:-mod}
true
false
diff --git a/Directory.Build.targets b/Directory.Build.targets
index d2a3986..84ed1ef 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -1,8 +1,13 @@
-
+
-
+
-
+
+
+
+
+
+
diff --git a/UnitTests/Common/Packing/Ar/ArReaderTests.cs b/UnitTests/Common/Packing/Ar/ArReaderTests.cs
new file mode 100644
index 0000000..5b0bc48
--- /dev/null
+++ b/UnitTests/Common/Packing/Ar/ArReaderTests.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using AMWD.Common.Packing.Ar;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace UnitTests.Common.Packing.Ar
+{
+ [TestClass]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public class ArReaderTests
+ {
+ private readonly DateTime fixedDateTime = new(2023, 03, 01, 10, 20, 30, 0, DateTimeKind.Utc);
+
+ private Dictionary files;
+
+ private Stream inStream;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ files = new Dictionary
+ {
+ {
+ "abcd.tmp",
+ new ArFileInfo
+ {
+ FileName = "abcd.tmp",
+ FileSize = 14,
+ GroupId = 456,
+ Mode = 33188,
+ ModifyTime = fixedDateTime,
+ UserId = 123
+ }
+ },
+ {
+ "efgh.tmp",
+ new ArFileInfo
+ {
+ FileName = "efgh.tmp",
+ FileSize = 14,
+ GroupId = 456,
+ Mode = 33188,
+ ModifyTime = fixedDateTime,
+ UserId = 123
+ }
+ },
+ {
+ "ijkl.tmp",
+ new ArFileInfo
+ {
+ FileName = "ijkl.tmp",
+ FileSize = 13,
+ GroupId = 456,
+ Mode = 33188,
+ ModifyTime = fixedDateTime,
+ UserId = 123
+ }
+ }
+ };
+
+ inStream = new MemoryStream();
+ inStream.Write(Encoding.ASCII.GetBytes("!\n"));
+
+ foreach (var file in files)
+ {
+ int unixSeconds = (int)file.Value.ModifyTime.Subtract(DateTime.UnixEpoch).TotalSeconds;
+
+ inStream.Write(Encoding.ASCII.GetBytes($"{file.Key,-16}{unixSeconds,-12}123 456 100644 {file.Value.FileSize,-10}`\n"));
+ inStream.Write(Encoding.UTF8.GetBytes(new string('a', (int)file.Value.FileSize)));
+ if (file.Value.FileSize % 2 != 0)
+ inStream.Write(Encoding.ASCII.GetBytes("\n"));
+ }
+
+ inStream.Seek(0, SeekOrigin.Begin);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ inStream.Dispose();
+ inStream = null;
+ }
+
+ [TestMethod]
+ public void ShouldInitializeArchive()
+ {
+ // Arrange
+ inStream.Dispose();
+ inStream = new MemoryStream();
+ inStream.Write(Encoding.ASCII.GetBytes("!\n"));
+ inStream.Seek(0, SeekOrigin.Begin);
+
+ // Act
+ var reader = new ArReader(inStream);
+
+ // Assert
+ Assert.IsNotNull(reader);
+ Assert.IsFalse(reader.GetFileList().Any());
+ }
+
+ [TestMethod]
+ public void ShouldInitializeWithFiles()
+ {
+ // Arrange
+
+ // Act
+ var reader = new ArReader(inStream);
+
+ // Assert
+ Assert.IsNotNull(reader);
+ Assert.IsTrue(reader.GetFileList().Any());
+ }
+
+ [TestMethod]
+ public void ShouldListFileNames()
+ {
+ // Arrange
+ var reader = new ArReader(inStream);
+
+ // Act
+ var fileList = reader.GetFileList().ToList();
+
+ // Assert
+ Assert.IsNotNull(reader);
+ Assert.AreEqual(files.Count, fileList.Count);
+
+ foreach (string name in files.Keys)
+ Assert.IsTrue(fileList.Contains(name));
+ }
+
+ [TestMethod]
+ public void ShouldReturnValidFileInfo()
+ {
+ // Arrange
+ var infos = new List();
+ var reader = new ArReader(inStream);
+
+ // Act
+ foreach (string name in files.Keys)
+ infos.Add(reader.GetFileInfo(name));
+
+ // Assert
+ Assert.IsNotNull(reader);
+ Assert.AreEqual(files.Count, infos.Count);
+
+ foreach (var expected in files.Values)
+ {
+ var actual = infos.Single(fi => fi.FileName == expected.FileName);
+
+ Assert.AreEqual(expected.FileName, actual.FileName);
+ Assert.AreEqual(expected.FileSize, actual.FileSize);
+ Assert.AreEqual(expected.GroupId, actual.GroupId);
+ Assert.AreEqual(expected.Mode, actual.Mode);
+ Assert.AreEqual(expected.ModifyTime, actual.ModifyTime);
+ Assert.AreEqual(expected.UserId, actual.UserId);
+ }
+ }
+
+ [TestMethod]
+ public void ShouldReturnValidFileContent()
+ {
+ // Arrange
+ var contents = new Dictionary();
+ var reader = new ArReader(inStream);
+
+ // Act
+ foreach (string name in files.Keys)
+ {
+ using var ms = new MemoryStream();
+ reader.ReadFile(name, ms);
+ ms.Seek(0, SeekOrigin.Begin);
+
+ contents.Add(name, Encoding.UTF8.GetString(ms.ToArray()));
+ }
+
+ // Assert
+ Assert.IsNotNull(reader);
+ Assert.AreEqual(files.Count, contents.Count);
+
+ foreach (var expected in files.Values)
+ {
+ string content = contents[expected.FileName];
+
+ if (expected.FileSize % 2 != 0)
+ Assert.AreEqual(13, content.Length);
+ else
+ Assert.AreEqual(14, content.Length);
+
+ Assert.AreEqual(new string('a', (int)expected.FileSize), content);
+ }
+ }
+
+ [TestMethod]
+ public void ShouldWriteFileToDisk()
+ {
+ // Arrange
+ string tmpFile = Path.GetTempFileName();
+ var reader = new ArReader(inStream);
+
+ try
+ {
+ // Act
+ using var ms = new MemoryStream();
+
+ reader.ReadFile("abcd.tmp", ms);
+ reader.ReadFile("abcd.tmp", tmpFile);
+
+ // Assert
+ CollectionAssert.AreEqual(ms.ToArray(), File.ReadAllBytes(tmpFile));
+ }
+ finally
+ {
+ File.Delete(tmpFile);
+ }
+ }
+
+ private class OverrideStream : MemoryStream
+ {
+ public override bool CanWrite => CanWriteOR;
+
+ public bool CanWriteOR { get; set; }
+
+ public override bool CanSeek => CanSeekOR;
+
+ public bool CanSeekOR { get; set; }
+ }
+ }
+}
diff --git a/UnitTests/Common/Packing/Ar/ArWriterTests.cs b/UnitTests/Common/Packing/Ar/ArWriterTests.cs
new file mode 100644
index 0000000..f872687
--- /dev/null
+++ b/UnitTests/Common/Packing/Ar/ArWriterTests.cs
@@ -0,0 +1,306 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using AMWD.Common.Packing.Ar;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace UnitTests.Common.Packing.Ar
+{
+ [TestClass]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public class ArWriterTests
+ {
+ private readonly DateTime fixedDateTime = new(2023, 03, 01, 10, 20, 30, 0, DateTimeKind.Utc);
+
+ private readonly Dictionary files = new();
+
+ private Stream outStream;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ files.Clear();
+ for (int i = 0; i < 3; i++)
+ {
+ var (filePath, content) = GenerateTestFile();
+ files.Add(filePath, content);
+ }
+
+ outStream = new MemoryStream();
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ foreach (var kvp in files)
+ File.Delete(kvp.Key);
+
+ files.Clear();
+
+ outStream.Dispose();
+ outStream = null;
+ }
+
+ [TestMethod]
+ public void ShouldInitializeArchive()
+ {
+ // Arrange
+ byte[] initBytes = new byte[8];
+
+ // Act
+ _ = new ArWriter(outStream);
+
+ outStream.Seek(0, SeekOrigin.Begin);
+
+ // Assert
+ Assert.AreEqual(initBytes.Length, outStream.Length);
+
+ outStream.Read(initBytes, 0, initBytes.Length);
+ CollectionAssert.AreEqual(Encoding.ASCII.GetBytes("!\n"), initBytes);
+ }
+
+ [TestMethod]
+ public void ShouldWriteOneFile()
+ {
+ // Arrange
+ var firstFileKvp = files.First();
+ byte[] headerBytes = new byte[60];
+ byte[] contentBytes = new byte[14];
+
+ var writer = new ArWriter(outStream);
+
+ // Act
+ writer.WriteFile(firstFileKvp.Key, 123, 456);
+
+ outStream.Seek(8, SeekOrigin.Begin); // set behind init bytes
+
+ // Assert
+ Assert.AreEqual(8 + headerBytes.Length + contentBytes.Length, outStream.Length);
+
+ outStream.Read(headerBytes, 0, headerBytes.Length);
+ outStream.Read(contentBytes, 0, contentBytes.Length);
+
+ string header = Encoding.ASCII.GetString(headerBytes);
+ string content = Encoding.UTF8.GetString(contentBytes);
+
+ Assert.AreEqual($"{Path.GetFileName(firstFileKvp.Key),-16}1677666030 123 456 100644 14 `\n", header);
+ Assert.AreEqual(firstFileKvp.Value, content);
+ }
+
+ [TestMethod]
+ public void ShouldWriteMultipleFiles()
+ {
+ // Arrange
+ var writer = new ArWriter(outStream);
+
+ // Act
+ foreach (var kvp in files)
+ writer.WriteFile(kvp.Key, 123, 456);
+
+ outStream.Seek(8, SeekOrigin.Begin);
+
+ // Assert
+ Assert.AreEqual((8 + 3 * 60 + 3 * 14), outStream.Length);
+
+ foreach (var kvp in files)
+ {
+ byte[] headerBytes = new byte[60];
+ byte[] contentBytes = new byte[14];
+
+ outStream.Read(headerBytes, 0, headerBytes.Length);
+ outStream.Read(contentBytes, 0, contentBytes.Length);
+
+ string header = Encoding.ASCII.GetString(headerBytes);
+ string content = Encoding.UTF8.GetString(contentBytes);
+
+ Assert.AreEqual($"{Path.GetFileName(kvp.Key),-16}1677666030 123 456 100644 14 `\n", header);
+ Assert.AreEqual(kvp.Value, content);
+ }
+ }
+
+ [TestMethod]
+ public void ShouldPadToEven()
+ {
+ // Arrange
+ var (filePath, fileContent) = GenerateTestFile(13);
+
+ try
+ {
+ byte[] headerBytes = new byte[60];
+ byte[] contentBytes = new byte[14];
+
+ var writer = new ArWriter(outStream);
+
+ // Act
+ writer.WriteFile(filePath, 123, 456);
+
+ outStream.Seek(8, SeekOrigin.Begin);
+
+ // Assert
+ Assert.AreEqual(8 + headerBytes.Length + contentBytes.Length, outStream.Length);
+
+ outStream.Read(headerBytes, 0, headerBytes.Length);
+ outStream.Read(contentBytes, 0, contentBytes.Length);
+
+ string header = Encoding.ASCII.GetString(headerBytes);
+ string content = Encoding.UTF8.GetString(contentBytes);
+
+ Assert.AreEqual($"{Path.GetFileName(filePath),-16}1677666030 123 456 100644 13 `\n", header);
+ Assert.AreEqual(fileContent + "\n", content);
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentException))]
+ public void ShouldFailOnFileNameTooLong()
+ {
+ // Arrange
+ var (filePath, _) = GenerateTestFile();
+ try
+ {
+ string path = Path.GetDirectoryName(filePath);
+ string fileName = Path.GetFileName(filePath);
+ fileName = fileName.PadLeft(20, 'a');
+
+ File.Move(filePath, Path.Combine(path, fileName));
+ filePath = Path.Combine(path, fileName);
+
+ var writer = new ArWriter(outStream);
+
+ // Act
+ writer.WriteFile(filePath, 123, 456);
+
+ // Assert - Exception
+ Assert.Fail();
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [TestMethod]
+ public void ShouldWriteEmptyOnNegativeUserId()
+ {
+ // Arrange
+ var firstFileKvp = files.First();
+ byte[] headerBytes = new byte[60];
+ byte[] contentBytes = new byte[14];
+
+ var writer = new ArWriter(outStream);
+
+ // Act
+ writer.WriteFile(firstFileKvp.Key, -123, 456);
+
+ outStream.Seek(8, SeekOrigin.Begin);
+
+ // Assert
+ Assert.AreEqual(8 + headerBytes.Length + contentBytes.Length, outStream.Length);
+
+ outStream.Read(headerBytes, 0, headerBytes.Length);
+ outStream.Read(contentBytes, 0, contentBytes.Length);
+
+ string header = Encoding.ASCII.GetString(headerBytes);
+ string content = Encoding.UTF8.GetString(contentBytes);
+
+ Assert.AreEqual($"{Path.GetFileName(firstFileKvp.Key),-16}1677666030 456 100644 14 `\n", header);
+ Assert.AreEqual(firstFileKvp.Value, content);
+ }
+
+ [TestMethod]
+ public void ShouldWriteEmptyOnNegativeGroupId()
+ {
+ // Arrange
+ var firstFileKvp = files.First();
+ byte[] headerBytes = new byte[60];
+ byte[] contentBytes = new byte[14];
+
+ var writer = new ArWriter(outStream);
+
+ // Act
+ writer.WriteFile(firstFileKvp.Key, 123, -456);
+
+ outStream.Seek(8, SeekOrigin.Begin);
+
+ // Assert
+ Assert.AreEqual(8 + headerBytes.Length + contentBytes.Length, outStream.Length);
+
+ outStream.Read(headerBytes, 0, headerBytes.Length);
+ outStream.Read(contentBytes, 0, contentBytes.Length);
+
+ string header = Encoding.ASCII.GetString(headerBytes);
+ string content = Encoding.UTF8.GetString(contentBytes);
+
+ Assert.AreEqual($"{Path.GetFileName(firstFileKvp.Key),-16}1677666030 123 100644 14 `\n", header);
+ Assert.AreEqual(firstFileKvp.Value, content);
+ }
+
+ [TestMethod]
+ public void ShouldWriteEmptyOnNegativeMode()
+ {
+ // Arrange
+ var firstFileKvp = files.First();
+ byte[] headerBytes = new byte[60];
+ byte[] contentBytes = new byte[14];
+
+ var writer = new ArWriter(outStream);
+
+ // Act
+ writer.WriteFile(firstFileKvp.Key, 123, 456, -1);
+
+ outStream.Seek(8, SeekOrigin.Begin);
+
+ // Assert
+ Assert.AreEqual(8 + headerBytes.Length + contentBytes.Length, outStream.Length);
+
+ outStream.Read(headerBytes, 0, headerBytes.Length);
+ outStream.Read(contentBytes, 0, contentBytes.Length);
+
+ string header = Encoding.ASCII.GetString(headerBytes);
+ string content = Encoding.UTF8.GetString(contentBytes);
+
+ Assert.AreEqual($"{Path.GetFileName(firstFileKvp.Key),-16}1677666030 123 456 14 `\n", header);
+ Assert.AreEqual(firstFileKvp.Value, content);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentException))]
+ public void ShouldFailOnNonWritableStream()
+ {
+ // Arrange
+ using var testStream = new NonWriteStream();
+
+ // Act
+ _ = new ArWriter(testStream);
+
+ // Assert - ArgumentException
+ Assert.Fail();
+ }
+
+ private (string filePath, string content) GenerateTestFile(int length = 14)
+ {
+ string filePath = Path.GetTempFileName();
+ string text = CryptographyHelper.GetRandomString(length);
+
+ File.WriteAllText(filePath, text);
+ File.SetLastWriteTimeUtc(filePath, fixedDateTime);
+ return (filePath, text);
+ }
+
+ private class NonWriteStream : MemoryStream
+ {
+ public NonWriteStream()
+ : base()
+ { }
+
+ public override bool CanWrite => false;
+ }
+ }
+}