Skip to content

Add OneFuzz harness for ZipRangeExtractor and fix a bounds gap it found - #765

Merged
Nikola Metulev (nmetulev) merged 12 commits into
mainfrom
fuzz-ziprangeextractor
Aug 18, 2026
Merged

Add OneFuzz harness for ZipRangeExtractor and fix a bounds gap it found#765
Nikola Metulev (nmetulev) merged 12 commits into
mainfrom
fuzz-ziprangeextractor

Conversation

@azchohfi

Copy link
Copy Markdown
Contributor

Why

ZipRangeExtractor is the only hand-written parser in winapp that consumes bytes originating off the network. WinDbgJsProviderAcquirer range-downloads the WinDbg .msixbundle and parses its ZIP64 central directory — then a nested inner .msix — to locate JsProvider.dll. That parsing necessarily happens before the extracted DLL's Authenticode signature can be checked, so the archive bytes are untrusted at parse time.

The SDL fuzzing requirement applies to memory-safe languages that use unsafe code or P/Invoke, which winapp does extensively (pointer dereferencing in SlugGenerator/CrashDumpService, P/Invoke into wintrust.dll, d3d11.dll, catalog crypto APIs). This is the one parser sitting on an untrusted-input boundary.

The bug this found

ParseCentralDirectory read three attacker-controlled 16-bit lengths — name, extra, comment — and sliced on all of them while only guarding the 46-byte fixed header:

ArgumentOutOfRangeException x608  (of 3000 seeded mutations)
"Index and count must refer to a location within the buffer."

It now rejects malformed directories with InvalidDataException. To be precise about severity: .NET's bounds checks caught this, so it is not memory corruption, and TryAcquireCoreAsync already swallowed it and degraded gracefully. The value is a correct rejection contract, plus a clean baseline for the fuzzer.

What's here

  • WinApp.Cli.Fuzz — two libFuzzer targets: the pure central-directory parser (high iteration rate) and the full outer → inner → extract descent mirroring ExtractJsProviderAsync.
  • OneFuzzConfig.jsonfuzzer.$type, FuzzingTargetBinaries: winapp.dll so the compliance claim attributes to the shipping binary, and SdlWorkItemId linking claims and bugs to the SDL task.
  • .pipelines/fuzz.ymltrigger: none / pr: none. Fuzzing runs on demand, not per-build; jobs run for hours and file bugs.
  • FuzzHarnessTests — guards that the target signature and OneFuzzConfig.json stay in sync with the code, generates a seed corpus, and asserts every seed is structurally valid.

Verification

Both targets ran on OneFuzz against a seeded corpus:

Target Job Iterations Crashes
ziprangeextractor-centraldirectory f1646fe1-be66-48ed-b9d5-3de43e98dcd0 ~155,000,000 0
ziprangeextractor-archive 2c2fc7a1-4c80-409c-81ac-8660a4934659 ~127,000,000 0

Submission dc437f41-cbf4-49eb-af73-bfc24b224536, 2/2 job configs succeeded, no task errors.

The seed corpus matters more than it looks. libFuzzer mutates existing inputs and will not synthesise a valid ZIP64 central directory from random bytes — an unseeded run looks busy and covers nothing. GenerateSeedCorpus round-trips every seed through the parser and fails if any produces no entries, because a corpus the parser rejects at byte zero is worse than none.

Local: 25/25 tests pass, build-cli.ps1 clean.

ZipRangeExtractor is the only hand-written parser in winapp that consumes
bytes originating off the network: WinDbgJsProviderAcquirer range-downloads
the WinDbg .msixbundle and parses its ZIP64 central directory, and then a
nested inner .msix, before the extracted DLL's Authenticode signature can be
checked. The archive bytes are therefore untrusted at parse time.

ParseCentralDirectory read three attacker-controlled 16-bit lengths (name,
extra, comment) and sliced on all of them while only guarding the 46-byte
fixed header. A seeded mutation run escaped 608 ArgumentOutOfRangeExceptions
in 3000 iterations; it now rejects with InvalidDataException instead.

Adds WinApp.Cli.Fuzz with two libFuzzer targets - the pure central-directory
parser and the full outer/inner descent - plus a validated seed corpus
generator, and guards that the harness signature and OneFuzzConfig stay in
sync with the code. Fuzzing runs from a separate on-demand pipeline rather
than on every build.
Copilot AI balanced review requested due to automatic review settings August 17, 2026 23:23
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 38.62 MB 38.63 MB 📈 +7.5 KB (+0.02%)
CLI (x64) 38.74 MB 38.74 MB 📈 +7.0 KB (+0.02%)
MSIX (ARM64) 16.02 MB 16.02 MB 📈 +0.7 KB (+0.00%)
MSIX (x64) 17.02 MB 17.02 MB 📈 +5.8 KB (+0.03%)
NPM Package 33.42 MB 33.43 MB 📈 +5.1 KB (+0.01%)
NuGet Package 33.46 MB 33.46 MB 📈 +5.6 KB (+0.02%)

Test Results

4596 passed, 5 skipped out of 4601 tests in 612.8s (+20 tests, -1.0s vs. baseline)

Test Coverage

89.1% line coverage, 82.4% branch coverage · ✅ no change vs. baseline

CLI Startup Time

49ms median (x64, winapp --version) · ✅ no change vs. baseline

Try This Build

Installs the MSIX for your architecture, replacing any previously installed build. Needs the GitHub CLI — the command offers to install it and sign you in if it is missing.

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) 765
Switching between builds often?

Put the tool on your PATH once:

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPath

Then this build is just:

winapp-pr 765

Run winapp-pr with no arguments to pick from a list of open PRs.


Updated 2026-08-18 08:45:30 UTC · commit 1a6df98 · workflow run

Path.Join replaces Path.Combine for the fixed relative segments: Combine treats a
rooted later segment as a reset point, so it can silently discard the base
directory. Same swap already applied elsewhere in this test project.

Escape reporting now includes the first exception in full. The aggregate counts
say how reachable a gap is, but only the stack says where it is.

The catch in RunMutationCampaign stays unfiltered by design - it collects
whatever the harness failed to absorb, and narrowing it would hide the very
exception types the test exists to surface. Documented in place.
Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/winapp-CLI/WinApp.Cli.Fuzz/FuzzableCode.cs:118

  • Suppressing every ArgumentOutOfRangeException masks the same class of parser bounds gaps this harness is intended to find. For example, an input ending with only the EOCD signature reaches tail.AsSpan(eocd + 10) in FindCentralDirectoryAsync and throws this exception, but OneFuzz reports it as a normal rejection. Translate out-of-range reads to a dedicated expected rejection at the reader boundary, then let parser-originated ArgumentOutOfRangeException escape.
        or ArgumentOutOfRangeException;  // range read outside the archive, which both range readers reject

src/winapp-CLI/WinApp.Cli.Fuzz/FuzzableCode.cs:81

  • BuildNestedBundle puts AppxMetadata/AppxBundleManifest.xml before the inner MSIX. This first iteration treats the XML as an archive, FindCentralDirectoryAsync rejects it, and the method-level catch exits the entire loop, so the seeded target never reaches the nested MSIX or its entries. Catch expected rejection per candidate and continue to the next outer entry.

This issue also appears on line 118 of the same file.

                VisitAsInnerArchive(reader, inner);

Comment thread src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs
Collecting an OOM and then continuing to allocate for the rest of the campaign is unsound. Everything else stays unfiltered, which is the point of the test.
Three related problems, all found in review:

- IsExpectedArchiveRejection absorbed ArgumentOutOfRangeException, which conflated a range read past EOF (malformed input) with the parser indexing past a buffer it holds (a bounds bug). BoundedRangeReader now reports the former as InvalidDataException so the latter escapes.

- FindCentralDirectoryAsync read the fixed EOCD fields without checking the 22-byte record actually fit, so any archive whose tail ends in PK05 06 walked off the buffer. 29 of 3000 seeded mutations reach it.

- The try wrapped the whole entry loop, so the first non-archive entry ended the walk before it reached the nested msix. It is now per entry.
@azchohfi

Copy link
Copy Markdown
Contributor Author

Follow-up on the Copilot review

Both of the suppressed comments were correct, and they were the two most valuable findings on this PR. Fixed in the latest push.

1. The archive target was swallowing the bug class it exists to find.

IsExpectedArchiveRejection listed ArgumentOutOfRangeException, on the reasoning that a range read past the end of the archive is a normal rejection. It is - but it was collapsing two very different things: "the parser asked the reader for bytes past EOF" (fine, malformed input) and "the parser indexed past a buffer it already holds" (a bounds bug). The fix is the one suggested: translate the reader-level case, then let parser-originated ones escape. BoundedRangeReader now converts an out-of-range read into InvalidDataException, and ArgumentOutOfRangeException is no longer in the expected set.

2. There was a second, real bounds gap behind it - the exact one called out.

FindCentralDirectoryAsync locates the EOCD signature and then reads tail.AsSpan(eocd + 10 / +12 / +16) without ever checking that the full 22-byte record fits. Since the signature can legitimately appear in the final 21 bytes, any archive whose tail ends in PK\x05\x06 walks off the buffer. It now rejects with InvalidDataException, matching the ParseCentralDirectory fix already in this PR.

This is not theoretical. Disabling only the new guard and re-running the mutation campaign:

Assert.AreEqual failed. Expected:<0>. Actual:<29>.
FuzzArchive let 29 unexpected exception(s) escape:
ArgumentOutOfRangeException x29 (e.g. ... (Parameter 'length'))

29 of 3000 mutations reach it. With the guard, clean. That is also the proof that the harness is no longer blind to this class - before this change all 29 were silently absorbed as "expected".

Production is not crashing on this today: WinDbgJsProviderAcquirer.TryAcquireCoreAsync has a catch-all and degrades gracefully. Which is really the point - because production tolerates everything, "what production tolerates" was the wrong thing to model the filter on. The harness has to enforce a tighter contract than the caller does, or it validates nothing.

3. The archive target was also much shallower than intended.

BuildNestedBundle puts AppxMetadata/AppxBundleManifest.xml first, and the try wrapped the whole foreach. So the first entry (XML, not an archive) threw, the catch outside the loop swallowed it, and the walk ended before ever reaching the nested .msix or its entries. The catch is now per entry, so a non-archive entry no longer ends the walk.

This one has a consequence beyond the code: the OneFuzz run I recorded on ziprangeextractor-archive (126,910,485 iterations, 0 crashes) was exercising a shallower path than intended, so that clean result is weaker evidence than it looks. The central-directory target is unaffected. I would re-run the archive job against the merged commit before treating its result as evidence.

The remaining inline comment about Describe being evaluated eagerly is answered in-thread - it was not correct for MSTest v4, but I made the helper total anyway.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:172

  • These output directories are reused across test runs but never cleared. If a seed is renamed or removed, its old file remains under bin, and the final file-count assertions fail until the developer manually cleans the build output. Delete the corpus root before recreating the two directories so generation is deterministic.
        var root = Path.Join(AppContext.BaseDirectory, "fuzz-corpus");
        var archiveDir = Path.Join(root, "ziprangeextractor-archive");
        var directoryDir = Path.Join(root, "ziprangeextractor-centraldirectory");
        Directory.CreateDirectory(archiveDir);
        Directory.CreateDirectory(directoryDir);

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:69

  • The last raw EOCD signature is not necessarily the EOCD record: an archive comment may legally contain PK\x05\x06. If the comment ends with that sequence, eocd points at the final four bytes and this guard rejects an otherwise valid archive instead of continuing backward to the real record. Scan backward for a candidate with a complete fixed record whose declared comment length reaches the archive end, and add a regression test with the signature in a valid comment.
        // The signature can appear in the last 21 bytes, leaving the fixed fields below truncated.
        if (eocd + MinEocdSize > tail.Length)
        {
            throw new InvalidDataException(
                $"End-of-central-directory record at offset {eocd} is truncated: {tail.Length - eocd} of {MinEocdSize} bytes present.");

src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:107

  • This only verifies that the array is nonempty, so changing it to a non-shipping binary such as WinApp.Cli.Fuzz.dll would still pass while OneFuzz no longer attributes the compliance claim to winapp.dll. Assert that winapp.dll is present, which is the invariant described by the message and PR.

This issue also appears on line 168 of the same file.

            Assert.IsTrue(target.GetProperty("FuzzingTargetBinaries").GetArrayLength() > 0,
                "FuzzingTargetBinaries must name the shipping binary, or OneFuzz generates no claim.");

A ZIP comment may legally contain PK05 06, so taking the last match landed inside the comment and rejected a valid archive. FindEocd now scans backward for a candidate with complete fixed fields whose declared comment length runs to the end of the archive, which also subsumes the truncated-record guard.

Also assert FuzzingTargetBinaries actually names winapp.dll rather than merely being non-empty, and clear the seed corpus directory so a renamed seed cannot leave a stale file behind and break the counts.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:238

  • This still accepts a complete EOCD-shaped sequence inside a legal ZIP comment. For example, append a 22-byte zero-field EOCD as the comment and set the real EOCD's comment length to 22: the reverse scan accepts the fake record at the start of the comment and returns offset/size zero, so a valid non-empty archive parses as empty. The added test covers only a four-byte truncated signature. Candidate selection needs to validate the referenced central directory (and ZIP64 chain where applicable) and continue scanning when that validation fails.
            var commentLen = BinaryPrimitives.ReadUInt16LittleEndian(tail.Slice(i + 20));
            if (i + MinEocdSize + commentLen == tail.Length)
            {
                return i;

@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
A whole zeroed EOCD placed in a real archive's comment satisfies the comment-length rule, so the backward scan returned it and a non-empty archive parsed as empty. Prefer a record that declares a directory, falling back to the empty one only when there is nothing better.

Also strip a duplicated UTF-8 BOM from FuzzableCode.cs; it did not break either the local or CI build, but it was unintended and inconsistent with the sibling file.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:188

  • Every generated seed here is a small ZipArchive, so it uses ZIP32 EOCD and 32-bit entry fields. The corpus therefore does not seed either the ZIP64 locator/record path or ZIP64 extra-field parsing, despite the harness targeting this ZIP64 parser and the remarks noting libFuzzer will not synthesize that structure. Add at least one valid ZIP64 archive seed (and its carved directory seed), ideally including the nested path.
        var seeds = new Dictionary<string, byte[]>
        {
            ["minimal-stored"] = BuildZip([("a.bin", Encoding.UTF8.GetBytes("stored"), CompressionLevel.NoCompression)]),
            ["deflate"] = BuildZip([("a.bin", FakePe(), CompressionLevel.Optimal)]),
            ["multi-entry"] = BuildZip([("a.bin", Encoding.UTF8.GetBytes("one"), CompressionLevel.NoCompression),

src/winapp-CLI/WinApp.Cli.Fuzz/FuzzableCode.cs:161

  • The wrapped reader checks offset + length, which can overflow for a positive ZIP64-derived offset near long.MaxValue; that bypasses its guard and Array.Copy can then throw ArgumentException. This catch misses that range failure, so the harness reports a false parser crash even though its contract says out-of-archive reads become InvalidDataException. Catch the base argument exception from this byte-array reader, or validate the range with an overflow-safe comparison first.
        catch (ArgumentOutOfRangeException ex)

continue;
}

return i;
Preferring a non-empty record only covered one fake shape: a comment can hold a record with any count/size/offset. Candidates are now walked backward and accepted only once the declared directory is inside the archive and starts with a central header, with a no-directory record kept as a fallback for a genuinely empty archive.

Also make the memory reader's bounds check overflow-safe, since offset + length wraps for a ZIP64-sized offset and slipped past the guard into Array.Copy, and seed the corpus with ZIP64 archives so the locator/record path is reachable at all - ZipArchive never emits ZIP64 at these sizes and a mutator will not synthesise it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:74

  • A legal ZIP comment can contain a fake EOCD with a ZIP64 marker. If that candidate has no valid locator/record, ReadZip64DirectoryAsync throws and exits the loop, so the scanner never reaches the real earlier EOCD and rejects an otherwise valid archive. Treat an invalid ZIP64 record as a rejected candidate and continue scanning; only return after validating the resolved directory.
            {
                // The ZIP64 record carries its own signature, so that path rejects a fake for us.
                (cdOffset, cdSize) = await ReadZip64DirectoryAsync(
                    reader, archiveBase, tail, tailStart, eocd, cancellationToken);
                return (archiveBase + cdOffset, cdSize);

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:107

  • Checking only the first four bytes does not confirm the candidate's declared directory. A fake EOCD in the comment can set count = 1, cdSize = 4, and cdOffset to the real directory; this check succeeds, the caller parses only four bytes as zero entries, and the real EOCD is never considered. Validate the complete declared size/count (or another structural relationship to the candidate EOCD) before accepting it.
            return false;
        }

        var head = await reader.ReadAsync(archiveBase + cdOffset, 4, cancellationToken);
        return BinaryPrimitives.ReadUInt32LittleEndian(head) == CentralHeaderSignature;

src/winapp-CLI/WinApp.Cli.Tests/FuzzHarnessTests.cs:244

  • These ToZip64 seeds exercise only the ZIP64 EOCD path. The helper leaves every central-directory size/offset as a normal 32-bit value and adds no 0x0001 extra field, so ApplyZip64Extra is never reached despite the stated corpus goal. Add a seed whose central header uses 0xFFFFFFFF markers and carries a valid ZIP64 extra field.
    /// <see cref="ZipArchive"/> only emits ZIP64 for archives too large to build here, so without this
    /// the corpus never reaches the ZIP64 locator/record path or the ZIP64 extra field — the parts a
    /// mutator cannot synthesise on its own.

A comment-planted record carrying ZIP64 markers made ReadZip64DirectoryAsync throw straight out of the candidate loop, so a valid archive was rejected before the real record was reached. That is now a rejected candidate.

Directory validation also required only a signature, which a record declaring count=1 and a 4-byte size could satisfy; it now requires room for a header and that the directory runs up to the record describing it. The ZIP64 branch keeps relying on its own record signature, since the resolved directory is not adjacent to the EOCD there.

Adds a seed whose central header carries a 0x0001 extra field, since ToZip64 alone never reaches ApplyZip64Extra.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:86

  • This ZIP64 path returns immediately without the confirmation applied to ZIP32 candidates. Because ZIP comments may contain arbitrary bytes, a comment can legally contain a forged ZIP64 EOCD record, locator, and marker-bearing EOCD; the signature checks all pass and the attacker-controlled directory offset/size wins over the real EOCD. Validate that the resolved directory is in the archive, begins with a central header, and ends at the referenced ZIP64 record before accepting this candidate.
                return (archiveBase + cdOffset, cdSize);

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:286

  • This leaves two consecutive <summary>/<remarks> blocks on NextEocdCandidate; the first is stale documentation from the removed finder and causes generated API documentation to expose duplicate summaries. Keep only the block that documents the current method.
    /// <summary>
    /// Finds the end-of-central-directory record in the archive tail.
    /// </summary>

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:83

  • A fake marker-bearing candidate can fail with more than InvalidDataException: its locator's 64-bit record offset is used for ReadAsync without bounds validation, so an invalid/overflowed offset produces ArgumentOutOfRangeException for the memory reader and ultimately IOException for the HTTP reader. Those exceptions abort the scan instead of continuing to the genuine EOCD earlier in an otherwise valid archive comment. Validate the locator/record range and convert candidate-format failures to InvalidDataException before reading, while preserving genuine transport failures.

This issue also appears on line 86 of the same file.

                catch (InvalidDataException)
                {
                    // A marker-bearing record planted in a comment has no usable locator or record;
                    // keep scanning rather than failing the whole archive.
                    continue;

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:337

  • Although the new guard avoids overflowing offset + length, the exception message still performs that same addition. For the newly tested near-long.MaxValue case it reports a wrapped negative end offset, making the diagnostic misleading. Format the start and length separately instead.
        if (offset < 0 || length < 0 || offset > data.Length || length > data.Length - offset)

The ZIP64 branch returned on signature checks alone, so a comment carrying a forged locator and record could supply the directory. It now gets the same confirmation as ZIP32, anchored on the record it points at rather than the EOCD.

The locator's 64-bit record offset also went straight to the reader, turning a malformed archive into an out-of-range or transport error that aborted the scan; it is bounds-checked into a rejection first. The two ZIP64 tests now build a real central header instead of a placeholder.

Also drops a stale duplicate doc block and stops the reader's message computing the sum its own guard avoids.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:101

  • The empty fallback accepts an unvalidated offset and preserves the newest fake candidate because of ??=. For example, a zero-entry EOCD with cdSize == 0 and cdOffset == uint.MaxValue is returned as a successful location even though it is outside the archive; in an empty archive with a fake EOCD in its comment, the fake also wins over the earlier real record. Require the empty directory to end at this EOCD and let the earlier candidate replace a later comment candidate.
            if (count == 0 && cdSize == 0)
            {
                emptyFallback ??= (archiveBase + cdOffset, 0);
                continue;

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:104

  • Every syntactically valid candidate that reaches this branch performs another IRangeReader read before scanning continues. A 64 KiB tail can contain roughly 3,000 consecutive 22-byte EOCD candidates whose comment lengths all run to EOF and whose directory heads are invalid; with the production HttpRangeReader, one malformed archive therefore triggers thousands of sequential HTTP range requests. Reuse the already-downloaded tail for heads that fall inside it and place a bound or batching strategy around any external candidate probes.
            // In a ZIP32 archive the directory runs right up to the record describing it.
            if (await IsCentralDirectoryAsync(reader, archiveBase, archiveSize, cdOffset, cdSize, tailStart + eocd, cancellationToken))

Comment on lines +132 to +133
var head = await reader.ReadAsync(archiveBase + cdOffset, 4, cancellationToken);
return BinaryPrimitives.ReadUInt32LittleEndian(head) == CentralHeaderSignature;
Confirming candidates issued a read per candidate, so a 64 KiB comment packed with EOCD-shaped bytes could drive thousands of sequential range requests through HttpRangeReader. Heads inside the already-downloaded tail are now served from it, and reads outside it are capped.

The empty fallback also kept the first candidate found scanning backward, which is the one nearest the comment, and never checked its offset. It now has to sit where an empty directory would, and an earlier record replaces a later one.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/winapp-CLI/WinApp.Cli.Tests/ZipRangeExtractorTests.cs:330

  • These candidates do not exercise the ranged-read cap: pos remains within the last 20,000 bytes, so pos - 46 is also inside the 65,557-byte tail and IsCentralDirectoryAsync returns with Probed == false. The test therefore passes with only the initial tail read even if the probe limiter is removed. Make the directory start outside the tail while preserving cdOffset + cdSize == pos.
            WriteU32(data, pos + 12, 46);
            WriteU32(data, pos + 16, (uint)(pos - 46));

Comment on lines +80 to +84
catch (InvalidDataException)
{
// A marker-bearing record planted in a comment has no usable locator or record;
// keep scanning rather than failing the whole archive.
continue;
The test pointed each fake directory just before its own record, which is inside the tail, so every candidate resolved locally and the test passed with the limiter removed. The directories now start at offset 0, outside the tail: without the budget the scan issues 910 reads and the test fails.

Failed ZIP64 candidates also skipped the budget even though resolving one can scan for a locator and issue a ranged read before throwing, so a comment full of marker-bearing records could still amplify.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/winapp-CLI/WinApp.Cli/Helpers/ZipRangeExtractor.cs:85

  • A marker-bearing candidate is charged even when ReadZip64DirectoryAsync fails because no locator exists, which performs no range read. A valid ZIP comment containing eight ZIP64-shaped EOCD records therefore exhausts this budget and breaks before the real EOCD is examined. Track whether resolution actually read outside the tail (or handle the no-locator case without charging) so the network-read cap does not reject valid archives based only on comment bytes.
                    if (++probes >= MaxDirectoryProbes)

Charging every failed ZIP64 candidate was too blunt: a candidate whose locator is missing performs no read at all, so a legal comment holding a handful of ZIP64-shaped records could exhaust the budget and reject a valid archive. Resolution now reports failure instead of throwing, along with whether it actually read outside the tail, and only that is charged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

@nmetulev
Nikola Metulev (nmetulev) merged commit 9e5cb0c into main Aug 18, 2026
31 checks passed
@nmetulev
Nikola Metulev (nmetulev) deleted the fuzz-ziprangeextractor branch August 18, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants