Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d4380b6
Initial plan
Copilot Nov 27, 2025
c082d42
Changes before error encountered
Copilot Nov 27, 2025
0d487df
Add IProgress support for compression operations with tests
Copilot Nov 27, 2025
0f374b2
Address code review: ProgressReportingStream now throws on writes
Copilot Nov 27, 2025
aa0356d
Changes before error encountered
Copilot Nov 29, 2025
8fc5ca5
Unify progress reporting: remove IExtractionListener and add IProgres…
Copilot Nov 29, 2025
7af029b
Address code review: properly handle zero-sized entries in progress r…
Copilot Nov 29, 2025
e2df789
Remove IArchiveExtractionListener and add IProgress support to Archiv…
Copilot Nov 30, 2025
0fdf9c7
Address code review: Replace dynamic with IArchiveProgressInfo interface
Copilot Nov 30, 2025
14d432e
Pass progress as parameter to WriteTo/WriteToAsync instead of storing…
Copilot Nov 30, 2025
85f3b17
Merge remote-tracking branch 'origin/master' into copilot/add-progres…
Copilot Dec 5, 2025
9291f58
Merge master and add comprehensive tests for archive and reader progress
Copilot Dec 5, 2025
3396f8f
Refactor to use ProgressReportingStream for progress tracking
Copilot Dec 5, 2025
c2f6055
format
adamhathcock Dec 8, 2025
c294071
Update src/SharpCompress/Archives/IArchiveEntryExtensions.cs
adamhathcock Dec 8, 2025
fd453e9
Update src/SharpCompress/IO/ProgressReportingStream.cs
adamhathcock Dec 8, 2025
1eaf3e6
format with csharpier
adamhathcock Dec 8, 2025
618b4bb
try to tell agents to format
adamhathcock Dec 8, 2025
40e72ad
fix AI edit
adamhathcock Dec 8, 2025
aaa97e2
Merge master branch - add ZStandard compression support and TarHeader…
Copilot Dec 19, 2025
b23827a
Initial plan
Copilot Dec 19, 2025
48a2ad7
Fix ExtractAll test to use synchronous extraction methods for 7Zip ar…
Copilot Dec 19, 2025
6d3e4e8
Merge remote-tracking branch 'origin/master' into copilot/add-progres…
adamhathcock Dec 19, 2025
93504cf
Add sync test and attempt to fix async LZMA extraction bug
Copilot Dec 19, 2025
ca743ea
fix for running net 10 tests
adamhathcock Dec 19, 2025
bee51af
Merge pull request #1044 from adamhathcock/copilot/add-progress-repor…
adamhathcock Dec 19, 2025
c790fd2
reading a single byte shouldn't be async
adamhathcock Dec 19, 2025
99d355e
Merge remote-tracking branch 'origin/master' into copilot/sub-pr-1076…
adamhathcock Dec 19, 2025
e57e870
Implement option 2: Avoid async LZMA state corruption for 7Zip extrac…
Copilot Dec 19, 2025
8fc8295
Add TODO and explanation for SyncOnlyStream workaround
Copilot Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ SharpCompress is a pure C# compression library supporting multiple archive forma

## Code Formatting

**Copilot agents: You MUST run the `format` task after making code changes to ensure consistency.**

- Use CSharpier for code formatting to ensure consistent style across the project
- CSharpier is configured as a local tool in `.config/dotnet-tools.json`
- **To format code, run the task: `format` task (which runs `dotnet csharpier .` from project root)**
- Restore tools with: `dotnet tool restore`
- Format files from the project root with: `dotnet csharpier .`
- **Run `dotnet csharpier .` from the project root after making code changes before committing**
- Configure your IDE to format on save using CSharpier for the best experience
- The project also uses `.editorconfig` for editor settings (indentation, encoding, etc.)
- Let CSharpier handle code style while `.editorconfig` handles editor behavior
Expand Down
14 changes: 13 additions & 1 deletion FORMATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,26 @@
1. SOLID Rars are only supported in the RarReader API.
2. Zip format supports pkware and WinzipAES encryption. However, encrypted LZMA is not supported. Zip64 reading/writing is supported but only with seekable streams as the Zip spec doesn't support Zip64 data in post data descriptors. Deflate64 is only supported for reading. See [Zip Format Notes](#zip-format-notes) for details on multi-volume archives and streaming behavior.
3. The Tar format requires a file size in the header. If no size is specified to the TarWriter and the stream is not seekable, then an exception will be thrown.
4. The 7Zip format doesn't allow for reading as a forward-only stream so 7Zip is only supported through the Archive API
4. The 7Zip format doesn't allow for reading as a forward-only stream so 7Zip is only supported through the Archive API. See [7Zip Format Notes](#7zip-format-notes) for details on async extraction behavior.
5. LZip has no support for extra data like the file name or timestamp. There is a default filename used when looking at the entry Key on the archive.

### Zip Format Notes

- Multi-volume/split ZIP archives require ZipArchive (seekable streams) as ZipReader cannot seek across volume files.
- ZipReader processes entries from LocalEntry headers (which include directory entries ending with `/`) and intentionally skips DirectoryEntry headers from the central directory, as they are redundant in streaming mode - all entry data comes from LocalEntry headers which ZipReader has already processed.

### 7Zip Format Notes

- **Async Extraction Performance**: When using async extraction methods (e.g., `ExtractAllEntries()` with `MoveToNextEntryAsync()`), each file creates its own decompression stream to avoid state corruption in the LZMA decoder. This is less efficient than synchronous extraction, which can reuse a single decompression stream for multiple files in the same folder.

**Performance Impact**: For archives with many small files in the same compression folder, async extraction will be slower than synchronous extraction because it must:
1. Create a new LZMA decoder for each file
2. Skip through the decompressed data to reach each file's starting position

**Recommendation**: For best performance with 7Zip archives, use synchronous extraction methods (`MoveToNextEntry()` and `WriteEntryToDirectory()`) when possible. Use async methods only when you need to avoid blocking the thread (e.g., in UI applications or async-only contexts).

**Technical Details**: 7Zip archives group files into "folders" (compression units), where all files in a folder share one continuous LZMA-compressed stream. The LZMA decoder maintains internal state (dictionary window, decoder positions) that assumes sequential, non-interruptible processing. Async operations can yield control during awaits, which would corrupt this shared state. To avoid this, async extraction creates a fresh decoder stream for each file.

## Compression Streams

For those who want to directly compress/decompress bits. The single file formats are represented here as well. However, BZip2, LZip and XZ have no metadata (GZip has a little) so using them without something like a Tar file makes little sense.
Expand Down
11 changes: 10 additions & 1 deletion build/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
const string Build = "build";
const string Test = "test";
const string Format = "format";
const string CheckFormat = "check-format";
const string Publish = "publish";

Target(
Expand Down Expand Up @@ -42,12 +43,20 @@ void RemoveDirectory(string d)
Target(
Format,
() =>
{
Run("dotnet", "tool restore");
Run("dotnet", "csharpier format .");
}
);
Target(
CheckFormat,
() =>
{
Run("dotnet", "tool restore");
Run("dotnet", "csharpier check .");
}
);
Target(Restore, [Format], () => Run("dotnet", "restore"));
Target(Restore, [CheckFormat], () => Run("dotnet", "restore"));

Target(
Build,
Expand Down
45 changes: 4 additions & 41 deletions src/SharpCompress/Archives/AbstractArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace SharpCompress.Archives;

public abstract class AbstractArchive<TEntry, TVolume> : IArchive, IArchiveExtractionListener
public abstract class AbstractArchive<TEntry, TVolume> : IArchive
where TEntry : IArchiveEntry
where TVolume : IVolume
{
Expand All @@ -17,11 +17,6 @@ public abstract class AbstractArchive<TEntry, TVolume> : IArchive, IArchiveExtra
private bool _disposed;
private readonly SourceStream? _sourceStream;

public event EventHandler<ArchiveExtractionEventArgs<IArchiveEntry>>? EntryExtractionBegin;
public event EventHandler<ArchiveExtractionEventArgs<IArchiveEntry>>? EntryExtractionEnd;

public event EventHandler<CompressedBytesReadEventArgs>? CompressedBytesRead;
public event EventHandler<FilePartExtractionBeginEventArgs>? FilePartExtractionBegin;
protected ReaderOptions ReaderOptions { get; }

internal AbstractArchive(ArchiveType type, SourceStream sourceStream)
Expand All @@ -43,12 +38,6 @@ internal AbstractArchive(ArchiveType type)

public ArchiveType Type { get; }

void IArchiveExtractionListener.FireEntryExtractionBegin(IArchiveEntry entry) =>
EntryExtractionBegin?.Invoke(this, new ArchiveExtractionEventArgs<IArchiveEntry>(entry));

void IArchiveExtractionListener.FireEntryExtractionEnd(IArchiveEntry entry) =>
EntryExtractionEnd?.Invoke(this, new ArchiveExtractionEventArgs<IArchiveEntry>(entry));

private static Stream CheckStreams(Stream stream)
{
if (!stream.CanSeek || !stream.CanRead)
Expand Down Expand Up @@ -99,38 +88,12 @@ public virtual void Dispose()
}
}

void IArchiveExtractionListener.EnsureEntriesLoaded()
private void EnsureEntriesLoaded()
{
_lazyEntries.EnsureFullyLoaded();
_lazyVolumes.EnsureFullyLoaded();
}

void IExtractionListener.FireCompressedBytesRead(
long currentPartCompressedBytes,
long compressedReadBytes
) =>
CompressedBytesRead?.Invoke(
this,
new CompressedBytesReadEventArgs(
currentFilePartCompressedBytesRead: currentPartCompressedBytes,
compressedBytesRead: compressedReadBytes
)
);

void IExtractionListener.FireFilePartExtractionBegin(
string name,
long size,
long compressedSize
) =>
FilePartExtractionBegin?.Invoke(
this,
new FilePartExtractionBeginEventArgs(
compressedSize: compressedSize,
size: size,
name: name
)
);

/// <summary>
/// Use this method to extract all entries in an archive in order.
/// This is primarily for SOLID Rar Archives or 7Zip Archives as they need to be
Expand All @@ -150,7 +113,7 @@ public IReader ExtractAllEntries()
"ExtractAllEntries can only be used on solid archives or 7Zip archives (which require random access)."
);
}
((IArchiveExtractionListener)this).EnsureEntriesLoaded();
EnsureEntriesLoaded();
return CreateReaderForSolidExtraction();
}

Expand All @@ -173,7 +136,7 @@ public bool IsComplete
{
get
{
((IArchiveExtractionListener)this).EnsureEntriesLoaded();
EnsureEntriesLoaded();
return Entries.All(x => x.IsComplete);
}
}
Expand Down
6 changes: 0 additions & 6 deletions src/SharpCompress/Archives/IArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@ namespace SharpCompress.Archives;

public interface IArchive : IDisposable
{
event EventHandler<ArchiveExtractionEventArgs<IArchiveEntry>> EntryExtractionBegin;
event EventHandler<ArchiveExtractionEventArgs<IArchiveEntry>> EntryExtractionEnd;

event EventHandler<CompressedBytesReadEventArgs> CompressedBytesRead;
event EventHandler<FilePartExtractionBeginEventArgs> FilePartExtractionBegin;

IEnumerable<IArchiveEntry> Entries { get; }
IEnumerable<IVolume> Volumes { get; }

Expand Down
96 changes: 66 additions & 30 deletions src/SharpCompress/Archives/IArchiveEntryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -8,56 +9,89 @@ namespace SharpCompress.Archives;

public static class IArchiveEntryExtensions
{
public static void WriteTo(this IArchiveEntry archiveEntry, Stream streamToWriteTo)
private const int BufferSize = 81920;

/// <summary>
/// Extract entry to the specified stream.
/// </summary>
/// <param name="archiveEntry">The archive entry to extract.</param>
/// <param name="streamToWriteTo">The stream to write the entry content to.</param>
/// <param name="progress">Optional progress reporter for tracking extraction progress.</param>
public static void WriteTo(
this IArchiveEntry archiveEntry,
Stream streamToWriteTo,
IProgress<ProgressReport>? progress = null
)
{
if (archiveEntry.IsDirectory)
{
throw new ExtractionException("Entry is a file directory and cannot be extracted.");
}

var streamListener = (IArchiveExtractionListener)archiveEntry.Archive;
streamListener.EnsureEntriesLoaded();
streamListener.FireEntryExtractionBegin(archiveEntry);
streamListener.FireFilePartExtractionBegin(
archiveEntry.Key ?? "Key",
archiveEntry.Size,
archiveEntry.CompressedSize
);
var entryStream = archiveEntry.OpenEntryStream();
using (entryStream)
{
using Stream s = new ListeningStream(streamListener, entryStream);
s.CopyTo(streamToWriteTo);
}
streamListener.FireEntryExtractionEnd(archiveEntry);
using var entryStream = archiveEntry.OpenEntryStream();
var sourceStream = WrapWithProgress(entryStream, archiveEntry, progress);
sourceStream.CopyTo(streamToWriteTo, BufferSize);
}

/// <summary>
/// Extract entry to the specified stream asynchronously.
/// </summary>
/// <param name="archiveEntry">The archive entry to extract.</param>
/// <param name="streamToWriteTo">The stream to write the entry content to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="progress">Optional progress reporter for tracking extraction progress.</param>
public static async Task WriteToAsync(
this IArchiveEntry archiveEntry,
Stream streamToWriteTo,
CancellationToken cancellationToken = default
CancellationToken cancellationToken = default,
IProgress<ProgressReport>? progress = null
)
{
if (archiveEntry.IsDirectory)
{
throw new ExtractionException("Entry is a file directory and cannot be extracted.");
}

var streamListener = (IArchiveExtractionListener)archiveEntry.Archive;
streamListener.EnsureEntriesLoaded();
streamListener.FireEntryExtractionBegin(archiveEntry);
streamListener.FireFilePartExtractionBegin(
archiveEntry.Key ?? "Key",
archiveEntry.Size,
archiveEntry.CompressedSize
using var entryStream = archiveEntry.OpenEntryStream();
var sourceStream = WrapWithProgress(entryStream, archiveEntry, progress);
await sourceStream
.CopyToAsync(streamToWriteTo, BufferSize, cancellationToken)
.ConfigureAwait(false);
}

private static Stream WrapWithProgress(
Stream source,
IArchiveEntry entry,
IProgress<ProgressReport>? progress
)
{
if (progress is null)
{
return source;
}

var entryPath = entry.Key ?? string.Empty;
long? totalBytes = GetEntrySizeSafe(entry);
return new ProgressReportingStream(
source,
progress,
entryPath,
totalBytes,
leaveOpen: true
);
var entryStream = archiveEntry.OpenEntryStream();
using (entryStream)
}

private static long? GetEntrySizeSafe(IArchiveEntry entry)
{
try
{
var size = entry.Size;
return size >= 0 ? size : null;
}
catch (NotImplementedException)
{
using Stream s = new ListeningStream(streamListener, entryStream);
await s.CopyToAsync(streamToWriteTo, 81920, cancellationToken).ConfigureAwait(false);
return null;
}
streamListener.FireEntryExtractionEnd(archiveEntry);
}

/// <summary>
Expand Down Expand Up @@ -127,7 +161,9 @@ public static Task WriteToFileAsync(
async (x, fm) =>
{
using var fs = File.Open(destinationFileName, fm);
await entry.WriteToAsync(fs, cancellationToken).ConfigureAwait(false);
await entry
.WriteToAsync(fs, progress: null, cancellationToken: cancellationToken)
.ConfigureAwait(false);
},
cancellationToken
);
Expand Down
10 changes: 0 additions & 10 deletions src/SharpCompress/Archives/IArchiveExtractionListener.cs

This file was deleted.

8 changes: 4 additions & 4 deletions src/SharpCompress/Archives/Rar/RarArchiveEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ public Stream OpenEntryStream()
stream = new RarStream(
archive.UnpackV1.Value,
FileHeader,
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>(), archive)
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>())
);
}
else
{
stream = new RarStream(
archive.UnpackV2017.Value,
FileHeader,
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>(), archive)
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>())
);
}

Expand All @@ -100,15 +100,15 @@ public async Task<Stream> OpenEntryStreamAsync(CancellationToken cancellationTok
stream = new RarStream(
archive.UnpackV1.Value,
FileHeader,
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>(), archive)
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>())
);
}
else
{
stream = new RarStream(
archive.UnpackV2017.Value,
FileHeader,
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>(), archive)
new MultiVolumeReadOnlyStream(Parts.Cast<RarFilePart>())
);
}

Expand Down
Loading