7zip writing has more details

This commit is contained in:
Adam Hathcock
2026-03-09 11:32:20 +00:00
parent c6e2221b24
commit f3ee50bf91
9 changed files with 586 additions and 257 deletions

View File

@@ -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;
/// <summary>
/// Result of compressing a stream - contains folder metadata, compressed sizes, and CRCs.
/// Result of compressing one 7z folder.
/// </summary>
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; } = [];
}
/// <summary>
/// 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.
/// </summary>
internal sealed class SevenZipStreamsCompressor(Stream outputStream)
internal sealed class SevenZipFolderCompressor : IDisposable
{
/// <summary>
/// Compresses the input stream to the output stream using the specified method.
/// Returns a PackedStream containing folder metadata, compressed size, and CRCs.
/// </summary>
/// <param name="inputStream">Uncompressed data to compress.</param>
/// <param name="compressionType">Compression method (LZMA or LZMA2).</param>
/// <param name="encoderProperties">LZMA encoder properties (null for defaults).</param>
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<ulong> unpackSizes = [];
private readonly List<uint?> 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;
}
}
/// <summary>
/// Asynchronously compresses the input stream to the output stream using the specified method.
/// Returns a PackedStream containing folder metadata, compressed size, and CRCs.
/// </summary>
/// <param name="inputStream">Uncompressed data to compress.</param>
/// <param name="compressionType">Compression method (LZMA or LZMA2).</param>
/// <param name="encoderProperties">LZMA encoder properties (null for defaults).</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async ValueTask<PackedStream> 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()
);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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,
};
}
}
/// <summary>
/// Compresses input streams using LZMA or LZMA2 and writes the resulting folder data.
/// </summary>
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<PackedFolder> 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();
}
}

View File

@@ -0,0 +1,32 @@
namespace SharpCompress.Writers.SevenZip;
/// <summary>
/// Controls how consecutive file writes are grouped into 7z folders.
/// </summary>
public enum SevenZipSolidMode
{
/// <summary>
/// Compress each file independently.
/// </summary>
None,
/// <summary>
/// Compress all consecutive non-empty files into a single folder.
/// </summary>
All,
/// <summary>
/// Group consecutive files by their normalized parent directory.
/// </summary>
ByDirectory,
/// <summary>
/// Group consecutive files by file extension.
/// </summary>
ByExtension,
/// <summary>
/// Group consecutive files by a custom selector.
/// </summary>
Custom,
}

View File

@@ -0,0 +1,46 @@
using System;
namespace SharpCompress.Writers.SevenZip;
/// <summary>
/// Configures how consecutive file writes are grouped into shared 7z folders.
/// </summary>
public sealed record SevenZipSolidOptions
{
/// <summary>
/// Disables solid grouping so each file is compressed independently.
/// </summary>
public static SevenZipSolidOptions Disabled { get; } = new();
/// <summary>
/// Determines how consecutive files are grouped.
/// </summary>
public SevenZipSolidMode Mode { get; init; } = SevenZipSolidMode.None;
/// <summary>
/// Resolves a group key when <see cref="Mode" /> is <see cref="SevenZipSolidMode.Custom" />.
/// Returning <see langword="null" /> writes the file as its own folder.
/// </summary>
public Func<SevenZipWriteContext, string?>? 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}"),
};
}

View File

@@ -0,0 +1,48 @@
using System;
using System.IO;
namespace SharpCompress.Writers.SevenZip;
/// <summary>
/// Provides normalized metadata for deciding how a file should be grouped into a 7z folder.
/// </summary>
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;
}
/// <summary>
/// Gets the normalized archive entry path.
/// </summary>
public string EntryPath { get; }
/// <summary>
/// Gets the normalized parent directory path, or an empty string for root entries.
/// </summary>
public string DirectoryPath { get; }
/// <summary>
/// Gets the file name portion of the entry path.
/// </summary>
public string FileName { get; }
/// <summary>
/// Gets the lowercase file extension, including the leading period when present.
/// </summary>
public string Extension { get; }
/// <summary>
/// Gets the file modification time supplied to the writer.
/// </summary>
public DateTime? ModificationTime { get; }
}

View File

@@ -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();
}
}
/// <summary>

View File

@@ -10,16 +10,17 @@ using SharpCompress.IO;
namespace SharpCompress.Writers.SevenZip;
/// <summary>
/// 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.
/// </summary>
public partial class SevenZipWriter : AbstractWriter
{
private readonly SevenZipWriterOptions sevenZipOptions;
private readonly List<SevenZipWriteEntry> entries = [];
private readonly List<PackedStream> packedStreams = [];
private readonly List<PackedFolder> packedFolders = [];
private SevenZipFolderCompressor? activeFolderCompressor;
private string? activeSolidGroupKey;
private bool finalized;
/// <summary>
@@ -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();
}
}
/// <summary>
@@ -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,
}
);
}
/// <summary>
/// Normalizes a filename for 7z archive storage.
/// Converts backslashes to forward slashes and removes leading slashes.

View File

@@ -64,6 +64,12 @@ public sealed record SevenZipWriterOptions : IWriterOptions
public CompressionProviderRegistry Providers { get; init; } =
CompressionProviderRegistry.Default;
/// <summary>
/// Controls whether consecutive file writes are grouped into shared 7z folders.
/// Default is disabled so each file is compressed independently.
/// </summary>
public SevenZipSolidOptions Solid { get; init; } = SevenZipSolidOptions.Disabled;
/// <summary>
/// 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;
}
}
/// <summary>

View File

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

View File

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