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()
{