feat(debug-files): --strip-sources-content (re-targeted to main) - #50
Conversation
… their source uploaded Audit §8: a source map's `sourcesContent` embeds the original source verbatim, and it rides into the upload with the map. That is how a symbolicated crash shows source lines — and also means the customer's code leaves the build machine. There was no way to say no. `--strip-sources-content` (`--type sourcemaps`) removes it from the COPY that is uploaded. File, line and column symbolication is unaffected; what is lost is the snippet shown beside a frame. - The map on disk is never modified. It is the caller's build output and their own debugging, and the bundler plugins delete these maps after a successful upload anyway. - The declared `hash` describes the stripped bytes, not the file that was read — the wire field is supposed to describe what was uploaded. - The debug-id is carried over untouched: it is the key, and re-deriving it would break the pair with the bundle `sourcemaps inject` stamped. - A map with no `sourcesContent`, or one that is not the JSON object we expect, is uploaded byte-for-byte unchanged. This is a privacy preference, not a validator. - Rejected with exit 20 for every other `--type`, like the other sourcemap-only flags: no other symbol format embeds source. Measured on a real esbuild bundle: 253 → 181 bytes uploaded, local map untouched. Five mutants caught (flag ignored, always-on, nothing-to-strip still re-serialized, wrong hash, flag accepted for other types) — the third needed the test strengthened to assert BYTE equality, not just the absence of the key. Two new e2e flows unzip the captured PUT and check both the upload and the file on disk. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…rce entirely
A review of the whole 0.7.11 delta found the flag's promise failing silently on the one map shape it
could not see. An INDEXED map (spec §Index-Map, `{"version":3,"sections":[{"offset":…,"map":{…}}]}`)
keeps its source inside each section, not at the top level — so `map.remove("sourcesContent")` found
nothing, the map uploaded verbatim WITH the source, and the run reported success without even a log
line. A team turning this on precisely so their code never leaves the build machine got the opposite,
quietly.
`sourcesContent` is now removed wherever a map can carry it: the top level and every
`sections[].map`, recursively (sections nest). Verified against a capturing mock — the PUT body no
longer contains the source, while sections, offsets, mappings and the debug-id survive.
Also from the same review: the strip's JSON round-trip moved to `spawn_blocking`. Parsing and
re-serializing a multi-megabyte map costs more than the zstd that already moved there, and every
upload future is polled by one task.
Docs: README's synopsis was missing `--strip-sources-content`, its "both flags" line had grown to
three, and neither document mentioned indexed maps. Three mutants caught (sections branch skipped,
recursion dropped, top-level removal dropped).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
| if !strip { | ||
| return Ok(None); | ||
| } | ||
| let bytes = std::fs::read(map_path)?; |
There was a problem hiding this comment.
std::fs::read(map_path)? (and std::fs::write(&out, &stripped)? at line 1832) convert io::Error straight into anyhow::Error, bypassing crate::error::Error::Io. classify() (src/error.rs) only maps exit codes by downcast_ref::<Error>(), so an I/O failure here — the map file vanishing or losing read permission between sourcemap::identify() (line ~1609, upstream) and this second read, or a transient write failure into the fresh tmpdir — lands on ExitCode::Unexpected (1, "caller should fall back"), whereas the same file failing the first read in sourcemap::identify() is typed and correctly exits 10 (InputNotFound, no fallback). That's an integrator-visible inconsistency for the identical failure mode, gated only by whether --strip-sources-content is passed.
Suggest changing this function's return type to crate::error::Result<...> (or map the io errors explicitly with input_not_found/input_invalid) so failures here classify the same way as the rest of the sourcemap-identify path.
Code reviewAdds Findings: 1 inline (0 blocking).
Non-blocking — the flag's mainline behavior, wire shape, and hash semantics are correct. Fine to merge as is, or fix the error-typing nit first if you want the exit-code contract airtight for this new path. |
… reading a zstd zip Two failures on #50. **The review's finding.** `stripped_copy_without_sources` read the map and wrote the copy through a bare `?` into `anyhow`, so an I/O failure there classified as exit 1 ("unexpected — you may fall back") while `sourcemap::identify` classifies one on the SAME file, moments earlier, as exit 10 ("input not found — do not"). Both now go through `Error::Io`, and the JSON re-serialization failure through `input_invalid`. The test drives the function directly, because `run_sourcemap_upload` reads the map through `identify` first — which is exactly why this could sit here unnoticed: an end-to-end test cannot reach the strip's own read. It covers the map vanishing (the TOCTOU window), an unreadable map, and an unwritable destination; both untyped-error mutants are caught. **The CI failure, which was mine.** The e2e strip check reads the uploaded zip with Python's `zipfile`, which cannot decompress zstd (method 93) before Python 3.14 — the macOS runner has 3.14, ubuntu and windows do not, so the flow passed locally and failed on three runners. That flow now uploads with `--no-zstd`; it is about `sourcesContent`, not compression, and `sourcemaps_upload` already covers the zstd path. The "could not verify" branch is also no longer a `[warn]`: a check that cannot run is a check that failed, and it reported as one. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
| if std::env::var_os("USER").is_some_and(|u| u == "root") { | ||
| return; | ||
| } | ||
| use std::os::unix::fs::PermissionsExt; |
There was a problem hiding this comment.
Cross-platform break: use std::os::unix::fs::PermissionsExt; has no #[cfg(unix)] guard, so this whole test module fails to compile on Windows (cargo test builds this crate there too, per CLAUDE.md). The existing test at src/inject/mod.rs:1166 does the exact same chmod-based check and correctly gates it with #[cfg(unix)] above #[test] — this test should follow the same pattern.
Code reviewAdds Findings: 1 inline (1 blocking).
Recommend fixing the Windows build break before merge; everything else is sound. |
… for Windows in CI The review caught a test that would not compile on Windows: `PermissionsExt` is Unix-only and my new `a_strip_that_cannot_read_or_write_is_input_not_found` used it with no `#[cfg(unix)]`, unlike `an_unreadable_map_fails_before_the_bundle_is_modified` right next door. Gated to match. No job could have caught it, which is the more interesting half. The `test` matrix is ubuntu + macOS, and the Windows job runs `cargo build --release` — the binary only, so `#[cfg(test)]` code is never compiled for that target. All 13 checks passed on a tree that broke `cargo test` for every Windows developer. That job now builds `--all-targets`, so test code compiles there too. Compiling is not running: the matrix above still owns behaviour, and this stays a build job. Running the suite on Windows would be the fuller fix — 535 tests that have never executed there — but that is its own change, with its own fallout, and not something to fold into a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The suite has never executed on Windows: the matrix was ubuntu + macOS and the Windows leg built the binary only. This ships there, so it should be tested there. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Code reviewAdds I read the full diff against CLAUDE.md's contracts (
Findings: None. This is a well-scoped, carefully tested change (including an indexed-source-map edge case that would otherwise have silently defeated the flag's whole purpose). Merge as is. |
Code reviewAdds I traced the new path against every contract in CLAUDE.md: exit-code mapping ( Findings: None. Sound change, well-tested (including the TOCTOU/permission-failure and index-map edge cases), consistent with the codebase's existing conventions. Merge as is. |
…at found x64 and the native ARM64 runner now run `cargo test --all-targets`, not just a release build. The release ships both, and they differ in more than instruction set — `ring`'s ARM64 Windows assembly is why the release leg is pinned to a native runner rather than cross-compiled. The first run found a real break, and it was not from this PR: `tests/env_robustness.rs` defines a `cli()` helper used only by its `#[cfg(unix)]` tests, so on Windows it is dead code — and `RUSTFLAGS: -D warnings` makes that a hard error. `cargo test` had been failing on Windows for anyone who ran it, while every CI job stayed green, because no job compiled test code for the platform. Gated the helper to match its users. The ARM64 job drops the `--all-targets` I added a commit ago: with a real ARM test job it is back to what it says on the tin, proof that the release profile still links for the target it ships. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Code reviewAdds I traced the full path: flag threading through Findings: None. Recommend merging as is. |
Gating `cli()` to unix left `use assert_cmd::Command;` unused there, which `-D warnings` also makes a hard error — the next layer of the same break. Both are gated now, with the reason recorded: the single test in this file needs a non-UTF-8 environment variable, which is not constructible on Windows. Swept the rest of the test surface for the same class: every other `os::unix` / `PermissionsExt` use is already inside a gated item or block (update_e2e.rs is Windows-aware by construction), and the two remaining greps are `is_symlink()`, which is cross-platform. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Code reviewThis PR adds I traced the new flag end-to-end against the checklist: the clap doc comment is present and accurate, Findings: 0 inline (0 blocking) — none. Recommend merging as is. |
Running the suite on Windows for the first time found a real defect in the tests, not the product.
519 unit tests pass there; `tests/elf_upload.rs` did not:
WARN symbol metadata POST: transport error; backing off attempt=1 error=error sending request
error: upload failed: symbol metadata POST: error sending request (exit 31, after 3 retries)
The tests clear the child's environment so an ambient `BUGSEE_*` or CI variable cannot decide what
the binary does. On Unix that is all `env_clear()` means. On Windows it also removes `SystemRoot`,
and WinSock cannot initialise without it — so every request from the child fails, with nothing in
the message to say why. `tests/update_e2e.rs` makes the same kind of call and passes precisely
because it does not clear the environment.
All three network-driving test files now share one `common::cli()` that clears the environment and
then puts the OS's own variables back on Windows (`SystemRoot`, `windir`, `SystemDrive`, `TEMP`,
`TMP`, `USERPROFILE`). Shared rather than repeated three times, so the next test written this way
cannot repeat it; `xcode_post_action.rs` keeps its local `cli()` name as a thin wrapper, since its
doc comment explains why that file in particular needs a hermetic environment.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
| /// | ||
| /// A map's `sourcesContent` carries your source verbatim, which is how a symbolicated crash | ||
| /// shows source lines. Stripping it keeps file/line/column symbolication and drops the | ||
| /// snippet, for teams who would rather their code did not leave the build machine. The map |
There was a problem hiding this comment.
--help drift: this doc comment (the actual --help text for --strip-sources-content) still only describes the flag as if sourcesContent lived at the top level. A later commit in this PR (fix(debug-files): --strip-sources-content missed an indexed map's source entirely) taught the implementation to also strip sections[].map recursively for indexed maps, and updated both README.md and CHANGELOG.md to say so — but this clap doc comment, which is what debug-files upload --help actually prints, was never revisited. Per CLAUDE.md's "--help is part of the public surface" rule, this should gain a line like the README's ("including the copies an indexed map keeps inside sections[].map") in the same change that added indexed-map support.
Code reviewThis PR adds Findings: 1 inline (0 blocking).
Nothing blocking. The one finding is a small doc-sync gap, easy to fix in a follow-up line; safe to merge as is or with that one-line addition. |
Next thing running the suite on Windows found. `src/cli/update.rs` asks for `bugsee-cli-<triple>.zip` on Windows and `.tar.xz` elsewhere (`artifact_urls`), and looks for `bugsee-cli.exe` inside — all correct. The TEST hard-coded the Unix half: it mounted `.tar.xz` and packed a binary named `bugsee-cli`, so the Windows run asked the mock for a file it had never mounted and read the 404 as a product failure. The harness now builds what this host's release would publish: a real ZIP containing `bugsee-cli-<triple>/bugsee-cli.exe` on Windows (via the `zip` crate already in the tree), the plain tar elsewhere, with the extension derived by the same split the product makes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Code reviewThis PR adds I traced the new flow against every category in scope: Findings: None. Sound and thorough — merge as is. |
… reading a zstd zip Two failures on #50. **The review's finding.** `stripped_copy_without_sources` read the map and wrote the copy through a bare `?` into `anyhow`, so an I/O failure there classified as exit 1 ("unexpected — you may fall back") while `sourcemap::identify` classifies one on the SAME file, moments earlier, as exit 10 ("input not found — do not"). Both now go through `Error::Io`, and the JSON re-serialization failure through `input_invalid`. The test drives the function directly, because `run_sourcemap_upload` reads the map through `identify` first — which is exactly why this could sit here unnoticed: an end-to-end test cannot reach the strip's own read. It covers the map vanishing (the TOCTOU window), an unreadable map, and an unwritable destination; both untyped-error mutants are caught. **The CI failure, which was mine.** The e2e strip check reads the uploaded zip with Python's `zipfile`, which cannot decompress zstd (method 93) before Python 3.14 — the macOS runner has 3.14, ubuntu and windows do not, so the flow passed locally and failed on three runners. That flow now uploads with `--no-zstd`; it is about `sourcesContent`, not compression, and `sourcemaps_upload` already covers the zstd path. The "could not verify" branch is also no longer a `[warn]`: a check that cannot run is a check that failed, and it reported as one. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Re-open of #48, which never reached
main— the same stacking trap as #46/#49. #48 targetedfeat/upload-dry-run; #47 (that branch → main) merged first, so #48 merged into a branch that had already been consumed.maintoday has no--strip-sources-content. Same two commits, cherry-picked onto currentmain.What it does
A source map's
sourcesContentembeds the original source verbatim. It is what lets a symbolicated crash show source lines — and it also means the customer's code leaves the build machine on every upload.--strip-sources-content(--type sourcemaps) removes it from the copy that is uploaded.Measured on a real esbuild bundle: 253 → 181 bytes uploaded, local map untouched.
hashdescribes the stripped bytes, not the file that was read.sourcemaps injectstamped.sections[].map, so removing only the top-level key uploaded the source verbatim and reported success. Now stripped recursively.--type— no other symbol format embeds source.spawn_blocking, like the zstd: on a multi-megabyte map it costs more than the compression does, and every upload future is polled by one task.Tests
Five unit tests plus the flag-rejection matrix and two e2e flows that unzip the captured PUT. Eight mutants caught across both commits (flag ignored, always-on, nothing-to-strip re-serialized, wrong hash, accepted for other types, sections branch skipped, recursion dropped, top-level removal dropped).
Verified on this branch against current
main:cargo fmt --check,clippy --all-targets -D warnings(0), all unit tests + suites,e2e_flows.pyALL PASS.🤖 Generated with Claude Code