From f3ee50bf917c5e3cd939c3ceb9e1411dd1e643f7 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 9 Mar 2026 11:32:20 +0000 Subject: [PATCH] 7zip writing has more details --- .../SevenZip/SevenZipStreamsCompressor.cs | 327 ++++++++++-------- .../Writers/SevenZip/SevenZipSolidMode.cs | 32 ++ .../Writers/SevenZip/SevenZipSolidOptions.cs | 46 +++ .../Writers/SevenZip/SevenZipWriteContext.cs | 48 +++ .../Writers/SevenZip/SevenZipWriter.Async.cs | 53 +-- .../Writers/SevenZip/SevenZipWriter.cs | 165 +++++---- .../Writers/SevenZip/SevenZipWriterOptions.cs | 13 + .../SevenZip/SevenZipWriterAsyncTests.cs | 42 +++ .../SevenZip/SevenZipWriterTests.cs | 117 +++++++ 9 files changed, 586 insertions(+), 257 deletions(-) create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipSolidMode.cs create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipSolidOptions.cs create mode 100644 src/SharpCompress/Writers/SevenZip/SevenZipWriteContext.cs diff --git a/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs b/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs index cb9dc542..34f29f27 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -9,190 +10,157 @@ using SharpCompress.Crypto; namespace SharpCompress.Common.SevenZip; /// -/// Result of compressing a stream - contains folder metadata, compressed sizes, and CRCs. +/// Result of compressing one 7z folder. /// -internal sealed class PackedStream +internal sealed class PackedFolder { public CFolder Folder { get; init; } = new(); - public ulong[] Sizes { get; init; } = []; - public uint?[] CRCs { get; init; } = []; + public ulong PackSize { get; init; } + public uint? PackCrc { get; init; } + public ulong[] UnPackSizes { get; init; } = []; + public uint?[] FileCrcs { 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. +/// Compresses one or more consecutive files into a single 7z folder. /// -internal sealed class SevenZipStreamsCompressor(Stream outputStream) +internal sealed class SevenZipFolderCompressor : IDisposable { - /// - /// 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. - /// Compression method (LZMA or LZMA2). - /// LZMA encoder properties (null for defaults). - public PackedStream Compress( - Stream inputStream, + private readonly Stream outputStream; + private readonly long outStartOffset; + private readonly Crc32Stream packedCrcStream; + private readonly Stream compressionStream; + private readonly bool isLzma2; + private readonly byte[] properties; + private readonly List unpackSizes = []; + private readonly List fileCrcs = []; + private bool finalized; + + public SevenZipFolderCompressor( + Stream outputStream, CompressionType compressionType, LzmaEncoderProperties? encoderProperties = null ) { - var isLzma2 = compressionType == CompressionType.LZMA2; + if (compressionType != CompressionType.LZMA && compressionType != CompressionType.LZMA2) + { + throw new ArgumentException( + $"SevenZipWriter only supports CompressionType.LZMA and CompressionType.LZMA2. Got: {compressionType}", + nameof(compressionType) + ); + } + + this.outputStream = outputStream; + isLzma2 = compressionType == CompressionType.LZMA2; encoderProperties ??= new LzmaEncoderProperties(eos: !isLzma2); - var outStartOffset = outputStream.Position; - - // Wrap the output stream in CRC calculator - using var outCrcStream = new Crc32Stream(outputStream); - - byte[] properties; + outStartOffset = outputStream.Position; + packedCrcStream = new Crc32Stream(outputStream); if (isLzma2) { - // LZMA2: use Lzma2EncoderStream for chunk-based framing - using var lzma2Stream = new Lzma2EncoderStream( - outCrcStream, + var lzma2Stream = new Lzma2EncoderStream( + packedCrcStream, encoderProperties.DictionarySize, encoderProperties.NumFastBytes ); - - CopyWithCrc(inputStream, lzma2Stream, out var inputCrc2, out var inputSize2); - lzma2Stream.Dispose(); - + compressionStream = lzma2Stream; properties = lzma2Stream.Properties; - - return BuildPackedStream( - isLzma2: true, - properties, - (ulong)(outputStream.Position - outStartOffset), - (ulong)inputSize2, - inputCrc2, - outCrcStream.Crc - ); } - - // LZMA - using var lzmaStream = LzmaStream.Create(encoderProperties, false, outCrcStream); - properties = lzmaStream.Properties; - - CopyWithCrc(inputStream, lzmaStream, out var inputCrc, out var inputSize); - lzmaStream.Dispose(); - - return BuildPackedStream( - isLzma2: false, - properties, - (ulong)(outputStream.Position - outStartOffset), - (ulong)inputSize, - inputCrc, - outCrcStream.Crc - ); + else + { + var lzmaStream = LzmaStream.Create(encoderProperties, false, packedCrcStream); + compressionStream = lzmaStream; + properties = lzmaStream.Properties; + } } - /// - /// Asynchronously 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. - /// Compression method (LZMA or LZMA2). - /// LZMA encoder properties (null for defaults). - /// Cancellation token. - public async ValueTask CompressAsync( + public void Append(Stream inputStream, byte firstByte) + { + ThrowIfFinalized(); + + CopyWithCrc(inputStream, compressionStream, firstByte, out var inputCrc, out var inputSize); + unpackSizes.Add((ulong)inputSize); + fileCrcs.Add(inputCrc); + } + + public async ValueTask AppendAsync( Stream inputStream, - CompressionType compressionType, - LzmaEncoderProperties? encoderProperties = null, + byte firstByte, CancellationToken cancellationToken = default ) { + ThrowIfFinalized(); cancellationToken.ThrowIfCancellationRequested(); - var isLzma2 = compressionType == CompressionType.LZMA2; - encoderProperties ??= new LzmaEncoderProperties(eos: !isLzma2); + var (inputCrc, inputSize) = await CopyWithCrcAsync( + inputStream, + compressionStream, + firstByte, + cancellationToken + ) + .ConfigureAwait(false); + unpackSizes.Add((ulong)inputSize); + fileCrcs.Add(inputCrc); + } - var outStartOffset = outputStream.Position; + public PackedFolder FinalizeFolder() + { + ThrowIfFinalized(); + finalized = true; - // Wrap the output stream in CRC calculator - using var outCrcStream = new Crc32Stream(outputStream); + compressionStream.Dispose(); + packedCrcStream.Dispose(); - byte[] properties; - - if (isLzma2) - { - // LZMA2: use Lzma2EncoderStream for chunk-based framing - uint inputCrc2; - long inputSize2; - { - using var lzma2Stream = new Lzma2EncoderStream( - outCrcStream, - encoderProperties.DictionarySize, - encoderProperties.NumFastBytes - ); - - (inputCrc2, inputSize2) = await CopyWithCrcAsync( - inputStream, - lzma2Stream, - cancellationToken - ) - .ConfigureAwait(false); - - properties = lzma2Stream.Properties; - } - - return BuildPackedStream( - isLzma2: true, - properties, - (ulong)(outputStream.Position - outStartOffset), - (ulong)inputSize2, - inputCrc2, - outCrcStream.Crc - ); - } - - // LZMA - uint inputCrc; - long inputSize; - { - using var lzmaStream = LzmaStream.Create(encoderProperties, false, outCrcStream); - properties = lzmaStream.Properties; - - (inputCrc, inputSize) = await CopyWithCrcAsync( - inputStream, - lzmaStream, - cancellationToken - ) - .ConfigureAwait(false); - } - - return BuildPackedStream( - isLzma2: false, + return BuildPackedFolder( + isLzma2, properties, (ulong)(outputStream.Position - outStartOffset), - (ulong)inputSize, - inputCrc, - outCrcStream.Crc + packedCrcStream.Crc, + unpackSizes.ToArray(), + fileCrcs.ToArray() ); } - /// - /// Copies data from source to destination while computing CRC32 of the source data. - /// Uses Crc32Stream.Compute for CRC calculation to avoid duplicating the table/algorithm. - /// + public void Dispose() + { + if (finalized) + { + return; + } + + finalized = true; + compressionStream.Dispose(); + packedCrcStream.Dispose(); + } + + private void ThrowIfFinalized() + { + if (finalized) + { + throw new ObjectDisposedException(nameof(SevenZipFolderCompressor)); + } + } + private static void CopyWithCrc( Stream source, Stream destination, + byte firstByte, out uint crc, out long bytesRead ) { var seed = Crc32Stream.DEFAULT_SEED; var buffer = new byte[81920]; - long totalRead = 0; + long totalRead = 1; + + buffer[0] = firstByte; + seed = ~Crc32Stream.Compute(Crc32Stream.DEFAULT_POLYNOMIAL, seed, buffer.AsSpan(0, 1)); + destination.Write(buffer, 0, 1); int read; while ((read = source.Read(buffer, 0, buffer.Length)) > 0) { - // Crc32Stream.Compute returns ~CalculateCrc(table, seed, data), - // so passing ~result as next seed chains correctly. seed = ~Crc32Stream.Compute( Crc32Stream.DEFAULT_POLYNOMIAL, seed, @@ -206,19 +174,20 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) bytesRead = totalRead; } - /// - /// Asynchronously copies data from source to destination while computing CRC32 of source data. - /// Uses Crc32Stream.Compute for CRC calculation to avoid duplicating the table/algorithm. - /// private static async ValueTask<(uint crc, long bytesRead)> CopyWithCrcAsync( Stream source, Stream destination, + byte firstByte, CancellationToken cancellationToken ) { var seed = Crc32Stream.DEFAULT_SEED; var buffer = new byte[81920]; - long totalRead = 0; + long totalRead = 1; + + buffer[0] = firstByte; + seed = ~Crc32Stream.Compute(Crc32Stream.DEFAULT_POLYNOMIAL, seed, buffer.AsSpan(0, 1)); + await destination.WriteAsync(buffer, 0, 1, cancellationToken).ConfigureAwait(false); int read; while ( @@ -229,8 +198,6 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) ) > 0 ) { - // Crc32Stream.Compute returns ~CalculateCrc(table, seed, data), - // so passing ~result as next seed chains correctly. seed = ~Crc32Stream.Compute( Crc32Stream.DEFAULT_POLYNOMIAL, seed, @@ -243,16 +210,21 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) return (~seed, totalRead); } - private static PackedStream BuildPackedStream( + private static PackedFolder BuildPackedFolder( bool isLzma2, byte[] properties, ulong compressedSize, - ulong uncompressedSize, - uint inputCrc, - uint? outputCrc + uint? packedCrc, + ulong[] uncompressedSizes, + uint?[] fileCrcs ) { var methodId = isLzma2 ? CMethodId.K_LZMA2 : CMethodId.K_LZMA; + ulong totalUncompressedSize = 0; + for (var i = 0; i < uncompressedSizes.Length; i++) + { + totalUncompressedSize += uncompressedSizes[i]; + } var folder = new CFolder(); folder._coders.Add( @@ -265,14 +237,71 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) } ); folder._packStreams.Add(0); - folder._unpackSizes.Add((long)uncompressedSize); - folder._unpackCrc = inputCrc; + folder._unpackSizes.Add((long)totalUncompressedSize); - return new PackedStream + return new PackedFolder { Folder = folder, - Sizes = [compressedSize], - CRCs = [outputCrc], + PackSize = compressedSize, + PackCrc = packedCrc, + UnPackSizes = uncompressedSizes, + FileCrcs = fileCrcs, }; } } + +/// +/// Compresses input streams using LZMA or LZMA2 and writes the resulting folder data. +/// +internal sealed class SevenZipStreamsCompressor(Stream outputStream) +{ + public PackedFolder Compress( + Stream inputStream, + CompressionType compressionType, + LzmaEncoderProperties? encoderProperties = null + ) + { + var firstByte = inputStream.ReadByte(); + if (firstByte < 0) + { + throw new InvalidOperationException("Cannot compress an empty stream."); + } + + using var folderCompressor = new SevenZipFolderCompressor( + outputStream, + compressionType, + encoderProperties + ); + folderCompressor.Append(inputStream, (byte)firstByte); + return folderCompressor.FinalizeFolder(); + } + + public async ValueTask CompressAsync( + Stream inputStream, + CompressionType compressionType, + LzmaEncoderProperties? encoderProperties = null, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var buffer = new byte[1]; + var bytesRead = await inputStream + .ReadAsync(buffer, 0, 1, cancellationToken) + .ConfigureAwait(false); + if (bytesRead == 0) + { + throw new InvalidOperationException("Cannot compress an empty stream."); + } + + using var folderCompressor = new SevenZipFolderCompressor( + outputStream, + compressionType, + encoderProperties + ); + await folderCompressor + .AppendAsync(inputStream, buffer[0], cancellationToken) + .ConfigureAwait(false); + return folderCompressor.FinalizeFolder(); + } +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipSolidMode.cs b/src/SharpCompress/Writers/SevenZip/SevenZipSolidMode.cs new file mode 100644 index 00000000..51a51abe --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipSolidMode.cs @@ -0,0 +1,32 @@ +namespace SharpCompress.Writers.SevenZip; + +/// +/// Controls how consecutive file writes are grouped into 7z folders. +/// +public enum SevenZipSolidMode +{ + /// + /// Compress each file independently. + /// + None, + + /// + /// Compress all consecutive non-empty files into a single folder. + /// + All, + + /// + /// Group consecutive files by their normalized parent directory. + /// + ByDirectory, + + /// + /// Group consecutive files by file extension. + /// + ByExtension, + + /// + /// Group consecutive files by a custom selector. + /// + Custom, +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipSolidOptions.cs b/src/SharpCompress/Writers/SevenZip/SevenZipSolidOptions.cs new file mode 100644 index 00000000..0bc0b193 --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipSolidOptions.cs @@ -0,0 +1,46 @@ +using System; + +namespace SharpCompress.Writers.SevenZip; + +/// +/// Configures how consecutive file writes are grouped into shared 7z folders. +/// +public sealed record SevenZipSolidOptions +{ + /// + /// Disables solid grouping so each file is compressed independently. + /// + public static SevenZipSolidOptions Disabled { get; } = new(); + + /// + /// Determines how consecutive files are grouped. + /// + public SevenZipSolidMode Mode { get; init; } = SevenZipSolidMode.None; + + /// + /// Resolves a group key when is . + /// Returning writes the file as its own folder. + /// + public Func? GroupKeySelector { get; init; } + + internal void Validate() + { + if (Mode == SevenZipSolidMode.Custom && GroupKeySelector is null) + { + throw new ArgumentException( + "SevenZip solid grouping in Custom mode requires GroupKeySelector." + ); + } + } + + internal string? ResolveGroupKey(SevenZipWriteContext context) => + Mode switch + { + SevenZipSolidMode.None => null, + SevenZipSolidMode.All => string.Empty, + SevenZipSolidMode.ByDirectory => context.DirectoryPath, + SevenZipSolidMode.ByExtension => context.Extension, + SevenZipSolidMode.Custom => GroupKeySelector!(context), + _ => throw new InvalidOperationException($"Unsupported solid mode: {Mode}"), + }; +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriteContext.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriteContext.cs new file mode 100644 index 00000000..4c6c273b --- /dev/null +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriteContext.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace SharpCompress.Writers.SevenZip; + +/// +/// Provides normalized metadata for deciding how a file should be grouped into a 7z folder. +/// +public sealed class SevenZipWriteContext +{ + internal SevenZipWriteContext(string entryPath, DateTime? modificationTime) + { + EntryPath = entryPath; + ModificationTime = modificationTime; + + var fileName = Path.GetFileName(entryPath); + FileName = fileName; + Extension = Path.GetExtension(fileName).ToLowerInvariant(); + + var separatorIndex = entryPath.LastIndexOf('/'); + DirectoryPath = separatorIndex >= 0 ? entryPath.Substring(0, separatorIndex) : string.Empty; + } + + /// + /// Gets the normalized archive entry path. + /// + public string EntryPath { get; } + + /// + /// Gets the normalized parent directory path, or an empty string for root entries. + /// + public string DirectoryPath { get; } + + /// + /// Gets the file name portion of the entry path. + /// + public string FileName { get; } + + /// + /// Gets the lowercase file extension, including the leading period when present. + /// + public string Extension { get; } + + /// + /// Gets the file modification time supplied to the writer. + /// + public DateTime? ModificationTime { get; } +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs index 30f0eeee..05707c3a 100644 --- a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs @@ -29,56 +29,41 @@ public partial class SevenZipWriter cancellationToken.ThrowIfCancellationRequested(); filename = NormalizeFilename(filename); + var context = new SevenZipWriteContext(filename, modificationTime); var progressStream = WrapWithProgress(source, filename); - var isEmpty = source.CanSeek && source.Length == 0; - - if (isEmpty) + var firstByteBuffer = new byte[1]; + var firstByteRead = await progressStream + .ReadAsync(firstByteBuffer, 0, 1, cancellationToken) + .ConfigureAwait(false); + if (firstByteRead == 0) { - entries.Add( - new SevenZipWriteEntry - { - Name = filename, - ModificationTime = modificationTime, - IsDirectory = false, - IsEmpty = true, - } - ); + FinalizeActiveFolder(); + AddEmptyFileEntry(filename, modificationTime); return; } - var output = OutputStream.NotNull(); - var outputPosBefore = output.Position; - var compressor = new SevenZipStreamsCompressor(output); - var packed = await compressor - .CompressAsync( - progressStream, - sevenZipOptions.CompressionType, - sevenZipOptions.LzmaProperties, - cancellationToken - ) + var groupKey = sevenZipOptions.Solid.ResolveGroupKey(context); + EnsureActiveFolder(groupKey); + await activeFolderCompressor + .NotNull() + .AppendAsync(progressStream, firstByteBuffer[0], cancellationToken) .ConfigureAwait(false); - var actuallyEmpty = packed.Folder.GetUnpackSize() == 0; - if (!actuallyEmpty) - { - packedStreams.Add(packed); - } - else - { - output.Position = outputPosBefore; - output.SetLength(outputPosBefore); - } - entries.Add( new SevenZipWriteEntry { Name = filename, ModificationTime = modificationTime, IsDirectory = false, - IsEmpty = isEmpty || actuallyEmpty, + IsEmpty = false, } ); + + if (groupKey is null) + { + FinalizeActiveFolder(); + } } /// diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs index a785aaf2..32f7e72f 100644 --- a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs @@ -10,16 +10,17 @@ using SharpCompress.IO; namespace SharpCompress.Writers.SevenZip; /// -/// Writes 7z archives in non-solid mode (each file compressed independently). +/// Writes 7z archives using standalone or consecutive solid folders. /// 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 readonly List packedFolders = []; + private SevenZipFolderCompressor? activeFolderCompressor; + private string? activeSolidGroupKey; private bool finalized; /// @@ -38,6 +39,7 @@ public partial class SevenZipWriter : AbstractWriter } sevenZipOptions = options; + sevenZipOptions.Solid.Validate(); if (options.LeaveStreamOpen) { @@ -64,48 +66,20 @@ public partial class SevenZipWriter : AbstractWriter } filename = NormalizeFilename(filename); + var context = new SevenZipWriteContext(filename, modificationTime); var progressStream = WrapWithProgress(source, filename); - var isEmpty = source.CanSeek && source.Length == 0; - - if (isEmpty) + var firstByte = progressStream.ReadByte(); + if (firstByte < 0) { - // Empty file - no compression, just record metadata - entries.Add( - new SevenZipWriteEntry - { - Name = filename, - ModificationTime = modificationTime, - IsDirectory = false, - IsEmpty = true, - } - ); + FinalizeActiveFolder(); + AddEmptyFileEntry(filename, modificationTime); return; } - // Compress file data to output stream - var output = OutputStream.NotNull(); - var outputPosBefore = output.Position; - var compressor = new SevenZipStreamsCompressor(output); - var packed = compressor.Compress( - progressStream, - sevenZipOptions.CompressionType, - sevenZipOptions.LzmaProperties - ); - - // Check if the stream was actually empty (handles non-seekable streams with no data) - var actuallyEmpty = packed.Folder.GetUnpackSize() == 0; - if (!actuallyEmpty) - { - packedStreams.Add(packed); - } - else - { - // Rewind output to erase orphaned encoder header/end-marker bytes - // so they don't shift subsequent pack stream offsets - output.Position = outputPosBefore; - output.SetLength(outputPosBefore); - } + var groupKey = sevenZipOptions.Solid.ResolveGroupKey(context); + EnsureActiveFolder(groupKey); + activeFolderCompressor.NotNull().Append(progressStream, (byte)firstByte); entries.Add( new SevenZipWriteEntry @@ -113,9 +87,14 @@ public partial class SevenZipWriter : AbstractWriter Name = filename, ModificationTime = modificationTime, IsDirectory = false, - IsEmpty = isEmpty || actuallyEmpty, + IsEmpty = false, } ); + + if (groupKey is null) + { + FinalizeActiveFolder(); + } } /// @@ -133,6 +112,7 @@ public partial class SevenZipWriter : AbstractWriter directoryName = NormalizeFilename(directoryName); directoryName = directoryName.TrimEnd('/'); + FinalizeActiveFolder(); entries.Add( new SevenZipWriteEntry @@ -161,6 +141,8 @@ public partial class SevenZipWriter : AbstractWriter private void FinalizeArchive() { + FinalizeActiveFolder(); + var output = OutputStream.NotNull(); // Current position = end of packed data streams @@ -205,8 +187,8 @@ public partial class SevenZipWriter : AbstractWriter PackInfo = new SevenZipPackInfoWriter { PackPos = headerPackPos, - Sizes = headerPacked.Sizes, - CRCs = headerPacked.CRCs, + Sizes = [headerPacked.PackSize], + CRCs = [headerPacked.PackCrc], }, UnPackInfo = new SevenZipUnPackInfoWriter { Folders = [headerPacked.Folder] }, }; @@ -275,49 +257,39 @@ public partial class SevenZipWriter : AbstractWriter private SevenZipStreamsInfoWriter? BuildStreamsInfo() { - if (packedStreams.Count == 0) + if (packedFolders.Count == 0) { return null; } - // Collect all packed sizes and CRCs across all folders - var totalPackStreams = 0; - for (var i = 0; i < packedStreams.Count; i++) + var totalUnpackStreams = 0; + for (var i = 0; i < packedFolders.Count; i++) { - totalPackStreams += packedStreams[i].Sizes.Length; + totalUnpackStreams += packedFolders[i].UnPackSizes.Length; } - var allSizes = new ulong[totalPackStreams]; - var allCRCs = new uint?[totalPackStreams]; - var folders = new CFolder[packedStreams.Count]; + var allSizes = new ulong[packedFolders.Count]; + var allCRCs = new uint?[packedFolders.Count]; + var folders = new CFolder[packedFolders.Count]; + var numUnPackStreamsPerFolder = new ulong[packedFolders.Count]; + var unpackSizes = new ulong[totalUnpackStreams]; + var fileCRCs = new uint?[totalUnpackStreams]; - var sizeIndex = 0; - for (var i = 0; i < packedStreams.Count; i++) + var unpackStreamIndex = 0; + for (var i = 0; i < packedFolders.Count; i++) { - var ps = packedStreams[i]; - for (var j = 0; j < ps.Sizes.Length; j++) + var folder = packedFolders[i]; + allSizes[i] = folder.PackSize; + allCRCs[i] = folder.PackCrc; + folders[i] = folder.Folder; + numUnPackStreamsPerFolder[i] = (ulong)folder.UnPackSizes.Length; + + for (var j = 0; j < folder.UnPackSizes.Length; j++) { - allSizes[sizeIndex] = ps.Sizes[j]; - allCRCs[sizeIndex] = ps.CRCs[j]; - sizeIndex++; + unpackSizes[unpackStreamIndex] = folder.UnPackSizes[j]; + fileCRCs[unpackStreamIndex] = folder.FileCrcs[j]; + unpackStreamIndex++; } - 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 @@ -339,6 +311,51 @@ public partial class SevenZipWriter : AbstractWriter }; } + private void EnsureActiveFolder(string? groupKey) + { + if ( + activeFolderCompressor != null + && string.Equals(activeSolidGroupKey, groupKey, StringComparison.Ordinal) + ) + { + return; + } + + FinalizeActiveFolder(); + activeFolderCompressor = new SevenZipFolderCompressor( + OutputStream.NotNull(), + sevenZipOptions.CompressionType, + sevenZipOptions.LzmaProperties + ); + activeSolidGroupKey = groupKey; + } + + private void FinalizeActiveFolder() + { + if (activeFolderCompressor is null) + { + activeSolidGroupKey = null; + return; + } + + packedFolders.Add(activeFolderCompressor.FinalizeFolder()); + activeFolderCompressor = null; + activeSolidGroupKey = null; + } + + private void AddEmptyFileEntry(string filename, DateTime? modificationTime) + { + entries.Add( + new SevenZipWriteEntry + { + Name = filename, + ModificationTime = modificationTime, + IsDirectory = false, + IsEmpty = true, + } + ); + } + /// /// Normalizes a filename for 7z archive storage. /// Converts backslashes to forward slashes and removes leading slashes. diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs index 033f07fd..d5d392fd 100644 --- a/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs @@ -64,6 +64,12 @@ public sealed record SevenZipWriterOptions : IWriterOptions public CompressionProviderRegistry Providers { get; init; } = CompressionProviderRegistry.Default; + /// + /// Controls whether consecutive file writes are grouped into shared 7z folders. + /// Default is disabled so each file is compressed independently. + /// + public SevenZipSolidOptions Solid { get; init; } = SevenZipSolidOptions.Disabled; + /// /// Whether to compress the archive header itself using LZMA. /// Default is true, matching standard 7-Zip behavior. @@ -118,6 +124,13 @@ public sealed record SevenZipWriterOptions : IWriterOptions ArchiveEncoding = options.ArchiveEncoding; Progress = options.Progress; Providers = options.Providers; + + if (options is SevenZipWriterOptions sevenZipOptions) + { + Solid = sevenZipOptions.Solid; + CompressHeader = sevenZipOptions.CompressHeader; + LzmaProperties = sevenZipOptions.LzmaProperties; + } } /// diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipWriterAsyncTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipWriterAsyncTests.cs index e61b77d7..1b1690a3 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipWriterAsyncTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipWriterAsyncTests.cs @@ -142,6 +142,48 @@ public class SevenZipWriterAsyncTests : TestBase Assert.Equal(content, output.ToArray()); } + [Fact] + public async ValueTask SevenZipWriter_Async_SolidAll_GroupsConsecutiveFiles() + { + using var archiveStream = new MemoryStream(); + + await using ( + var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions + { + Solid = new SevenZipSolidOptions { Mode = SevenZipSolidMode.All }, + } + ) + ) + { + await writer.WriteAsync( + "first.txt", + new MemoryStream("first"u8.ToArray()), + DateTime.UtcNow + ); + await writer.WriteAsync( + "second.txt", + new MemoryStream("second"u8.ToArray()), + DateTime.UtcNow + ); + await writer.WriteAsync( + "third.txt", + new MemoryStream("third"u8.ToArray()), + DateTime.UtcNow + ); + } + + archiveStream.Position = 0; + using var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream); + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + Assert.True(archive.IsSolid); + Assert.False(entries[0].IsSolid); + Assert.True(entries[1].IsSolid); + Assert.True(entries[2].IsSolid); + } + [Fact] public async ValueTask SevenZipWriter_Async_Cancelled_Throws() { diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs index 8209db4c..0cf54211 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipWriterTests.cs @@ -402,6 +402,123 @@ public class SevenZipWriterTests : TestBase } } + [Fact] + public void SevenZipWriter_SolidAll_GroupsConsecutiveFiles() + { + var files = new[] + { + ("first.txt", "first"), + ("second.txt", "second"), + ("third.txt", "third"), + }; + + using var archiveStream = new MemoryStream(); + using ( + var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions + { + Solid = new SevenZipSolidOptions { Mode = SevenZipSolidMode.All }, + } + ) + ) + { + foreach (var (name, text) in files) + { + using var source = new MemoryStream(Encoding.UTF8.GetBytes(text)); + writer.Write(name, source, DateTime.UtcNow); + } + } + + archiveStream.Position = 0; + using var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream); + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + Assert.True(archive.IsSolid); + Assert.Equal(files.Length, entries.Count); + Assert.False(entries[0].IsSolid); + Assert.True(entries[1].IsSolid); + Assert.True(entries[2].IsSolid); + } + + [Fact] + public void SevenZipWriter_SolidCustom_OnlyGroupsConsecutiveMatches() + { + var files = new[] + { + ("first.txt", "first"), + ("second.txt", "second"), + ("third.bin", "third"), + ("fourth.txt", "fourth"), + }; + + using var archiveStream = new MemoryStream(); + using ( + var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions + { + Solid = new SevenZipSolidOptions + { + Mode = SevenZipSolidMode.Custom, + GroupKeySelector = context => context.Extension, + }, + } + ) + ) + { + foreach (var (name, text) in files) + { + using var source = new MemoryStream(Encoding.UTF8.GetBytes(text)); + writer.Write(name, source, DateTime.UtcNow); + } + } + + archiveStream.Position = 0; + using var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream); + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + Assert.True(archive.IsSolid); + Assert.False(entries[0].IsSolid); + Assert.True(entries[1].IsSolid); + Assert.False(entries[2].IsSolid); + Assert.False(entries[3].IsSolid); + } + + [Fact] + public void SevenZipWriter_SolidAll_EmptyFileBreaksGrouping() + { + using var archiveStream = new MemoryStream(); + using ( + var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions + { + Solid = new SevenZipSolidOptions { Mode = SevenZipSolidMode.All }, + } + ) + ) + { + using var first = new MemoryStream("first"u8.ToArray()); + writer.Write("first.txt", first, DateTime.UtcNow); + + using var empty = new MemoryStream(); + writer.Write("empty.txt", empty, DateTime.UtcNow); + + using var second = new MemoryStream("second"u8.ToArray()); + writer.Write("second.txt", second, DateTime.UtcNow); + } + + archiveStream.Position = 0; + using var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(archiveStream); + var entries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + Assert.False(archive.IsSolid); + Assert.False(entries[0].IsSolid); + Assert.False(entries[1].IsSolid); + Assert.False(entries[2].IsSolid); + } + [Fact] public void SevenZipWriter_LargerFile_RoundTrip() {