From 7cd5358ac82a50e8c16b8c4cd59cf2c5690b79e2 Mon Sep 17 00:00:00 2001 From: Andreas Mueller Date: Mon, 13 Mar 2023 10:08:50 +0100 Subject: [PATCH] WIP: Added packing of AR and TAR --- AMWD.Common/Packing/Ar/ArFileInfo.cs | 53 +++ AMWD.Common/Packing/Ar/ArReader.cs | 180 +++++++++++ AMWD.Common/Packing/Ar/ArWriter.cs | 147 +++++++++ .../Tar/Interfaces/IArchiveDataWriter.cs | 26 ++ .../Packing/Tar/Interfaces/ITarHeader.cs | 125 +++++++ AMWD.Common/Packing/Tar/TarReader.cs | 210 ++++++++++++ AMWD.Common/Packing/Tar/TarWriter.cs | 63 ++++ AMWD.Common/Packing/Tar/Utils/DataWriter.cs | 45 +++ AMWD.Common/Packing/Tar/Utils/EntryType.cs | 73 +++++ .../Packing/Tar/Utils/LegacyTarWriter.cs | 258 +++++++++++++++ AMWD.Common/Packing/Tar/Utils/TarException.cs | 13 + AMWD.Common/Packing/Tar/Utils/TarHeader.cs | 194 +++++++++++ AMWD.Common/Packing/Tar/Utils/UsTarHeader.cs | 136 ++++++++ CHANGELOG.md | 2 + Directory.Build.props | 2 +- Directory.Build.targets | 11 +- UnitTests/Common/Packing/Ar/ArReaderTests.cs | 231 +++++++++++++ UnitTests/Common/Packing/Ar/ArWriterTests.cs | 306 ++++++++++++++++++ 18 files changed, 2071 insertions(+), 4 deletions(-) create mode 100644 AMWD.Common/Packing/Ar/ArFileInfo.cs create mode 100644 AMWD.Common/Packing/Ar/ArReader.cs create mode 100644 AMWD.Common/Packing/Ar/ArWriter.cs create mode 100644 AMWD.Common/Packing/Tar/Interfaces/IArchiveDataWriter.cs create mode 100644 AMWD.Common/Packing/Tar/Interfaces/ITarHeader.cs create mode 100644 AMWD.Common/Packing/Tar/TarReader.cs create mode 100644 AMWD.Common/Packing/Tar/TarWriter.cs create mode 100644 AMWD.Common/Packing/Tar/Utils/DataWriter.cs create mode 100644 AMWD.Common/Packing/Tar/Utils/EntryType.cs create mode 100644 AMWD.Common/Packing/Tar/Utils/LegacyTarWriter.cs create mode 100644 AMWD.Common/Packing/Tar/Utils/TarException.cs create mode 100644 AMWD.Common/Packing/Tar/Utils/TarHeader.cs create mode 100644 AMWD.Common/Packing/Tar/Utils/UsTarHeader.cs create mode 100644 UnitTests/Common/Packing/Ar/ArReaderTests.cs create mode 100644 UnitTests/Common/Packing/Ar/ArWriterTests.cs 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; + } + } +}