Skip to content

Commit c5886f9

Browse files
committed
.
1 parent 4161487 commit c5886f9

9 files changed

Lines changed: 284 additions & 12 deletions

File tree

claude.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ dotnet run --project src/RemoteZip.Tests --configuration Release --no-build -- -
2121

2222
## What this library is
2323

24-
One public type, `RemoteZipArchive`: reads a zip over HTTP range requests without downloading the whole file. Open = one suffix request for the tail → parse end-of-central-directory (EOCD) → parse central directory (fetched separately only if bigger than the tail). Read = one range request per entry (`local header + name + extra-slack + compressed data`), decompress, crc-check — or no request at all when the entry falls inside the retained tail. Batched reads coalesce entries whose ranges are within 8 KiB and issue the clusters that remain concurrently, bounded by `MaxConcurrency`.
24+
Two public types, `RemoteZipArchive` and `StubZipServer` (a test double for consumers, shipped in the same package — it depends on nothing beyond `HttpMessageHandler` and trims away when unreferenced, so a separate package would cost more than it saves): reads a zip over HTTP range requests without downloading the whole file. Batched reads come in entry-keyed and name-keyed forms (`Read`/`ReadText` over `IReadOnlyCollection` of entries or names); name-keyed results omit missing names rather than erroring, so callers probe optional files without existence checks. Open = one suffix request for the tail → parse end-of-central-directory (EOCD) → parse central directory (fetched separately only if bigger than the tail). Read = one range request per entry (`local header + name + extra-slack + compressed data`), decompress, crc-check — or no request at all when the entry falls inside the retained tail. Batched reads coalesce entries whose ranges are within 8 KiB and issue the clusters that remain concurrently, bounded by `MaxConcurrency`.
2525

2626
Design constraints that are not obvious from the code alone:
2727

@@ -34,7 +34,7 @@ Design constraints that are not obvious from the code alone:
3434

3535
## Test infrastructure
3636

37-
- `StubZipServer` mimics nuget.org's observed behavior (206 slices, suffix ranges, 200-with-full-body for unsatisfiable ranges). Toggles: `SupportRanges` (range-less servers), `ExposeContentRange` (browser CORS simulation), `Delay` (needed before `MaxConcurrentRequests` can observe overlap — without it each response completes before the next request is issued). Batched reads hit the handler concurrently, so its logs are lock-guarded and their *order* is not meaningful; assert on counts.
37+
- `StubZipServer` (shipped public in `src/RemoteZip`, so the tests dogfood what consumers get) mimics nuget.org's observed behavior (206 slices, suffix ranges, 200-with-full-body for unsatisfiable ranges). Toggles: `SupportRanges` (range-less servers), `ExposeContentRange` (browser CORS simulation), `Delay` (needed before `MaxConcurrentRequests` can observe overlap — without it each response completes before the next request is issued). Batched reads hit the handler concurrently, so its logs are lock-guarded and their *order* is not meaningful; assert on counts.
3838
- `Zips.Padded` + `TailLength = 1024` is the pattern for forcing the ranged path; small zips otherwise fit entirely inside the default 128 KiB tail (`DownloadedWholeFile == true`) and reads cost zero requests.
3939
- **Padding goes last in a test zip, not first.** Since `TailCachedRangeReader` serves anything inside the tail for free, an entry written near the end of the file is read without a request. A fixture that pads first pushes its interesting entries to the end and silently stops testing the ranged path — it will pass, with a lower request count than the test asserts. `Zips.Padded` and the `ZipBuilder` fixtures all append `padding.bin` for this reason; `EntryInsideTail_ReadsWithoutAnotherRequest` is the one test that deliberately inverts it.
4040
- `ZipBuilder` hand-writes zip bytes for what `ZipArchive` can't produce: archive comments, oversized local extras, encrypted/unsupported-method flags, wrong crcs, zip64 records.

readme.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ public static async Task<string?> ReadNuspec(HttpClient http, string packageUrl)
6767
<!-- endSnippet -->
6868

6969

70+
### Reading by name
71+
72+
For a known set of files, `Read` / `ReadText` accept names directly and return name-keyed results — no `Find` calls, no null checks, same coalescing:
73+
74+
<!-- snippet: read-by-name -->
75+
<a id='snippet-read-by-name'></a>
76+
```cs
77+
public static async Task<string?> ReadLicense(HttpClient http, string url)
78+
{
79+
var zip = await RemoteZipArchive.Open(http, url);
80+
81+
// Names with no matching entry are simply absent from the result.
82+
var texts = await zip.ReadText(["license.md", "license.txt"]);
83+
return texts.GetValueOrDefault("license.md") ?? texts.GetValueOrDefault("license.txt");
84+
}
85+
```
86+
<sup><a href='/src/RemoteZip.Tests/Usage.cs#L37-L46' title='Snippet source file'>snippet source</a> | <a href='#snippet-read-by-name' title='Start of snippet'>anchor</a></sup>
87+
<!-- endSnippet -->
88+
89+
7090
## How it works
7191

7292
Opening sends one suffix range request (`Range: bytes=-131072`) and parses the end-of-central-directory record from it. For typical archives the whole central directory is inside that tail, so enumeration costs exactly one request; an oversized central directory costs one more. Each `Read` then fetches `local header + entry data` in one range request, over-fetching by 512 bytes to cover local extra fields; a batched `Read` merges entries whose ranges are within 8 KB of each other.
@@ -106,6 +126,31 @@ var options = new RemoteZipOptions
106126
The server must support `Range` requests (`206 Partial Content`). For browser use it must also allow the `Range` header in its CORS policy (`Access-Control-Allow-Headers: range`). Exposing `Content-Range` is *not* required. nuget.org's flat container satisfies all of this, including from `localhost` origins.
107127

108128

129+
## Testing consumers
130+
131+
`StubZipServer` ships in the package: an `HttpMessageHandler` that serves an in-memory `byte[]` with the Range semantics observed on nuget.org — 206 slices, suffix ranges, 200-with-full-body for unsatisfiable ranges. `SupportRanges = false` simulates a server without range support, `ExposeContentRange = false` simulates browser CORS hiding the header, and `Requests` / `HeaderLog` / `BytesServed` / `MaxConcurrentRequests` record the traffic so tests can assert fetch efficiency as well as correctness:
132+
133+
<!-- snippet: stub-zip-server -->
134+
<a id='snippet-stub-zip-server'></a>
135+
```cs
136+
[Test]
137+
public async Task StubServesRanges()
138+
{
139+
var server = new StubZipServer(SampleZipBytes());
140+
using var client = new HttpClient(server);
141+
142+
var archive = await RemoteZipArchive.Open(client, "https://example/archive.zip");
143+
var text = await archive.ReadText(archive.Find("readme.md")!);
144+
145+
await Assert.That(text).IsEqualTo("# Sample");
146+
// Requests, HeaderLog, BytesServed and MaxConcurrentRequests record the traffic.
147+
await Assert.That(server.Requests).Count().IsEqualTo(1);
148+
}
149+
```
150+
<sup><a href='/src/RemoteZip.Tests/TestingUsage.cs#L3-L17' title='Snippet source file'>snippet source</a> | <a href='#snippet-stub-zip-server' title='Start of snippet'>anchor</a></sup>
151+
<!-- endSnippet -->
152+
153+
109154
## Limitations
110155

111156
- Encrypted entries and compression methods other than stored/deflate throw.

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project>
33
<PropertyGroup>
44
<NoWarn>CS1591;NU1608;NU1109;NU1901;PolyfillTargetsForNuget;SC023</NoWarn>
5-
<Version>0.1.0</Version>
5+
<Version>0.2.0</Version>
66
<AssemblyVersion>1.0.0</AssemblyVersion>
77
<Description>Read entries from a remote zip over HTTP range requests, without downloading the whole archive. Works from Blazor WebAssembly.</Description>
88
<ResolveAssemblyReferencesSilent>true</ResolveAssemblyReferencesSilent>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
public class NameKeyedReadTests
2+
{
3+
static readonly RemoteZipOptions rangedOptions = new()
4+
{
5+
TailLength = 1024
6+
};
7+
8+
[Test]
9+
public async Task ReadByNames_MissingNamesAbsent_SingleCoalescedRequest()
10+
{
11+
var data = Zips.Padded(4096, ("docs/a.txt", "alpha"), ("docs/b.txt", "beta"));
12+
var (client, server) = Zips.Serve(data);
13+
using (client)
14+
{
15+
var zip = await RemoteZipArchive.Open(client, "https://example/archive.zip", rangedOptions);
16+
var contents = await zip.Read(["docs/a.txt", "docs/b.txt", "missing.txt"]);
17+
18+
await Assert.That(contents.Count).IsEqualTo(2);
19+
await Assert.That(Encoding.UTF8.GetString(contents["docs/a.txt"])).IsEqualTo("alpha");
20+
await Assert.That(Encoding.UTF8.GetString(contents["docs/b.txt"])).IsEqualTo("beta");
21+
await Assert.That(contents.ContainsKey("missing.txt")).IsFalse();
22+
await Assert.That(server.Requests).Count().IsEqualTo(2);
23+
}
24+
}
25+
26+
[Test]
27+
public async Task ReadByNames_DuplicateNamesCollapse()
28+
{
29+
var data = Zips.Padded(4096, ("a.txt", "alpha"));
30+
var (client, _) = Zips.Serve(data);
31+
using (client)
32+
{
33+
var zip = await RemoteZipArchive.Open(client, "https://example/archive.zip", rangedOptions);
34+
var contents = await zip.Read(["a.txt", "a.txt"]);
35+
36+
await Assert.That(contents.Count).IsEqualTo(1);
37+
await Assert.That(Encoding.UTF8.GetString(contents["a.txt"])).IsEqualTo("alpha");
38+
}
39+
}
40+
41+
[Test]
42+
public async Task ReadTextByNames_HonorsByteOrderMark()
43+
{
44+
var data = Zips.Padded(4096, ("bom.txt", "hi"), ("plain.txt", "there"));
45+
var (client, _) = Zips.Serve(data);
46+
using (client)
47+
{
48+
var zip = await RemoteZipArchive.Open(client, "https://example/archive.zip", rangedOptions);
49+
var texts = await zip.ReadText(["bom.txt", "plain.txt", "missing.txt"]);
50+
51+
await Assert.That(texts.Count).IsEqualTo(2);
52+
await Assert.That(texts["bom.txt"]).IsEqualTo("hi");
53+
await Assert.That(texts["plain.txt"]).IsEqualTo("there");
54+
}
55+
}
56+
57+
[Test]
58+
public async Task ReadTextByEntries_KeyedByEntry()
59+
{
60+
var data = Zips.Padded(4096, ("a.txt", "alpha"), ("b.txt", "beta"));
61+
var (client, _) = Zips.Serve(data);
62+
using (client)
63+
{
64+
var zip = await RemoteZipArchive.Open(client, "https://example/archive.zip", rangedOptions);
65+
var a = zip.Find("a.txt")!;
66+
var b = zip.Find("b.txt")!;
67+
var texts = await zip.ReadText([a, b]);
68+
69+
await Assert.That(texts[a]).IsEqualTo("alpha");
70+
await Assert.That(texts[b]).IsEqualTo("beta");
71+
}
72+
}
73+
74+
[Test]
75+
public async Task ReadByNames_EmptyInput_NoRequests()
76+
{
77+
var data = Zips.Padded(4096, ("a.txt", "alpha"));
78+
var (client, server) = Zips.Serve(data);
79+
using (client)
80+
{
81+
var zip = await RemoteZipArchive.Open(client, "https://example/archive.zip", rangedOptions);
82+
var contents = await zip.Read(Array.Empty<string>());
83+
84+
await Assert.That(contents.Count).IsEqualTo(0);
85+
await Assert.That(server.Requests).Count().IsEqualTo(1);
86+
}
87+
}
88+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
public class TestingUsage
2+
{
3+
// begin-snippet: stub-zip-server
4+
[Test]
5+
public async Task StubServesRanges()
6+
{
7+
var server = new StubZipServer(SampleZipBytes());
8+
using var client = new HttpClient(server);
9+
10+
var archive = await RemoteZipArchive.Open(client, "https://example/archive.zip");
11+
var text = await archive.ReadText(archive.Find("readme.md")!);
12+
13+
await Assert.That(text).IsEqualTo("# Sample");
14+
// Requests, HeaderLog, BytesServed and MaxConcurrentRequests record the traffic.
15+
await Assert.That(server.Requests).Count().IsEqualTo(1);
16+
}
17+
// end-snippet
18+
19+
static byte[] SampleZipBytes() =>
20+
Zips.Normal(("readme.md", "# Sample"));
21+
}

src/RemoteZip.Tests/Usage.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ public static async Task PrintRemoteZip(HttpClient http, string url)
3434
}
3535
// end-snippet
3636

37+
// begin-snippet: read-by-name
38+
public static async Task<string?> ReadLicense(HttpClient http, string url)
39+
{
40+
var zip = await RemoteZipArchive.Open(http, url);
41+
42+
// Names with no matching entry are simply absent from the result.
43+
var texts = await zip.ReadText(["license.md", "license.txt"]);
44+
return texts.GetValueOrDefault("license.md") ?? texts.GetValueOrDefault("license.txt");
45+
}
46+
// end-snippet
47+
3748
[Test]
3849
public async Task Runs()
3950
{
@@ -45,6 +56,18 @@ public async Task Runs()
4556
}
4657
}
4758

59+
[Test]
60+
public async Task ReadLicenseRuns()
61+
{
62+
var data = Zips.Normal(("license.txt", "MIT"), ("lib/app.dll", "not really a dll"));
63+
var (client, _) = Zips.Serve(data);
64+
using (client)
65+
{
66+
var license = await ReadLicense(client, "https://example/archive.zip");
67+
await Assert.That(license).IsEqualTo("MIT");
68+
}
69+
}
70+
4871
[Test]
4972
public async Task BatchRuns()
5073
{

src/RemoteZip/RemoteZipArchive.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,10 +389,81 @@ async Task<List<KeyValuePair<RemoteZipEntry, byte[]>>> ReadCluster(Cluster clust
389389

390390
sealed record Cluster(long Start, long End, List<RemoteZipEntry> Entries);
391391

392+
/// <summary>
393+
/// Downloads and decompresses the named entries, keyed by those names. A name with no
394+
/// matching entry is simply absent from the result rather than an error, so probing
395+
/// for optional files needs no existence checks. Fetching follows the same plan as the
396+
/// entry batch: close entries coalesce into one request, remaining requests overlap.
397+
/// </summary>
398+
public async Task<IReadOnlyDictionary<string, byte[]>> Read(IReadOnlyCollection<string> fullNames, Cancel cancel = default)
399+
{
400+
var found = ResolveNames(fullNames);
401+
var contents = await Read(found.Values, cancel);
402+
var results = new Dictionary<string, byte[]>(found.Count);
403+
foreach (var (name, entry) in found)
404+
{
405+
results[name] = contents[entry];
406+
}
407+
408+
return results;
409+
}
410+
411+
/// <summary>Downloads the named entries and decodes each as text, honoring byte-order marks.</summary>
412+
public async Task<IReadOnlyDictionary<string, string>> ReadText(IReadOnlyCollection<string> fullNames, Cancel cancel = default)
413+
{
414+
var contents = await Read(fullNames, cancel);
415+
var results = new Dictionary<string, string>(contents.Count);
416+
foreach (var (name, bytes) in contents)
417+
{
418+
results[name] = await DecodeText(bytes, cancel);
419+
}
420+
421+
return results;
422+
}
423+
424+
/// <summary>Downloads and decompresses multiple entries, decoding each as text, honoring byte-order marks.</summary>
425+
public async Task<IReadOnlyDictionary<RemoteZipEntry, string>> ReadText(IReadOnlyCollection<RemoteZipEntry> batch, Cancel cancel = default)
426+
{
427+
var contents = await Read(batch, cancel);
428+
var results = new Dictionary<RemoteZipEntry, string>(contents.Count);
429+
foreach (var (entry, bytes) in contents)
430+
{
431+
results[entry] = await DecodeText(bytes, cancel);
432+
}
433+
434+
return results;
435+
}
436+
437+
/// <summary>First matching entry per distinct name; names without a match are skipped.</summary>
438+
Dictionary<string, RemoteZipEntry> ResolveNames(IReadOnlyCollection<string> fullNames)
439+
{
440+
var found = new Dictionary<string, RemoteZipEntry>(fullNames.Count);
441+
foreach (var name in fullNames)
442+
{
443+
if (found.ContainsKey(name))
444+
{
445+
continue;
446+
}
447+
448+
var entry = Find(name);
449+
if (entry != null)
450+
{
451+
found[name] = entry;
452+
}
453+
}
454+
455+
return found;
456+
}
457+
392458
/// <summary>Downloads an entry and decodes it as text, honoring a byte-order mark.</summary>
393459
public async Task<string> ReadText(RemoteZipEntry entry, Cancel cancel = default)
394460
{
395461
var bytes = await Read(entry, cancel);
462+
return await DecodeText(bytes, cancel);
463+
}
464+
465+
static async Task<string> DecodeText(byte[] bytes, Cancel cancel)
466+
{
396467
using var streamReader = new StreamReader(new MemoryStream(bytes));
397468
return await streamReader.ReadToEndAsync(cancel);
398469
}
Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,43 @@
1+
namespace RemoteZip;
2+
13
/// <summary>
2-
/// Serves a byte[] the way nuget.org's CDN was observed to behave: suffix and absolute
3-
/// ranges get 206 with the requested slice; an unsatisfiable range gets 200 with the whole
4-
/// file (not 416). Toggles simulate servers without range support and browser contexts
5-
/// where CORS hides Content-Range. Batched reads issue requests concurrently, so the logs
6-
/// are guarded and their order is not meaningful — assert on counts, not sequence.
4+
/// An <see cref="HttpMessageHandler" /> for testing consumers, serving a byte[] the way
5+
/// nuget.org's CDN was observed to behave: suffix and absolute ranges get 206 with the
6+
/// requested slice; an unsatisfiable range gets 200 with the whole file (not 416). Toggles
7+
/// simulate servers without range support and browser contexts where CORS hides
8+
/// Content-Range. Batched reads issue requests concurrently, so the logs are lock-guarded
9+
/// and their order is not meaningful — assert on counts, not sequence.
710
/// </summary>
8-
class StubZipServer(byte[] data) : HttpMessageHandler
11+
public class StubZipServer(byte[] data) : HttpMessageHandler
912
{
1013
readonly Lock padlock = new();
1114
int inFlight;
1215

16+
/// <summary>False simulates a server without range support: every response is a 200 with the full body.</summary>
1317
public bool SupportRanges { get; set; } = true;
1418

19+
/// <summary>False omits Content-Range from 206 responses, matching what browser CORS lets a caller see on nuget.org.</summary>
1520
public bool ExposeContentRange { get; set; } = true;
1621

1722
/// <summary>
1823
/// Held open before responding. A non-zero delay is what makes overlapping requests
19-
/// observable in <see cref="MaxConcurrentRequests" />; without it each response
24+
/// observable via <see cref="MaxConcurrentRequests" />; without it each response
2025
/// completes before the next request is issued.
2126
/// </summary>
2227
public TimeSpan Delay { get; set; }
2328

29+
/// <summary>One entry per request: the Range header served, or "full".</summary>
2430
public List<string> Requests { get; } = [];
2531

32+
/// <summary>The full request headers of every request, for asserting on configured extras.</summary>
2633
public List<string> HeaderLog { get; } = [];
2734

2835
/// <summary>High-water mark of requests in flight at the same time.</summary>
2936
public int MaxConcurrentRequests { get; private set; }
3037

38+
/// <summary>Total body bytes served, for asserting a consumer's fetch efficiency.</summary>
39+
public long BytesServed { get; private set; }
40+
3141
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, Cancel cancel)
3242
{
3343
lock (padlock)
@@ -87,6 +97,7 @@ HttpResponseMessage Respond(HttpRequestMessage request)
8797

8898
var slice = new byte[to - from + 1];
8999
Array.Copy(data, from, slice, 0, slice.Length);
100+
Count(slice.Length);
90101
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
91102
{
92103
Content = new ByteArrayContent(slice)
@@ -107,9 +118,20 @@ void Log(string request)
107118
}
108119
}
109120

110-
HttpResponseMessage Full() =>
111-
new(HttpStatusCode.OK)
121+
void Count(long bytes)
122+
{
123+
lock (padlock)
124+
{
125+
BytesServed += bytes;
126+
}
127+
}
128+
129+
HttpResponseMessage Full()
130+
{
131+
Count(data.Length);
132+
return new(HttpStatusCode.OK)
112133
{
113134
Content = new ByteArrayContent(data)
114135
};
136+
}
115137
}

0 commit comments

Comments
 (0)