mirror of
https://github.com/adamhathcock/sharpcompress.git
synced 2026-07-08 18:16:30 +00:00
7zip writing has more details
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
32
src/SharpCompress/Writers/SevenZip/SevenZipSolidMode.cs
Normal file
32
src/SharpCompress/Writers/SevenZip/SevenZipSolidMode.cs
Normal 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,
|
||||
}
|
||||
46
src/SharpCompress/Writers/SevenZip/SevenZipSolidOptions.cs
Normal file
46
src/SharpCompress/Writers/SevenZip/SevenZipSolidOptions.cs
Normal 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}"),
|
||||
};
|
||||
}
|
||||
48
src/SharpCompress/Writers/SevenZip/SevenZipWriteContext.cs
Normal file
48
src/SharpCompress/Writers/SevenZip/SevenZipWriteContext.cs
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user