From 5e8ea67e2184a408726c98adc46c3cbd1cc85140 Mon Sep 17 00:00:00 2001 From: Daniil Bystrukhin Date: Sat, 21 Feb 2026 23:12:38 -0600 Subject: [PATCH] Add 7z writer with LZMA compression support --- .../Common/SevenZip/ArchiveWriter.cs | 49 +++ .../Common/SevenZip/SevenZipFilesInfo.cs | 219 +++++++++++ .../SevenZip/SevenZipHeaderStructures.cs | 305 ++++++++++++++++ .../SevenZip/SevenZipSignatureHeader.cs | 87 +++++ .../SevenZip/SevenZipStreamsCompressor.cs | 131 +++++++ .../SevenZip/SevenZipWriteExtensions.cs | 97 +++++ .../Factories/SevenZipFactory.cs | 32 +- .../Writers/SevenZip/SevenZipWriter.Async.cs | 39 ++ .../SevenZip/SevenZipWriter.Factory.cs | 62 ++++ .../Writers/SevenZip/SevenZipWriter.cs | 339 ++++++++++++++++++ .../Writers/SevenZip/SevenZipWriterOptions.cs | 123 +++++++ .../SevenZip/SevenZipWriterTests.cs | 296 +++++++++++++++ 12 files changed, 1778 insertions(+), 1 deletion(-) create mode 100644 src/SharpCompress/Common/SevenZip/ArchiveWriter.cs create mode 100644 src/SharpCompress/Common/SevenZip/SevenZipFilesInfo.cs create mode 100644 src/SharpCompress/Common/SevenZip/SevenZipHeaderStructures.cs create mode 100644 src/SharpCompress/Common/SevenZip/SevenZipSignatureHeader.cs create mode 100644 src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs create mode 100644 src/SharpCompress/Common/SevenZip/SevenZipWriteExtensions.cs create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipWriter.Factory.cs create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs create mode 100644 tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs diff --git a/src/SharpCompress/Common/SevenZip/ArchiveWriter.cs b/src/SharpCompress/Common/SevenZip/ArchiveWriter.cs new file mode 100644 index 00000000..b3d93daf --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/ArchiveWriter.cs @@ -0,0 +1,49 @@ +using System.IO; +using SharpCompress.Compressors.LZMA.Utilities; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Top-level orchestrator for writing 7z archive headers. +/// Assembles the complete header from StreamsInfo and FilesInfo, +/// and supports writing either a raw header (kHeader) or an +/// encoded/compressed header (kEncodedHeader). +/// +internal static class ArchiveHeaderWriter +{ + /// + /// Writes a raw (uncompressed) header containing MainStreamsInfo and FilesInfo. + /// + public static void WriteRawHeader( + Stream stream, + SevenZipStreamsInfoWriter? mainStreamsInfo, + SevenZipFilesInfoWriter? filesInfo + ) + { + stream.WriteByte((byte)BlockType.Header); + + if (mainStreamsInfo != null) + { + stream.WriteByte((byte)BlockType.MainStreamsInfo); + mainStreamsInfo.Write(stream); + } + + if (filesInfo != null) + { + stream.WriteByte((byte)BlockType.FilesInfo); + filesInfo.Write(stream); + } + + stream.WriteByte((byte)BlockType.End); + } + + /// + /// Writes an encoded header - a StreamsInfo block that describes + /// how to decompress the actual header data. + /// + public static void WriteEncodedHeader(Stream stream, SevenZipStreamsInfoWriter headerStreamsInfo) + { + stream.WriteByte((byte)BlockType.EncodedHeader); + headerStreamsInfo.Write(stream); + } +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipFilesInfo.cs b/src/SharpCompress/Common/SevenZip/SevenZipFilesInfo.cs new file mode 100644 index 00000000..c5310842 --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/SevenZipFilesInfo.cs @@ -0,0 +1,219 @@ +using System; +using System.IO; +using System.Text; +using SharpCompress.Compressors.LZMA.Utilities; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Entry metadata collected during writing, used to build FilesInfo header. +/// +internal sealed class SevenZipWriteEntry +{ + public string Name { get; init; } = string.Empty; + public DateTime? ModificationTime { get; init; } + public uint? Attributes { get; init; } + public bool IsDirectory { get; init; } + public bool IsEmpty { get; init; } +} + +/// +/// Writes the FilesInfo section of a 7z header, including all file properties +/// (names, timestamps, attributes, empty stream/file markers). +/// +internal sealed class SevenZipFilesInfoWriter +{ + public SevenZipWriteEntry[] Entries { get; init; } = []; + + public void Write(Stream stream) + { + var numFiles = (ulong)Entries.Length; + stream.WriteEncodedUInt64(numFiles); + + // Count empty streams (directories + zero-length files) + var emptyStreamCount = 0; + for (var i = 0; i < Entries.Length; i++) + { + if (Entries[i].IsEmpty || Entries[i].IsDirectory) + { + emptyStreamCount++; + } + } + + // EmptyStream property + if (emptyStreamCount > 0) + { + WriteEmptyStreamProperty(stream, emptyStreamCount); + } + + // Names property + WriteNameProperty(stream); + + // MTime property + WriteMTimeProperty(stream); + + // Attributes property + WriteAttributesProperty(stream); + + stream.WriteByte((byte)BlockType.End); + } + + private void WriteEmptyStreamProperty(Stream stream, int emptyStreamCount) + { + var emptyStreams = new bool[Entries.Length]; + var emptyFiles = new bool[emptyStreamCount]; + var hasEmptyFile = false; + var emptyIndex = 0; + + for (var i = 0; i < Entries.Length; i++) + { + if (Entries[i].IsEmpty || Entries[i].IsDirectory) + { + emptyStreams[i] = true; + var isEmptyFile = !Entries[i].IsDirectory; + emptyFiles[emptyIndex++] = isEmptyFile; + if (isEmptyFile) + { + hasEmptyFile = true; + } + } + } + + // kEmptyStream + WriteFileProperty(stream, BlockType.EmptyStream, s => s.WriteBoolVector(emptyStreams)); + + // kEmptyFile (only if there are actual empty files, not just directories) + if (hasEmptyFile) + { + WriteFileProperty(stream, BlockType.EmptyFile, s => s.WriteBoolVector(emptyFiles)); + } + } + + private void WriteNameProperty(Stream stream) + { + WriteFileProperty( + stream, + BlockType.Name, + s => + { + // External = 0 (inline) + s.WriteByte(0); + + for (var i = 0; i < Entries.Length; i++) + { + var nameBytes = Encoding.Unicode.GetBytes(Entries[i].Name); + s.Write(nameBytes); + // null terminator (2 bytes for UTF-16) + s.WriteByte(0); + s.WriteByte(0); + } + } + ); + } + + private void WriteMTimeProperty(Stream stream) + { + var hasTimes = false; + for (var i = 0; i < Entries.Length; i++) + { + if (Entries[i].ModificationTime != null) + { + hasTimes = true; + break; + } + } + + if (!hasTimes) + { + return; + } + + WriteFileProperty( + stream, + BlockType.MTime, + s => + { + var defined = new bool[Entries.Length]; + for (var i = 0; i < Entries.Length; i++) + { + defined[i] = Entries[i].ModificationTime != null; + } + s.WriteOptionalBoolVector(defined); + + // External = 0 (inline) + s.WriteByte(0); + + var buf = new byte[8]; + for (var i = 0; i < Entries.Length; i++) + { + if (Entries[i].ModificationTime is { } mtime) + { + var fileTime = (ulong)mtime.ToUniversalTime().ToFileTimeUtc(); + System.Buffers.Binary.BinaryPrimitives.WriteUInt64LittleEndian(buf, fileTime); + s.Write(buf, 0, 8); + } + } + } + ); + } + + private void WriteAttributesProperty(Stream stream) + { + var hasAttrs = false; + for (var i = 0; i < Entries.Length; i++) + { + if (Entries[i].Attributes != null) + { + hasAttrs = true; + break; + } + } + + if (!hasAttrs) + { + return; + } + + WriteFileProperty( + stream, + BlockType.WinAttributes, + s => + { + var defined = new bool[Entries.Length]; + for (var i = 0; i < Entries.Length; i++) + { + defined[i] = Entries[i].Attributes != null; + } + s.WriteOptionalBoolVector(defined); + + // External = 0 (inline) + s.WriteByte(0); + + var buf = new byte[4]; + for (var i = 0; i < Entries.Length; i++) + { + if (Entries[i].Attributes is { } attrs) + { + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buf, attrs); + s.Write(buf, 0, 4); + } + } + } + ); + } + + /// + /// Writes a file property block: PropertyID + size + data. + /// Size is computed by writing to a temporary buffer first. + /// + private static void WriteFileProperty(Stream stream, BlockType propertyId, Action writeData) + { + using var dataStream = new MemoryStream(); + writeData(dataStream); + + stream.WriteByte((byte)propertyId); + stream.WriteEncodedUInt64((ulong)dataStream.Length); + dataStream.Position = 0; + dataStream.CopyTo(stream); + } +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipHeaderStructures.cs b/src/SharpCompress/Common/SevenZip/SevenZipHeaderStructures.cs new file mode 100644 index 00000000..ed476d78 --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/SevenZipHeaderStructures.cs @@ -0,0 +1,305 @@ +using System; +using System.IO; +using SharpCompress.Compressors.LZMA.Utilities; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Writes Digests (CRC32 arrays with optional-defined-vector) for 7z headers. +/// +internal sealed class SevenZipDigestsWriter(uint?[] crcs) +{ + public uint?[] CRCs { get; } = crcs; + + public void Write(Stream stream) + { + var defined = new bool[CRCs.Length]; + for (var i = 0; i < CRCs.Length; i++) + { + defined[i] = CRCs[i] != null; + } + + stream.WriteOptionalBoolVector(defined); + + var buf = new byte[4]; + for (var i = 0; i < CRCs.Length; i++) + { + if (CRCs[i] is { } crcValue) + { + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buf, crcValue); + stream.Write(buf, 0, 4); + } + } + } + + public bool HasAnyDefined() + { + for (var i = 0; i < CRCs.Length; i++) + { + if (CRCs[i] != null) + { + return true; + } + } + return false; + } +} + +/// +/// Writes PackInfo section: packed stream positions, sizes, and CRCs. +/// +internal sealed class SevenZipPackInfoWriter +{ + public ulong PackPos { get; init; } + public ulong[] Sizes { get; init; } = []; + public uint?[] CRCs { get; init; } = []; + + public void Write(Stream stream) + { + stream.WriteEncodedUInt64(PackPos); + stream.WriteEncodedUInt64((ulong)Sizes.Length); + + // Sizes + stream.WriteByte((byte)BlockType.Size); + for (var i = 0; i < Sizes.Length; i++) + { + stream.WriteEncodedUInt64(Sizes[i]); + } + + // CRCs (optional) + var digests = new SevenZipDigestsWriter(CRCs); + if (digests.HasAnyDefined()) + { + stream.WriteByte((byte)BlockType.Crc); + digests.Write(stream); + } + + stream.WriteByte((byte)BlockType.End); + } +} + +/// +/// Writes UnPackInfo section: folder definitions (coders, bind pairs, unpack sizes, CRCs). +/// +internal sealed class SevenZipUnPackInfoWriter +{ + public CFolder[] Folders { get; init; } = []; + + public void Write(Stream stream) + { + stream.WriteByte((byte)BlockType.Folder); + + // Number of folders + stream.WriteEncodedUInt64((ulong)Folders.Length); + + // External = 0 (inline) + stream.WriteByte(0); + + // Write each folder's coder definitions + for (var i = 0; i < Folders.Length; i++) + { + WriteFolder(stream, Folders[i]); + } + + // CodersUnPackSize + stream.WriteByte((byte)BlockType.CodersUnpackSize); + for (var i = 0; i < Folders.Length; i++) + { + for (var j = 0; j < Folders[i]._unpackSizes.Count; j++) + { + stream.WriteEncodedUInt64((ulong)Folders[i]._unpackSizes[j]); + } + } + + // UnPackDigests (CRCs per folder) + var hasCrc = false; + for (var i = 0; i < Folders.Length; i++) + { + if (Folders[i]._unpackCrc != null) + { + hasCrc = true; + break; + } + } + + if (hasCrc) + { + stream.WriteByte((byte)BlockType.Crc); + var crcs = new uint?[Folders.Length]; + for (var i = 0; i < Folders.Length; i++) + { + crcs[i] = Folders[i]._unpackCrc; + } + new SevenZipDigestsWriter(crcs).Write(stream); + } + + stream.WriteByte((byte)BlockType.End); + } + + private static void WriteFolder(Stream stream, CFolder folder) + { + // NumCoders + stream.WriteEncodedUInt64((ulong)folder._coders.Count); + + for (var i = 0; i < folder._coders.Count; i++) + { + WriteCoder(stream, folder._coders[i]); + } + + // BindPairs + for (var i = 0; i < folder._bindPairs.Count; i++) + { + stream.WriteEncodedUInt64((ulong)folder._bindPairs[i]._inIndex); + stream.WriteEncodedUInt64((ulong)folder._bindPairs[i]._outIndex); + } + + // PackedIndices (only if > 1 packed stream) + var numPackStreams = folder._packStreams.Count; + if (numPackStreams > 1) + { + for (var i = 0; i < numPackStreams; i++) + { + stream.WriteEncodedUInt64((ulong)folder._packStreams[i]); + } + } + } + + private static void WriteCoder(Stream stream, CCoderInfo coder) + { + var codecIdLength = coder._methodId.GetLength(); + byte attributes = (byte)(codecIdLength & 0x0F); + + var isComplex = coder._numInStreams != 1 || coder._numOutStreams != 1; + if (isComplex) + { + attributes |= 0x10; + } + + var hasProperties = coder._props != null && coder._props.Length > 0; + if (hasProperties) + { + attributes |= 0x20; + } + + stream.WriteByte(attributes); + + // Codec ID bytes (big-endian, most significant byte first) + var codecId = new byte[codecIdLength]; + var id = coder._methodId._id; + for (var i = codecIdLength - 1; i >= 0; i--) + { + codecId[i] = (byte)(id & 0xFF); + id >>= 8; + } + stream.Write(codecId, 0, codecIdLength); + + if (isComplex) + { + stream.WriteEncodedUInt64((ulong)coder._numInStreams); + stream.WriteEncodedUInt64((ulong)coder._numOutStreams); + } + + if (hasProperties) + { + stream.WriteEncodedUInt64((ulong)coder._props!.Length); + stream.Write(coder._props); + } + } +} + +/// +/// Writes SubStreamsInfo section: per-file unpack sizes and CRCs within folders. +/// +internal sealed class SevenZipSubStreamsInfoWriter +{ + public CFolder[] Folders { get; init; } = []; + public ulong[] NumUnPackStreamsInFolders { get; init; } = []; + public ulong[] UnPackSizes { get; init; } = []; + public uint?[] CRCs { get; init; } = []; + + public void Write(Stream stream) + { + var numFolders = (ulong)Folders.Length; + + // NumUnPackStream per folder (skip if all folders have exactly 1 stream) + var totalStreams = 0UL; + var allSingle = true; + for (var i = 0; i < NumUnPackStreamsInFolders.Length; i++) + { + totalStreams += NumUnPackStreamsInFolders[i]; + if (NumUnPackStreamsInFolders[i] != 1) + { + allSingle = false; + } + } + + if (!allSingle) + { + stream.WriteByte((byte)BlockType.NumUnpackStream); + for (var i = 0; i < NumUnPackStreamsInFolders.Length; i++) + { + stream.WriteEncodedUInt64(NumUnPackStreamsInFolders[i]); + } + } + + // UnPackSizes - write all except the last per folder (it's implicit from folder unpack size) + if (UnPackSizes.Length > 0) + { + stream.WriteByte((byte)BlockType.Size); + + var sizeIndex = 0; + for (var i = 0; i < NumUnPackStreamsInFolders.Length; i++) + { + var numStreams = NumUnPackStreamsInFolders[i]; + for (var j = 1UL; j < numStreams; j++) + { + stream.WriteEncodedUInt64(UnPackSizes[sizeIndex++]); + } + sizeIndex++; // skip the last (implicit) + } + } + + // Digests for streams with unknown CRCs + var digests = new SevenZipDigestsWriter(CRCs); + if (digests.HasAnyDefined()) + { + stream.WriteByte((byte)BlockType.Crc); + digests.Write(stream); + } + + stream.WriteByte((byte)BlockType.End); + } +} + +/// +/// Writes the complete StreamsInfo section (PackInfo + UnPackInfo + SubStreamsInfo). +/// +internal sealed class SevenZipStreamsInfoWriter +{ + public SevenZipPackInfoWriter? PackInfo { get; init; } + public SevenZipUnPackInfoWriter? UnPackInfo { get; init; } + public SevenZipSubStreamsInfoWriter? SubStreamsInfo { get; init; } + + public void Write(Stream stream) + { + if (PackInfo != null) + { + stream.WriteByte((byte)BlockType.PackInfo); + PackInfo.Write(stream); + } + + if (UnPackInfo != null) + { + stream.WriteByte((byte)BlockType.UnpackInfo); + UnPackInfo.Write(stream); + } + + if (SubStreamsInfo != null) + { + stream.WriteByte((byte)BlockType.SubStreamsInfo); + SubStreamsInfo.Write(stream); + } + + stream.WriteByte((byte)BlockType.End); + } +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipSignatureHeader.cs b/src/SharpCompress/Common/SevenZip/SevenZipSignatureHeader.cs new file mode 100644 index 00000000..0a9b98a0 --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/SevenZipSignatureHeader.cs @@ -0,0 +1,87 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using SharpCompress.Crypto; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Handles writing the 7z signature header (32 bytes at position 0 of the archive). +/// Layout: [6 bytes magic] [2 bytes version] [4 bytes StartHeaderCRC] [20 bytes StartHeader] +/// +internal static class SevenZipSignatureHeaderWriter +{ + /// + /// 7z file magic signature bytes. + /// + private static readonly byte[] Signature = [(byte)'7', (byte)'z', 0xBC, 0xAF, 0x27, 0x1C]; + + /// + /// Total size of the signature header in bytes (6+2+4+8+8+4 = 32). + /// + public const int HeaderSize = 32; + + /// + /// Writes a placeholder signature header (all zeros for CRC/offset fields). + /// Call this at the start of archive creation to reserve space. + /// + public static void WritePlaceholder(Stream stream) + { + var header = new byte[HeaderSize]; + + // magic signature + Array.Copy(Signature, 0, header, 0, Signature.Length); + + // version: major=0, minor=2 (standard 7z format) + header[6] = 0; + header[7] = 2; + + // remaining 24 bytes are zero (placeholder for CRC and StartHeader) + stream.Write(header, 0, header.Length); + } + + /// + /// Writes the final signature header with correct offsets and CRCs. + /// The stream must be seekable; this method seeks to position 0. + /// + /// The archive output stream (seekable). + /// Offset from end of signature header to start of metadata header. + /// Size of the metadata header in bytes. + /// CRC32 of the metadata header bytes. + public static void WriteFinal( + Stream stream, + ulong nextHeaderOffset, + ulong nextHeaderSize, + uint nextHeaderCrc + ) + { + // Build StartHeader (20 bytes): NextHeaderOffset(8) + NextHeaderSize(8) + NextHeaderCRC(4) + var startHeader = new byte[20]; + BinaryPrimitives.WriteUInt64LittleEndian(startHeader.AsSpan(0, 8), nextHeaderOffset); + BinaryPrimitives.WriteUInt64LittleEndian(startHeader.AsSpan(8, 8), nextHeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(startHeader.AsSpan(16, 4), nextHeaderCrc); + + // CRC32 of StartHeader + var startHeaderCrc = Crc32Stream.Compute(Crc32Stream.DEFAULT_POLYNOMIAL, Crc32Stream.DEFAULT_SEED, startHeader); + + // Assemble full 32-byte header + var header = new byte[HeaderSize]; + + // magic signature + Array.Copy(Signature, 0, header, 0, Signature.Length); + + // version + header[6] = 0; + header[7] = 2; + + // StartHeaderCRC + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(8, 4), startHeaderCrc); + + // StartHeader + Array.Copy(startHeader, 0, header, 12, startHeader.Length); + + // Write at position 0 + stream.Position = 0; + stream.Write(header, 0, header.Length); + } +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs b/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs new file mode 100644 index 00000000..44241c24 --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Crypto; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Result of compressing a stream - contains folder metadata, compressed sizes, and CRCs. +/// +internal sealed class PackedStream +{ + public CFolder Folder { get; init; } = new(); + public ulong[] Sizes { get; init; } = []; + public uint?[] CRCs { get; init; } = []; +} + +/// +/// Compresses a single input stream using LZMA or LZMA2, writing compressed output +/// to the archive stream. Builds the CFolder metadata describing the compression. +/// Uses SharpCompress's existing LzmaStream encoder. +/// +internal sealed class SevenZipStreamsCompressor(Stream outputStream) +{ + /// + /// Compresses the input stream to the output stream using the specified method. + /// Returns a PackedStream containing folder metadata, compressed size, and CRCs. + /// + /// Uncompressed data to compress. + /// True for LZMA2, false for LZMA. + /// LZMA encoder properties (null for defaults). + public PackedStream Compress( + Stream inputStream, + bool isLzma2, + LzmaEncoderProperties? encoderProperties = null + ) + { + encoderProperties ??= new LzmaEncoderProperties(eos: true); + + var outStartOffset = outputStream.Position; + var inStartOffset = inputStream.CanSeek ? inputStream.Position : 0L; + + // Wrap the output stream in CRC calculator + using var outCrcStream = new Crc32Stream(outputStream); + + // Create LZMA encoder writing to CRC-wrapped output + using var lzmaStream = LzmaStream.Create(encoderProperties, isLzma2, outCrcStream); + var properties = lzmaStream.Properties; + + // Copy input through the LZMA encoder while computing input CRC + uint inputCrc; + long inputSize; + CopyWithCrc(inputStream, lzmaStream, out inputCrc, out inputSize); + + // Flush/finalize the LZMA encoder (writes remaining compressed data) + lzmaStream.Dispose(); + + var compressedSize = (ulong)(outputStream.Position - outStartOffset); + var uncompressedSize = (ulong)inputSize; + var outputCrc = outCrcStream.Crc; + + // Build method ID + var methodId = isLzma2 ? CMethodId.K_LZMA2 : CMethodId.K_LZMA; + + // Build folder metadata + var folder = new CFolder(); + folder._coders.Add( + new CCoderInfo + { + _methodId = methodId, + _numInStreams = 1, + _numOutStreams = 1, + _props = properties, + } + ); + folder._packStreams.Add(0); + folder._unpackSizes.Add((long)uncompressedSize); + folder._unpackCrc = inputCrc; + + return new PackedStream + { + Folder = folder, + Sizes = [compressedSize], + CRCs = [outputCrc], + }; + } + + /// + /// Copies data from source to destination while computing CRC32 of the source data. + /// + private static void CopyWithCrc(Stream source, Stream destination, out uint crc, out long bytesRead) + { + var crcValue = Crc32Stream.DEFAULT_SEED; + var table = InitCrcTable(); + var buffer = new byte[81920]; + long totalRead = 0; + + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + // Update CRC + for (var i = 0; i < read; i++) + { + crcValue = (crcValue >> 8) ^ table[(crcValue ^ buffer[i]) & 0xFF]; + } + + destination.Write(buffer, 0, read); + totalRead += read; + } + + crc = ~crcValue; + bytesRead = totalRead; + } + + private static uint[] InitCrcTable() + { + var table = new uint[256]; + for (var i = 0; i < 256; i++) + { + var entry = (uint)i; + for (var j = 0; j < 8; j++) + { + entry = (entry & 1) == 1 ? (entry >> 1) ^ Crc32Stream.DEFAULT_POLYNOMIAL : entry >> 1; + } + table[i] = entry; + } + return table; + } +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipWriteExtensions.cs b/src/SharpCompress/Common/SevenZip/SevenZipWriteExtensions.cs new file mode 100644 index 00000000..26b8692c --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/SevenZipWriteExtensions.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Stream extension methods for writing 7z binary format primitives. +/// Mirrors the read-side encoding in DataReader.ReadNumber() and the reference +/// StreamExtensions (ReadDecodedUInt64/WriteEncodedUInt64/WriteBoolVector). +/// +internal static class SevenZipWriteExtensions +{ + /// + /// Writes a variable-length encoded 64-bit unsigned integer to the stream. + /// Uses the 7z VLQ format: the first byte has leading 1-bits indicating how many + /// extra bytes follow, with remaining bits holding the high part of the value. + /// + public static int WriteEncodedUInt64(this Stream stream, ulong value) + { + var data = new byte[9]; + data[0] = 0xFF; + byte mask = 0x80; + var length = 1; + + for (var i = 0; i < 8; i++) + { + if (value < mask) + { + var headerMask = (byte)((0xFF ^ mask) ^ (mask - 1u)); + data[0] = (byte)(value | headerMask); + break; + } + + data[length++] = (byte)(value & 0xFF); + value >>= 8; + mask >>= 1; + } + + stream.Write(data, 0, length); + return length; + } + + /// + /// Writes a boolean vector as a packed bitmask. + /// Each bool becomes one bit, MSB first, padded to byte boundary. + /// + public static ulong WriteBoolVector(this Stream stream, bool[] vector) + { + byte mask = 0x80; + byte b = 0; + ulong bytesWritten = 0; + + for (var i = 0L; i < vector.LongLength; i++) + { + if (vector[i]) + { + b |= mask; + } + + mask >>= 1; + if (mask == 0) + { + stream.WriteByte(b); + bytesWritten++; + mask = 0x80; + b = 0; + } + } + + if (mask != 0x80) + { + stream.WriteByte(b); + bytesWritten++; + } + + return bytesWritten; + } + + /// + /// Writes an optional bool vector. If all elements are true, writes a single 0x01 byte + /// (AllAreDefined marker). Otherwise writes 0x00 followed by the packed bitmask. + /// + public static void WriteOptionalBoolVector(this Stream stream, bool[] vector) + { + for (var i = 0L; i < vector.LongLength; i++) + { + if (!vector[i]) + { + stream.WriteByte(0); + stream.WriteBoolVector(vector); + return; + } + } + + stream.WriteByte(1); + } +} diff --git a/src/SharpCompress/Factories/SevenZipFactory.cs b/src/SharpCompress/Factories/SevenZipFactory.cs index 93826468..01a6eb15 100644 --- a/src/SharpCompress/Factories/SevenZipFactory.cs +++ b/src/SharpCompress/Factories/SevenZipFactory.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -5,15 +6,18 @@ using System.Threading.Tasks; using SharpCompress.Archives; using SharpCompress.Archives.SevenZip; using SharpCompress.Common; +using SharpCompress.Common.Options; using SharpCompress.IO; using SharpCompress.Readers; +using SharpCompress.Writers; +using SharpCompress.Writers.SevenZip; namespace SharpCompress.Factories; /// /// Represents the foundation factory of 7Zip archive. /// -public class SevenZipFactory : Factory, IArchiveFactory, IMultiArchiveFactory +public class SevenZipFactory : Factory, IArchiveFactory, IMultiArchiveFactory, IWriterFactory { #region IFactory @@ -117,4 +121,30 @@ public class SevenZipFactory : Factory, IArchiveFactory, IMultiArchiveFactory } #endregion + + #region IWriterFactory + + /// + public IWriter OpenWriter(Stream stream, IWriterOptions writerOptions) + { + SevenZipWriterOptions sevenZipOptions = writerOptions switch + { + SevenZipWriterOptions szo => szo, + WriterOptions wo => new SevenZipWriterOptions(wo), + _ => throw new ArgumentException( + $"Expected WriterOptions or SevenZipWriterOptions, got {writerOptions.GetType().Name}", + nameof(writerOptions) + ), + }; + return new SevenZipWriter(stream, sevenZipOptions); + } + + /// + public IAsyncWriter OpenAsyncWriter( + Stream stream, + IWriterOptions writerOptions, + CancellationToken cancellationToken = default + ) => (IAsyncWriter)OpenWriter(stream, writerOptions); + + #endregion } diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs new file mode 100644 index 00000000..6fd59d87 --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Writers.SevenZip; + +public partial class SevenZipWriter +{ + /// + /// Asynchronously writes a file entry to the 7z archive. + /// Note: LZMA compression itself is synchronous; async is used for stream copying. + /// + public override ValueTask WriteAsync( + string filename, + Stream source, + DateTime? modificationTime, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + Write(filename, source, modificationTime); + return new ValueTask(); + } + + /// + /// Asynchronously writes a directory entry to the 7z archive. + /// + public override ValueTask WriteDirectoryAsync( + string directoryName, + DateTime? modificationTime, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + WriteDirectory(directoryName, modificationTime); + return new ValueTask(); + } +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Factory.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Factory.cs new file mode 100644 index 00000000..89e80414 --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Factory.cs @@ -0,0 +1,62 @@ +#if NET8_0_OR_GREATER +using System.IO; + +namespace SharpCompress.Writers.SevenZip; + +public partial class SevenZipWriter : IWriterOpenable +{ + /// + /// Opens a new SevenZipWriter for the specified file path. + /// + public static IWriter OpenWriter(string filePath, SevenZipWriterOptions writerOptions) + { + filePath.NotNullOrEmpty(nameof(filePath)); + return OpenWriter(new FileInfo(filePath), writerOptions); + } + + /// + /// Opens a new SevenZipWriter for the specified file. + /// + public static IWriter OpenWriter(FileInfo fileInfo, SevenZipWriterOptions writerOptions) + { + fileInfo.NotNull(nameof(fileInfo)); + return new SevenZipWriter(fileInfo.OpenWrite(), writerOptions); + } + + /// + /// Opens a new SevenZipWriter for the specified stream. + /// + public static IWriter OpenWriter(Stream stream, SevenZipWriterOptions writerOptions) + { + stream.NotNull(nameof(stream)); + return new SevenZipWriter(stream, writerOptions); + } + + /// + /// Opens a new async SevenZipWriter for the specified file path. + /// + public static IAsyncWriter OpenAsyncWriter(string filePath, SevenZipWriterOptions writerOptions) + { + return (IAsyncWriter)OpenWriter(filePath, writerOptions); + } + + /// + /// Opens a new async SevenZipWriter for the specified stream. + /// + public static IAsyncWriter OpenAsyncWriter(Stream stream, SevenZipWriterOptions writerOptions) + { + return (IAsyncWriter)OpenWriter(stream, writerOptions); + } + + /// + /// Opens a new async SevenZipWriter for the specified file. + /// + public static IAsyncWriter OpenAsyncWriter( + FileInfo fileInfo, + SevenZipWriterOptions writerOptions + ) + { + return (IAsyncWriter)OpenWriter(fileInfo, writerOptions); + } +} +#endif diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs new file mode 100644 index 00000000..ff92d853 --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using System.IO; +using SharpCompress.Common; +using SharpCompress.Common.SevenZip; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Crypto; +using SharpCompress.IO; + +namespace SharpCompress.Writers.SevenZip; + +/// +/// Writes 7z archives in non-solid mode (each file compressed independently). +/// Requires a seekable output stream for back-patching the signature header. +/// TODO: solid mode support in a future iteration. +/// TODO: IWritableArchive support in a future iteration. +/// +public partial class SevenZipWriter : AbstractWriter +{ + private readonly SevenZipWriterOptions sevenZipOptions; + private readonly List entries = []; + private readonly List packedStreams = []; + private bool finalized; + + /// + /// Creates a new SevenZipWriter writing to the specified stream. + /// + /// Seekable output stream. + /// Writer options. + public SevenZipWriter(Stream destination, SevenZipWriterOptions options) + : base(ArchiveType.SevenZip, options) + { + if (!destination.CanSeek) + { + throw new ArchiveOperationException("7z writing requires a seekable stream for header back-patching."); + } + + sevenZipOptions = options; + + if (options.LeaveStreamOpen) + { + destination = SharpCompressStream.CreateNonDisposing(destination); + } + + InitializeStream(destination); + + // Write placeholder signature header (32 bytes) - will be back-patched on finalize + SevenZipSignatureHeaderWriter.WritePlaceholder(OutputStream.NotNull()); + } + + /// + /// Writes a file entry to the archive. + /// + public override void Write(string filename, Stream source, DateTime? modificationTime) + { + filename = NormalizeFilename(filename); + var progressStream = WrapWithProgress(source, filename); + + var isEmpty = source.CanSeek && source.Length == 0; + + if (isEmpty) + { + // Empty file - no compression, just record metadata + entries.Add( + new SevenZipWriteEntry + { + Name = filename, + ModificationTime = modificationTime, + IsDirectory = false, + IsEmpty = true, + } + ); + return; + } + + // Compress file data to output stream + // TODO: LZMA2 encoding is not yet implemented in SharpCompress's LzmaStream + if (sevenZipOptions.IsLzma2) + { + throw new ArchiveOperationException( + "LZMA2 encoding is not yet implemented. Use LZMA (IsLzma2 = false) instead." + ); + } + + var compressor = new SevenZipStreamsCompressor(OutputStream.NotNull()); + var packed = compressor.Compress( + progressStream, + isLzma2: false, + sevenZipOptions.LzmaProperties + ); + packedStreams.Add(packed); + + entries.Add( + new SevenZipWriteEntry + { + Name = filename, + ModificationTime = modificationTime, + IsDirectory = false, + IsEmpty = false, + } + ); + } + + /// + /// Writes a directory entry to the archive. + /// + public override void WriteDirectory(string directoryName, DateTime? modificationTime) + { + directoryName = NormalizeFilename(directoryName); + if (!directoryName.EndsWith('/')) + { + directoryName += '/'; + } + + entries.Add( + new SevenZipWriteEntry + { + Name = directoryName, + ModificationTime = modificationTime, + IsDirectory = true, + IsEmpty = true, + Attributes = 0x10, // FILE_ATTRIBUTE_DIRECTORY + } + ); + } + + /// + /// Finalizes the archive - writes metadata headers and back-patches the signature header. + /// + protected override void Dispose(bool isDisposing) + { + if (isDisposing && !finalized) + { + finalized = true; + FinalizeArchive(); + } + base.Dispose(isDisposing); + } + + private void FinalizeArchive() + { + var output = OutputStream.NotNull(); + + // Current position = end of packed data streams + var endOfPackedData = output.Position; + + // Build the header structures + var mainStreamsInfo = BuildStreamsInfo(); + var filesInfo = new SevenZipFilesInfoWriter { Entries = entries.ToArray() }; + + // Write header to a temporary stream first + using var headerStream = new MemoryStream(); + ArchiveHeaderWriter.WriteRawHeader(headerStream, mainStreamsInfo, filesInfo); + + // Optionally compress the header + if (sevenZipOptions.CompressHeader && headerStream.Length > 0) + { + WriteCompressedHeader(headerStream, endOfPackedData); + } + else + { + WriteRawHeaderToOutput(headerStream, endOfPackedData); + } + } + + private void WriteCompressedHeader(MemoryStream rawHeaderStream, long endOfPackedData) + { + var output = OutputStream.NotNull(); + + // Compress header using LZMA (always LZMA, not LZMA2, matching 7-Zip standard behavior) + rawHeaderStream.Position = 0; + var headerCompressor = new SevenZipStreamsCompressor(output); + var headerPacked = headerCompressor.Compress( + rawHeaderStream, + isLzma2: false, + sevenZipOptions.LzmaProperties + ); + + // Build EncodedHeader StreamsInfo (describes how to decompress the header) + var headerPackPos = (ulong)(endOfPackedData - SevenZipSignatureHeaderWriter.HeaderSize); + var headerStreamsInfo = new SevenZipStreamsInfoWriter + { + PackInfo = new SevenZipPackInfoWriter + { + PackPos = headerPackPos, + Sizes = headerPacked.Sizes, + CRCs = headerPacked.CRCs, + }, + UnPackInfo = new SevenZipUnPackInfoWriter { Folders = [headerPacked.Folder] }, + }; + + // Write encoded header to a second temporary stream + using var encodedHeaderStream = new MemoryStream(); + ArchiveHeaderWriter.WriteEncodedHeader(encodedHeaderStream, headerStreamsInfo); + + // Write the encoded header to the output + var headerStartPos = output.Position; + encodedHeaderStream.Position = 0; + encodedHeaderStream.CopyTo(output); + + // Compute CRC of the encoded header + var headerCrc = Crc32Stream.Compute( + Crc32Stream.DEFAULT_POLYNOMIAL, + Crc32Stream.DEFAULT_SEED, + encodedHeaderStream.GetBuffer().AsSpan(0, (int)encodedHeaderStream.Length) + ); + + // Back-patch signature header + var nextHeaderOffset = (ulong)(headerStartPos - SevenZipSignatureHeaderWriter.HeaderSize); + var nextHeaderSize = (ulong)encodedHeaderStream.Length; + + SevenZipSignatureHeaderWriter.WriteFinal( + output, + nextHeaderOffset, + nextHeaderSize, + headerCrc + ); + + // Seek to end + output.Seek(0, SeekOrigin.End); + } + + private void WriteRawHeaderToOutput(MemoryStream rawHeaderStream, long endOfPackedData) + { + var output = OutputStream.NotNull(); + + // Write raw header directly + var headerStartPos = output.Position; + rawHeaderStream.Position = 0; + rawHeaderStream.CopyTo(output); + + // Compute CRC of the raw header + var headerCrc = Crc32Stream.Compute( + Crc32Stream.DEFAULT_POLYNOMIAL, + Crc32Stream.DEFAULT_SEED, + rawHeaderStream.GetBuffer().AsSpan(0, (int)rawHeaderStream.Length) + ); + + // Back-patch signature header + var nextHeaderOffset = (ulong)(headerStartPos - SevenZipSignatureHeaderWriter.HeaderSize); + var nextHeaderSize = (ulong)rawHeaderStream.Length; + + SevenZipSignatureHeaderWriter.WriteFinal( + output, + nextHeaderOffset, + nextHeaderSize, + headerCrc + ); + + // Seek to end + output.Seek(0, SeekOrigin.End); + } + + private SevenZipStreamsInfoWriter? BuildStreamsInfo() + { + if (packedStreams.Count == 0) + { + return null; + } + + // Collect all packed sizes and CRCs across all folders + var totalPackStreams = 0; + for (var i = 0; i < packedStreams.Count; i++) + { + totalPackStreams += packedStreams[i].Sizes.Length; + } + + var allSizes = new ulong[totalPackStreams]; + var allCRCs = new uint?[totalPackStreams]; + var folders = new CFolder[packedStreams.Count]; + + var sizeIndex = 0; + for (var i = 0; i < packedStreams.Count; i++) + { + var ps = packedStreams[i]; + for (var j = 0; j < ps.Sizes.Length; j++) + { + allSizes[sizeIndex] = ps.Sizes[j]; + allCRCs[sizeIndex] = ps.CRCs[j]; + sizeIndex++; + } + folders[i] = ps.Folder; + } + + // Build per-file unpack sizes and CRCs for SubStreamsInfo + // In non-solid mode, each folder has exactly 1 file + var numUnPackStreamsPerFolder = new ulong[packedStreams.Count]; + var unpackSizes = new ulong[packedStreams.Count]; + var fileCRCs = new uint?[packedStreams.Count]; + + for (var i = 0; i < packedStreams.Count; i++) + { + numUnPackStreamsPerFolder[i] = 1; + unpackSizes[i] = (ulong)packedStreams[i].Folder.GetUnpackSize(); + fileCRCs[i] = packedStreams[i].Folder._unpackCrc; + + // Clear folder-level CRC (it's moved to SubStreamsInfo) + packedStreams[i].Folder._unpackCrc = null; + } + + return new SevenZipStreamsInfoWriter + { + PackInfo = new SevenZipPackInfoWriter + { + PackPos = 0, + Sizes = allSizes, + CRCs = allCRCs, + }, + UnPackInfo = new SevenZipUnPackInfoWriter { Folders = folders }, + SubStreamsInfo = new SevenZipSubStreamsInfoWriter + { + Folders = folders, + NumUnPackStreamsInFolders = numUnPackStreamsPerFolder, + UnPackSizes = unpackSizes, + CRCs = fileCRCs, + }, + }; + } + + /// + /// Normalizes a filename for 7z archive storage. + /// Converts backslashes to forward slashes and removes leading slashes. + /// + private static string NormalizeFilename(string filename) + { + filename = filename.Replace('\\', '/'); + + // Remove drive letter prefix (e.g., "C:/") + if (filename.Length >= 3 && filename[1] == ':' && filename[2] == '/') + { + filename = filename.Substring(3); + } + + // Remove leading slashes + filename = filename.TrimStart('/'); + + return filename; + } +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs new file mode 100644 index 00000000..65202d20 --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs @@ -0,0 +1,123 @@ +using System; +using SharpCompress.Common; +using SharpCompress.Common.Options; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Providers; + +namespace SharpCompress.Writers.SevenZip; + +/// +/// Options for configuring 7z writer behavior. +/// +public sealed record SevenZipWriterOptions : IWriterOptions +{ + private CompressionType _compressionType; + private int _compressionLevel; + + /// + /// The compression type to use. Supported: LZMA (default), LZMA2 (via CompressionType.LZMA with IsLzma2=true). + /// + public CompressionType CompressionType + { + get => _compressionType; + init => _compressionType = value; + } + + /// + /// Compression level (not used for LZMA in this implementation; reserved for future use). + /// + public int CompressionLevel + { + get => _compressionLevel; + init => _compressionLevel = value; + } + + /// + /// SharpCompress will keep the supplied streams open. Default is true. + /// + public bool LeaveStreamOpen { get; init; } = true; + + /// + /// Encoding to use for archive entry names. + /// + public IArchiveEncoding ArchiveEncoding { get; init; } = new ArchiveEncoding(); + + /// + /// An optional progress reporter for tracking compression operations. + /// + public IProgress? Progress { get; init; } + + /// + /// Registry of compression providers. + /// Defaults to but can be replaced with custom implementations. + /// + public CompressionProviderRegistry Providers { get; init; } = + CompressionProviderRegistry.Default; + + /// + /// Whether to use LZMA2 instead of LZMA. Default is false (LZMA). + /// + public bool IsLzma2 { get; init; } + + /// + /// Whether to compress the archive header itself using LZMA. + /// Default is true, matching standard 7-Zip behavior. + /// + public bool CompressHeader { get; init; } = true; + + /// + /// Custom LZMA encoder properties. Null uses defaults (1MB dictionary, 32 fast bytes). + /// + public LzmaEncoderProperties? LzmaProperties { get; init; } + + /// + /// Creates a new SevenZipWriterOptions instance with LZMA compression. + /// + public SevenZipWriterOptions() + { + CompressionType = CompressionType.LZMA; + } + + /// + /// Creates a new SevenZipWriterOptions instance with the specified compression type. + /// + /// The compression type for the archive. + public SevenZipWriterOptions(CompressionType compressionType) + { + CompressionType = compressionType; + } + + /// + /// Creates a new SevenZipWriterOptions instance from an existing WriterOptions instance. + /// + /// The WriterOptions to copy values from. + public SevenZipWriterOptions(WriterOptions options) + { + CompressionType = options.CompressionType; + CompressionLevel = options.CompressionLevel; + LeaveStreamOpen = options.LeaveStreamOpen; + ArchiveEncoding = options.ArchiveEncoding; + Progress = options.Progress; + Providers = options.Providers; + } + + /// + /// Creates a new SevenZipWriterOptions from an existing IWriterOptions instance. + /// + /// The IWriterOptions to copy values from. + public SevenZipWriterOptions(IWriterOptions options) + { + CompressionType = options.CompressionType; + CompressionLevel = options.CompressionLevel; + LeaveStreamOpen = options.LeaveStreamOpen; + ArchiveEncoding = options.ArchiveEncoding; + Progress = options.Progress; + Providers = options.Providers; + } + + /// + /// Implicit conversion from CompressionType to SevenZipWriterOptions. + /// + public static implicit operator SevenZipWriterOptions(CompressionType compressionType) => + new(compressionType); +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs new file mode 100644 index 00000000..5e74bb61 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs @@ -0,0 +1,296 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.SevenZip; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipWriterTests : TestBase +{ + [Fact] + public void SevenZipWriter_SingleFile_RoundTrip() + { + var content = "Hello, 7z world! This is a test of the SevenZipWriter."u8.ToArray(); + + using var archiveStream = new MemoryStream(); + + // Write archive + using (var writer = new SevenZipWriter(archiveStream, new SevenZipWriterOptions())) + { + using var source = new MemoryStream(content); + writer.Write("test.txt", source, DateTime.UtcNow); + } + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Single(entries); + Assert.Equal("test.txt", entries[0].Key); + Assert.Equal(content.Length, (int)entries[0].Size); + + using var output = new MemoryStream(); + using (var entryStream = entries[0].OpenEntryStream()) + { + entryStream.CopyTo(output); + } + Assert.Equal(content, output.ToArray()); + } + } + + [Fact] + public void SevenZipWriter_MultipleFiles_RoundTrip() + { + var files = new[] + { + ("file1.txt", "Content of file 1"), + ("subdir/file2.txt", "Content of file 2 in subdirectory"), + ("file3.bin", "Some binary-ish content with special bytes"), + }; + + using var archiveStream = new MemoryStream(); + + // Write archive + using (var writer = new SevenZipWriter(archiveStream, new SevenZipWriterOptions())) + { + foreach (var (name, text) in files) + { + using var source = new MemoryStream(Encoding.UTF8.GetBytes(text)); + writer.Write(name, source, DateTime.UtcNow); + } + } + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Equal(files.Length, entries.Count); + + for (var i = 0; i < files.Length; i++) + { + var entry = entries.First(e => e.Key == files[i].Item1); + using var output = new MemoryStream(); + using (var entryStream = entry.OpenEntryStream()) + { + entryStream.CopyTo(output); + } + var extractedText = Encoding.UTF8.GetString(output.ToArray()); + Assert.Equal(files[i].Item2, extractedText); + } + } + } + + [Fact] + public void SevenZipWriter_WithDirectory_RoundTrip() + { + using var archiveStream = new MemoryStream(); + + // Write archive with directory and file + using (var writer = new SevenZipWriter(archiveStream, new SevenZipWriterOptions())) + { + writer.WriteDirectory("mydir", DateTime.UtcNow); + + using var source = new MemoryStream("file inside dir"u8.ToArray()); + writer.Write("mydir/data.txt", source, DateTime.UtcNow); + } + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var allEntries = archive.Entries.ToList(); + Assert.Equal(2, allEntries.Count); + + var dirEntry = allEntries.FirstOrDefault(e => e.IsDirectory); + Assert.NotNull(dirEntry); + + var fileEntry = allEntries.FirstOrDefault(e => !e.IsDirectory); + Assert.NotNull(fileEntry); + Assert.Equal("mydir/data.txt", fileEntry!.Key); + + using var output = new MemoryStream(); + using (var entryStream = fileEntry.OpenEntryStream()) + { + entryStream.CopyTo(output); + } + Assert.Equal("file inside dir", Encoding.UTF8.GetString(output.ToArray())); + } + } + + [Fact] + public void SevenZipWriter_EmptyFile_RoundTrip() + { + using var archiveStream = new MemoryStream(); + + // Write archive with an empty file + using (var writer = new SevenZipWriter(archiveStream, new SevenZipWriterOptions())) + { + using var source = new MemoryStream(); + writer.Write("empty.txt", source, DateTime.UtcNow); + + using var source2 = new MemoryStream("not empty"u8.ToArray()); + writer.Write("notempty.txt", source2, DateTime.UtcNow); + } + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Equal(2, entries.Count); + + var emptyEntry = entries.First(e => e.Key == "empty.txt"); + Assert.Equal(0, (int)emptyEntry.Size); + + var nonEmptyEntry = entries.First(e => e.Key == "notempty.txt"); + using var output = new MemoryStream(); + using (var entryStream = nonEmptyEntry.OpenEntryStream()) + { + entryStream.CopyTo(output); + } + Assert.Equal("not empty", Encoding.UTF8.GetString(output.ToArray())); + } + } + + [Fact] + public void SevenZipWriter_LZMA2_ThrowsNotSupported() + { + // LZMA2 encoding is not yet implemented in SharpCompress's LzmaStream + using var archiveStream = new MemoryStream(); + using var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions { IsLzma2 = true } + ); + + using var source = new MemoryStream("test"u8.ToArray()); + Assert.Throws(() => writer.Write("test.txt", source, DateTime.UtcNow)); + } + + [Fact] + public void SevenZipWriter_UncompressedHeader_RoundTrip() + { + var content = "Testing with uncompressed header"u8.ToArray(); + + using var archiveStream = new MemoryStream(); + + // Write archive with uncompressed header + using (var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions { CompressHeader = false } + )) + { + using var source = new MemoryStream(content); + writer.Write("rawheader.txt", source, DateTime.UtcNow); + } + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Single(entries); + + using var output = new MemoryStream(); + using (var entryStream = entries[0].OpenEntryStream()) + { + entryStream.CopyTo(output); + } + Assert.Equal(content, output.ToArray()); + } + } + + [Fact] + public void SevenZipWriter_ViaWriterFactory() + { + var content = "Factory-created archive"u8.ToArray(); + + using var archiveStream = new MemoryStream(); + + // Write via WriterFactory + using (var writer = WriterFactory.OpenWriter( + archiveStream, + ArchiveType.SevenZip, + new SevenZipWriterOptions() + )) + { + using var source = new MemoryStream(content); + writer.Write("factory.txt", source, DateTime.UtcNow); + } + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Single(entries); + + using var output = new MemoryStream(); + using (var entryStream = entries[0].OpenEntryStream()) + { + entryStream.CopyTo(output); + } + Assert.Equal(content, output.ToArray()); + } + } + + [Fact] + public void SevenZipWriter_LargerFile_RoundTrip() + { + // Create 100KB of repeating pattern data (compresses well) + var content = new byte[100 * 1024]; + var pattern = Encoding.UTF8.GetBytes("This is a repeating pattern for compression testing. "); + for (var i = 0; i < content.Length; i++) + { + content[i] = pattern[i % pattern.Length]; + } + + using var archiveStream = new MemoryStream(); + + // Write archive + using (var writer = new SevenZipWriter(archiveStream, new SevenZipWriterOptions())) + { + using var source = new MemoryStream(content); + writer.Write("large.bin", source, DateTime.UtcNow); + } + + // Verify compressed size is smaller than original + Assert.True(archiveStream.Length < content.Length, "Archive should be smaller than uncompressed data"); + + // Read back and verify + archiveStream.Position = 0; + using (var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream)) + { + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + Assert.Single(entries); + Assert.Equal(content.Length, (int)entries[0].Size); + + using var output = new MemoryStream(); + using (var entryStream = entries[0].OpenEntryStream()) + { + entryStream.CopyTo(output); + } + Assert.Equal(content, output.ToArray()); + } + } + + [Fact] + public void SevenZipWriter_RequiresSeekableStream() + { + var nonSeekable = new NonSeekableStream(); + Assert.Throws( + () => new SevenZipWriter(nonSeekable, new SevenZipWriterOptions()) + ); + } + + private class NonSeekableStream : MemoryStream + { + public override bool CanSeek => false; + } +}