Skip to content

feat(debug-files): --strip-sources-content - #48

Merged
krassx merged 2 commits into
feat/upload-dry-runfrom
feat/strip-sources-content
Sep 18, 2026
Merged

krassx merged 2 commits into
feat/upload-dry-runfrom
feat/strip-sources-content

Conversation

@krassx

@krassx krassx commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #47. Audit §8's last CLI-side item, batched into the same release as #45/#46/#47.

Why

A source map's sourcesContent embeds 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. There was no way to say no.

What

--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.

Measured on a real esbuild bundle:

$ bugsee-cli debug-files upload . --type sourcemaps --strip-sources-content …
INFO stripped sourcesContent from the uploaded copy path=./app.js.map before_bytes=253 after_bytes=181
INFO upload complete uploaded=1 already_existed=0 skipped=0
$ python3 -c "import json; print('sourcesContent' in json.load(open('app.js.map')))"
True        # the local map is untouched

Decisions worth naming:

  • 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 (exit 20) for every other --type, like the other sourcemap-only flags — no other symbol format embeds source.

Tests

Four unit tests (strip removes it and keeps mappings/sources/debug_id while leaving the file on disk alone; the declared hash matches the uploaded bytes; the default keeps the source; nothing-to-strip is byte-identical) plus the flag-rejection matrix and two e2e flows that unzip the captured PUT.

Five mutants caught: flag ignored, always-on, nothing-to-strip still re-serialized, wrong hash, flag accepted for other types. The third initially survived — the test asserted the key was absent, which was true either way; it now asserts byte equality.

Gates: cargo fmt --check, clippy --all-targets -D warnings (0), 500 unit tests + all suites, e2e_flows.py ALL PASS.

🤖 Generated with Claude Code

Comment thread src/cli/debug_files.rs
Comment on lines +1787 to +1800
let bytes = std::fs::read(map_path)?;
let Ok(serde_json::Value::Object(mut map)) = serde_json::from_slice(&bytes) else {
return Ok(None);
};
if map.remove("sourcesContent").is_none() {
return Ok(None);
}
let stripped = serde_json::to_vec(&map)?;
let name = map_path
.file_name()
.map(Path::new)
.unwrap_or_else(|| Path::new("bundle.js.map"));
let out = tmpdir.join(name);
std::fs::write(&out, &stripped)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

std::fs::read(map_path)? and std::fs::write(&out, &stripped)? propagate a bare std::io::Error through anyhow::Result here, not crate::error::Error::Io. Because this function's error type is anyhow::Result rather than crate::error::Result, ? picks anyhow's blanket From<io::Error> instead of the crate's #[from] impl, so the boxed error is a raw io::Error. main's classify does err.downcast_ref::<crate::error::Error>(), which will fail on that, falling through to ExitCode::Unexpected (1, "caller should fall back") — even though the exact same read failing in sourcemap::identify (src/symbols/sourcemap.rs:21, which returns crate::error::Result) correctly maps to ExitCode::InputNotFound (10, no fallback). So a map that goes unreadable/unwritable between pass 1 and this pass-2 stripping step gets a different, inconsistent exit code than the identical failure one call earlier — and per CLAUDE.md, integrators rely on that boundary to decide whether to fall back. Consider having this helper return crate::error::Result (like identify) instead of anyhow::Result, so the #[from] std::io::Error conversion applies.

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code review

Adds --strip-sources-content to debug-files upload --type sourcemaps: an opt-in copy-before-upload that removes sourcesContent from the packed map while leaving the on-disk file untouched, recomputing the declared hash from the stripped bytes. The flag plumbing (rejection for non-sourcemap types, --help text, README/CHANGELOG) is consistent with the file's existing conventions, and the new tests exercise the strip/no-strip/nothing-to-strip/hash paths plus an e2e round trip through the actual PUT body.

Findings: 1 inline (1 blocking).

  • src/cli/debug_files.rs:1787-1800 — the new stripped_copy_without_sources helper returns anyhow::Result, so its std::fs::read/std::fs::write calls propagate a raw io::Error instead of going through crate::error::Error::Io. classify's downcast_ref::<crate::error::Error>() can't recover a typed variant from that, so a read/write failure here exits 1 (Unexpected, "caller should fall back") instead of 10 (InputNotFound) — inconsistent with the identical file read one call earlier in sourcemap::identify, which does return the typed error and gets the documented code.

Otherwise the change holds together: identity/hash recomputation is scoped correctly to the stripped bytes, the per-file tempdir (created fresh inside upload_one_sourcemap) rules out any cross-upload path collision under --concurrency, and the "not JSON / no sourcesContent → upload unchanged" fallback is a deliberate, documented privacy-preference choice rather than a silent bug. Recommend fixing the exit-code typing before merge; everything else is fine as is.

@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code review

Adds --strip-sources-content to debug-files upload --type sourcemaps: uploads a copy of each .map with sourcesContent removed (including nested sections[].map for indexed maps), leaves the file on disk untouched, and recomputes the declared hash from the stripped bytes. Rejected for every other --type, matching the existing --concurrency/--allow-empty pattern.

I traced the full path: CLI validation → stripped_copy_without_sources (on the blocking pool) → remove_sources_content's recursion into indexed-map sections → the recomputed SourcemapIdentity used for the packed zip entry and the POST hash → README/CHANGELOG/--help docs → the new unit tests (byte-identical-when-nothing-to-strip, indexed-map recursion, hash recompute) and the e2e script addition. Debug-id keying (pass 1, sourcemap::identify on the original file) is untouched by stripping, so --uuid overrides and dedup-by-debug_id still work correctly even though the wire hash now describes different bytes — which the code comments and sourcemap.rs docs already note the appserver doesn't dedup on. All 17 run_sourcemap_upload call sites were updated for the new parameter, --help text is present and non-empty, exit code 20 (ConfigInvalid) matches the existing rejection pattern, no Cargo.toml or stdout changes, and the spawn_blocking usage avoids blocking the runtime thread.

Findings: None.

This is a well-scoped, well-tested addition — the implementation matches its documentation claims. Merge as is.

@krassx

krassx commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Review of the full 0.7.11 delta — SEV1 found here, fixed in 3709d36

--strip-sources-content uploaded the source of an INDEXED source map, silently. A spec §Index-Map file ({"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 flag on precisely so their code never leaves the build machine got the opposite.

Verified with the release binary against a capturing mock: the PUT body contained "sourcesContent":["const password = 'hunter2';"], exit 0, no "stripped" log.

sourcesContent is now removed wherever a map can carry it — the top level and every sections[].map, recursively — while sections, offsets, mappings and the debug-id survive. Three mutants caught (sections branch skipped, recursion dropped, top-level removal dropped).

Also fixed here 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 omitted --strip-sources-content, its "both flags" line had grown to three, and neither document mentioned indexed maps.

What the review checked and found sound: strip + --dry-run (packs the stripped copy, sends nothing), strip + --uuid (override keys it, hash = sha1 of the stripped body), strip + --concurrency 8 over 10 maps (each PUT a single entry, no sourcesContent, debug_id preserved, per-upload tempdir outlives the PUT), strip + the 16004 duplicate path, and the map on disk never modified in any of them.

🤖 Generated with Claude Code

… 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)
@krassx
krassx force-pushed the feat/upload-dry-run branch from 1500e60 to a295e0f Compare September 18, 2026 15:58
@krassx
krassx force-pushed the feat/strip-sources-content branch from 3709d36 to 9dc7c14 Compare September 18, 2026 16:01
@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code review

Adds debug-files upload --strip-sources-content (sourcemaps only): uploads a stripped in-memory copy of each .map with sourcesContent removed — recursively through indexed-map sections[].map too — while leaving the on-disk file untouched and recomputing the declared hash to match the uploaded bytes. Rejected with exit 20 for every other --type, consistent with --concurrency/--allow-empty.

Traced the flow end to end: the CPU-bound strip runs on spawn_blocking (matching the existing packing pattern), the per-file tempdir avoids any cross-upload races, the debug-id used to key the upload (resolved_id) is read in pass 1 from the original file and is untouched by stripping, only the hash/uploaded bytes change, and every existing call site of run_sourcemap_upload (including all the pre-existing tests) was updated with the new trailing arg. New tests cover: source removed from the uploaded bytes, disk file untouched, hash recomputed, indexed-map sections stripped, default-off behavior preserved, and byte-identical passthrough when there's nothing to strip. Help text, README, and CHANGELOG are all updated in step with the new flag, and e2e_flows.py gained a matching end-to-end check.

Findings: None.

Looks solid — merge as is.

@krassx
krassx merged commit 35fb8c4 into feat/upload-dry-run Sep 18, 2026
7 checks passed
@krassx
krassx deleted the feat/strip-sources-content branch September 18, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant