mirror of
https://github.com/adamhathcock/sharpcompress.git
synced 2026-11-15 17:02:03 +00:00
Share writable archive entry identities across sync and async APIs
This commit is contained in:
@@ -37,9 +37,52 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
}
|
||||
}
|
||||
|
||||
// Async property moved from main file
|
||||
private IAsyncEnumerable<TEntry> OldEntriesAsync =>
|
||||
base.EntriesAsync.Where(x => !removedEntries.Contains(x));
|
||||
LoadOriginalEntriesAsync().Where(x => !removedEntries.Contains(x));
|
||||
|
||||
private async IAsyncEnumerable<TEntry> LoadOriginalEntriesAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
while (index < originalEntries.Count)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return originalEntries[index++];
|
||||
}
|
||||
if (originalEntriesLoaded)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var generation = ++originalEntriesGeneration;
|
||||
await using var source = LoadEntriesAsync(VolumesAsync)
|
||||
.GetAsyncEnumerator(cancellationToken);
|
||||
for (var skipped = 0; skipped < index; skipped++)
|
||||
{
|
||||
if (!await source.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
throw new IncompleteArchiveException("Archive entries changed while loading.");
|
||||
}
|
||||
}
|
||||
while (
|
||||
generation == originalEntriesGeneration
|
||||
&& await source.MoveNextAsync().ConfigureAwait(false)
|
||||
)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
originalEntries.Add(source.Current);
|
||||
index++;
|
||||
yield return source.Current;
|
||||
}
|
||||
if (generation == originalEntriesGeneration)
|
||||
{
|
||||
originalEntriesLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RebuildModifiedCollectionAsync()
|
||||
{
|
||||
@@ -155,6 +198,7 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync().ConfigureAwait(false);
|
||||
originalEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
newEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
removedEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
modifiedEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
|
||||
@@ -41,6 +41,10 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
|
||||
private readonly List<TEntry> newEntries = new();
|
||||
private readonly List<TEntry> removedEntries = new();
|
||||
private readonly List<TEntry> originalEntries = new();
|
||||
private readonly LazyReadOnlyCollection<TEntry> originalEntriesView;
|
||||
private bool originalEntriesLoaded;
|
||||
private int originalEntriesGeneration;
|
||||
|
||||
private readonly List<TEntry> modifiedEntries = new();
|
||||
private bool hasModifications;
|
||||
@@ -48,10 +52,17 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
private bool asyncRebuildPending;
|
||||
|
||||
internal AbstractWritableArchive(ArchiveType type)
|
||||
: base(type) { }
|
||||
: base(type)
|
||||
{
|
||||
originalEntriesLoaded = true;
|
||||
originalEntriesView = new LazyReadOnlyCollection<TEntry>(LoadOriginalEntries());
|
||||
}
|
||||
|
||||
internal AbstractWritableArchive(ArchiveType type, SourceStream sourceStream)
|
||||
: base(type, sourceStream) { }
|
||||
: base(type, sourceStream)
|
||||
{
|
||||
originalEntriesView = new LazyReadOnlyCollection<TEntry>(LoadOriginalEntries());
|
||||
}
|
||||
|
||||
public override ICollection<TEntry> Entries
|
||||
{
|
||||
@@ -65,7 +76,7 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
{
|
||||
return modifiedEntries;
|
||||
}
|
||||
return base.Entries;
|
||||
return originalEntriesView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +95,46 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
modifiedEntries.AddRange(OldEntries.Concat(newEntries));
|
||||
}
|
||||
|
||||
private IEnumerable<TEntry> OldEntries => base.Entries.Where(x => !removedEntries.Contains(x));
|
||||
private IEnumerable<TEntry> LoadOriginalEntries()
|
||||
{
|
||||
var index = 0;
|
||||
while (true)
|
||||
{
|
||||
while (index < originalEntries.Count)
|
||||
{
|
||||
yield return originalEntries[index++];
|
||||
}
|
||||
if (originalEntriesLoaded)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Both APIs share entry identities. Switching loaders must restart
|
||||
// parsing: the other loader may have moved the archive's source stream.
|
||||
var generation = ++originalEntriesGeneration;
|
||||
using var source = LoadEntries(Volumes).GetEnumerator();
|
||||
for (var skipped = 0; skipped < index; skipped++)
|
||||
{
|
||||
if (!source.MoveNext())
|
||||
{
|
||||
throw new IncompleteArchiveException("Archive entries changed while loading.");
|
||||
}
|
||||
}
|
||||
while (generation == originalEntriesGeneration && source.MoveNext())
|
||||
{
|
||||
originalEntries.Add(source.Current);
|
||||
index++;
|
||||
yield return source.Current;
|
||||
}
|
||||
if (generation == originalEntriesGeneration)
|
||||
{
|
||||
originalEntriesLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<TEntry> OldEntries =>
|
||||
originalEntriesView.Where(x => !removedEntries.Contains(x));
|
||||
|
||||
public void RemoveEntry(TEntry entry)
|
||||
{
|
||||
@@ -256,6 +306,7 @@ public abstract partial class AbstractWritableArchive<TEntry, TVolume, TOptions>
|
||||
public override void Dispose()
|
||||
{
|
||||
base.Dispose();
|
||||
originalEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
newEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
removedEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
modifiedEntries.Cast<Entry>().ForEach(x => x.Close());
|
||||
|
||||
@@ -85,7 +85,8 @@ public class GZipArchiveAsyncTests : ArchiveTests
|
||||
#endif
|
||||
await using (var archive = await GZipArchive.OpenAsyncArchive(new AsyncOnlyStream(stream)))
|
||||
{
|
||||
await Assert.ThrowsAsync<NotSupportedException>(async () =>
|
||||
// Shared cached entries avoid synchronous I/O on the async-only source.
|
||||
await Assert.ThrowsAsync<InvalidFormatException>(async () =>
|
||||
await archive.AddEntryAsync("jpg\\test.jpg", File.OpenRead(jpg), closeStream: true)
|
||||
);
|
||||
await archive.SaveToAsync(
|
||||
|
||||
189
tests/SharpCompress.Test/WritableArchiveIdentityTests.cs
Normal file
189
tests/SharpCompress.Test/WritableArchiveIdentityTests.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Archives.Tar;
|
||||
using SharpCompress.Archives.Zip;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
using SharpCompress.Writers;
|
||||
using SharpCompress.Writers.Tar;
|
||||
using SharpCompress.Writers.Zip;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpCompress.Test;
|
||||
|
||||
public class WritableArchiveIdentityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ArchiveType.Zip, false, false)]
|
||||
[InlineData(ArchiveType.Zip, false, true)]
|
||||
[InlineData(ArchiveType.Zip, true, false)]
|
||||
[InlineData(ArchiveType.Zip, true, true)]
|
||||
[InlineData(ArchiveType.Tar, false, false)]
|
||||
[InlineData(ArchiveType.Tar, false, true)]
|
||||
[InlineData(ArchiveType.Tar, true, false)]
|
||||
[InlineData(ArchiveType.Tar, true, true)]
|
||||
public async Task Removal_Survives_Switching_Apis_And_Rebuilding(
|
||||
ArchiveType type,
|
||||
bool asyncFirst,
|
||||
bool paused
|
||||
)
|
||||
{
|
||||
using var input = CreateArchive(type, "remove", "keep");
|
||||
using var archive = ArchiveFactory.OpenArchive(input, ReaderOptions.ForExternalStream);
|
||||
var writable = (IWritableArchive)archive;
|
||||
var asyncWritable = (IWritableAsyncArchive)archive;
|
||||
// Leave the first loader partially consumed before switching APIs.
|
||||
var entry = asyncFirst
|
||||
? await asyncWritable.EntriesAsync.FirstAsync(CancellationToken.None)
|
||||
: archive.Entries.First();
|
||||
var otherEntry = asyncFirst
|
||||
? archive.Entries.First()
|
||||
: await asyncWritable.EntriesAsync.FirstAsync(CancellationToken.None);
|
||||
Assert.Same(entry, otherEntry);
|
||||
|
||||
using var source = new MemoryStream(new byte[] { 42 });
|
||||
using (paused ? writable.PauseEntryRebuilding() : null)
|
||||
{
|
||||
if (asyncFirst)
|
||||
{
|
||||
writable.RemoveEntry(entry);
|
||||
writable.AddEntry("new", source, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await asyncWritable.RemoveEntryAsync(entry);
|
||||
await asyncWritable.AddEntryAsync(
|
||||
"new",
|
||||
source,
|
||||
false,
|
||||
cancellationToken: CancellationToken.None
|
||||
);
|
||||
}
|
||||
}
|
||||
var syncEntries = archive.Entries.ToList();
|
||||
var asyncEntries = await asyncWritable.EntriesAsync.ToListAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { "keep", "new" }, syncEntries.Select(e => e.Key));
|
||||
Assert.Equal(syncEntries, asyncEntries);
|
||||
|
||||
foreach (var useAsync in new[] { false, true })
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
await Save(archive, output, useAsync);
|
||||
output.Position = 0;
|
||||
using var saved = ArchiveFactory.OpenArchive(output, ReaderOptions.ForExternalStream);
|
||||
Assert.Equal(new[] { "keep", "new" }, saved.Entries.Select(e => e.Key));
|
||||
}
|
||||
Assert.True(input.CanRead);
|
||||
Assert.True(source.CanRead);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ArchiveType.Zip, false)]
|
||||
[InlineData(ArchiveType.Zip, true)]
|
||||
[InlineData(ArchiveType.Tar, false)]
|
||||
[InlineData(ArchiveType.Tar, true)]
|
||||
public async Task Shared_Identity_Does_Not_Remove_Other_Entries_With_The_Same_Name(
|
||||
ArchiveType type,
|
||||
bool asyncFirst
|
||||
)
|
||||
{
|
||||
using var input = CreateArchive(type, "same", "same");
|
||||
using var archive = ArchiveFactory.OpenArchive(input, ReaderOptions.ForExternalStream);
|
||||
var asyncArchive = (IWritableAsyncArchive)archive;
|
||||
if (asyncFirst)
|
||||
{
|
||||
var entry = await asyncArchive.EntriesAsync.FirstAsync(CancellationToken.None);
|
||||
((IWritableArchive)archive).RemoveEntry(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
await asyncArchive.RemoveEntryAsync(archive.Entries.First());
|
||||
}
|
||||
var remaining = Assert.Single(archive.Entries);
|
||||
Assert.Same(
|
||||
remaining,
|
||||
Assert.Single(await asyncArchive.EntriesAsync.ToListAsync(CancellationToken.None))
|
||||
);
|
||||
using var payload = remaining.OpenEntryStream();
|
||||
Assert.Equal(2, payload.ReadByte());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ArchiveType.Zip)]
|
||||
[InlineData(ArchiveType.Tar)]
|
||||
public async Task Partially_Consumed_Loaders_Resume_From_The_Shared_Cache(ArchiveType type)
|
||||
{
|
||||
using var input = CreateArchive(type, "first", "second", "third");
|
||||
using var archive = ArchiveFactory.OpenArchive(input, ReaderOptions.ForExternalStream);
|
||||
using var syncEntries = archive.Entries.GetEnumerator();
|
||||
await using var asyncEntries = ((IAsyncArchive)archive).EntriesAsync.GetAsyncEnumerator(
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(syncEntries.MoveNext());
|
||||
Assert.True(await asyncEntries.MoveNextAsync());
|
||||
Assert.Same(syncEntries.Current, asyncEntries.Current);
|
||||
Assert.True(await asyncEntries.MoveNextAsync());
|
||||
var second = asyncEntries.Current;
|
||||
Assert.True(syncEntries.MoveNext());
|
||||
Assert.Same(second, syncEntries.Current);
|
||||
Assert.True(syncEntries.MoveNext());
|
||||
Assert.True(await asyncEntries.MoveNextAsync());
|
||||
Assert.Same(syncEntries.Current, asyncEntries.Current);
|
||||
Assert.False(syncEntries.MoveNext());
|
||||
Assert.False(await asyncEntries.MoveNextAsync());
|
||||
}
|
||||
|
||||
private static MemoryStream CreateArchive(ArchiveType type, params string[] names)
|
||||
{
|
||||
var input = new MemoryStream();
|
||||
using (
|
||||
var writer = WriterFactory.OpenWriter(
|
||||
input,
|
||||
type,
|
||||
new WriterOptions(CompressionType.None) { LeaveStreamOpen = true }
|
||||
)
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
{
|
||||
using var source = new MemoryStream(new[] { (byte)(i + 1) });
|
||||
writer.Write(names[i], source, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
input.Position = 0;
|
||||
return input;
|
||||
}
|
||||
|
||||
private static async Task Save(IArchive archive, Stream output, bool useAsync)
|
||||
{
|
||||
if (archive is ZipArchive zip)
|
||||
{
|
||||
var options = new ZipWriterOptions(CompressionType.None) { LeaveStreamOpen = true };
|
||||
if (useAsync)
|
||||
{
|
||||
await zip.SaveToAsync(output, options, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
zip.SaveTo(output, options);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var tar = (TarArchive)archive;
|
||||
var options = new TarWriterOptions(CompressionType.None) { LeaveStreamOpen = true };
|
||||
if (useAsync)
|
||||
{
|
||||
await tar.SaveToAsync(output, options, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
tar.SaveTo(output, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user