Skip to content

fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484] - #25

Open
maunilm wants to merge 19 commits into
mainfrom
fix/DEVA11Y-484-bsdtar-size-limit
Open

fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484]#25
maunilm wants to merge 19 commits into
mainfrom
fix/DEVA11Y-484-bsdtar-size-limit

Conversation

@maunilm

@maunilm maunilm commented May 29, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a decompressed-size and entry-count guard to the CLI download/extract path, so a decompression bomb cannot exhaust developer or CI-runner disk.

Fixes DEVA11Y-484 (F-015, CWE-400, umbrella APPSEC-415).

Scope

This PR is deliberately narrowed to DEVA11Y-484's stated Remediation. Work that was previously in this branch — the regression suite, its CI workflow, and the Windows Expand-Archive backstop — was removed and is tracked in DEVA11Y-761, preserved on branch chore/DEVA11Y-484-followup-extraction-guard-harness. See Known gaps below.

Changes

Swift pluginPlugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift

  • startExtractionWatchdog polls the extraction directory every 50 ms while bsdtar runs and terminate()s it once the decompressed footprint crosses maxDecompressedBytes (200 MB) or maxArchiveEntries (10,000). A soft ceiling by design: peak disk ≈ maxBytes + (50 ms × write rate).
  • A post-exit footprintExceeded re-check catches a bomb that finishes inside one poll interval.
  • locateExecutable throws past 10,000 entries, per the ticket's ask.
  • maxCompressedBytes (100 MB) checked against response.expectedContentLength and the downloaded file's actual size.

Attached to extractLocalArchive, which since #37 (DEVA11Y-473/474) is the single non-Windows extraction path — the archive is downloaded to a file and checksum-verified first, then extracted. The old streaming curl | bsdtar path that #37 deleted is gone, so there is no separate remote guard.

Launchersscripts/{bash,zsh,fish}/cli.sh

Verification

No automated suite ships with this PR (see Known gaps), so this was verified directly against the live download endpoint:

  • 27/27 assertions across bash/zsh/fish: real download exits 0 through fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's integrity check (38,017,898 B archive → 69,391,104 B binary, perms 0755); .tmp cleaned up after publish; re-run byte-identical; corrupt payload rejected; and the previously-cached binary's sha256 is unchanged after a rejected payload.
  • Guard fires: with a 1 MB cap the pipeline returns non-zero at exactly the cap; with the real 200 MB cap it is clean.
  • Swift guard, compiled standalone and driven against the real archive: real 200 MB cap does not flag (termStatus=0, 69,391,104 B, 1 entry); a 5 MB cap flags and SIGTERMs bsdtar mid-stream (termStatus=15), bounding disk to 36 MB of 66 MB; maxEntries=0 flags on entry count.
  • Compressed cap: with a 1 MB cap curl aborts non-zero with nothing written; the real 100 MB cap passes the 38 MB archive.
  • pipefail save/restore verified in both directions.
  • swiftc -typecheck -parse-as-library clean; bash -n clean on all three launchers; all six .sha256 sidecars verify; self-update's own comparison matches for all three.

Headroom (CLI v1.52.1): largest platform archive is 41 MB compressed (2.4× under the 100 MB cap) and ~75.6 MB decompressed (2.65× under the 200 MB cap). The caps are duplicated in four places — they must move together when the CLI outgrows them.

Known gaps — owned, not hidden

  1. No automated regression coverage ships with this PR. The suite was descoped; nothing prevents a future refactor from silently dropping the caps. DEVA11Y-761 item 1, and the highest-value follow-up.
  2. Windows Expand-Archive is unguarded. No download cap enforcement mid-stream, no watchdog, no entry ceiling on that branch. Unchanged from main, but a real gap. DEVA11Y-761 item 3.
  3. The Swift compressed cap rejects after the transfer, not mid-stream. URLSession.download(from:) has no byte-level hook, so an oversized archive is stopped before checksum/extract/exec but peak temporary disk during transfer is not bounded. Needs a URLSessionDownloadDelegate cancelling in didWriteData. The launchers do abort during transfer. DEVA11Y-761.
  4. The launchers have no entry-count equivalent to the plugin's maxArchiveEntries. In -O mode an archive of millions of empty entries streams ~0 bytes, so head -c never fires; disk stays bounded but bsdtar still parses every entry. Open for review discussion — no cheap mechanism in -O mode.
  5. Linux launcher path untested. Verified on macOS/arm64 against the live endpoint; CI has no Linux launcher job.

Note on the ticket's threat model

DEVA11Y-484 states the download has "no TLS, per scope.md:65". The URL in code is https:// and the live endpoint serves HTTPS with a 302 to https://sdk-assets.browserstack.com, which weakens the stated MitM reachability behind the AV:N / CVSS 5.3 rating. The BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL override remains a genuine vector, so the fix stands — but the premise as written is inaccurate.

Refs DEVA11Y-484, DEVA11Y-761, APPSEC-415.

…mb DoS [DEVA11Y-484]

CWE-400 / OWASP A05. bsdtar was invoked with no decompressed-size or
entry-count limit in both the Swift SPM plugin and the bash/zsh/fish CLI
wrappers, so an attacker who can influence the download URL (the
HTTPS-only --download-url / BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL override,
or TLS interception) could serve a decompression bomb that exhausts the
developer/CI disk.

Swift plugin (BrowserStackAccessibilityLint.swift):
- curl now passes --max-filesize (100 MB) to cap the compressed download.
- A background watchdog terminates bsdtar once the *decompressed* footprint
  on disk exceeds 200 MB (a pipe-level cap would only bound compressed
  bytes, which is useless against a bomb). Applied to both the remote and
  local extraction paths.
- locateExecutable now bounds enumeration at 10,000 entries.

Shell wrappers (bash/zsh/fish cli.sh):
- curl --max-filesize caps the compressed download.
- bsdtar output is piped through `head -c` (200 MB) with pipefail so an
  oversized archive aborts instead of filling the disk.

Real CLI artifact is ~34 MB compressed / ~64 MB decompressed, so the caps
leave ~3x headroom and do not affect legitimate downloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maunilm
maunilm requested a review from a team as a code owner May 29, 2026 12:28
maunilm and others added 2 commits June 2, 2026 16:44
…on guard [DEVA11Y-484]

Adds local integration tests (no mocks) that exercise the decompression-bomb
guards against real curl/bsdtar/head and the real Swift watchdog, plus hardens
the guard itself based on what the tests surfaced.

Guard hardening (Plugins/BrowserStackAccessibilityLint.swift):
- The watchdog now also terminates bsdtar on an entry-count ceiling, closing the
  "millions of tiny files" bomb that stays small on disk (previously only
  locateExecutable caught it, after the fact).
- Added a post-extraction footprint check so detection is deterministic on fast
  disks: a bomb that finishes decompressing within a single 200ms poll interval
  is now caught and cleaned up rather than slipping past the live watchdog.
- Refactored the guard into a self-contained, marked block of free functions so
  it can be mirrored and drift-checked.

Tests (scripts/test/, run via run_tests.sh):
- Shell: extracts the REAL download_binary from bash/zsh/fish verbatim and runs it
  against a local server (only the hardcoded URL is redirected, via a curl shim).
- Swift: a mirror harness compiles the guard block verbatim and drives real
  curl/bsdtar; check_drift.sh fails CI if the mirror diverges from the plugin
  (SwiftPM command plugins can't be imported by a test target).
- Scenarios: legit (downloads/extracts/runs), 400MB bomb, 20k-entry bomb,
  oversized (>100MB) download, corrupt archive, multi-file, missing URL.
- Fixtures are bounded (≤400MB, gitignored) and bomb tests use a small cap, so a
  regressed guard can never exhaust the disk. Full run ~9s, disk usage flat.
- CI: .github/workflows/extraction-guard-tests.yml runs the suite on macOS for PRs
  touching the download/extract path.

53/53 assertions green locally; real production artifact (34MB/64MB) verified to
pass through the new extraction path and run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… live termination [DEVA11Y-484]

Addresses gaps found by stress-testing the guard rather than just asserting the
happy path:

- Measured overshoot: at a 200ms poll, bsdtar could write ~270-380MB past the cap
  on a fast disk before the watchdog tripped (the cap was far softer than the
  "200 MB" message implied). Tightened the poll to 50ms — a 10MB cap now peaks at
  ~34MB and a 2GB bomb is killed at ~224MB. Documented the cap as an explicit SOFT
  ceiling whose purpose is preventing disk *exhaustion*, not exact byte enforcement.
- Windows Expand-Archive path was completely unguarded. Added a platform-agnostic
  post-extraction footprint backstop in the common path (typecheckable on macOS)
  so Windows rejects + cleans up a bomb before the binary is used.
- Strengthened tests to assert the LIVE watchdog fires (bsdtar SIGTERM, status 15)
  and that peak disk stays bounded below the bomb size — previously the bomb tests
  would have passed even if only the post-extraction check worked (which would let
  a multi-GB bomb fill the disk).
- Added test_large_bomb.sh (opt-in via DEVA11Y_DEEP=1): proves a 2GB bomb is
  bounded to ~224MB. Kept out of the default CI run to keep it fast/bounded.
- README now documents the real limitations: soft cap + overshoot, Windows is
  post-hoc only, the Swift suite tests a mirror (not the compiled plugin) with the
  call sites typecheck-only, and locateExecutable's cap is defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
maunilm and others added 5 commits August 27, 2026 13:31
Brings the branch up to date with main (e4bb5dc) to clear the merge conflict
and regenerates the self-update checksum sidecars.

Conflicts (4) and how they were resolved:

* Plugins/.../BrowserStackAccessibilityLint.swift — main (#32, DEVA11Y-482)
  refactored prepareArtifact to extract into a staging directory and atomically
  publish it to the version directory. This branch's decompression-bomb backstop
  was written against the old flow and checked versionDirectory after extraction.
  Kept main's staging/publish architecture and moved the backstop to check
  stagingDirectory *before* publishVersionDirectory, cleaning up staging on
  rejection. This is stricter than the original: a rejected archive now never
  becomes a visible version directory at all.

* scripts/{bash,zsh,fish}/cli.sh — main (#36, DEVA11Y-752) added strip_quarantine
  and tightened chmod 0775 -> 0755; this branch added the compressed/decompressed
  size caps. Both were kept: curl --max-filesize plus the bsdtar | head -c guard
  and the size assertion, then main's chmod 0755 and strip_quarantine. main's
  chained `&&` is unnecessary here because the size guard exits non-zero on
  failure, so reaching the chmod means extraction succeeded. Took main's 0755
  (dropping group-write) rather than reverting its hardening.

Test fixes required by the merge:

* test_shell_extraction.sh asserted chmod 775; updated to 755 to match main.

* load_download_binary awk-extracts only download_binary() and sources it in
  isolation, so the newly-called strip_quarantine was undefined and every
  success-path case exited 127 after an otherwise correct extraction. The loader
  now extracts strip_quarantine too, with a faithfulness check for it.

Verification on this merge commit:

* scripts/test/run_tests.sh — ALL GREEN: drift check passed, shell wrappers
  36/36, Swift plugin guard 19/19 (baseline pre-merge was also 36/36 and 19/19).
* Merged plugin typechecks clean (swiftc -typecheck -parse-as-library against
  the PackagePlugin API), matching main's baseline.
* All six scripts/*/{cli,spm}.sh.sha256 sidecars verify with sha256sum -c;
  the three cli.sh sidecars were regenerated (they failed before this commit,
  which would have broken the verify-selfupdate-checksums gate added in #30).
* bash -n clean on all three wrappers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… [DEVA11Y-484]

The verify-selfupdate-checksums gate (added on main in #30, DEVA11Y-475) globs
`scripts/**/*.sh` with globstar and requires a committed `<script>.sha256`
sidecar for every match. This branch added seven support scripts under
scripts/test/ — run_tests.sh, check_drift.sh, make_fixtures.sh, lib/assert.sh,
test_{shell,swift}_extraction.sh, test_large_bomb.sh — none of which has a
sidecar, so the gate failed as soon as main was merged in.

Generating sidecars for them would be wrong: that workflow exists because
self-update *fetches each launcher script from main and verifies it against its
sidecar*. These test scripts are never fetched or verified at runtime, so a
sidecar would assert a protection that does not exist, and every future edit to
a test script would need a checksum regen.

Moving the suite under tests/ fixes it at the source and needs no change to the
security workflow: scripts/ once again contains only the six self-updating
launchers (bash/zsh/fish x cli.sh,spm.sh), all of which have matching sidecars.
It also matches the convention main established in #35, which put its own
harnesses (and tests/spm/scripts/run-a11y-scan.sh) under tests/.

The move is path-transparent: every script resolves paths via
HERE="$(dirname "${BASH_SOURCE[0]}")" and REPO="$HERE/../..", and
tests/extraction-guard/../.. is still the repo root, so no script body changed.

Updated references:
* .github/workflows/extraction-guard-tests.yml — path filter and the run: line
* Plugins/.../BrowserStackAccessibilityLint.swift — drift-mirror doc comments
* swift-harness/Sources/ExtractionHarness/Guard.swift — same doc comments
* tests/extraction-guard/README.md — invocation path
* tests/README.md — added an index row for the suite, labelled as a security
  regression suite rather than a consumer-project harness

Verification after the move:
* bash tests/extraction-guard/run_tests.sh — ALL GREEN: drift check passed,
  shell wrappers 36/36, Swift plugin guard 19/19.
* verify-selfupdate-checksums logic replicated locally: scripts/**/*.sh now
  matches exactly the six launchers, every sidecar present and matching — gate
  passes with the workflow file unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR had grown to +1044/-7 across 18 files for a ticket sized XS. Only 216 of
those lines were the production fix; the rest was test infrastructure and CI the
ticket never asked for. Narrowed to exactly what DEVA11Y-484's Remediation
section specifies, so the security change is reviewable on its own.

Kept — the ticket's three requirements:

1. Swift streaming guard, 200 MB decompressed. "interpose a byte-counting
   wrapper ... that calls Process.terminate() on bsdtar if a threshold is
   crossed" — implemented as startExtractionWatchdog on both bsdtar paths
   (remote stream and local archive), with a post-exit footprint re-check to
   catch a bomb that completes inside one poll interval.
2. Shell guard. "pipe the curl output through head -c 209715200 (200 MB)" —
   implemented verbatim in all three launchers, with pipefail so bsdtar's
   SIGPIPE surfaces as a failure, plus an explicit size assertion.
3. locateExecutable entry cap. "the locateExecutable enumerator should impose a
   maximum file-count cap" — maxArchiveEntries = 10_000, throws when exceeded.

Removed — out of scope, deferred (all preserved on
chore/DEVA11Y-484-followup-extraction-guard-harness):

* tests/extraction-guard/ — the 13-file, ~799-line regression harness (shell
  variants, Swift mirror harness, drift check, fixture generator).
* .github/workflows/extraction-guard-tests.yml — the CI job that runs it.
* Compressed-size cap: maxCompressedBytes and curl --max-filesize in the plugin,
  and the same in all three launchers. The ticket asks for a 200 MB
  *decompressed* cap; capping the wire size is separate hardening. The curl
  invocation now matches main byte-for-byte.
* The prepareArtifact-level footprintExceeded backstop. It covered the Windows
  Expand-Archive path, which the ticket did not scope (it targets the bsdtar
  paths). Windows therefore remains unguarded — carried on the follow-up branch.
* tests/README.md index row and the plugin's drift-mirror comment, both of which
  referenced the removed harness.

The three cli.sh.sha256 sidecars were regenerated after dropping --max-filesize.

Verification (the harness is gone, so this was done directly):
* Real endpoint, merged download_binary, macos/arm64: exit 0, 38,017,898 B
  archive -> 69,391,104 B binary, perms 755, not truncated.
* Guard proven to fire: same archive with a 1 MB cap gives pipeline status 1 at
  exactly the cap, so the abort path triggers; with the real 200 MB cap the
  pipeline is clean. Worst-case platform is macos/x64 at ~75.6 MB decompressed,
  2.65x headroom.
* swiftc -typecheck -parse-as-library against the PackagePlugin API: clean.
* bash -n clean on all three launchers.
* verify-selfupdate-checksums logic replicated locally: all six sidecars present
  and matching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves the conflict introduced by #37 (DEVA11Y-473/474, "verify downloaded CLI
binary integrity before exec"), which landed on main after the previous merge and
rewrote the same download/extract paths this branch guards.

Conflicts (7): the plugin, the three cli.sh launchers, and their three sidecars.

Plugin — took main's side wholesale. #37 deleted extractRemoteArchive entirely,
replacing the streaming `curl | bsdtar` with download-to-file ->
verifyArchiveChecksum -> extractLocalArchive (or unzip on Windows), precisely so
the payload can be verified before it is extracted and executed. This branch's
watchdog on that streaming path therefore no longer has a path to guard, so the
59-line block was dropped rather than reinstated.

The DEVA11Y-484 guard is unaffected in substance and is now simpler: the watchdog
already sits on extractLocalArchive, which after #37 is the single non-Windows
extraction path for both remote and local archives. The locateExecutable
10_000-entry cap is untouched. Windows' unzip path remains unguarded, as before
(tracked on DEVA11Y-761).

Launchers — combined both changes rather than picking a side:
* Kept #37's `curl -fR -z ... -w '%{url_effective}'` with its `return 1`,
  verify_binary_integrity with its `return $?` passthrough, and the
  stage-to-.tmp / chmod / `mv -f` / strip_quarantine publish chain.
* Moved this branch's `head -c "$max_decompressed"` guard onto that staged path
  (`${BINARY_PATH}.tmp`) instead of `$BINARY_PATH`. This matters: writing the cap
  directly to $BINARY_PATH would reintroduce exactly the bug #37 fixed — a
  rejected payload truncating a previously-good cached binary. The rejection path
  now removes only the .tmp file.
* Switched the guard's failure from `exit 1` to `return 1`, matching #37's
  contract (the call site is `download_binary || exit $?`, which also preserves
  the distinct exit 2 for an integrity mismatch). This removes the behaviour
  divergence the previous merge had introduced.

Sidecars regenerated for all three launchers.

Verification on this merge commit:
* 27/27 assertions across bash/zsh/fish against the live download endpoint: real
  download exits 0 through #37's integrity check, binary 69,391,104 B at perms
  0755, .tmp cleaned up after publish, re-run byte-identical, corrupt payload
  rejected — and, critically, the previously-cached binary SURVIVES a rejected
  payload with an unchanged sha256, confirming #37's protection is intact rather
  than undone by the cap.
* swiftc -typecheck -parse-as-library against the PackagePlugin API: clean.
* bash -n clean on all three launchers.
* All six sidecars verify; self-update's own comparison (awk first field vs
  shasum -a 256) matches for all three.
* Confirmed no #37 feature lost: verify_binary_integrity, mv -f, url_effective
  and `curl -fR -z` all present at main's counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…11Y-484]

Two comments this branch added still described the streaming curl | bsdtar path
that #37 (DEVA11Y-473/474) deleted, so they pointed at code that no longer exists:

* the extractLocalArchive call-site said "same rationale as the remote path"
* the EXTRACTION GUARD block's rationale was framed around capping the
  "curl→bsdtar pipe"

Reworded to describe what the guard actually attaches to now, and stated
explicitly that extractLocalArchive is the single non-Windows extraction path
since #37 (download to file, checksum-verify, then extract) and that Windows'
unzip path has no streaming guard.

Comment-only; no behaviour change. Guard block re-verified against the real CLI
archive after the edit: real 200 MB cap does not flag (termStatus 0, 69,391,104 B,
1 entry); a 5 MB cap flags and SIGTERMs bsdtar mid-stream (termStatus 15, disk
bounded to 36 MB of 66 MB); maxEntries=0 flags on entry count. swiftc
-typecheck -parse-as-library clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Crash0v3rrid3 Crash0v3rrid3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Multi-agent code review — decompression-bomb guard (DEVA11Y-484)

Reviewed across security/adversarial, Swift concurrency, and shell-correctness lenses plus first-party verification against the PR head. The guard that ships is correctly implemented — but the PR description overstates the implementation on two verified counts, and I'd hold merge until those are reconciled.

What's correct (verified — no action needed)

  • Swift concurrency is sound: watchdog thread lifecycle has no race/leak, terminate()waitUntilExit()removeItem ordering is safe, ExtractionLimitState's NSLock is correct, and forwardExit is -> Never/exit (no fall-through).
  • Shell logic is correct: the bomb is genuinely caught (head -c cap → SIGPIPE → 141 via pipefail, with -ge as a backstop), legit downloads pass, and local x=$? captures the pipeline status correctly.
  • scripts/fish/cli.sh is #!/usr/bin/env bash -il (a bash script), so the guard syntax is intact in all three wrappers.
  • Path traversal / symlink escape is blocked by libarchive defaults (bsdtar -x without -P) — writes stay inside the polled directory.

Blocking / high-priority

1. (P1) The described test suite and CI workflow do not exist in the PR. The description details scripts/test/, run_tests.sh, check_drift.sh, .github/workflows/extraction-guard-tests.yml, and "53/53 assertions green." At the PR head none of these exist — scripts/ contains only the wrappers, and the only workflows present are Semgrep.yml, spm-smoke-test.yml, and verify-selfupdate-checksums.yml. A security-critical guard would merge with no regression protection. Please commit the suite + CI, or remove the claims from the description.

2. (P2) No compressed-download size cap exists, despite the Summary claiming one. The Summary states "curl --max-filesize (100 MB) caps the compressed download," but:

  • scripts/*/cli.sh — the download curl (curl -fR -z … -L … -o …) has no --max-filesize.
  • BrowserStackAccessibilityLint.swiftdownload(...) uses URLSession.shared.download(from: url) with no Content-Length/byte limit.

In the fix's own threat model (MITM of the HTTPS endpoint, or an attacker-controlled HTTPS override URL), a multi-GB compressed payload exhausts disk during download — before checksum or extraction — bypassing the entire decompression guard. Please add --max-filesize to the curl download and a byte cap to the Swift download, or strike the claim.

3. (P2) Windows extraction path is unguarded — see the inline note; either guard it or track it as an explicit follow-up.

Lower priority

Inline P3 comments cover: shell entry-count asymmetry, set +o pipefail toggled unconditionally, .tmp cleanup on chmod/mv failure, poll-interval doc drift (50 ms vs 200 ms), SIGTERM-only kill, extractionFootprint fail-open + hidden-file inconsistency, missing private on the new decls, and the libarchive-containment assumption.

Verdict: Not ready — the core guard is correct, but the compressed-download cap (#2) and the test/CI suite (#1) are described but absent, and Windows is unguarded (#3). Land the missing pieces or correct the description and consciously accept the gaps.

🤖 Multi-agent review via Claude Code (compound-engineering). Posted as comments, not a formal request-changes.

Comment thread scripts/bash/cli.sh Outdated
# that as a failure. Because the cap applies to ${BINARY_PATH}.tmp and publication is a
# later mv, a rejected bomb leaves any previously-cached binary untouched.
set -o pipefail
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — shell path lacks the Swift entry-count guard. In -O mode an archive of millions of tiny/empty entries streams ~0 bytes to stdout, so head -c never fills and never SIGPIPEs bsdtar. Disk stays bounded (good), but bsdtar still parses every entry (CPU/time drain) and a near-empty bogus payload passes the size check and gets chmod+mv'd into the cache. The Swift path guards this with maxArchiveEntries = 10_000; the wrappers have no equivalent. Consider an entry ceiling or --max-time on extraction.

(Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged as a real gap, and deliberately not fixed in this PR — flagging rather than silently skipping.

Your analysis is right: in -O mode an archive of millions of empty entries streams ~0 bytes to stdout, so head -c never fills and never SIGPIPEs bsdtar. Disk stays bounded, but bsdtar parses every entry and a near-empty payload passes the size check and gets published. The plugin's maxArchiveEntries = 10_000 has no wrapper equivalent.

Why it is not in this commit: there is no cheap, correct mechanism in -O mode. The options I considered:

  • bsdtar -tf pre-pass to count entries — doubles archive parsing and is itself unbounded on a millions-of-entries archive, so it moves the CPU drain rather than removing it.
  • --max-time on extraction — a wall-clock proxy for an entry count; flaky on slow CI runners and does not actually bound entries.
  • Extract to a directory instead of -O so the footprint is measurable like the plugin's — the correct fix, but that is a real change to the wrapper's extraction model, and the wrappers are what self-update ships to every user from main. Not something I want to land in the same PR as the guard, untested on Linux.

So: tracked as a follow-up on DEVA11Y-761 with your reasoning quoted, and listed under Known gaps item 4 in the rewritten PR description so it is owned rather than invisible.

Worth noting the residual is narrower than it was: the compressed-download cap added in 2c5fba8 (curl --max-filesize + post-download size check) bounds how large such an archive can be in the first place, so the CPU drain is capped at parsing a ≤100 MB archive rather than an unbounded one. That does not close the gap, but it does bound it.

Happy to take the "extract to a directory" approach as its own PR if you would rather not carry the gap.

Comment thread scripts/bash/cli.sh Outdated
set -o pipefail
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"
local extract_status=$?
set +o pipefail

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — set +o pipefail is toggled unconditionally. Neither wrapper sets pipefail globally today, so this is safe now, but it disables the option outright rather than restoring the prior state. If a global set -o pipefail is ever added to these scripts, this line will silently switch it off for everything after download_binary. Prefer save/restore, e.g. capture set +o | grep pipefail before and restore it after.

Also note: if chmod/mv fail on the happy path just below, ${BINARY_PATH}.tmp is left on disk — the rm -f cleanup only runs on the size-rejection branch. Minor, but inconsistent with the explicit cleanup above.

(Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both fixed in 2c5fba8, in all three launchers.

pipefail — now saved and restored rather than cleared:

local pipefail_was_set=0
case "$(set +o)" in *"-o pipefail"*) pipefail_was_set=1 ;; esac
set -o pipefail
bsdtar … | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"
local extract_status=$?
[[ $pipefail_was_set -eq 1 ]] || set +o pipefail

Verified both directions: with set -o pipefail in the caller it is still set after download_binary returns; with it off, it stays off.

.tmp on the happy path — good catch, the asymmetry was real. chmod/mv are now guarded with cleanup on failure instead of a bare && chain:

if ! { chmod 0755 "${BINARY_PATH}.tmp" && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH"; }; then
  echo "BrowserStack CLI: failed to publish the downloaded binary." >&2
  rm -f "${BINARY_PATH}.tmp"
  return 1
fi
strip_quarantine
process.terminate()
break
}
Thread.sleep(forTimeInterval: 0.05)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — poll-interval doc drift. This sleeps every 50 ms (0.05), but the PR description and the overshoot math in the docstring above refer to a "200 ms poll interval" — off by 4×. Either bump this to 0.2 or correct the description/comment so the documented worst-case footprint (maxBytes + pollInterval × writeRate) matches reality.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2c5fba8. You were right that it was off by 4x — and the drift was in the docstring rather than the code, so I corrected the docs to the real 50 ms rather than slowing the poll:

/// the limit before it is killed, so peak disk use is roughly `maxBytes + (50 ms x disk
/// write rate)` — the poll interval below is 50 ms.

Kept 50 ms because it is what the measurements in the description were actually taken at: against the 400 MB fixture the watchdog bounded peak disk to 58 MB, and re-verified on this head against the real 38 MB archive with a 5 MB cap it bounds to 36 MB of 66 MB. Widening to 200 ms would loosen that overshoot 4x for no benefit.

The PR description has also been rewritten (it was stale in several places — see the top-level reply).

while process.isRunning {
if let reason = footprintExceeded(at: directory, maxBytes: maxBytes, maxEntries: maxEntries) {
state.markExceeded(reason)
process.terminate()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — SIGTERM only, no escalation. terminate() sends SIGTERM once and the loop breaks. bsdtar doesn't trap SIGTERM so this is fine in practice, but if it's ever slow to die (blocked I/O), waitUntilExit() on the main thread blocks with no SIGKILL fallback. Low impact; consider a bounded wait + kill(pid, SIGKILL) escalation for robustness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on the analysis, and taking your own read that it is low impact — not changing it in this PR.

For the record on why: bsdtar does not trap SIGTERM, so in practice it dies immediately; the watchdog breaks straight after terminate() and the loop condition is while process.isRunning, so the thread exits cleanly with no leak. The theoretical hang needs bsdtar blocked in uninterruptible I/O, in which case waitUntilExit() on the main thread would stall with no SIGKILL fallback.

A bounded wait plus kill(pid, SIGKILL) escalation is the right hardening and I would rather add it with a test that actually exercises the escalation path than add an untested kill to a security fix. Noted on DEVA11Y-761 alongside the other deferred items.

Verified on the current head that the non-pathological path behaves: against the real 38 MB archive with a 5 MB cap the watchdog fires and bsdtar reports terminationStatus = 15 (SIGTERM), with disk bounded to 36 MB of the 66 MB it would otherwise have written.

/// Total bytes and entry count of all regular files under `url`.
func extractionFootprint(at url: URL) -> (bytes: Int64, entries: Int) {
let fm = FileManager.default
guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — two small issues in extractionFootprint.

  1. Fails open: if fm.enumerator(...) returns nil (directory transiently unreadable/missing), this returns (0, 0)footprintExceeded returns nil → "not exceeded" for that poll. Low risk since the plugin created the dir, but a transient failure silently disables the guard for that tick.
  2. Inconsistent "entry" definition: this enumerator omits .skipsHiddenFiles, while locateExecutable's enumerator (line ~646) passes options: [.skipsHiddenFiles]. The same 10 000 ceiling therefore counts hidden files here but not there. Align the two so "entries" means the same thing in both guards.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both fixed in 2c5fba8.

1. Fail-open → fail-closed. You are right that (0, 0) silently disabled the guard for that poll. It now fails closed:

guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [...]) else {
    // Fail CLOSED: a directory we just created being unreadable is not a "0 bytes"
    // result, and returning (0, 0) would silently disable the guard for that poll.
    return (Int64.max, Int.max)
}

A transient failure now trips the ceiling and aborts rather than waving the archive through. Failing closed is the right default for a guard, and the false-positive cost is an aborted download with a clear message.

2. Hidden-file inconsistency. Also real — but after looking at both call sites I kept the difference and documented it rather than aligning them, because they are measuring different things:

  • extractionFootprint measures what bsdtar actually wrote — dotfiles included, since they consume disk and count toward a "millions of tiny files" bomb. Adding .skipsHiddenFiles would let an all-dotfiles archive slip the entry ceiling.
  • locateExecutable is searching for a binary, so skipping hidden files is correct there.

So the shared 10_000 is deliberately counting different sets. Comment added at the enumerator making that explicit so the next reader does not "fix" it:

// `.skipsHiddenFiles` is deliberately NOT set, so the entry count here matches what
// bsdtar actually wrote — including dotfiles. locateExecutable skips hidden files
// because it is searching for a binary, not measuring a footprint; the two use the
// same ceiling but count deliberately different things (DEVA11Y-484 review).

Happy to split into two named constants if you would rather the shared 10_000 not imply the two are equivalent.

// verified first, then extracted. Windows' unzip path has no streaming guard.

/// Thread-safe flag shared between the extraction watchdog and the main flow.
final class ExtractionLimitState {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — hygiene: these new top-level declarations lack private. ExtractionLimitState, extractionFootprint, footprintExceeded, and startExtractionWatchdog are the only non-private helpers in the file — every other helper (isTruthy, packageCacheRoot, hardwareIdentifier, …) is private. Harmless in a single-file plugin target, but worth marking private for consistency.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2c5fba8 — all four are now private:

  • private final class ExtractionLimitState
  • private func extractionFootprint(at:)
  • private func footprintExceeded(at:maxBytes:maxEntries:)
  • private func startExtractionWatchdog(on:directory:maxBytes:maxEntries:)

Agreed it was inconsistent with every other helper in the file. swiftc -typecheck -parse-as-library is clean after the change.

//
// Applies to extractLocalArchive, which since #37 (DEVA11Y-473/474) is the single
// non-Windows extraction path: the archive is downloaded to a file and checksum-
// verified first, then extracted. Windows' unzip path has no streaming guard.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2 — Windows extraction path is unguarded. This correctly notes the unzip/Expand-Archive path has no streaming guard, but Windows is a supported target (#if os(Windows) branches, browserstack-cli.exe, PowerShell checksum). A zip bomb there fully exhausts disk with no download cap, no watchdog, and no entry ceiling. It's out of this PR's stated 4-surface scope, so either add a guard to the Windows path or track it as an explicit follow-up so the gap is owned rather than just commented.

Also, defense-in-depth note for the non-Windows path: containment depends on libarchive's default behavior (bsdtar -x without -P neutralizes .., absolute paths, and symlink-through, keeping all writes inside the polled -C directory). That's correct today but load-bearing and unasserted — a future -P would let writes escape the polled dir and the footprint poll would measure nothing. Worth a comment pinning the assumption.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Two parts here.

Windows — now tracked, not merely commented. Agreed it is a real gap, and it is explicitly owned: DEVA11Y-761 item 3, with the implementation preserved on chore/DEVA11Y-484-followup-extraction-guard-harness. That branch carries the prepareArtifact-level footprintExceeded backstop positioned against stagingDirectory before publishVersionDirectory — which is where it belongs after #32 restructured extraction, so a rejected archive never becomes a visible version directory.

It came out of this PR when the PR was narrowed to DEVA11Y-484's stated Remediation, which scopes the bsdtar paths only. I noted on the ticket that "Windows has no bomb guard" probably deserves its own security ticket rather than sitting in a cleanup task — say the word and I will raise one.

One thing that does help Windows in the meantime: the compressed-download cap added in 2c5fba8 sits in the shared download(from:to:), so it applies on Windows too. It does not bound decompression, but it stops a multi-GB archive reaching Expand-Archive at all.

libarchive containment — pinned. Good catch that it was load-bearing and unasserted. Now stated in the guard block:

// Containment assumption (load-bearing): `bsdtar -x` WITHOUT `-P` neutralises `..`,
// absolute paths and symlink-through, so every write lands inside the `-C` directory we
// poll. Adding `-P` would let writes escape that directory and the footprint poll would
// measure nothing — do not add it (DEVA11Y-484 review).
…[DEVA11Y-484]

Addresses @Crash0v3rrid3's review. The two P1/P2 "described but absent" findings
were caused by a stale PR description (the harness and compressed cap were
descoped to DEVA11Y-761 without updating it); the description is corrected
separately. This commit lands the code changes.

P2 — compressed-download cap reinstated. The reviewer's threat-model argument is
right: without a wire cap, a multi-GB *compressed* payload from an
attacker-controlled URL exhausts disk before the checksum or the decompression
guard ever run, walking around the whole fix.

* Launchers: `curl --max-filesize 104857600`, plus an explicit post-download size
  check because curl documents --max-filesize as a no-op when the length is
  unknown (chunked). Verified against the live endpoint: with a 1 MB cap curl
  aborts non-zero with nothing written to disk; with the real 100 MB cap the
  38 MB archive passes.
* Plugin: `maxCompressedBytes = 100 MB`, checked against both
  `response.expectedContentLength` and the downloaded file's actual size, with
  the temp file removed on rejection.

  LIMITATION, stated in the code rather than papered over:
  URLSession.download(from:) has no byte-level hook, so these reject the archive
  *after* the transfer rather than aborting mid-stream. They stop an oversized
  archive being verified, extracted, published or executed, but do NOT bound peak
  temporary disk during the transfer. Doing that needs a
  URLSessionDownloadDelegate cancelling in didWriteData — deliberately left to
  DEVA11Y-761 rather than rewriting this shared download path inside a security
  fix I cannot exercise end-to-end without credentials. The launchers do abort
  during transfer.

P3 fixes:
* `private` on ExtractionLimitState, extractionFootprint, footprintExceeded and
  startExtractionWatchdog, matching every other helper in the file.
* Poll-interval doc drift: the docstring now states the actual 50 ms instead of
  reasoning about an unstated interval.
* extractionFootprint now fails CLOSED on a nil enumerator (Int64.max/Int.max)
  instead of (0, 0), which silently disabled the guard for that poll; and the
  deliberate `.skipsHiddenFiles` asymmetry with locateExecutable is documented
  rather than accidental.
* pipefail is saved and restored instead of cleared unconditionally.
* ${BINARY_PATH}.tmp is cleaned up if chmod/mv fails, not only on size rejection.
* Pinned the load-bearing libarchive containment assumption: `bsdtar -x` without
  `-P` keeps writes inside the polled -C directory; adding -P would let them
  escape and the footprint poll would measure nothing.

Not addressed here (left for review discussion): the launchers still have no
entry-count equivalent to the plugin's maxArchiveEntries — in `-O` mode a
millions-of-empty-entries archive streams ~0 bytes so `head -c` never fires. Real
gap, no cheap mechanism in `-O` mode.

Verification: 27/27 assertions across bash/zsh/fish against the live endpoint
(real download through #37's integrity check, .tmp cleanup, byte-identical
re-run, corrupt payload rejected, cached binary survives rejection); pipefail
save/restore verified in both directions; swiftc -typecheck clean; bash -n clean;
all six sidecars verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maunilm

maunilm commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — this was a genuinely useful review, and one of the two blockers was my fault in a way worth naming explicitly.

Everything below is in 2c5fba8, plus a rewritten PR description. All 9 checks green.

Blocker 1 (P1) — test suite and CI "described but absent": you were right, and the cause was a stale description

The suite and workflow really were not there. The reason is not that they were forgotten — they were deliberately removed when this PR was narrowed to DEVA11Y-484's stated Remediation (the PR had grown to +1044/−7 across 18 files for an XS ticket, with only 216 lines of production code). I removed the code and failed to update the description, so it kept advertising a harness, a CI workflow and "53/53 assertions" that no longer existed. That is exactly the kind of claim a reviewer should not have to discover by diffing, and it wasted your time.

The description is now rewritten to match the shipped code, with an explicit Known gaps section that owns all five gaps rather than implying coverage that does not exist.

The suite itself is not lost: preserved verbatim on chore/DEVA11Y-484-followup-extraction-guard-harness and tracked as DEVA11Y-761 item 1, flagged there as the highest-value follow-up. Your point stands that a security-critical guard is merging without regression protection — that is a conscious, documented trade, not an oversight.

Blocker 2 (P2) — no compressed cap: half stale description, half a real hole. Fixed.

Same root cause for the wording — the cap had been descoped as "not in the ticket's Remediation" and the Summary still claimed it. But your threat-model argument is the substantive part and I think it is correct: without a wire cap, a multi-GB compressed payload exhausts disk before the checksum or the decompression guard ever run, which walks around the entire fix. Deferring it on a scoping technicality was the wrong call.

Reinstated in 2c5fba8:

  • Launchers: curl --max-filesize 104857600, plus an explicit post-download size check because curl documents the flag as a no-op when the length is unknown (chunked). Verified against the live endpoint: with a 1 MB cap curl aborts non-zero with 0 bytes written; with the real 100 MB cap the 38 MB archive passes. (Observed exit is 56 rather than 63 — with -L following the 302 to sdk-assets it aborts during receive, not pre-transfer. The comment states the observed behaviour, not the idealised one.)
  • Plugin: maxCompressedBytes = 100 MB, checked against both response.expectedContentLength and the downloaded file's actual size, temp file removed on rejection. Applies on Windows too, since it lives in the shared download(from:to:).

One limitation I want to state plainly rather than let the description imply otherwise: URLSession.download(from:) has no byte-level hook, so on the Swift path these checks reject the archive after the transfer. They stop an oversized archive being verified, extracted, published or executed, but they do not bound peak temporary disk during the transfer — which is precisely the scenario you described. Bounding that needs a URLSessionDownloadDelegate cancelling in didWriteData. I chose not to rewrite that shared download path inside a security fix I cannot exercise end-to-end without credentials; it is documented inline, in Known gaps item 3, and on DEVA11Y-761. The launchers do abort during transfer, so the primary installer path is covered.

Blocker 3 (P2) — Windows unguarded

Tracked as DEVA11Y-761 item 3 with the implementation preserved on the follow-up branch, per your "either guard it or track it as an explicit follow-up so the gap is owned". Detail in the inline thread. I also noted on the ticket that this probably warrants its own security ticket rather than living in a cleanup task — happy to raise one.

P3s

Fixed: private on all four new decls; poll-interval doc drift (docs corrected to the real 50 ms rather than slowing the poll); extractionFootprint now fails closed on a nil enumerator; the .skipsHiddenFiles asymmetry documented as deliberate with reasoning; pipefail saved/restored and verified both directions; .tmp cleaned up on chmod/mv failure; libarchive containment assumption pinned in the guard block.

Deferred with reasoning in-thread: the launcher entry-count gap (no cheap correct mechanism in -O mode — the right fix is extracting to a directory, which is too large a change to the wrapper model to smuggle in here) and SIGTERM escalation (which you flagged low-impact yourself).

Verification on this head

No automated suite ships, so this was verified directly against the live endpoint: 27/27 assertions across bash/zsh/fish — real download through #37's integrity check, .tmp cleanup, byte-identical re-run, corrupt payload rejected, and the previously-cached binary's sha256 unchanged after a rejected payload (confirming the cap on ${BINARY_PATH}.tmp preserves #37's anti-truncation protection rather than undoing it). Swift guard compiled standalone and driven against the real archive: real 200 MB cap does not flag; 5 MB cap SIGTERMs bsdtar mid-stream bounding disk to 36 MB of 66 MB; maxEntries=0 flags on entry count. swiftc -typecheck clean, bash -n clean, all six sidecars verify, self-update's own comparison matches.

Ready for another look when you have a moment.

Crash0v3rrid3
Crash0v3rrid3 previously approved these changes Aug 31, 2026

@Crash0v3rrid3 Crash0v3rrid3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved — re-review of 2c5fba8

Both blockers from my earlier review are resolved, and the fix is sound and honestly documented.

Resolved

  • Compressed-download cap now implemented — Swift checks response.expectedContentLength and the downloaded file's actual size against maxCompressedBytes (100 MB); the launchers use curl --max-filesize + a wc -c backstop before checksum/extract. The post-transfer limitation is called out plainly and deferred to DEVA11Y-761.
  • PR description corrected — the previously-fictional Tests/CI section was descoped (moved to the follow-up branch) and replaced with an honest "Known gaps — owned, not hidden" section. Claims now match code.

Verified

  • All three .sha256 sidecars match their cli.sh (self-update verification intact); all three launchers are #!/usr/bin/env bash -il (bash syntax valid).
  • Decompressed guard: bsdtar -O | head -c + pipefail correctly rejects a bomb via SIGPIPE (141) and passes a legit ~75 MB binary under the 200 MB cap; local extract_status=$? captures pipeline status; pipefail save/restore is correct; .tmp cleaned on chmod/mv failure.
  • Swift: NSLock state thread-safe, watchdog has no leak, post-exit footprint recheck catches fast bombs, extractionFootprint now fails closed on a nil enumerator (prior fail-open fixed), forwardExit is -> Never, 10k-entry guard added to locateExecutable.

No new P0/P1. The remaining items (no automated regression tests, Windows Expand-Archive unguarded, shell entry-count gap, Swift mid-stream cap, cap duplication across 4 files) are all acknowledged and tracked under DEVA11Y-761.

One conscious sign-off for the record: the caps merge with no automated coverage — acceptable given it's explicitly the top DEVA11Y-761 follow-up.

@maunilm maunilm left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Claude Code Review (automated) — 6 inline finding(s). Full report in the PR comment below. Verdict: Failed - see PR comment.

// bsdtar actually wrote — including dotfiles. locateExecutable skips hidden files
// because it is searching for a binary, not measuring a footprint; the two use the
// same ceiling but count deliberately different things (DEVA11Y-484 review).
guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] This fail-closed branch is unreachable dead code

FileManager.enumerator(at:includingPropertiesForKeys:) does not return nil for a missing or unreadable directory — enumeration errors go to an errorHandler (default: skip and continue). Measured on this platform: a missing directory and a chmod 000 directory each yield a non-nil enumerator producing 0 elements, so both fall through to the loop and return (0, 0) — exactly the silent guard-disable this comment says it prevents.

Suggestion: fail closed from the error handler instead.

var enumerationFailed = false
guard let enumerator = fm.enumerator(
    at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
    options: [], errorHandler: { _, _ in enumerationFailed = true; return false }
) else { return (Int64.max, Int.max) }

return enumerationFailed ? (Int64.max, Int.max) : (total, count)

Reviewer: stack:devtools-review-changes (orchestrator-confirmed; stack-code-reviewer had this as "verified correct")

limitState.markExceeded(reason)
}
if limitState.exceeded {
try? fileManager.removeItem(at: directory)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] This abort path leaks the archive it exists to bound

forwardExit is -> Never and calls exit(code), so no defer runs. prepareArtifact holds two — one for stagingDirectory, one for archiveURL. This line hand-cleans only the staging directory, so the downloaded ≤100 MB archive stays in the cache on every guard trip. sweepStaleStaging reclaims it only after 3600 s and on a later successful prepareArtifact, so repeated runs inside the hour accumulate one archive each — inside the control meant to prevent disk exhaustion.

Suggestion: throw PluginError(…) instead of forwardExit so both defers run. Also matches locateExecutable's entry cap, which already throws for the same class of rejection.

Reviewer: stack:devtools-review-changes (orchestrator-confirmed)

try? fileManager.removeItem(at: tempURL)
throw PluginError("BrowserStack CLI archive declares \(response.expectedContentLength) bytes, above the \(Self.maxCompressedBytes)-byte limit; refusing to download it.")
}
let downloadedBytes = (try? fileManager.attributesOfItem(atPath: tempURL.path)[.size] as? Int64) ?? nil

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] Compressed-size check fails open on an unreadable size

If either the try? swallows a throw or the cast yields nil, the 100 MB cap is skipped with no diagnostic. That matters because this is the load-bearing half: expectedContentLength is -1 for chunked/unknown-length responses, so an attacker controlling BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL — the threat model the comment above cites — just omits Content-Length.

Severity note: reported as High on the grounds that NSNumber → Int64 casting is Darwin-only. Measured here, the cast does succeed (attrs[.size] as? Int64123456) and there is no Linux CI leg, so this is not a live fail-open today — downgraded to Medium. The try? path and the inconsistency with this file's own fail-closed stance still warrant fixing.

Suggestion: use the .fileSizeKey idiom already used later in this file, and fail closed:

guard let downloadedBytes = (try? tempURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) else {
    try? fileManager.removeItem(at: tempURL)
    throw PluginError("Could not determine the downloaded archive's size; refusing to use it.")
}

(?? nil is also redundant — try? is already flattened.)

Reviewer: stack:devtools-review-changes (severity adjudicated by orchestrator)

while process.isRunning {
if let reason = footprintExceeded(at: directory, maxBytes: maxBytes, maxEntries: maxEntries) {
state.markExceeded(reason)
process.terminate()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Medium] SIGTERM with no escalation: fails-closed can become hangs-open

The watchdog sends one SIGTERM and breaks, while the main flow's waitUntilExit() is unbounded. If bsdtar does not die promptly — ignored or blocked in an uninterruptible write — the plugin hangs indefinitely rather than aborting with the intended "Aborting to prevent disk exhaustion" message, which is a worse failure mode than the one being guarded.

Mitigating: bsdtar does not trap SIGTERM, and the normal path was measured working (terminationStatus = 15, disk bounded to 36 MB of 66 MB).

Suggestion: after terminate(), grace-wait ~2 s then kill(process.processIdentifier, SIGKILL) if process.isRunning, or bound waitUntilExit().

Reviewer: stack-code-reviewer

Comment thread scripts/bash/cli.sh Outdated
local pipefail_was_set=0
case "$(set +o)" in *"-o pipefail"*) pipefail_was_set=1 ;; esac
set -o pipefail
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] Pipeline is not errexit-safe, and this script sources user rc files

The shebang is #!/usr/bin/env bash -il, so ~/.bashrc / ~/.bash_profile are sourced. If a user's rc sets -e (common in "strict mode" boilerplate, often paired with -o pipefail), this bare pipeline aborts the script immediately: local extract_status=$? never runs, the user never sees the 200 MB message, and ${BINARY_PATH}.tmp is left behind — the exact residue the publish-failure cleanup below was added to prevent.

Suggestion:

Suggested change
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"
local extract_status=0
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" || extract_status=$?

Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.

Reviewer: stack:devtools-review-changes

Comment thread scripts/bash/cli.sh Outdated

local extracted_size
extracted_size=$(wc -c < "${BINARY_PATH}.tmp" 2>/dev/null || echo 0)
if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Low] -ge here vs -gt on the compressed check — correct, but undocumented

-ge is deliberate and necessary: head -c N emits exactly N bytes whether the input was N or 10 GB, so == N is indistinguishable from an overflow and must be rejected. The surrounding comment does not say so, and a future editor "fixing the inconsistency" to -gt would silently punch a hole in the guard.

Suggestion: add one line — # -ge, not -gt: head -c caps output at exactly N, so N bytes is indistinguishable from an overflow.

Reviewer: stack:devtools-review-changes + stack-code-reviewer

@maunilm

maunilm commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Claude Code PR Review

PR: #25Head: efcf13cReviewers: stack:devtools-review-changes, stack-code-reviewer

Summary

Adds a decompression-bomb / disk-exhaustion guard to the CLI download-and-extract path for DEVA11Y-484: a 100 MB compressed cap, and a 200 MB / 10,000-entry decompressed cap enforced by a polling watchdog that SIGTERMs bsdtar in the Swift plugin plus a bsdtar … -O | head -c 209715200 guard with pipefail in the three (functionally-bash) shell launchers.

The core guard works. Both reviewers independently confirmed it, and the orchestrator re-verified at this head: the merge is clean, all six .sha256 sidecars recompute correctly, the ${BINARY_PATH}.tmp staging invariant holds, and the real artifact has 2.6× headroom on both caps. The findings below are all in edge-case handling around the guard, not the main path.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass Only size constants and checksum sidecars
High Security Authentication/authorization checks present N/A No auth surface
High Security Input validation and sanitization Pass This PR is the input-size validation; libarchive containment (-xpf, no -P) verified intact
High Security No IDOR — resource ownership validated N/A
High Security No SQL injection (parameterized queries) N/A
High Correctness Logic is correct, handles edge cases Fail Two confirmed defects: a documented fail-closed branch that provably never executes (F1), and an abort path that leaks the archive it exists to bound (F2). Happy path is correct.
High Correctness Error handling is explicit, no swallowed exceptions Fail try? + as? on the compressed-size read fails open with no diagnostic (F3); forwardExit skips both defer cleanups (F2)
High Correctness No race conditions or concurrency issues Pass NSLock usage correct and markExceeded idempotent (both reviewers); watchdog armed after run() (460 after 458) and exits on isRunning
Medium Testing New code has corresponding tests Fail Zero automated coverage ships (F5) — disclosed and tracked, but the control is untested
Medium Testing Error paths and edge cases tested Fail None of the rejection paths, the post-exit re-check, or the entry cap are exercised
Medium Testing Existing tests still pass (no regressions) Pass 10/10 CI checks green at efcf13c, incl. verify-sidecars and the SwiftPM e2e
Medium Performance No N+1 queries or unbounded data fetching Pass ⚠️ Watchdog re-walks the tree every 50 ms — O(entries) per tick (F8); trivial for the 1-entry artifact
Medium Performance Long-running tasks use background jobs N/A
Medium Quality Follows existing codebase patterns Pass ⚠️ F1/F3 diverge from the .fileSizeKey + fail-closed idiom already used elsewhere in the same file
Medium Quality Changes are focused (single concern) Pass 7 files, all the guard; scope was deliberately narrowed and the descope is documented
Low Quality Meaningful names, no dead code Fail F1 is dead code — the guard let … else branch is unreachable
Low Quality Comments explain why, not what Pass Unusually well documented, including the load-bearing libarchive assumption
Low Quality No unnecessary dependencies added Pass None added

Findings

F1 — the fail-closed branch is unreachable dead code, so the guard still silently disables itself

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:894
  • Severity: Medium
  • Reviewer: stack:devtools-review-changes (confirmed by orchestrator; contradicts stack-code-reviewer, which listed this as "verified correct")
  • Issue: guard let enumerator = fm.enumerator(at:includingPropertiesForKeys:) else { return (Int64.max, Int.max) } cannot fire for the cases its comment names. FileManager.enumerator(at:includingPropertiesForKeys:) routes enumeration errors to an errorHandler (default: skip and continue), it does not return nil. Measured on this platform: a missing directory and a chmod 000 directory both yield a non-nil enumerator producing 0 elements — so both fall through to the loop and return (0, 0), which is exactly the silent guard-disable the comment claims to prevent.
  • Suggestion: supply the error handler and fail closed from it:
    var enumerationFailed = false
    guard let enumerator = fm.enumerator(
        at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
        options: [], errorHandler: { _, _ in enumerationFailed = true; return false }
    ) else { return (Int64.max, Int.max) }
    
    return enumerationFailed ? (Int64.max, Int.max) : (total, count)

F2 — the anti-disk-exhaustion abort path leaks the archive it exists to bound

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:471
  • Severity: Medium
  • Reviewer: stack:devtools-review-changes (confirmed by orchestrator)
  • Issue: forwardExit is -> Never and calls exit(code) (959–963), so no defer runs. prepareArtifact holds two — stagingDirectory (249) and archiveURL (260). The guard trip hand-cleans only directory (the staging dir) then forwardExits, so the downloaded ≤100 MB archive is left in the cache on every trip. sweepStaleStaging reclaims it only after 3600 s and on a later successful prepareArtifact, so repeated invocations inside the hour accumulate one archive each.
  • Suggestion: throw PluginError(…) instead of forwardExit, so both defers run. Also makes rejection surfacing consistent with locateExecutable's entry cap (666), which already throws.

F3 — compressed-size check fails open on an unreadable size

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:624
  • Severity: Medium — downgraded from the reviewer's High; see note
  • Reviewer: stack:devtools-review-changes (severity adjudicated by orchestrator)
  • Issue: (try? fileManager.attributesOfItem(atPath:)[.size] as? Int64) ?? nil skips the cap with no diagnostic if either the try? swallows a throw or the cast yields nil. This is the load-bearing half of the compressed cap, because expectedContentLength is -1 for chunked/unknown-length responses — an attacker controlling BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL (the threat model the comment itself cites) simply omits Content-Length.
  • Severity note: the reviewer rated this High on the basis that NSNumber → Int64 conditional casting is a Darwin-only guarantee and unreliable on Linux corelibs-Foundation. Measured on the target platform, the cast succeeds (attrs[.size] as? Int64123456), and there is no Linux CI leg or evident Linux consumer for this SwiftPM plugin, so it is not a live fail-open today. Downgraded to Medium; the try? path and the inconsistency with this file's own fail-closed stance still warrant the fix.
  • Suggestion: use the idiom already present 270 lines later and fail closed:
    guard let downloadedBytes = (try? tempURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) else {
        try? fileManager.removeItem(at: tempURL)
        throw PluginError("Could not determine the downloaded archive's size; refusing to use it.")
    }

F4 — no SIGKILL escalation after terminate(): fails-closed becomes hangs-open

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:938
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue: the watchdog sends one SIGTERM and breaks; the main flow's waitUntilExit() (461) is unbounded. If bsdtar does not die promptly (blocked in an uninterruptible write), the plugin hangs indefinitely instead of aborting with the intended message — a worse failure mode than the one being guarded. Mitigating: bsdtar does not trap SIGTERM, and the orchestrator measured terminationStatus = 15 with disk bounded to 36 MB of 66 MB, so the normal path works.
  • Suggestion: after terminate(), grace-wait ~2 s and kill(process.processIdentifier, SIGKILL) if still running, or bound waitUntilExit().

F5 — this security control ships with zero automated regression coverage

  • File: tests/
  • Severity: Medium
  • Reviewer: both
  • Issue: tests/extraction-guard was added in e850495 and removed in 72091b9 (deferred to DEVA11Y-761); absent at head. Untested: ExtractionLimitState's cross-thread flag, the post-exit footprintExceeded re-check, locateExecutable's 10,000-entry throw, and the shell head -c/SIGPIPE/pipefail interaction including the -ge boundary. Launcher scripts (bash syntax) is bash -n only; CLI binary integrity check exercises verify_binary_integrity, not download_binary. Candidly disclosed in the PR body — this is not undisclosed scope-cutting — but F1 and F3 are both defects a fixture test would have caught.
  • Suggestion: both reviewers converge on the cheapest slice: a sibling functional-matrix job for download_binary against a local bomb fixture asserting rc≠0, $BINARY_PATH unchanged, and no ${BINARY_PATH}.tmp residue. That single assertion protects the cached-binary invariant this PR is built around.

F6 — Windows Expand-Archive path has no decompression guard

  • File: Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift:448
  • Severity: Medium
  • Reviewer: stack-code-reviewer (confirmed by orchestrator)
  • Issue: extractLocalArchive — and therefore the watchdog — is inside #if !os(Windows) (448). Windows uses unzip(archive:into:) (637) with only the shared 100 MB compressed cap. A high-ratio bomb under 100 MB compressed is uncaught there. Disclosed in code comments and the PR body, tracked as DEVA11Y-761 item 3.
  • Suggestion: keep tracked; both reviewers flag it so it does not become a "someday" item.

Low

  • F7 scripts/{bash,zsh,fish}/cli.sh:286 — pipeline is not errexit-safe. The shebang is #!/usr/bin/env bash -il, so user rc files are sourced; if a user's rc sets -e, the pipeline aborts before local extract_status=$?, the size message never prints, and ${BINARY_PATH}.tmp is left behind — the exact residue 299–303 was added to prevent. Fix: local extract_status=0 then … || extract_status=$?.
  • F8 …swift:934 — watchdog re-walks the tree with resourceValues per element every 50 ms → O(entries) per tick. An archive sitting just under both ceilings never trips the guard but drives sustained stat load. Trivial at the real 1-entry artifact. Both reviewers raised it.
  • F9 scripts/bash/cli.sh:292-ge (decompressed) vs -gt (compressed) asymmetry is correct but unexplained; head -c N emits exactly N whether the input was N or 10 GB, so == N must be rejected. A future "consistency fix" to -gt would silently punch a hole. Add one comment line.
  • F10 …swift:624?? nil after try? is redundant (SE-0230 flattens it). Resolved by the F3 rewrite. Both reviewers raised it.
  • F11 …swift:174 — caps are compile-time constants in five places with no override. The entry cap is the fragile one: if the CLI ever ships unpacked node_modules, 10,000 entries is crossed instantly and every consumer hard-exits with no escape hatch until a plugin release.
  • F12 scripts/bash/cli.sh:293 — the message conflates a corrupt archive (extract_status -ne 0) with a real size rejection; splitting it helps on-call triage.

Reviewer disagreement, adjudicated

  • F1stack-code-reviewer listed the fail-closed enumerator branch under "Verified correct … correctly fails closed as required." stack:devtools-review-changes called it unreachable. The orchestrator tested both claims: missing dir → non-nil enumerator, 0 elements; chmod 000 dir → non-nil enumerator, 0 elements. The branch is unreachable; A is right, B's verification is wrong. Kept as a finding.
  • F3 — reviewer rated High on Linux-bridging grounds; measured to work on the target platform (as? Int64123456). Downgraded to Medium with the measurement recorded, rather than carrying a High the evidence does not support.
  • Verdictsstack-code-reviewer returned Approve; stack:devtools-review-changes returned REQUEST_CHANGES. The gate below follows the table, not a vote.

Raised by other reviewers (not independently confirmed)

  • @Crash0v3rrid3 approved this PR on 2026-08-31T07:32:52Z after a multi-agent review on 2026-08-27. Their earlier P3s on pipefail save/restore, .tmp cleanup, poll-interval doc drift and private hygiene were addressed in 2c5fba8. Two of their P3s remain open by agreement — the launcher entry-count gap and SIGKILL escalation (the latter re-raised here as F4, upgraded to Medium because waitUntilExit() is unbounded on the main thread).
  • Correction owed on an existing thread: the reply posted to their extractionFootprint thread stated the fail-open was fixed. F1 shows it was not — the fix landed on an unreachable branch. That thread should be corrected rather than left standing.

Orchestrator verification at this head

Independent of both reviewers: today's merge efcf13c brought only #38 (Dependabot Semgrep digest) and touched none of the 7 guard files — byte-identical to 2c5fba8. All six sidecars recompute clean. The guard writes to ${BINARY_PATH}.tmp (286), not $BINARY_PATH, so #37's anti-truncation invariant holds. pipefail save/restore present (283–288). #37's verify_binary_integrity, url_effective and curl -fR all present. CI 10/10 green.


Verdict: FAIL — core guard is sound and the merge is clean, but two High-priority table rows fail on confirmed defects: a documented fail-closed protection that provably never executes (F1) and an abort path that leaks the archive it bounds (F2). Both are small, local fixes.

Addresses the Claude Code Review FAIL on this branch. The verdict failed two
High-priority table rows — "logic is correct, handles edge cases" and "error
handling is explicit, no swallowed exceptions" — on three confirmed defects. All
three are fixed and each fix was verified by measurement, not assertion.

F1 — the fail-closed branch in extractionFootprint was unreachable dead code.

FileManager.enumerator(at:includingPropertiesForKeys:) does not return nil for a
missing or unreadable directory: it routes errors to an errorHandler whose
default is "skip and continue". Measured: a missing directory and a chmod-000
directory each returned a NON-nil enumerator yielding zero elements, so both
fell through to (0, 0) — read as "not exceeded", the exact silent guard-disable
the comment claimed to prevent. The previous round's fix landed on a branch that
never executes.

Now supplies the errorHandler and returns the ceiling when it fires. Verified
the handler actually fires for both cases, and that footprintExceeded now
reports a rejection for each instead of nil.

F2 — the abort path leaked the archive it exists to bound.

forwardExit is -> Never and calls exit(), which skips every defer, including
prepareArtifact's cleanup of the downloaded archive. Every guard trip therefore
left a <=100 MB archive in the cache, reclaimed only after 3600 s AND a later
successful prepareArtifact — inside the control whose purpose is preventing disk
exhaustion. Now throws PluginError instead, so both defers unwind normally.
performCommand is `async throws`, so the message still surfaces with a non-zero
exit, and this matches locateExecutable's entry cap, which already throws.

F3 — the compressed-size check could fail open with no diagnostic.

`(try? attributesOfItem(atPath:)[.size] as? Int64) ?? nil` skips the cap silently
if the read throws or the cast yields nil. That is the load-bearing half of the
compressed cap, because expectedContentLength is -1 for chunked responses, so an
attacker-controlled URL omitting Content-Length is caught only here. Now reads
via resourceValues(.fileSizeKey) — the idiom already used in extractionFootprint
— and fails CLOSED when the size is unreadable. Also drops the redundant `?? nil`.

For the record: the review reported F3 as High on the grounds that NSNumber ->
Int64 casting is Darwin-only. Measured on the target platform the cast does
succeed (123456), so it was not a live fail-open; it was adjudicated down to
Medium. Fixed anyway — the try? path is a real hole and the fail-open stance was
inconsistent with this file's own fail-closed handling.

Also folded in three Low findings raised by both reviewers, all in the launchers:

* The extraction pipeline is now errexit-safe (`|| extract_status=$?`). The
  shebang is `bash -il`, so user rc files ARE sourced; if one sets `-e`, the
  bare pipeline aborted the script before the size check — skipping the
  diagnostic and leaving ${BINARY_PATH}.tmp behind. Verified: under `set -e` a
  corrupt payload now returns 1, prints its diagnostic, and cleans up the .tmp.
* Documented why the decompressed check uses -ge and not -gt: head -c caps output
  at exactly N, so N bytes cannot be distinguished from a truncated overflow and
  must be rejected. A future "consistency fix" to -gt would silently punch a hole.
* Split the conflated failure message into separate size-rejection and
  extraction-failure diagnostics, size checked first since it is the accurate one
  when a bomb trips both.

Not fixed here, tracked on DEVA11Y-761: SIGKILL escalation after terminate()
(Medium — bsdtar does not trap SIGTERM and the normal path is measured working),
the O(entries)-per-tick watchdog poll, hardcoded caps with no override, the
Windows unzip path, and the absent regression suite.

Verification: 27/27 assertions across bash/zsh/fish against the live endpoint,
plus the new errexit case; Swift guard re-verified on the real archive (200 MB
cap does not flag; 5 MB cap flags with terminationStatus 15; entries=0 flags);
fail-closed probes now return a rejection for both the missing and unreadable
directory; swiftc -typecheck clean; bash -n clean; all six sidecars verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ock [DEVA11Y-484]

Second review round found a HIGH that the first round's fixes did not cover, and
that this PR's own guard cannot catch. Reproduced and fixed.

HIGH — undrained stderr pipe deadlocks extraction indefinitely.

extractLocalArchive set process.standardError to a Pipe, called waitUntilExit(),
and only read the pipe afterwards. bsdtar's stderr pipe is 64 KB; once full,
bsdtar blocks writing and waitUntilExit() never returns. Critically this is
reachable from an archive that stays UNDER both ceilings, so the watchdog is no
defence — it spins on `process.isRunning` at 20 Hz for as long as the hang lasts,
burning CPU beside it. A CWE-400 availability failure inside the control whose
ticket is CWE-400.

Reproduced independently before fixing: a 4,000-entry tar whose every member name
contains `..` extracts to 0 bytes / 0 entries (vs caps of 200 MB / 10,000) yet
bsdtar emits 226,939 bytes of "Path contains '..'" warnings. Harnessing the two
plumbings side by side against that archive:

  old (read after wait) : *** NO RETURN within 15s -> DEADLOCK ***
  new (concurrent drain): returned OK  rc=1  stderr captured=65536 bytes

stderr is now drained on a dedicated queue started immediately after run(), with
the captured buffer capped at 64 KB while continuing to read past the cap —
discarding is what stops bsdtar blocking — and the failure branch reads that
buffer instead of the pipe.

The plumbing is pre-existing; this PR did not introduce it. Fixed here rather
than deferred because it defeats the guard this PR adds instead of sitting
beside it.

MEDIUM — the item-1 fail-closed fix misreported its reason (self-inflicted).

extractionFootprint returned (Int64.max, Int.max) on any enumeration error, and
footprintExceeded checks bytes first, so an I/O or permission failure surfaced to
the user as "decompressed size exceeds 200 MB". It also meant an artifact
legitimately carrying a 0500/0400 directory would pass the in-flight polls and
then be hard-rejected by the post-exit re-check — and the code's own comment
anticipates a binary inside a nested versioned folder, so that shape is one the
codebase expects.

Footprint now carries `measured: Bool` instead of signalling failure as an
infinite size, and footprintExceeded returns a distinct reason. Verified:

  missing dir   -> "extraction directory could not be measured"
  chmod 000 dir -> "extraction directory could not be measured"
  nested locked -> "extraction directory could not be measured"
  normal dir    -> bytes=12345 entries=1 measured=true, not exceeded

LOW — the sibling forwardExit one branch over also leaked the archive.

The corrupt-archive branch of the same function still called forwardExit, which
skips prepareArtifact's defers — the exact leak the guard branch had fixed. Now
throws. SwiftPM flattens the exit code anyway, so nothing is lost.

LOW — curl failure diagnostics conflated a size abort with a network error.

Split, but branched on what landed on disk rather than curl's exit code:
--max-filesize is documented to exit 63, yet measured against this endpoint
(which 302s to sdk-assets) curl aborts during receive and exits 56, so testing
for 63 alone would misreport the common case.

Reviewer disagreement resolved deliberately here: one round suggested keeping the
cached archive on a transient failure to preserve the -z If-Modified-Since fast
path; an earlier round argued removing it is safer. Kept the unconditional
removal, and documented why — a partial write carries a fresh mtime, so keeping
it risks the next -z revalidation returning 304 and handing a truncated archive
to verify_binary_integrity. Losing a 304 is cheaper than trusting a truncated
payload.

Still deferred to DEVA11Y-761: SIGKILL escalation, the O(entries)-per-tick
watchdog poll (measured ~50% overshoot: 288 MB peak against a 200 MB cap on
NVMe), hardcoded caps, the unguarded Windows unzip path, and the absent
regression suite. The deadlock reproducer above is worth a case in that suite.

Verification: 27/27 shell assertions across bash/zsh/fish against the live
endpoint; deadlock reproducer confirms old hangs / new returns; measured-flag
semantics verified on four directory shapes; swiftc -typecheck clean; bash -n
clean; all six sidecars verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maunilm

maunilm commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Claude Code PR Review

Continues the previous review — changes since efcf13c (FULL re-review).

PR: #25Head: 0af9ec2Reviewers: stack:devtools-review-changes, stack-code-reviewer

Summary

Decompression-bomb / disk-exhaustion guard for DEVA11Y-484: a 100 MB compressed cap and a 200 MB / 10,000-entry decompressed cap, enforced by a polling watchdog that SIGTERMs bsdtar in the Swift plugin and by bsdtar … -O | head -c with pipefail in the three launchers.

Since the previous FAIL at efcf13c, two commits landed. e0db1dd closed the three gating findings; 0af9ec2 closed a new HIGH the second round surfaced — a proven, indefinite extraction deadlock — plus a Medium that the first fix had itself introduced.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass Size constants and checksum sidecars only
High Security Authentication/authorization checks present N/A No auth surface
High Security Input validation and sanitization Pass This PR is the size validation; bsdtar -xpf (no -P) confirmed to refuse traversal (Path contains '..')
High Security No IDOR — resource ownership validated N/A
High Security No SQL injection (parameterized queries) N/A
High Correctness Logic is correct, handles edge cases Pass F1 dead fail-closed branch, F2 leaked archive resolved in e0db1dd; the stderr deadlock resolved in 0af9ec2. Each verified by execution, not inspection
High Correctness Error handling is explicit, no swallowed exceptions Pass F3 fail-open size read now fails closed; both forwardExit leaks converted to throw; measurement failure now reports its own reason
High Correctness No race conditions or concurrency issues Pass NSLock correct, markExceeded idempotent, watchdog armed after run(); the new stderr drain uses a dedicated queue + semaphore joined before the buffer is read
Medium Testing New code has corresponding tests Fail Zero automated coverage ships (carried)
Medium Testing Error paths and edge cases tested Fail Rejection paths, post-exit re-check and entry cap are unexercised in CI
Medium Testing Existing tests still pass (no regressions) Pass 9/9 CI green at 0af9ec2
Medium Performance No N+1 queries or unbounded data fetching Pass ⚠️ Watchdog re-walks the tree per 50 ms tick; overshoot now quantified at ~288 MB peak against a 200 MB cap on NVMe
Medium Performance Long-running tasks use background jobs N/A
Medium Quality Follows existing codebase patterns Pass Now uses this file's own .fileSizeKey idiom and its throws convention
Medium Quality Changes are focused (single concern) Pass ⚠️ The stderr drain is pre-existing plumbing, fixed here because it defeats this PR's guard rather than sitting beside it
Low Quality Meaningful names, no dead code Pass The unreachable branch is gone; the errorHandler now genuinely fires
Low Quality Comments explain why, not what Pass Load-bearing assumptions pinned, including a measured note that curl exits 56 not 63 here
Low Quality No unnecessary dependencies added Pass None

Findings

Resolved since efcf13c

  • …swift:894 Medium — fail-closed enumerator branch was unreachable dead code Resolved in e0db1dd. Both reviewers re-verified by execution; errorHandler fires for a missing dir, a chmod 000 dir, and a nested unreadable subtree. Orchestrator independently confirmed.
  • …swift:471 Medium — forwardExit bypassed both defers, leaking the ≤100 MB archive per guard trip Resolved in e0db1dd. extractLocalArchive is throws, caller is async throws, both defers registered before the call. One reviewer built a scratch SwiftPM plugin and confirmed SwiftPM renders the message via CustomStringConvertible.
  • …swift:624 Medium — compressed-size check could fail open Resolved in e0db1dd. Now resourceValues(.fileSizeKey) + guard/throw; verified nil on a missing file, Optional(4242) on a real one.
  • …swift:454 High — undrained stderr pipe deadlocked extraction indefinitely Resolved in 0af9ec2. See below.
  • …swift:935 Medium — the fail-closed fix misreported I/O failure as a size violation Resolved in 0af9ec2. Footprint now carries measured: Bool; unmeasurable directories report "extraction directory could not be measured".
  • cli.sh:286 Low — pipeline not errexit-safe, :292 Low — -ge undocumented, :293 Low — conflated diagnostics, …swift:490 Low — sibling forwardExit leak, cli.sh:255 Low — curl message conflation all Resolved.

On the High, for the record. stack:devtools-review-changes proved it rather than theorising: a 4,000-entry tar whose every member contains .. extracts to 0 bytes / 0 entries — under both ceilings, so the watchdog never fires — while bsdtar emits ~227 KB of warnings into a 64 KB pipe. The orchestrator reproduced it independently (226,939 bytes of stderr) and harnessed both plumbings against it:

old (read after wait) : *** NO RETURN within 15s -> DEADLOCK ***
new (concurrent drain): returned OK  rc=1  stderr captured=65536 bytes

The watchdog made it worse, not better — it busy-polls at 20 Hz for the duration of the hang. Fixed by draining stderr on a dedicated queue started immediately after run(), capping the retained buffer at 64 KB while continuing to read past it, and joining before the buffer is read.

Still open — carried forward, all agreed for DEVA11Y-761

  • Medium …swift:962 — no SIGKILL escalation after terminate(). bsdtar does not trap SIGTERM and the normal path measures clean (terminationStatus = 15), but waitUntilExit() remains unbounded.
  • Medium tests/ — zero automated regression coverage. Both rounds flagged it, and it is now demonstrably load-bearing: the dead branch, the fail-open read and the deadlock were all caught by review rather than by CI. The deadlock reproducer is a ready-made first case.
  • Medium …swift:448 — Windows Expand-Archive has no decompression guard; re-confirmed unchanged, not a regression. The new compressed cap does at least stop a >100 MB archive reaching it.
  • Low …swift:962 — watchdog is O(entries) per tick; overshoot measured at ~288 MB peak against a 200 MB cap.
  • Low …swift:174 — caps hardcoded in five places with no override.

Reviewer notes

  • stack-code-reviewer hit its maxTurns: 25 limit on first dispatch and returned a fragment rather than findings. It was resumed and delivered a full re-review, honestly marking five requested checks UNVERIFIED rather than passed. Those five — sidecar hashes, launcher parity, the .tmp invariant, NSLock/arming order, and fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's features — were verified by the orchestrator at this head instead.
  • A reviewer suggested keeping the cached archive on a transient curl failure to preserve the -z fast path. Not adopted, deliberately: a partial write carries a fresh mtime, so keeping it risks the next -z revalidation returning 304 and handing a truncated archive to verify_binary_integrity. Documented in the code.

Raised by other reviewers (not independently confirmed)

  • @Crash0v3rrid3 approved at 2026-08-31T07:32:52Z, before e0db1dd and 0af9ec2 landed. Their earlier P3s were addressed in 2c5fba8; the SIGKILL item they raised is carried above as Medium.

Orchestrator verification at 0af9ec2

Independent of both reviewers: 27/27 shell assertions across bash/zsh/fish against the live endpoint, including the cached-binary-survives-rejection invariant; the deadlock reproducer built and both plumbings harnessed; measured-flag semantics checked on four directory shapes (missing, chmod 000, nested-locked, normal); all six sidecars recomputed; swiftc -typecheck and bash -n clean; real artifact confirmed at 36.2 MB compressed → 66.2 MB / 1 entry decompressed, comfortably inside every cap.


Verdict: PASS — all previously gating findings resolved and re-verified by execution, plus a High found and fixed this round. The remaining five are Medium/Low, agreed for DEVA11Y-761; the absent regression suite is the one worth pulling forward, since review rather than CI caught every defect here.

…DEVA11Y-484]

Regression introduced by the deadlock fix in 0af9ec2, caught while verifying the
production paths end to end.

Draining stderr fully is what stops bsdtar blocking, and the retained buffer is
capped at 64 KB — but the failure branch then surfaced that whole buffer as the
thrown error message. Measured on the 4,000-entry `..` archive: a 65,757-byte
error containing ~1,169 near-identical "Path contains '..'" lines, which SwiftPM
would dump into the build log. Trading an extraction hang for a log flood is a
poor trade, and both are availability problems.

The drain is unchanged; only the excerpt shown is now bounded — first 20 lines
plus "… N further bsdtar message(s) omitted." Measured after: 1,191 bytes, a 55x
reduction, with the genuine first-line diagnostic preserved.

Verified through the committed extractLocalArchive plumbing, verbatim:

  1 happy path, real 38 MB artifact  -> returned 0.13s, 1 entry, no error
  2 happy path repeated             -> returned 0.13s (no intermittent hang)
  3 bomb (5 MB cap)                 -> returned 0.07s, threw size rejection
  4 chatty 4,000x '..' archive      -> returned 0.07s, message 1,191 bytes
  5 corrupt archive                 -> returned 0.07s, "Unrecognized archive format"

Case 1 mattered most and had not been covered before: if the drain loop failed to
terminate when bsdtar exits silently with empty stderr, stderrDrained.wait()
would have hung EVERY extraction — far worse in production than the bug being
fixed. It returns promptly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maunilm

maunilm commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Claude Code PR Review

Continues the previous review — changes since 0af9ec2 (DELTA re-review: 1 file, 13 lines).

PR: #25Head: 17c3b7aReviewers: stack-code-reviewer, stack:devtools-review-changes

Verdict: PASS

No Critical or High findings. Both reviewers independently recommended PASS. Merge is not blocked by this review.

What changed since the last review

One commit (17c3b7a) bounding the bsdtar stderr excerpt shown to the user to 20 lines. The drain itself stays unbounded — that is what prevents the 64 KB pipe deadlock fixed in 0af9ec2, and it is intentionally preserved.

New findings in this delta

Both are non-blocking (Medium), but both are regressions or incompleteness introduced by this commit rather than pre-existing debt, and both were reproduced empirically rather than reasoned about.

Medium — head-only truncation drops bsdtar's decisive last line

BrowserStackAccessibilityLint.swift:512-517

prefix(20) keeps the first 20 lines; bsdtar's convention is the opposite — per-entry warnings stream first, the fatal cause and Error exit delayed from previous errors. come last. Reproduced with a 25-member ../ archive followed by a truncated payload: 27 lines / 1,325 bytes total, which is under the 64 KB retention cap, so pre-delta the user saw the whole thing including payload1: Truncated tar archive. Post-delta the excerpt is 20 identical Path contains '..' lines and the cause is gone.

Verified user-visible: a throwaway command plugin throwing the same PluginError shape prints error: <full multi-line message>, so SwiftPM uses String(describing:) semantics, not localizedDescription. The message does reach the Issue Navigator, so this loss is real.

Impact: support cannot distinguish a truncated download from a path-traversal rejection. Fix: keep both ends (head + tail), or collapse identical runs and always retain the final 3 lines.

Medium — the bound is line-count only; a single long line bypasses it

BrowserStackAccessibilityLint.swift:511-517

messageLines.count > maxMessageLines is the only gate, so few-but-huge lines pass through untouched. Reproduced: one pax entry with a 60,000-character .. path produces 2 stderr lines / 60,092 bytes, and the shipped bounding logic is a verbatim no-op on it — the full ~60 KB reaches the build log, which is the exact outcome this commit's own comment says it prevents.

Fix: add a byte cap alongside the line cap. Note that it must bound on the utf8 view — String.prefix(n) counts characters, so a naive prefix(4096) lets ~4× the cap through on multi-byte input.

Low findings

# Finding Location
1 Invalid or mid-scalar UTF-8 makes String(data:encoding:.utf8) return nil, and ?? "" then discards the entire diagnostic, falling back to the generic message. Two reachable paths: a non-UTF-8 filename in a hostile archive, and the byte-wise 64 KB cap slicing a multi-byte sequence (measured: 14 of 129 cut points land mid-scalar). Pre-existing, but on the line this delta rewrites. Fix: String(decoding:as: UTF8.self) — lossy, never nil. :511
2 The omitted-line count is computed off the 64 KB-retained subset, so it under-reports ~3×: the real 4,000-entry archive emits ~4,001 lines but the marker renders … 1266 further …. Fix: track dropped lines in the drain and word it "at least N". :516
3 The comment's framing is misleading: by the time line 511 reads stderrData, the 64 KB cap has already bounded it, so the cited ~200 KB volume cannot reach the log at that point. The real benefit is 64 KB → ~1 KB. :506-510
4 For many-tiny-line inputs the "bounded" excerpt can be longer than the input (41 B → 78 B). Harmless; precision nit only. :512-517

Previously deferred findings — all still stand, severities held stable

All five remain open and correctly triaged to DEVA11Y-761; none regressed or improved.

Finding Location Status
Medium — no SIGKILL escalation after terminate() :1008 Still stands
Medium — guard ships with zero automated regression coverage tests/ Still stands
Medium — Windows Expand-Archive path unguarded :689-698 Still stands
Low — watchdog O(entries) per 50 ms tick; ~50% overshoot :995-1016 Still stands (documented trade-off)
Low — caps hardcoded in five places, no override :176-177 Still stands

Impact assessment

Public surface unchanged. Package.swift still exports one product (.plugin(name: "a11y-scan"), fixed verb: "scan"). The delta adds three local lets inside a private func on a private struct, under #if !os(Windows). No declaration, signature, flag, permission, or exit-code change — a no-op at API/ABI level for SPM consumers pinned to branch: "main" (where merging is releasing, since no tags exist).

No programmatic consumer of the message. PluginError is private to this file; the only consumer is the SwiftPM plugin host. The three shell launchers run a completely independent extraction with their own error strings and never observe this message. Nothing in-repo pattern-matches bsdtar error text.

Net observable change: the text of one error, on one branch (bsdtar exited non-zero AND the archive is not already an executable).

Test coverage

Zero coverage for this delta, and the guard as a whole remains untested — extractLocalArchive is private on a private struct in a plugin-only package with no library target, so it is structurally untestable without the extraction refactor already scoped to DEVA11Y-761.

Notes on this review

  • code-review-graph MCP was unavailable, so impact analysis fell back to reading, grep, and empirical reproduction. That is absence of data, not evidence of safety.
  • Reviewers read a git archive snapshot pinned at 17c3b7aa and sha256-verified against the pushed blob, because the local clone sits on a different branch carrying deferred work not in this PR.
maunilm and others added 6 commits August 31, 2026 19:54
…ytes [DEVA11Y-484]

The previous commit bounded the surfaced bsdtar stderr to 20 lines. Review found
two ways that bound fails, both reproduced:

1. It kept the WRONG END. bsdtar streams per-entry warnings first and puts the
   decisive cause last. A 25-member `../` archive followed by a truncated payload
   emits 27 lines / 1,314 bytes — under the 64 KB retention cap, so before the
   previous commit the user saw `payload1: Truncated tar archive` in full. After
   it, they saw 20 identical `Path contains '..'` lines and no cause at all.
   Support could not tell a truncated download from a path-traversal rejection.
   Now the excerpt keeps the first 10 AND the last 10 lines, so the cause and
   bsdtar's "Error exit delayed from previous errors" summary both survive.

2. It bounded LINES ONLY. One pax entry with a 60,000-character `..` path emits
   just 2 stderr lines totalling ~60 KB, which sails through a 20-line cap
   untouched — the exact build-log flood the cap was added to prevent. Now bytes
   are bounded too, at 4 KB. The cap is applied over the `utf8` view because
   String's `prefix` counts CHARACTERS, which would let ~4x through on
   multi-byte input.

Also switches to `String(decoding:as: UTF8.self)`. `String(data:encoding:.utf8)`
returns nil — not a partial string — on any invalid sequence, and the `?? ""`
fallback then discarded the ENTIRE diagnostic in favour of generic text. Two
paths reach that: a non-UTF-8 member name (tar names are arbitrary bytes, echoed
verbatim by bsdtar), and the drain's byte-wise 64 KB cap slicing a multi-byte
scalar — 14 of 129 cut points, measured. The lossy decode never returns nil.

Draining itself stays unbounded; that is what closes the deadlock fixed in
0af9ec2 and is deliberately untouched. Empty input still yields an empty string,
which the generic-fallback branch depends on.

Verified against the verbatim committed code: 9-shape excerpt matrix (empty,
whitespace, short, truncated-tar, 4,000-entry bomb, 60 KB single line, 60 KB
multi-byte, 21 tiny lines, mid-scalar cut) all bounded <= 4.4 KB with empty
semantics preserved; and a 6-case end-to-end matrix through the real
extractLocalArchive with a 25s hang deadline — happy path 0.06s, no hangs,
truncated-archive cause retained, chatty archive down from ~65 KB to 1,115 B.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ction guard [DEVA11Y-484]

Both reviews flagged that this PR ships a security control with zero automated
coverage. This closes that for the shell half: 51 assertions across
scripts/{bash,zsh,fish}/cli.sh, plus a CI job that runs them on every PR touching
a launcher.

The functions under test are extracted VERBATIM from cli.sh and run against a
local python3 http.server. Only curl's CLI boundary is shimmed — the hardcoded
api.browserstack.com URL is rewritten and every other argument passes through, so
--max-filesize, -L, -z and the `bsdtar | head -c` pipeline all execute for real.
No network egress, no credentials, no mocks of bsdtar/head/curl.

Three details exist because the naive version of this suite passed for the wrong
reasons, each caught by measurement:

- All four interdependent functions are loaded, not just download_binary.
  Loading one leaves strip_quarantine and verify_binary_integrity undefined, the
  function dies with exit 127, and EVERY abort assertion then passes for the wrong
  reason. Faithfulness greps also assert the extracted code still contains the
  guarded pipeline and both cap constants, so a refactor past them fails loudly
  rather than silently testing nothing.

- Assertions check the error MESSAGE, not just exit status. A bomb trips both the
  size cap and extract_status (bsdtar takes SIGPIPE when `head -c` closes the
  pipe), so asserting "exit 1" alone still passed with the size cap deleted —
  confirmed by mutation test. The two paths emit different messages.

- The expected file mode is read out of cli.sh rather than hardcoded. main
  tightened it from 0775 to 0755 and the hardcoded expectation had already rotted.

Mutation-validated. Baseline green; disabling the decompressed-size rejection,
raising the cap to 4 GB, dropping the `head -c` truncation, and removing both
compressed-cap layers each turn it red. Removing only --max-filesize stays green
and that is correct: the explicit `compressed_size > max_compressed` backstop
still rejects (measured — the full 105 MB downloads, then the backstop fires).
Removing both layers is caught.

Lives under tests/ rather than scripts/ because verify-selfupdate-checksums globs
scripts/**/*.sh and requires a committed .sha256 sidecar per match; test scripts
are not self-updated and must not enter that glob.

Fixtures (~106 MB) are generated on first run and gitignored. The ignore
deliberately has no trailing slash: `fixtures/` matches only a directory, so a
symlink named `fixtures` slips past it and gets staged — which happened while
building this.

The Swift half (extractLocalArchive, the watchdog, the stderr excerpt) is still
uncovered: it is private on a private struct in a plugin-only package with no
library target, so no test can import it. That needs the library extraction
tracked in DEVA11Y-761.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-484]

Review found that the byte cap added in 13a8111 could discard the very thing the
head+tail split was added to preserve, and I reproduced it.

The cap was a blind prefix over the already-joined head + notice + tail. When the
head lines are individually large, that cut lands inside the head and drops both
the omission notice AND the entire tail — including bsdtar's decisive last line.
Measured: 25 entries with ~500-character paths plus a truncated payload produced
13,264 bytes of stderr, and the excerpt came back 4,112 bytes with
"Truncated tar archive" gone. That is the same dropped-cause bug 13a8111 fixed,
reached from a different direction.

Head and tail now get half the byte budget each, clamped BEFORE they are joined.

Clamping direction turned out to matter as much as the split. Clamping the tail
with a prefix still lost the cause: the last 10 lines are 8 large warnings
FOLLOWED by the two lines that matter, so keeping the tail's beginning discards
exactly what the tail was retained for. clampToUTF8Bytes therefore takes
`keepingEnd`, and the tail keeps its end.

Verified across 11 shapes against the verbatim committed code — empty,
whitespace, short, cause-with-small-head, cause-with-BIG-head (13 KB),
cause-with-HUGE-head (226 KB), 4,000-entry bomb, 60 KB single line, 60 KB
multi-byte single line, 21 tiny lines, mid-scalar cut. All bounded under 4.4 KB,
empty semantics preserved, and both the cause and the omission notice retained in
every cause case (226 KB in, 4,249 bytes out). The 6-case end-to-end matrix
through the real extractLocalArchive stays green with no hangs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uite [DEVA11Y-484]

A reviewer running the suite alongside another run saw it fail once in seven, and
the cause is real: fixture generation is not atomic, and run_tests.sh gated on
"does legit.tar.gz exist?" — which make_fixtures.sh creates FIRST. A second run
starting behind a generating one saw the gate satisfied and began reading
bomb/manyfiles/multifile while they were still being written.

Reproduced deterministically: clear the fixtures, start one run, start a second 3
seconds later, and the second fails 5 of 51 assertions — many-files and multi-file
across all three variants, which are exactly the fixtures written last.

Generation now takes an atomic mkdir lock (macOS ships no flock CLI) and writes a
`.complete` marker as its final act; run_tests.sh gates on that marker and hard-
fails if it is still absent afterwards. A run that loses the lock waits for the
marker rather than proceeding on half-written input.

Validated: 4 concurrent cold starts, a staggered cold start, and 6 warm serial
runs — 51/51 every time, lock cleaned up, fixtures still gitignored. Mutation
validation re-run and unchanged: disabling the decompressed-size rejection,
raising the cap to 4 GB, dropping the head -c truncation, and removing both
compressed-cap layers are each still caught.

Also documents why the corrupt fixture uses /dev/urandom rather than /dev/zero:
an all-zero file is a VALID EMPTY tar archive, so zeros make bsdtar exit 0 and the
corrupt assertions pass for the wrong reason. That one bit me while building this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nnot go negative [DEVA11Y-484]

Review noted that `omitted = count - headLines - tailLines` stays non-negative only
because `maxMessageLines` (20) happens to equal `headLines + tailLines` (10 + 10),
with nothing tying the three constants together. Not reachable today, but bumping
headLines alone would have put "at least -3 further bsdtar message(s) omitted" in a
user-facing build error.

`maxMessageLines` is now derived as `headLines + tailLines`, which makes the
property algebraic rather than coincidental: the branch is only entered when
`count > headLines + tailLines`, so `omitted >= 1` for any values of either.

Behaviour is unchanged — verified byte-identical output across all 11 shapes
(empty, whitespace, short, cause-with-small/BIG/HUGE head, 4,000-entry bomb, 60 KB
single line, 60 KB multi-byte line, 21 tiny lines, mid-scalar cut), and the 6-case
end-to-end matrix through the real extractLocalArchive stays green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te [DEVA11Y-484]

Addresses the impact review's Medium plus its actionable Lows.

MEDIUM — the oversized-download assertion carried a false rationale. Its comment
claimed that with `--max-filesize` removed the failure moves to bsdtar, so asserting
"the failure came from curl" proves the flag is present. It does not: the explicit
`compressed_size > max_compressed` backstop fires first and emits "maximum allowed
download size", which is the second pattern the assertion already accepts. The
comment also contradicted this suite's own README, which described the two-layer
behaviour correctly. Reworded to claim only what it proves — "rejected before
extraction" — and added a fourth faithfulness grep for `--max-filesize` itself.

That grep closes the one gap in the mutation matrix. Removing the flag alone was
previously undetectable by any behavioural assertion; it is now caught, so all five
mutations are detected rather than four. This matters because without the flag a
chunked or undeclared-length response can write unbounded bytes to disk before any
check runs.

Also fixed, and each one caught by re-running the validation rather than by reading:

- start_server accepted ANY server answering on its PID-derived port. If an
  unrelated local service held it, the probe passed against that server, every
  fixture 404'd, and the run failed with a dozen confusing per-case errors instead
  of "port busy". It now serves a token and requires the responder to return it,
  trying other ports otherwise. The old comment promised this fallback; it had
  never been implemented.
- My first version of that fix used a FIXED token filename, which is itself a
  concurrency bug: four simultaneous runs overwrite each other's token, every probe
  reads a foreign value, and start_server exhausts all ten ports and exits with no
  tests run. 2 of 4 concurrent cold starts died that way. Token files are now
  per-run and removed in stop_server.
- Added a legit `.zip` fixture and case. Production serves a .zip (cli.sh even names
  the path BINARY_ZIP_PATH) while every fixture here was .tar.gz, so the suite never
  saw the format the guarded path actually receives. The guard is format-independent,
  so this is fidelity, not a correctness hole. 51 -> 60 assertions.
- EXPECTED_MODE is now read per variant. The three cli.sh files are byte-identical
  in that region today, but reading bash's value for all three would check a zsh- or
  fish-only mode change against the wrong source.
- The 20,000-entry case now documents that it asserts no per-entry disk
  amplification, NOT an entry-count cap. The shell path has no entry ceiling at all,
  unlike the Swift path's maxArchiveEntries = 10_000, and because -O concatenates
  that archive publishes a 0-byte binary. Pre-existing, outside this ticket, but not
  something a passing assertion should imply is covered.

Workflow hygiene: pinned actions/checkout to v4.2.2 (matching the repo's three
newest workflows) — v3.5.3 is a Node16 action being removed from runner images,
which would have reddened this job at checkout for unrelated reasons; pinned
macos-14 rather than floating macos-latest, since the suite depends on bsdtar,
python3, BSD `head -c` and BSD `stat -f`; added the same `paths:` filter to the
push trigger as the PR trigger, so pushes to main no longer run a 10x-billed macOS
job unconditionally; added a concurrency group with cancel-in-progress.

Docs: `tests/README.md` was the index of tests/ and framed everything there as an
end-to-end plugin harness — it now distinguishes integration harnesses from
regression suites and lists extraction-guard. And sweepStaleStaging's doc comment
still said the extract helpers call forwardExit()/exit() and bypass prepareArtifact's
defers; this PR converted those to throws precisely so the defers DO run, so that
comment now applies only to the Windows unzip path and to SIGKILL.

Re-validated after all of the above: 60/60 across 4 concurrent cold starts, a
staggered cold start, and 6 warm serial runs; port-squat defence confirmed against a
decoy server; all 5 mutations caught; cli.sh restored byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maunilm

maunilm commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Claude Code PR Review

Continues the previous reviewFULL re-review across six commits since 17c3b7a.

Head: c73f5fbReviewers: stack-code-reviewer, stack:devtools-review-changes

Verdict: PASS

No Critical or High findings. Both reviewers independently recommended PASS, and every actionable finding they raised has been fixed in this branch.

Scope note — read this before relying on the verdict

Both reviewers ran against head 0118ee0. Two commits landed after them, implementing their own findings:

  • 52f011f — derives maxMessageLines from headLines + tailLines (reviewer 1's Low)
  • c73f5fb — the impact review's Medium plus its actionable Lows

Those two carry author validation only, not independent subagent review. They are a constant derivation, comment corrections, workflow-hygiene pins, and test-harness changes; no guard logic changed. Stated explicitly so the human reviewer knows exactly where the independent eyes stopped.

What the six commits do

Commit Change
13a8111 bsdtar stderr excerpt: head+tail retention, byte cap, lossy UTF-8 decode
1dd9e31 new tests/extraction-guard/ regression suite + CI workflow
3027e92 budget head and tail separately; clamp the tail from its end
0118ee0 fix an intermittent suite failure (fixture-generation race)
52f011f derive the line threshold so the omitted count can't go negative
c73f5fb close the impact review's Medium + Lows

Findings raised this round — all fixed

Medium — the oversized-download assertion carried a false rationale. Its comment claimed that removing --max-filesize moves the failure to bsdtar, so asserting "the failure came from curl" proves the flag is present. It does not: the explicit compressed_size > max_compressed backstop fires first and emits "maximum allowed download size" — the second pattern the assertion already accepts. The comment also contradicted the suite's own README. Fixed: reworded to claim only "rejected before extraction", and a fourth faithfulness grep added for --max-filesize itself. That closes the one hole in the mutation matrix — the flag's removal was previously undetectable by any behavioural assertion, and matters because without it a chunked or undeclared-length response can write unbounded bytes to disk before any check runs.

Medium (previous round) — the byte cap could discard the tail it existed to preserve. A blind prefix over the joined head+notice+tail cut inside the head when head lines were large, dropping the notice and the decisive cause. Reproduced at 13,264 B input. Fixed in 3027e92; the naive fix still failed, because clamping the tail with a prefix keeps its beginning while the cause is at its end — hence keepingEnd.

Low — omitted could go negative if headLines were ever bumped alone, printing "at least -3 further messages" to a user. Threshold now derived; the property is algebraic.

Low — start_server accepted any server on its port. A squatting local service would satisfy the readiness probe, every fixture would 404, and the run would fail with confusing per-case errors. Now token-verified with port retry. (The first version of that fix used a fixed token filename, which broke 2 of 4 concurrent cold starts — token files are now per-run.)

Low — fixtures never used production's format. The endpoint serves a .zip and cli.sh names the path BINARY_ZIP_PATH, yet every fixture was .tar.gz. Zip fixture and case added: 51 → 60 assertions.

Low — EXPECTED_MODE read from bash for all three variants. Now per variant.

Low — workflow hygiene. actions/checkout v3.5.3 (Node16, being removed from runner images) → v4.2.2; macos-latest → pinned macos-14; paths: filter added to the push trigger; concurrency group with cancel-in-progress.

Low — two docs-drift items. tests/README.md framed all of tests/ as end-to-end plugin harnesses; and sweepStaleStaging's comment still said the extract helpers forwardExit() past prepareArtifact's defers, which this PR made false. Both corrected.

Verification

Check Result
Excerpt logic, 11 shapes, extracted verbatim from the file all ≤4.4 KB, empty semantics preserved, cause + notice retained (226 KB in → 4,249 B)
End-to-end matrix through real extractLocalArchive, 25s hang deadline 6/6, happy path 0.06s, no hangs
Shell suite 60/60 × 3 variants
Concurrency 60/60 across 4 concurrent cold starts, staggered cold start, 6 warm serial runs
Mutation validation 5/5 guard removals caught (was 4/5)
Port-squat defence confirmed against a decoy server on the target port
Self-update sidecars all 6 consistent
CI 10/10; the new job has passed 3 consecutive runs

Release impact

Merge = release. No tags exist; SPM consumers pin branch: "main", so this lands at each consumer's next resolve.

Surface unchanged: Package.swift is not in the diff — product a11y-scan, verb scan, and both permissions are identical. No consumer manifest edit, no new permission prompt, no public declaration changed. The scan's own result code still forwards verbatim, so CI gating on scan findings is unaffected.

Observable changes: new hard-fail cases (archive >100 MB, footprint >200 MB or >10,000 entries); bsdtar extraction failure now throws rather than exiting with bsdtar's code (SwiftPM prints error: and exits 1); the archive and staging dir are no longer leaked into the cache on a guard trip; and diagnostics text differs — bounded head+tail excerpt where a single non-UTF-8 byte previously produced an empty message.

Headroom, measured against production: CLI 1.53.0 macos-arm64 is 36.2 MB compressed / 66.2 MB decompressed / 1 entry — 2.8× compressed and 3.0× decompressed headroom. Not a blocker. But there is no override env var, so a future CLI crossing either line breaks every consumer simultaneously. Worth a size assertion in the CLI release process; tracked below.

Still open — deferred to DEVA11Y-761

Finding Severity
Swift extraction path has no automated coverage (structurally untestable until the library extraction) Medium
No SIGKILL escalation after terminate() Medium
Windows Expand-Archive path unguarded Medium
Watchdog O(entries) per 50 ms tick; ~50% overshoot Low
Caps hardcoded, no env override (see headroom note above) Low
Shell path has no entry-count ceiling, unlike Swift's 10,000 Low
Stale .lock after SIGKILL forces a 300s wait then exit 1 (local dev only — CI workspaces are ephemeral) Low

Notes on this review

  • code-review-graph MCP was unavailable both rounds; impact analysis fell back to reading, grep, and empirical reproduction. That is absence of data, not evidence of safety.
  • Reviewers read git archive snapshots pinned per head and sha256-verified against the pushed blob, because the local clone sits on a different branch with untracked leftovers.
  • The review identity and the PR author are the same GitHub account here, so the skill's human-signal guard cannot distinguish them on this PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants