Skip to content

Commit b8de84f

Browse files
feat(apply): safety hardening — atomicity, locking, pnpm CoW, sidecars, Maven gate (#80)
* feat(apply): safety primitives — lock, CoW, atomic write, sidecar fixups Adds five new modules to `socket-patch-core` and refactors `apply_file_patch` to compose them safely with #79's perm-preservation: - **`patch::apply_lock`** — cross-platform advisory file lock at `<.socket>/apply.lock` via `fs2`. Used by every mutating subcommand to serialize against concurrent socket-patch runs. - **`patch::cow`** — hardlink + symlink copy-on-write. Before patching, if `filepath` is a symlink into a content-addressed store (pnpm) or a regular file with `nlink > 1` (bazel mirrors, nix store overlays), give this project a private inode. The pnpm content store and every other project pointing at it stay byte-identical. - **`patch::sidecars`** — ecosystem-aware sidecar fixups dispatched from `apply_package_patch`. Cargo: rewrite `.cargo-checksum.json` with new SHA256s so `cargo build` accepts patched sources. NuGet: delete `.nupkg.metadata` (the documented "unknown" state vs. a stale `contentHash` that would flag tampering). PyPI / gem / Go: advisory-only — surface a one-line note about downstream tooling consequences. - **`crawlers::pkg_managers`** — path-based detector for the four Node.js layout flavors (npm / pnpm / yarn-classic / yarn-berry PnP). Apply uses this to refuse yarn-berry PnP (packages live in `.yarn/cache/*.zip`) and to surface a pnpm-detected note. - **`apply_file_patch` atomic rewrite** — two-phase commit: 1. Hash `patched_content` in memory; error out before any disk write if it doesn't match `expected_hash`. Removes the prior "wrote bytes, post-write verify failed, can't restore" window. 2. CoW the target if it's a shared inode. 3. Stage write to `<parent>/.socket-stage-<uuid>`, `sync_all()`, then `rename(stage, target)`. POSIX `rename(2)` is atomic — observers see either the old or new bytes, never a truncated half-write. Composes cleanly with #79's mode + uid/gid restore step which now operates on the post-rename inode. `ApplyResult` grows `sidecars_updated: Vec<String>` and `sidecar_advisory: Option<String>` so the CLI envelope can surface fixup outcomes. `fs2` and `tempfile` added to socket-patch-core dependencies. Two new tests pin the headline invariants: - `test_apply_file_patch_hash_mismatch_leaves_original_intact` — atomic-write contract: hash mismatch leaves target byte-identical AND no `.socket-stage-*` litter in parent dir. - `test_apply_file_patch_does_not_propagate_to_hardlinked_sibling` — the pnpm content-store invariant at the integration level. Plus 10 unit tests for cow + apply_lock and 13 for sidecars/* + 9 for pkg_managers. Assisted-by: Claude Code:claude-opus-4-7 * feat(cli): wire safety primitives + Maven/NuGet experimental gates Integrates the new socket-patch-core safety primitives into the CLI via the v3.0 unified `GlobalArgs` + `Envelope` patterns from #79. **`commands::lock_cli`** (new) — envelope-aware wrapper around `apply_lock::acquire`. Takes `Command` so the failure envelope's `command` field reflects which subcommand was blocked. On contention the binary emits `{status: "error", error: {code: "lock_held", ...}}` in JSON mode or a one-line stderr message otherwise, then exits 1. **Lock acquisition** added to `apply`, `rollback`, `repair`, `remove` immediately after the manifest existence check. `remove`'s outer lock spans the inner `rollback_patches` call (which deliberately does NOT acquire the lock so the composition doesn't self-deadlock). **Apply pkg-manager gating** — after the lock, `apply` runs `detect_npm_pkg_manager`: - `YarnBerryPnP` → emit `EnvelopeError("yarn_pnp_unsupported", ...)` pointing at `yarn patch` and exit 1. - `Pnpm` → surface a one-line stderr note. CoW handles the substantive safety work; this just tells the user the layout was understood. **Sidecar JSON via `event.details`** — `result_to_event` extends the Applied event with `details.sidecarsUpdated: string[]` and `details.sidecarAdvisory: string | null` when either is non-empty. Narrower JSON-envelope contract than first-class fields; consumers read `event.details.sidecarsUpdated` from JSON. **Maven + NuGet experimental runtime gates** in `ecosystem_dispatch.rs`. Even when compiled with `--features maven`/`nuget`, the crawlers refuse to dispatch unless the matching `SOCKET_EXPERIMENTAL_MAVEN=1`/`SOCKET_EXPERIMENTAL_NUGET=1` env var is set. Without it, surface a warning event and skip those PURLs. Reasoning: Maven patches corrupt jar sidecar checksums (sha1/md5); NuGet patches corrupt `.nupkg.sha512` signature sidecars that `dotnet restore` reads as tamper-evidence. `fs2` added to socket-patch-cli dev-dependencies for the lock e2e test (same crate the binary uses internally). Assisted-by: Claude Code:claude-opus-4-7 * test(e2e): safety hardening suite + CI matrix + invariant fixups Adds four end-to-end integration test files exercising the safety primitives through the binary, plus shared `tests/common/mod.rs` helpers, plus two existing-test contract updates. **Suites added (20 new tests):** - `e2e_safety_lock.rs` (6 tests, non-ignored). Test holds the same `.socket/apply.lock` the binary uses via `fs2` directly, then spawns `socket-patch apply` and asserts the second process exits with `error.code == "lock_held"`. Zero production-code hooks. - `e2e_safety_yarn_pnp.rs` (5 tests, non-ignored). Yarn-berry PnP markers (`.pnp.cjs`, `.pnp.loader.mjs`) trigger `error.code == "yarn_pnp_unsupported"`. Negative control: plain npm layout does NOT trigger the refusal. - `e2e_safety_cargo_build.rs` (5 tests, `#[ignore]` + `--features cargo`). Three synthetic-vendor tests: 1. Baseline `cargo check --offline --frozen` succeeds. 2. Negative control — mutating the source WITHOUT the sidecar fixup makes cargo refuse with "checksum changed". Proves cargo actually verifies, which is what makes the positive test meaningful. 3. Sidecar fixup makes `cargo check` pass; `.cargo-checksum.json` is rewritten and the `package` field is preserved. 4. JSON envelope contract: `.cargo-checksum.json` appears in `event.details.sidecarsUpdated`. Plus `traitobject_real_socket_patch_round_trip` — the cargo layer-2+3 combined test: `cargo fetch traitobject@0.0.1` from crates.io → `socket-patch get b15f2b7f-d5cb-43c9-b793-80f71682188f` from patches-api.socket.dev → assert `.cargo-checksum.json` rewritten + `cargo check` succeeds against the real, production Socket patch. - `e2e_safety_pnpm.rs` (4 tests, `#[ignore]`). Two projects share a pnpm content store via `--config.package-import-method=hardlink`. `socket-patch get` in project A patches A; project B + store entry stay byte-identical. `pnpm install --frozen-lockfile` in B afterwards does not revert A. Exercises CoW against a real pnpm install rather than a hand-rolled hardlink. **`tests/common/mod.rs`** — shared helpers (`binary`, `run`, `assert_run_ok`, `git_sha256`, `sha256_hex`, `pnpm_run`, `cargo_run`, `write_minimal_manifest`, `write_blob`, `parse_json_envelope`, `envelope_error_code`, `envelope_error_message`) lifted from the duplicated copies in `e2e_npm.rs` etc. Additive; existing suites keep their inlined copies for now. **CI matrix** in `.github/workflows/ci.yml`: - `e2e_safety_cargo_build` on ubuntu + macos + windows - `e2e_safety_pnpm` on ubuntu + macos + windows (pnpm-on-Windows uses junctions + copies by default, so the CoW invariant holds vacuously; the test still runs to verify apply doesn't error on Windows. Semantic Windows nlink coverage is a follow-up — `std::fs::Metadata` doesn't expose nlink on Windows without `GetFileInformationByHandle` via `windows-sys`.) - New `Setup pnpm` step (`npm install -g pnpm@10`) gated on the pnpm suite. The fast non-ignored suites (`e2e_safety_lock`, `e2e_safety_yarn_pnp`) run via the standard `test` job on all three platforms. **Existing-test contract updates** (these tests were pinning the old, broken behavior; both still describe correct invariants — their assertions just needed to track the rebased semantics): - `tests/apply_invariants.rs`: `dir_hash` excludes `apply.lock`. The lock file is deliberate ephemeral session state, not patch content; the "apply is read-only against .socket/" invariant is about manifest + blobs + diffs + packages. - `tests/in_process_edge_cases.rs`: `apply_blob_after_hash_mismatch_reports_failure` now asserts the atomic-write contract — the target file is byte-identical to its pre-call state on the hash-mismatch failure path, no half-written corruption. Assisted-by: Claude Code:claude-opus-4-7 * refactor(sidecars): typed envelope contract with structured per-file + advisory data Replaces the previous `event.details.sidecarsUpdated` / `event.details.sidecarAdvisory` free-form JSON bag with a typed, top-level `Envelope.sidecars[]` list. ## New types (`socket-patch-core/src/patch/sidecars/types.rs`) pub struct SidecarRecord { purl, ecosystem, files, advisory } pub struct SidecarFile { path, action: SidecarFileAction } pub enum SidecarFileAction { Rewritten | Deleted | Created } pub struct SidecarAdvisory { code, severity, message } pub enum SidecarAdvisoryCode { PypiRecordStale | GemBundleInstallReverts | GoModVerifyFails | NugetSignedPackageTampered | SidecarFixupFailed } pub enum SidecarSeverity { Info | Warning | Error } All derive `serde::Serialize`. Structs use camelCase; enums use snake_case. Unit tests pin the JSON contract. ## JSON shape (consumer view) ```json { "command": "apply", "events": [...], "sidecars": [ { "purl": "pkg:cargo/...", "ecosystem": "cargo", "files": [{"path":".cargo-checksum.json","action":"rewritten"}] }, { "purl": "pkg:nuget/...", "ecosystem": "nuget", "files": [{"path":".nupkg.metadata","action":"deleted"}], "advisory": { "code":"nuget_signed_package_tampered", "severity":"warning", "message":"..." } } ] } ``` - `sidecars` omitted from JSON when empty. - `files` always present (possibly `[]` for advisory-only). - `advisory` omitted when absent. - `code` / `severity` are stable snake_case enum tags; `message` is human text. - `purl` joins to `events[].purl` for per-event context. ## Three real improvements over the old design 1. **No more lossy collapse.** NuGet's "deleted `.nupkg.metadata` AND has a `.nupkg.sha512` signature" case now carries BOTH a file entry AND an advisory. Before, the advisory was silently lost when the file entry took its slot. 2. **Stable codes + severity.** Consumers (CI bots, dashboards, telemetry, jq pipelines) can switch on `code` and route on `severity` without regex-matching free-form strings. 3. **Decoupled from events.** Sidecar reporting is a top-level `Envelope.sidecars` list. `PatchEvent.details` is no longer mixed with `list` / `repair` / `remove`'s command-specific bags — sidecar consumers have a typed schema all their own. ## Internal refactor - `SidecarOutcome` removed. Per-ecosystem fixups return `Result<Option<SidecarPayload>, SidecarError>` (internal `SidecarPayload = { files, advisory }`); the dispatcher in `sidecars/mod.rs` wraps the payload with PURL + ecosystem to produce the `SidecarRecord`. - `ApplyResult.sidecars_updated: Vec<String>` and `sidecar_advisory: Option<String>` consolidated into a single `sidecar: Option<SidecarRecord>` field. - Apply CLI's `result_to_event` no longer attaches to `event.details`; the run loop now calls `env.record_sidecar(record.clone())` after each apply result. - `Envelope` gains `sidecars: Vec<SidecarRecord>` field + `record_sidecar` method. - The error path (`SidecarError` returned by a fixup) is converted at the apply boundary into a `SidecarRecord` with `advisory.code = SidecarFixupFailed`, `severity = Error`. Single uniform shape for consumers. ## Pre-existing test fixups `in_process_remote_ecosystems_apply.rs` and `in_process_rollback_all_ecosystems.rs` now set `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1` when they explicitly exercise those paths. These were broken silently by the Maven/NuGet runtime gates added in the prior rebase (the gate was always there in commit 39a2321; tests just happened not to exercise the maven/nuget paths to a depth where the skip mattered). ## Test results - cargo build --workspace --all-features: clean - cargo build --release --workspace: clean (no warnings) - cargo clippy --workspace --all-features -- -D warnings: clean - cargo test --workspace --all-features: 1021 passed, 0 failed - cargo test --features cargo --test e2e_safety_cargo_build -- --ignored: 5 passed (includes traitobject real-patch round trip) The e2e cargo test `apply_reports_cargo_checksum_in_sidecars_updated` tightened from a substring match to a structured-shape assertion on `envelope.sidecars[].ecosystem=="cargo"` + `files[].path=".cargo-checksum.json"` + `files[].action=="rewritten"`. Assisted-by: Claude Code:claude-opus-4-7 * test(e2e): expand sidecar coverage + simplify PTY harness Five test surfaces, one bug fix, one YAGNI cleanup, one harness simplification — all motivated by closing the e2e gap on the new typed `Envelope.sidecars[]` contract. - **e2e_safety_advisories.rs** (new, 5 tests): drive the apply CLI against handcrafted layouts and assert `envelope.sidecars[].{ecosystem,advisory.code,advisory.severity, files[]}` for pypi (`pypi_record_stale`), gem (`gem_bundle_install_reverts`), golang (`go_mod_verify_fails`), nuget unsigned (deleted files only), and nuget signed (deleted files + `nuget_signed_package_tampered` advisory together — the case the pre-typed-contract design lost). - **e2e_safety_cow.rs** (new, 5 tests): cover `patch/cow.rs` end to end — hardlink isolation, symlink replacement, multi-file hardlink, regular-file no-op, and the failure-doesn't-cow path. Lifted file coverage from ~23% to ~80% (remaining gaps are defensive I/O error arms not reproducible in tests). - **e2e_safety_cargo_build.rs**: two new always-on tests for the cargo sidecar boundary — `apply_with_missing_files_field_reports_sidecar_fixup_failed` (the JSON-parses-but-no-`files`-field arm of `Malformed`, distinct from the existing parse-failure case) and `apply_without_cargo_checksum_emits_no_sidecar_record` (the `NotFound -> Ok(None)` early-return — proves no spurious record when the package isn't from a directory source). - **interactive_prompts_e2e.rs**: simplify the PTY harness. Replaces the prior reader-thread + mpsc-channel + try_wait polling loop with a synchronous three-piece composition (`read_to_end` reader, detached watchdog with cloned ChildKiller, blocking `child.wait()` on the main thread). No pre-write sleep — the PTY buffers input. All six prompt tests still pass with materially less harness code. - **common/mod.rs**: add `run_with_env(cwd, args, env)` so integration tests can flip per-ecosystem runtime gates (`SOCKET_EXPERIMENTAL_NUGET=1`) and discovery roots (`NUGET_PACKAGES`, `GOMODCACHE`) on the child only, keeping parent env untouched and parallel-safe. - **Bug fix**: `in_process_remote_ecosystems_apply.rs` and `in_process_rollback_all_ecosystems.rs` had ecosystem tests (golang/maven/composer/nuget/cargo) that assumed all features were on. Under default features (or anything narrower than --all-features), the crawler dispatch compiles out and the tests fail with "scannedPackages: 0". Gated each test on `#[cfg(feature = "<eco>")]` to match the build matrix. Quiet the resulting dead-code noise with a file-level allow. - **YAGNI**: drop `SidecarFileAction::Created`. No current ecosystem produces it; adding it back is a non-breaking enum extension when a real use case lands. All ~456 workspace tests pass under `--all-features`. Assisted-by: Claude Code:claude-opus-4-7 * test(e2e): close remaining cargo + nuget sidecar fixup-error arms Three additional defensive-path tests, lifting sidecar coverage toward its e2e ceiling: - **cargo.rs `read_to_string` non-NotFound arm** (lines 61-65): `apply_with_checksum_directory_reports_sidecar_fixup_failed` replaces `.cargo-checksum.json` with a directory of the same name. `read_to_string` on a directory returns `IsADirectory` (Linux) / `InvalidInput` (macOS) — not `NotFound` — so the fixup goes down the `Err(source)` arm. The directory-as-file ruse is uid-independent (unlike chmod) and platform-portable. - **cargo.rs `tokio::fs::write` failure arm** (lines 94-99): `apply_with_readonly_checksum_reports_sidecar_fixup_failed` chmods the checksum to 0444. Read + parse + in-memory update all succeed; the final overwrite fails with `EACCES`. Skipped under uid 0 (root bypasses mode bits) via an `id -u` probe — no `libc` dev-dep needed. - **nuget.rs `remove_file` non-NotFound arm** (lines 50-54): `nuget_apply_with_metadata_directory_reports_sidecar_fixup_failed` plants a non-empty directory at `.nupkg.metadata`. `remove_file` refuses to unlink directories, hitting the `Err(source) -> SidecarError::Io` arm. Each verifies that the patch itself committed atomically and that the envelope surfaces a structured `sidecar_fixup_failed` advisory with `severity = error` plus a diagnostic message referencing the offending path. With these in, the only remaining uncovered regions in `sidecars/{cargo,nuget,mod}.rs` are: - `cargo.rs:89-91` — `serde_json::to_vec_pretty` on a Value just parsed from valid JSON. Unreachable without UB. - `cargo.rs:126-128` — `sha256_file` of a file `apply` just atomically wrote. Race-only. - `sidecars/mod.rs:110, 115` — `patched.is_empty()` and unknown PURL guards, both gated by upstream apply.rs checks. - `nuget.rs:86, 93` — `read_dir` on a found package dir, and a non-UTF8 file name. No realistic e2e path. These are defensive guards by design; covering them would require mocking std::fs/tokio::fs at the syscall layer or accepting a test-only behavior toggle in production code. The lib unit tests already exercise the guards that matter. Coverage delta (regions, integration-test-only): sidecars/cargo.rs 76.7% → 90.1% sidecars/nuget.rs 91.4% → 96.6% sidecars/mod.rs 93.6% → 95.7% Assisted-by: Claude Code:claude-opus-4-7 * test(e2e): close internals guards + nuget non-UTF8 iteration arm Adds an `e2e_safety_internals.rs` integration test file that drives `socket-patch-core`'s pub APIs (`dispatch_fixup`, `break_hardlink_if_needed`) directly, closing the last few defensive guards that the apply-CLI surface can't reach: - **sidecars/mod.rs:110** (empty `patched` list short-circuit): `dispatch_fixup_empty_patched_returns_none`. - **sidecars/mod.rs:115** (unknown ecosystem short-circuit): `dispatch_fixup_unknown_ecosystem_returns_none`. - **cow.rs:59** (lstat non-NotFound I/O error): `cow_lstat_permission_denied_propagates_io_error` chmods a parent directory to 0000 so search permission is denied; skipped under uid 0 since root bypasses the check. - **cow.rs `NoFile` early return**: `cow_missing_path_yields_no_file` locks in the explicit-NotFound arm. Also adds `nuget_apply_with_non_utf8_filename_in_pkg_dir` in `e2e_safety_advisories.rs`, which plants a non-UTF-8 filename in the package directory so the `has_signed_marker` iteration's `entry.file_name().to_str() => None` arm fires (nuget.rs:93). Linux ext4/Unix filesystems accept the bytes natively; APFS rejects them at write time, so the test gracefully skips on macOS. `cow_rename_failure_runs_stage_cleanup` is parked as `#[ignore]` with a comment: the rename-failure cleanup arm (cow.rs:116-120) requires a test seam or syscall-level mock to reach from outside `tokio::fs`, and the cow tests module already exercises `write_via_stage_rename` in isolation. Final integration coverage of the touched files (regions): sidecars/mod.rs 96.4% → 100.0% sidecars/cargo.rs 76.7% → 90.1% sidecars/nuget.rs 91.4% → 96.6% (locally; Linux CI bumps to ~98%) patch/cow.rs 79.0% → 86.8% (locally; the lstat-EACCES test adds another two lines on the Linux/non-root path) Remaining uncovered lines are all defensive guards with no realistic e2e path: - `cargo.rs:89-91` — `serde_json::to_vec_pretty` on a Value we just deserialized from valid JSON. Total function; cannot fail. - `cargo.rs:126-128` — `sha256_file` of a file `apply` just atomically wrote. Race-only. - `nuget.rs:86` — `read_dir` error on a directory we just read packages from. Race-only. - `cow.rs:116-120` — `rename` failure inside `write_via_stage_rename`. Race-only without a test seam. Workspace test sweep: 456 passed / 0 failed under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * test(e2e): exercise sidecar/cow defensive arms via direct dispatch Layers three engine-direct integration tests on top of the apply-CLI suite to close the remaining defensive paths that the CLI flow can't naturally reach, plus a small production cleanup of one genuinely- dead error arm in cargo.rs. ## Production change **`sidecars/cargo.rs`**: replace the `serde_json::to_vec_pretty(&v).map_err(...)?` construction with `.expect("serializing a Value just deserialized from valid JSON must succeed")`. The Value is freshly parsed from on-disk JSON one step earlier; serde's `to_vec_pretty` is total over `Value`, so the `Err` arm was unreachable by construction. The `.expect()` documents the invariant in the call site rather than carrying dead-code-equivalent error plumbing through the checksum-rewrite path. ## New direct-dispatch tests (e2e_safety_internals.rs) - **`dispatch_fixup_cargo_sha256_file_failure_arm`** — calls `dispatch_fixup` with a `patched` entry naming a file that doesn't exist on disk. cargo::fixup parses the checksum successfully, then `update_entries` walks `patched` and `sha256_file(missing_path)` fails with NotFound, propagating as `SidecarError::Io`. Covers `cargo.rs:131-133`. In the apply-CLI flow this is race-only (apply atomically wrote the file before dispatch_fixup runs), so direct invocation is the only path. - **`dispatch_fixup_nuget_with_nonexistent_pkg_path`** — calls `dispatch_fixup` with a `pkg_path` that doesn't exist. Inside nuget::fixup, `remove_file(.nupkg.metadata)` returns NotFound (handled), then `has_signed_marker` runs and its `read_dir` fails with NotFound too — hitting `Err(_) => return false` at nuget.rs:86. Fixup returns `Ok(None)`. Same race-only-from-CLI caveat. - **`cow_rename_failure_runs_stage_cleanup`** — sets the BSD user-immutable flag (`chflags uchg`) on the cow target after creating a hardlink (nlink=2). The lstat / read / hardlink-detect upstream still works (immutable files are readable), but the final `rename(stage, target)` is refused with EPERM. The test asserts the error propagates AND that the cleanup arm (cow.rs:117-119) ran — no `.socket-cow-*` stage file is left in the directory. macOS-only because BSD `chflags` is the only portable hook for setting filesystem flags from userspace without root; Linux's `chattr +i` requires CAP_LINUX_IMMUTABLE. Both macOS and Linux skip uid 0 (root bypasses uchg/immutable). ## Coverage delta (regions, integration-test-only, macOS local) sidecars/mod.rs 100.0% → 100.0% (unchanged; already at ceiling) sidecars/cargo.rs 94.9% → 100.0% sidecars/nuget.rs 95.2% → 97.6% patch/cow.rs 86.8% → 94.7% The only macOS-local gap remaining is **nuget.rs:93** — the `entry.file_name().to_str()` None branch in `has_signed_marker`. APFS rejects non-UTF-8 filenames at the syscall layer, so the existing `nuget_apply_with_non_utf8_filename_in_pkg_dir` test (in `e2e_safety_advisories.rs`) gracefully skips on macOS and fires on Linux runners. Linux CI coverage reaches 100% across the sidecar/cow surface; the macOS local number stays at 97.6% for this filesystem-capability reason alone. Workspace test sweep: green under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * test(e2e): cover cow.rs symlink/hardlink/stage-write error arms Four new direct-dispatch tests in e2e_safety_internals.rs that exercise cow.rs's `?` propagation arms via the pub `break_hardlink_if_needed` API. Each sets up a filesystem state the apply-CLI flow can't naturally produce, drives the error, and asserts the propagated `io::Error::kind()`: - **`cow_symlink_to_missing_target_propagates_read_error`** — symlink to a non-existent target; cow takes the symlink branch, `read(path)` (which follows the link) returns NotFound, propagating via the symlink-branch `?` arm. Covers cow.rs:66. - **`cow_symlink_unremovable_propagates_remove_error`** — macOS-only: `chflags -h uchg <link>` sets the user-immutable flag on the symlink itself, not its target. `read(path)` succeeds (follows to the target), but `remove_file(path)` fails with EPERM. Covers cow.rs:70. - **`cow_hardlink_unreadable_propagates_read_error`** — creates a hardlink pair, chmods to 0000. lstat succeeds (mode bits don't gate lstat), nlink>1 check passes, then `read(path)` returns EACCES. Covers cow.rs:84. Skipped under uid 0 (root bypasses mode bits). - **`cow_stage_write_failure_propagates`** — creates a hardlink pair in a parent dir, then chmods the parent to 0500. read succeeds (file mode is 0644), write_via_stage_rename creates a stage filename in the parent — `tokio::fs::write` returns EACCES because parent is no longer writable. Covers cow.rs:111. Skipped under uid 0. Coverage delta on `patch/cow.rs` regions: 88.89% → 93.83%. The remaining 5 regions are: - **cow.rs:71** — `write_via_stage_rename(path,target_bytes).await?` in the symlink branch. Requires the function to fail AFTER `remove_file(path)` succeeds; on POSIX both calls go through the same parent-dir write permission, so there's no filesystem state that lets remove succeed but write fail. - **cow.rs:97, 105** — `.unwrap_or_else` defaults on `path.parent()` and `path.file_name()`. Both fire only when `path == "/"`, which the cow function never sees (callers pass package-internal file paths). - The other 2 are partial-region splits at branch boundaries that overlap with already-covered code paths. Workspace test sweep: green under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * refactor(sidecars,cow): collapse two dead-arm Result paths Two small production simplifications that eliminate genuinely- unreachable error plumbing while leaving function contracts unchanged. Each strips a defensive-but-dead `.unwrap_or_else` / streaming-loop pattern down to the single-`?` shape the integration test suite can actually exercise. ## `cow.rs::write_via_stage_rename` The previous code used `.unwrap_or_else(|| Path::new("."))` and `.unwrap_or_else(|| "anon".to_string())` as fallbacks for the case where `path.parent()` or `path.file_name()` returned None. That case is unreachable from cow's only callers — both branches of `break_hardlink_if_needed` pass `path` straight through from `apply.rs`, which always builds it as `pkg_path.join(<file>)` (a real, two-segment package-internal path). The defaults were documentation, not behavior. Replaced with `.expect("…")` that documents the precondition inline. The panic message names the invariant a future maintainer would need to violate to hit it. No behavior change for any existing caller. ## `cargo.rs::sha256_file` The streaming `loop { file.read(&mut buf).await?; … }` pattern was defensive against large vendored sources, but the `.cargo-checksum.json` rewriter only hashes files inside a single crate — cargo's own registry caps `.crate` tarballs near 10MB unpacked. A single `tokio::fs::read(path).await?` is both simpler and collapses open + read into one `?` arm (the arm the existing `dispatch_fixup_cargo_sha256_file_failure_arm` test exercises via a non-existent path). The loop's per-chunk `?` was the only sidecar/cow region the integration suite couldn't drive — open errors are reachable, but mid-stream read errors require a TOCTOU race against an atomic write that just succeeded one syscall earlier. ## Coverage delta on touched files (regions, integration-test-only) sidecars/mod.rs 100.0% → 100.0% (unchanged) sidecars/cargo.rs 99.1% → 100.0% sidecars/nuget.rs 98.3% → 98.3% (Linux CI: 100%; macOS: APFS rejects non-UTF-8 filenames so the has_signed_marker iteration test skips) patch/cow.rs 93.8% → 98.7% (1 region remains: write_via_stage_rename `?` from the symlink branch — this would require remove to succeed but the subsequent stage write inside the same parent directory to fail, which has no filesystem state expressible in tests) Function coverage on cow.rs goes 5/7 → 5/5 because the two `unwrap_or_else` closures (each counted as a function by llvm-cov) are now gone. Workspace sweep stays green under `cargo test --workspace --all-features` (456 lib + 65 integration test files). Assisted-by: Claude Code:claude-opus-4-7 * refactor(nuget,cow): byte-suffix match + ACL test → 100% region cov Two final pushes to close the last uncovered regions in the sidecars/cow surface from any integration test runner. ## `sidecars/nuget.rs::has_signed_marker` The previous body wrapped the `.nupkg.sha512` check in `if let Some(name) = entry.file_name().to_str() { ... }`, which left the implicit-else (non-UTF-8 filename) arm uncoverable on APFS — Apple's filesystem refuses to create non-UTF-8 names at the syscall layer, so the integration test could only fire it on Linux runners. Rewrote against `entry.file_name().as_encoded_bytes()` and `ends_with(b".nupkg.sha512")`. The suffix is pure ASCII so a byte-level match is exactly as correct as the `str`-level match would be, but the conditional gate disappears (every entry's filename has bytes, no Option). Side benefit: a non-UTF-8 file that legitimately ends in `.nupkg.sha512` (e.g., transmitted over an encoding-lossy filesystem-replication path) now correctly trips the signed-marker advisory; the old `to_str` path would silently miss it. ## `cow.rs` symlink-branch `write_via_stage_rename` `?` arm New macOS-only test `cow_symlink_stage_write_failure_propagates` sets a `chmod +a "<user> deny add_file"` ACL on the cow target's parent directory. POSIX mode bits couldn't express this state: `chmod 0500` would block both create AND delete; `chmod 0700` allows everything. The BSD extended ACL splits those, letting `remove_file(symlink_path)` succeed while denying the subsequent `tokio::fs::write(stage_path, bytes)`. With that state in place, cow's symlink branch does: read(link) → ok (target readable) remove_file(link) → ok (delete_child allowed) write_via_stage_rename(link, …): write(stage, …) → EACCES (add_file denied) `?` propagates ← this is cow.rs:71 That's the last region the e2e suite couldn't reach. Skipped under uid 0 (root bypasses ACL deny entries). ## Final integration-test region coverage (macOS local) sidecars/mod.rs 100.0% sidecars/cargo.rs 100.0% sidecars/nuget.rs 100.0% patch/cow.rs 100.0% Workspace test sweep: 456 lib + 65 integration test files, zero failures under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): remove dead manifest::recovery + fuzzy_match exports Two unused chunks of code that nothing reaches (no callers anywhere in the workspace, no integration test exercises them): - **`crates/socket-patch-core/src/manifest/recovery.rs`** (543 lines) — `recover_manifest`, `RecoveryResult`, `RecoveryEvent`, `RecoveryOptions`, the `RefetchPatchFn` type alias, all related structs and enums. `git grep` returns zero callers; the module was wired up in `manifest/mod.rs` but nothing imported it. Likely a stalled design experiment. Drop the file + the `pub mod` declaration. - **`utils::fuzzy_match::is_purl`** and **`::is_scoped_package`** — `is_purl` was a duplicate of `utils::purl::is_purl` (the one `commands/get.rs` actually uses). `is_scoped_package` had no callers anywhere. Dropped both + their unit tests. - **`utils::fuzzy_match::MatchType`** downgraded from `pub` to private. The enum was an internal sort key — `fuzzy_match_packages` returns plain `Vec<CrawledPackage>` to the one caller (`get.rs:921`), so the tag was never visible across the module boundary. Net: 543 + ~20 lines of unreachable code removed, no behavior change. Workspace test sweep stays green (`cargo test --workspace --all-features`). Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): purge dead utils::purl exports + duplicated tests Twelve `pub fn` exports in `utils/purl.rs` had zero call sites anywhere in the workspace (verified by ripgrep against the `crates/` tree). Removing them takes the file from 763 to 451 lines without touching any reachable code path: - `is_pypi_purl`, `is_npm_purl`, `is_gem_purl`, `is_maven_purl`, `is_golang_purl`, `is_composer_purl`, `is_nuget_purl`, `is_cargo_purl` — eight prefix-check helpers. Production code uses `Ecosystem::from_purl` (in `crawlers/types.rs`), which already does this dispatch with a proper enum return. The standalone `is_*_purl` boolean variants were a parallel universe nothing actually consumed. - `parse_npm_purl` — never called outside its own unit test. The `parse_*_purl` variants for other ecosystems ARE used (by their respective crawlers) and stay. - `parse_purl` — a stringly-typed (returns `&str` ecosystem) dispatcher that nothing in the workspace called. Each crawler uses the typed `parse_<eco>_purl` directly. - `build_pypi_purl` — no callers anywhere. (`build_npm_purl`, `build_gem_purl`, etc. ARE used by the crawlers when emitting PURLs from discovered packages, so they stay.) Plus the corresponding `#[cfg(test)] mod tests` blocks that tested only the removed functions. 312 lines of dead-export plumbing gone. Workspace sweep stays green under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): purge dead envelope builders + summary byte counters Three dead-by-disuse chunks in `socket-patch-cli/src/json_envelope.rs`: - **`PatchEvent::with_old_uuid` / `with_bytes`** + the underlying `old_uuid` and `bytes` fields on `PatchEvent`. Neither builder is ever called from production code; the `oldUuid` JSON key downstream consumers see (e.g. scan's update events) is emitted via direct `serde_json::json!` macros in `commands/get.rs` and `commands/scan.rs`, not via `PatchEvent`. Removing the unused plumbing simplifies the struct and drops two fields from the JSON envelope schema that always serialized to absent anyway (both were `skip_serializing_if = "Option::is_none"` and stayed `None` in every code path). - **`Summary::bytes_downloaded` and `Summary::bytes_freed`** counters. Both were summed from `PatchEvent.bytes` via `Summary::bump`, which now had nothing to sum because `with_bytes` was never called. The fields always serialized as `0`. The actual byte-tracking surface lives elsewhere — `commands/scan.rs::GcSummary::bytesFreed` (from `utils/cleanup_blobs.rs`). The envelope counters were parallel dead code. - **`PatchAction::as_tag` and `Command::as_tag`**. Both duplicated their respective `#[serde(rename_all = …)]` serialization paths and were only ever called from a single unit test in the same file — rewritten to assert directly against `serde_json::to_string` so the contract that matters (the JSON output) stays locked. `Summary::bump` shrank from `(action, bytes)` to `(action)`. Workspace test sweep stays green under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * test(core): integration coverage for diff + package + fuzzy_match Three new `crates/socket-patch-core/tests/` files lifting the previously-0%-from-integration files to full e2e coverage: - **`diff_e2e.rs`** (5 tests) — `apply_diff` round-trips text and binary deltas, handles empty→non-empty, surfaces malformed deltas as `Err`, and never panics on a wrong-source delta. Uses `qbsdiff::Bsdiff` from core's existing deps to synthesize deltas at test-construction time. - **`package_e2e.rs`** (9 tests) — `read_archive_to_map` and `read_archive_filtered` strip the `package/` prefix, drop symlink entries, propagate corrupt-gzip and missing-file errors, and reject unsafe paths (absolute, parent-traversal, Windows-style backslash) via a hand-crafted ustar header that bypasses `tar::Builder`'s writer-side validation. `read_archive_filtered` keeps only entries listed in the `PatchFileInfo` map and propagates the unsafe-path `ArchiveError::UnsafePath` from the underlying reader. - **`fuzzy_match_e2e.rs`** (8 tests) — `fuzzy_match_packages` orders results by the documented `MatchType` priority (ExactFull > ExactName > PrefixFull > PrefixName > ContainsFull > ContainsName), handles case-insensitivity, returns empty on empty/whitespace queries, and caps results at the supplied limit. Together these close three of the four previously-0% files in the integration coverage report. The fourth, `manifest/recovery.rs`, was deleted outright as dead code in commit 4e2f3a1. Lib unit tests for diff and package remain in place (they cover the same code from inside the crate boundary), so the workspace sweep now exercises each code path twice. Acceptable redundancy for the headline coverage gain. Workspace test sweep: green under `cargo test --workspace --all-features`. Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): remove dead Ecosystem::purl_prefix + manifest helpers Three more dead-export chunks identified by ripgrep audits: - **`Ecosystem::purl_prefix`** in `crawlers/types.rs` — five internal callers, all inside the unit-test module. Production code matches against `Ecosystem::from_purl` instead and never needs the raw prefix string. Removed the method + the per-ecosystem assertion against `.purl_prefix()` in each `test_*_properties` test (those tests still cover `cli_name()` and `display_name()`, which ARE used by `commands/scan.rs`). - **`manifest::operations::get_referenced_blobs`** — superset of `get_after_hash_blobs` + `get_before_hash_blobs`, never called by any apply/rollback/scan/repair path. The two narrower variants (after-only for apply, before-only for rollback) are what production code uses. - **`manifest::operations::diff_manifests`** + the supporting `ManifestDiff` struct — a clean three-set "added / removed / modified" diff over PURLs. Zero callers anywhere in the workspace. The scan path computes its own diffs inline with different semantics (per-patch, not per-PURL), so the helper was never adopted. Plus the corresponding unit tests for each removed export. Workspace test sweep stays green (118 + 419 lib tests). The next e2e sweep against the new total will surface as a coverage gain across `manifest/operations.rs` (which had several uncovered branches that were inside the removed dead functions). Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): remove test-only pub helpers (nuspec parser, multi-update) Two more pub items with no production callers — only their own inline unit tests referenced them: - **`crawlers/nuget_crawler::parse_nuspec_id_version`** + `extract_xml_element` — a `.nuspec` XML parser meant to back a nuspec-based discovery path that never landed. The NuGet crawler's actual discovery uses directory layout + filename conventions (`<lowercase-name>/<version>/`) and never reads the nuspec contents. Both functions dropped along with their three test cases. - **`package_json::update::update_multiple_package_jsons`** — a thin sequential wrapper over `update_package_json` that nothing in the workspace called. The setup command iterates workspace package.json files itself; this convenience never found a caller. Workspace test sweep stays green (118 + 415 lib tests). Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): drop duplicate utils::purl::build_npm_purl `utils::purl::build_npm_purl` was a byte-identical duplicate of `crawlers::npm_crawler::build_npm_purl`. The npm crawler version is what production code uses (crawlers/npm_crawler.rs:309 and :656 in the discovery loops); nothing imported the utils one. Removed the utils duplicate + its test. The npm-crawler version keeps its own tests. Workspace test sweep stays green (118 + 414 lib tests). Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): drop dead utils::env_compat::read_env_either Identical re-export of `read_env_with_legacy` with no callers anywhere. The doc comment claimed it was "exposed as a separate name to emphasize that the caller wants the *value*" — but no caller ever picked that name, so the alias was unused decoration. Workspace test sweep stays green (118 + 414 lib tests). Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): remove 4 unused .socket/* constants `DEFAULT_BLOB_FOLDER`, `DEFAULT_PACKAGES_FOLDER`, `DEFAULT_DIFFS_FOLDER`, and `DEFAULT_SOCKET_DIR` had zero callers anywhere in the workspace. The paths they encoded (`.socket/blob`, `.socket/packages`, `.socket/diffs`, `.socket`) are all constructed inline at use sites — never via the constant — so the constants were documentation-by-abandonment. `DEFAULT_PATCH_MANIFEST_PATH`, `DEFAULT_PATCH_API_PROXY_URL`, `DEFAULT_SOCKET_API_URL`, and `USER_AGENT` ARE used (clap defaults, public-proxy fallback, telemetry header) and stay. Workspace test sweep stays green (118 + 414 lib tests). Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): drop dead telemetry::track_patch_event_fire_and_forget Spawned a background tokio task to send a telemetry event without blocking the caller. Zero call sites anywhere — every actual telemetry callsite uses one of the typed `track_patch_*` helpers (applied/removed/rolled_back/etc.) which awaits the request directly. The fire-and-forget variant was unused infrastructure. Workspace test sweep stays green (118 + 414 lib tests). Assisted-by: Claude Code:claude-opus-4-7 * test(core): integration coverage for rollback new-file + error paths New `rollback_new_file_e2e.rs` exercises the `verify_file_rollback` branches the apply-CLI suite never drove: - **`verify_new_file_rollback_ready_when_after_hash_matches`** — empty `before_hash` + file on disk with the post-patch content. Rollback = delete, so the function reports `Ready`. Covers the `if is_new_file { ... Ready }` arm. - **`verify_new_file_rollback_already_original_when_missing`** — empty `before_hash`, file doesn't exist. The patch's addition has already been undone (operator deleted it manually, or the rollback was already run). Reports `AlreadyOriginal` so the rollback path can short-circuit. - **`verify_new_file_rollback_hash_mismatch_when_user_modified`** — empty `before_hash`, file exists with content that's neither the empty pre-state nor the post-patch state. The user has modified the patched file; rollback (delete) would lose their local edits — surfaces `HashMismatch` with a message callers can plumb into a UI prompt. - **`verify_existing_file_rollback_not_found_when_missing`** — non-empty `before_hash`, file doesn't exist. Reports `NotFound`. Locks in the contract distinction from the new-file `AlreadyOriginal` path. - **`verify_existing_file_rollback_missing_blob`** — file is on disk but the `before_hash` blob isn't staged in `blobs/`. Rollback can't synthesize the original content; reports `MissingBlob`. Workspace test sweep stays green. Assisted-by: Claude Code:claude-opus-4-7 * test(core): integration coverage for blob_fetcher early-return paths `blob_fetcher_edges_e2e.rs`: three tests that exercise the "nothing-to-do" branches of the blob fetcher API the apply/scan suite never naturally drives (those tests always stage all blobs in advance so the fetcher's early-return is masked by the through-path): - `fetch_missing_blobs_empty_manifest_short_circuits` — fresh manifest, no patches, no blobs to fetch. - `fetch_blobs_by_hash_empty_set_short_circuits` — caller passes an empty `HashSet<String>`. - `get_missing_blobs_empty_manifest_returns_empty_set` — the underlying scan also returns empty without touching disk. All three use a no-op `ApiClient` (points at localhost:1 — never contacted on the early-return path). Workspace test sweep stays green. Assisted-by: Claude Code:claude-opus-4-7 * chore(cleanup): silence test-only warnings (unused fixtures + stray attrs) Three small leftovers from prior cleanups: - **`utils/purl.rs`**: stray `#[cfg(feature = "maven")] #[test]` duplicated immediately above the golang test — leftover from the maven dead-test removal in commit b7c4cca. Deleted. - **`tests/in_process_python_envs.rs`**: helper `git_sha256` + its `sha2` / `Sha256` imports went unused after earlier test fixture refactors. Removed. - **`tests/in_process_remove_repair_lifecycle.rs`**: two `after_hash` test-fixture values that the surrounding mocks no longer reference. Prefixed with `_` so the reader still sees the intended fixture value. - **`tests/apply_network.rs`**: a `let mut args = vec![...]; let _ = args;` leftover from removing the apply-takes-api-flags path. Replaced with just the `argv` build the rest of the function actually uses. Build is now warning-clean under `cargo build --workspace --all-features --tests`. No behavior change. Assisted-by: Claude Code:claude-opus-4-7 * test(repair): cover --offline + --download-only mutual exclusion Two new tests in `repair_invariants.rs` exercising the early-exit branch of `commands::repair::run`: - `repair_offline_and_download_only_are_mutually_exclusive` — `--json` mode: exit 2, `error.code = invalid_args`, message mentions "mutually exclusive". - `repair_offline_and_download_only_human_mode_errors_to_stderr` — non-JSON: exit 2, error message goes to stderr. Covers `commands/repair.rs:35-46` (the `--offline && --download_only` guard that nothing was driving from integration tests). Assisted-by: Claude Code:claude-opus-4-7 * test(apply): cover no-.socket-dir status: noManifest envelope Two new tests in `apply_invariants.rs` for the apply early-exit: - `apply_with_no_socket_dir_emits_no_manifest_envelope` — apply against a fresh tree with NO `.socket/` directory emits `status: "noManifest"` in JSON mode and exits 0. - `apply_with_no_socket_dir_silent_emits_nothing` — non-JSON `--silent` path: exit 0, no stdout output (the friendly message is suppressed). Covers `commands/apply.rs:155-159` and the silent branch — the top-of-run early return that previously had no integration test asserting the JSON envelope shape. Assisted-by: Claude Code:claude-opus-4-7 * test(get): cover UUID-by-UUID paid-required path on public proxy `get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope` in `get_invariants.rs`: mocks the public-proxy `/patch/view/<uuid>` endpoint to serve `tier: "paid"` and asserts the JSON envelope shape (`status: paid_required`, `found:1, downloaded:0, applied:0`, `patches[0].tier: "paid"`). The existing paid-required test covered the package-name search path; this one closes the UUID-fetch branch in `commands/get.rs:756-768` that was never driven. Assisted-by: Claude Code:claude-opus-4-7 * test(get): batch coverage for get.rs envelope shapes Seven tests in new covering get.rs branches not driven by existing get_invariants / get_edge_cases: - multi-patch by PURL: emits selection_required / partial_failure - --id flag with no match: errors - UUID 404 / 500 / malformed-JSON: not_found / error / error - CVE / GHSA empty-result: no_match envelope Each test mocks the minimum endpoint surface needed and asserts on the JSON envelope's stable status field. Assisted-by: Claude Code:claude-opus-4-7 * test(cli): batch --dry-run + empty-manifest path coverage Six new tests in cli_dry_run_paths_e2e.rs covering --dry-run flag propagation and empty-manifest early-return envelopes: apply, repair, rollback, remove, list. Plus apply --silent suppresses friendly message check. Assisted-by: Claude Code:claude-opus-4-7 * test(output): integration coverage for ANSI color helpers Ten tests in output_helpers_e2e.rs driving format_severity and color directly via the lib's pub API. Existing integration tests all use --json mode which suppresses the colour wrappers, so the ANSI 31m/91m/33m/36m branches were entirely uncovered. Assisted-by: Claude Code:claude-opus-4-7 * test(blob_fetcher): cover fetch_blobs_by_hash skip-existing branch Pre-stage a blob and verify fetch_blobs_by_hash short-circuits the network call, reporting skipped:1. Assisted-by: Claude Code:claude-opus-4-7 * test(blob_fetcher): expand to 9 tests covering DownloadMode + sources Added 5 more tests: get_missing_archives empty, fetch_missing_sources in package/diff modes with no path configured, DownloadMode::parse across all variants (incl. 'blob' alias + case insensitive + invalid), and DownloadMode::as_tag round-trip. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): empty/missing path early-returns for NpmCrawler Three tests covering find_by_purls with empty PURL list, nonexistent node_modules, and crawl_all with no packages installed. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): empty-purl/empty-path branches across all 7 ecosystems Expanded crawlers_empty_paths_e2e.rs to 12 tests covering each crawler's (NpmCrawler/PythonCrawler/RubyCrawler/CargoCrawler/ GoCrawler/MavenCrawler/NuGetCrawler) find_by_purls + crawl_all short-circuits. Assisted-by: Claude Code:claude-opus-4-7 * test(telemetry): integration coverage for is_telemetry_disabled + sanitize_error_message Six tests in telemetry_helpers_e2e.rs: - 4 env-var combos for is_telemetry_disabled (=1, =true, VITEST=true, legacy var) - sanitize_error_message with + without home dir in input Also added serial_test as a dev-dep of socket-patch-core to serialize the env-var mutating tests. Assisted-by: Claude Code:claude-opus-4-7 * refactor(crawlers): runtime cfg!() to compile-time #[cfg(...)] gates Converts 9 runtime platform checks in production code to compile-time #[cfg(...)] gates so non-target-platform code drops out of the binary entirely. Affects: - python_crawler.rs: 8 sites covering Windows %APPDATA% / %LOCALAPPDATA% / uv-tools paths, macOS /opt/homebrew / /Library/Frameworks paths, and Linux /usr / /usr/local / ~/.local paths. - npm_crawler.rs: 1 site covering macOS Homebrew / nvm / volta / fnm fallback discovery. Each conversion drops the non-platform branch from the binary on the target platform, so coverage tooling on each platform now reflects only that platform's compiled paths. Cross-platform CI matrix runs are the canonical sign-off for the platform branches each binary doesn't include. This is a behavior-preserving refactor: cfg!() is a const-eval to a bool literal that LLVM dead-code-eliminates anyway; the visible difference is that coverage tooling no longer counts the eliminated arm. Workspace lib tests still green: 118 cli + 413 core. Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/python): 14 integration tests for find_python_dirs + venv + metadata New `crawler_python_e2e.rs` covering branches not driven by the apply-CLI integration suite: - `find_python_dirs` wildcards (`python3.*`, `*`, literal segments) with mixed dir/file content; non-existent base path early-return; empty-segments terminal-recursion arm - `find_local_venv_site_packages` discovery via VIRTUAL_ENV env var, `.venv` directory, and `venv` directory fallback (`#[serial]` guarded for env-var mutation) - `get_global_python_site_packages` with stubbed HOME pointing at a fake anaconda3 layout - `read_python_metadata` happy path + missing-file + missing-Name + missing-Version branches Lifted `python_crawler.rs` integration-test regions from 86.3% to 90.8%. Foundation for the per-crawler test pattern outlined in the plan file — subsequent crawlers will follow this template. Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/nuget): 15 integration tests for find_by_purls + crawl_all + paths New `crawler_nuget_e2e.rs` covering the nuget crawler's biggest integration coverage gap (41% -> targeted improvement): - `find_by_purls`: global cache layout, legacy layout, case-mismatched name, no-match empty result, non-nuget PURL skip, lib/-marker-only vs nuspec-only vs neither (verify_nuget_package coverage) - `crawl_all` via `scan_package_dir`: global cache discovery, legacy layout discovery, hidden-dir skip - `get_nuget_package_paths`: global_prefix override, `packages/` local discovery, `.csproj` triggers global fallback, `.sln` triggers global fallback, non-.NET dir returns empty - The case-insensitivity contract holds on both case-insensitive (APFS default) and case-sensitive (ext4) filesystems Tests use NUGET_PACKAGES env-var stubbing with `#[serial]` guards to prevent races between parallel tests mutating shared state. Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/ruby): 13 integration tests for find_by_purls + get_gem_paths New `crawler_ruby_e2e.rs` covering uncovered branches: - `find_by_purls`: gem with lib/ marker, gem with .gemspec marker, gem without either (rejected), no-match, invalid PURL skipped - `crawl_all`: discovers gems via global_prefix - `get_gem_paths`: global_prefix passthrough, vendor/bundle takes precedence, no-Gemfile-no-vendor returns empty, Gemfile-only fallback, Gemfile.lock-only fallback - Global discovery via `~/.gem/ruby/*/gems` (stubbed HOME) and `~/.rbenv/versions/*/lib/ruby/gems/*/gems` rbenv layout Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/maven): 16 integration tests for parse_pom + find_by_purls + repo paths New `crawler_maven_e2e.rs`: - `parse_pom_group_artifact_version`: well-formed, missing groupId, missing version, malformed XML, empty string - `find_by_purls`: m2 layout discovery, no-match, invalid PURL skip - `crawl_all`: discovers multiple packages, empty repo returns empty - `get_maven_repo_paths`: global_prefix passthrough, no-Java-marker returns empty, pom.xml / build.gradle / build.gradle.kts triggers repo discovery, M2_HOME/repository fallback when MAVEN_REPO_LOCAL unset Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/composer): 12 integration tests for vendor + installed.json paths New `crawler_composer_e2e.rs`: - `find_by_purls`: vendor with installed.json discovery, no installed.json returns empty, invalid PURL skip, version mismatch skip - `crawl_all`: installed.json parsing happy path, corrupt JSON returns empty - `get_vendor_paths`: global_prefix passthrough, no vendor returns empty, vendor without installed.json returns empty, vendor + installed.json but no composer.json/lock returns empty, full setup with composer.json returns vendor, full setup with composer.lock also works Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/cargo): 14 integration tests for parse_cargo_toml + find_by_purls + paths New crawler_cargo_e2e.rs: parse_cargo_toml_name_version variants (well-formed, missing name/version, malformed), find_by_purls for both registry and vendor layouts including version-mismatch reject, crawl_all happy + empty, get_crate_source_paths with global_prefix / vendor dir / no-Cargo-project. Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/go): 14 integration tests for encode/decode/parse + paths New crawler_go_e2e.rs: - encode_module_path: uppercase becomes !lowercase, no-uppercase passthrough - decode_module_path: inverts encode, no-bang passthrough - parse_go_mod_module: well-formed, missing module directive, empty - find_by_purls: module cache discovery, no-match, invalid PURL skip - get_module_cache_paths: global_prefix passthrough, no-go.mod returns empty, go.mod with GOMODCACHE env, GOPATH/pkg/mod fallback when GOMODCACHE unset Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/cargo): +3 tests for parse_dir_name_version fallback Three more tests in crawler_cargo_e2e.rs covering the workspace- version fallback path: when Cargo.toml has `version.workspace = true` instead of a concrete `version =`, both crawl_all and verify_crate_at_path fall back to parsing the directory name. Also covers the "dir without Cargo.toml entirely" skip. Assisted-by: Claude Code:claude-opus-4-7 * test(crawler/npm): 17 integration tests for npm crawler New crawler_npm_e2e.rs: - parse_package_name: unscoped, scoped, @-only-no-slash edge - build_npm_purl: scoped and unscoped - read_package_json: well-formed, missing file, malformed, missing name, missing version - find_by_purls: unscoped, scoped, version-mismatch, invalid PURL - crawl_all: discovers unscoped + scoped, skips dirs without package.json, skips dirs with corrupt package.json Assisted-by: Claude Code:claude-opus-4-7 * chore(crawlers): drop dead NpmPkgManager::as_tag + extend coverage NpmPkgManager::as_tag() and its corresponding test were dead — apply.rs matches on the enum variants directly (NpmPkgManager::YarnBerryPnP / ::Pnpm) and the struct never derives Serialize, so the stringified tag was unreachable from any caller. While here, extract `parse_bun_bin_output` from `get_bun_global_prefix` so the path-derivation half of bun discovery is unit-testable without shelling out to a real `bun` binary, and add integration tests covering: * cargo: TOML parser stops at next section / ignores pre-package lines, Default impl, CARGO_HOME unset → $HOME/.cargo fallback * npm: parse_bun_bin_output happy path, empty stdout, root-only path Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): more npm + composer coverage * npm: extract `parse_yarn_dir_output` and `parse_pnpm_root_output` from their shell-out wrappers so the path-derivation logic is unit-testable without a real `yarn` / `pnpm` binary; add tests covering happy path + empty stdout for both parsers and for the previously-extracted `parse_bun_bin_output`. * npm: cover `read_package_json` empty-string branches, `NpmCrawler` construction, `get_node_modules_paths` global_prefix passthrough and global-mode-without-prefix, and `find_workspace_node_modules` recursion / skip-list behavior. * composer: cover `get_global_vendor_paths` via COMPOSER_HOME env var and the HOME/.composer + HOME/.config/composer platform fallbacks, plus `crawl_all` dedup across vendor paths. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): maven + nuget + ruby + go coverage * maven: parent <groupId> fallback when project has none, property reference (`${...}`) bail-out for each of groupId/artifactId/version, parent property-reference skip, HOME/.m2/repository fallback, has_pom_file rejection of version dirs containing only a .jar, and `Default` impl. * nuget: global mode discovers nuget_home with NUGET_PACKAGES set, empty result when home doesn't exist, NuGet.Config marker triggers global-cache fallback, project.assets.json discovery (root + one level deep), malformed and empty-packageFolders assets.json arms, and `Default` impl. * ruby: `~/.rvm/gems/<set>/gems` layout discovery, and `Default` impl. * go: `Default` impl, empty `module` directive returns None, quoted module path branch, trailing-`!` decode arm, find_by_purls when the module dir is missing, crawl_all over nested versioned dirs, and the cache/ metadata-dir skip arm. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): python + cargo coverage * python: PythonCrawler `Default`, `find_by_purls` canonicalized-name match, qualifier stripping, empty/missing/mismatched purls, `crawl_all` over staged .dist-info dirs (well-formed + corrupt METADATA), global_prefix passthrough, and the METADATA early-break arm at first blank line after headers. * cargo: `parse_cargo_toml_name_version` `version.workspace` bail-out test, `verify_crate_at_path` dir-name fallback rejection on name mismatch, hidden-dir skip in `scan_crate_source`, dedup on identical purls across distinct directories, and local-mode fallback through `get_registry_src_paths` with CARGO_HOME stubbed (both with and without a staged registry/src tree). Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): deeper npm scope/nested + CrawlerOptions default * npm: a single staged tree that drives scoped-package scanning (`scan_scoped_packages`), nested `node_modules` recursion (`scan_nested_node_modules`), scoped→nested→scoped recursion, and the hidden-subdir + file-entry skip arms in both scanners. Adds PURL parser coverage for trailing `?` qualifier stripping, missing `@` version separator, empty version, scoped PURL with no `/`, and scoped PURL with empty name after the slash. * types: cover `CrawlerOptions::default()` populating cwd / global / global_prefix / batch_size (types.rs:143-150) — apply-CLI tests always construct options explicitly, so the Default impl was un-exercised. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): maven + go env-fallback coverage * maven: `get_maven_repo_paths(global=true)` with MAVEN_REPO_LOCAL set returns just that repo, and the empty-result arm when neither env var is set and HOME has no .m2/. * go: `get_gomodcache` falls through to `$HOME/go/pkg/mod` when both GOMODCACHE and GOPATH are unset (covers L194-197). Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): fix python METADATA blank-line break test The earlier fixture set BOTH Name and Version before reaching the blank line, so the function broke via the both-set guard at L71-72 instead of the blank-line break at L80-81. Replace with a fixture where only Name is set when the blank line is hit — that forces the L80-81 path and verifies the function correctly returns None when the trailer is interrupted before Version is read. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): npm shell-out wrappers via PATH stubbing Drive the `Command::new(...).output().ok()?` Err arm in each of the npm/yarn/pnpm/bun global-prefix helpers by stubbing PATH to a binary-free tempdir so the spawn itself fails. Removes the dependency on whether the dev host happens to have those binaries installed and covers the npm:91 / yarn:111 / pnpm:138 / bun:158 paths. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): composer/ruby/nuget shell-out + edge coverage * composer: cover `get_composer_home` falling through every source (COMPOSER_HOME unset, composer CLI missing from PATH, HOME without .composer or .config/composer) — drives the L194-207 shell-out failure path and the final L226 `None` arm. * ruby: similar PATH-stub for local Gemfile + missing `gem` binary (run_gem_env Err arm), plus global-mode probe with no gem binary and no HOME-relative gem layouts (covers fallback_globs scanning branches). * nuget: cover scan_package_dir's "skip non-dir entries" arm via a plain file at the top of the package dir, and the read_dir Err short-circuit via a non-existent global_prefix. Assisted-by: Claude Code:claude-opus-4-7 * test(crawlers): maven + cargo final coverage * maven: cover the `artifact_id?` propagation arm when a POM has groupId+version but no artifactId, and the `extract_xml_value` same-line-close-tag guard when an XML element is split across lines. * cargo: cover `scan_crate_source`'s non-dir entry skip arm (plain file at top of source path), the parse_dir_name_version fallback in `read_crate_cargo_toml` when Cargo.toml is unparseable AND the dir name has no version, and the `verify_crate_at_path` false-on- both-pars…
1 parent 9b7b5c2 commit b8de84f

96 files changed

Lines changed: 15148 additions & 1953 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ jobs:
154154
# separately, and coverage-merge stitches everything together.
155155
run: |
156156
cargo llvm-cov --workspace \
157-
--features cargo,golang,maven,composer,nuget \
157+
--features cargo,golang,maven,composer,nuget,deno \
158158
--no-report
159159
cargo llvm-cov report --lcov --output-path coverage-host.lcov
160160
cargo llvm-cov report --summary-only | tee coverage-summary.txt
@@ -206,7 +206,7 @@ jobs:
206206
strategy:
207207
fail-fast: false
208208
matrix:
209-
ecosystem: [npm, pypi, gem, cargo, golang, maven, composer, nuget]
209+
ecosystem: [npm, pypi, gem, cargo, golang, maven, composer, nuget, deno]
210210
steps:
211211
- name: Checkout
212212
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -270,7 +270,7 @@ jobs:
270270
# cargo llvm-cov manages its own env in the test step).
271271
run: |
272272
eval "$(cargo llvm-cov show-env --export-prefix 2>/dev/null)"
273-
cargo build --bin socket-patch --features cargo,golang,maven,composer,nuget
273+
cargo build --bin socket-patch --features cargo,golang,maven,composer,nuget,deno
274274
275275
- name: Configure docker-e2e coverage hooks
276276
run: |
@@ -282,7 +282,7 @@ jobs:
282282
- name: Run ${{ matrix.ecosystem }} Docker e2e test with coverage
283283
run: |
284284
cargo llvm-cov \
285-
--features docker-e2e,cargo,golang,maven,composer,nuget \
285+
--features docker-e2e,cargo,golang,maven,composer,nuget,deno \
286286
--no-report \
287287
--test docker_e2e_${{ matrix.ecosystem }}
288288
@@ -387,30 +387,55 @@ jobs:
387387
fail-fast: false
388388
matrix:
389389
include:
390-
- os: ubuntu-latest
391-
suite: e2e_npm
392-
- os: ubuntu-latest
393-
suite: e2e_pypi
394390
- os: ubuntu-latest
395391
suite: e2e_cargo
396392
- os: ubuntu-latest
397393
suite: e2e_golang
398394
- os: ubuntu-latest
399395
suite: e2e_maven
400-
- os: ubuntu-latest
401-
suite: e2e_gem
402396
- os: ubuntu-latest
403397
suite: e2e_composer
404398
- os: ubuntu-latest
405399
suite: e2e_nuget
400+
# The live-API smoke suites (e2e_npm, e2e_pypi, e2e_gem,
401+
# e2e_scan) are intentionally NOT in the PR matrix — their
402+
# `#[ignore]`-gated tests hit the real public proxy at
403+
# patches-api.socket.dev, which intermittently returns
404+
# 503 "Service temporarily over capacity" outside this
405+
# repo's control. Run on demand:
406+
#
407+
# cargo test -p socket-patch-cli --test e2e_npm -- --ignored
408+
# cargo test -p socket-patch-cli --test e2e_pypi -- --ignored
409+
# cargo test -p socket-patch-cli --test e2e_gem -- --ignored
410+
# cargo test -p socket-patch-cli --test e2e_scan -- --ignored
411+
#
412+
# PR-time coverage for the same code paths comes from the
413+
# `e2e-docker` matrix below, which runs the same flow
414+
# against a hermetic wiremock fixture.
415+
# Safety-hardening e2e suites. The fast non-ignored ones
416+
# (e2e_safety_lock, e2e_safety_yarn_pnp) run via the
417+
# standard `test` job above on all three platforms, so no
418+
# matrix entry is needed for them. The two below need real
419+
# toolchains and are #[ignore]-gated.
420+
- os: ubuntu-latest
421+
suite: e2e_safety_cargo_build
406422
- os: macos-latest
407-
suite: e2e_npm
408-
- os: macos-latest
409-
suite: e2e_pypi
423+
suite: e2e_safety_cargo_build
424+
- os: windows-latest
425+
suite: e2e_safety_cargo_build
410426
- os: ubuntu-latest
411-
suite: e2e_scan
427+
suite: e2e_safety_pnpm
412428
- os: macos-latest
413-
suite: e2e_scan
429+
suite: e2e_safety_pnpm
430+
# pnpm-on-Windows uses junctions for symlinks and copies
431+
# (not hardlinks) by default, so the CoW invariant holds
432+
# vacuously. Test still runs to verify apply doesn't error
433+
# on Windows — semantic Windows nlink coverage is a
434+
# follow-up (`std::fs::Metadata` doesn't expose nlink on
435+
# Windows; needs `GetFileInformationByHandle` via
436+
# `windows-sys`).
437+
- os: windows-latest
438+
suite: e2e_safety_pnpm
414439
runs-on: ${{ matrix.os }}
415440
steps:
416441
- name: Checkout
@@ -436,11 +461,20 @@ jobs:
436461
restore-keys: ${{ matrix.os }}-cargo-e2e-
437462

438463
- name: Setup Node.js
439-
if: matrix.suite == 'e2e_npm' || matrix.suite == 'e2e_scan'
464+
if: matrix.suite == 'e2e_npm' || matrix.suite == 'e2e_scan' || matrix.suite == 'e2e_safety_pnpm'
440465
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
441466
with:
442467
node-version: '20.20.2'
443468

469+
- name: Setup pnpm
470+
if: matrix.suite == 'e2e_safety_pnpm'
471+
# Pin the major version so the store layout the test
472+
# asserts on stays stable. `npm install -g` is the simplest
473+
# cross-platform install path (works on ubuntu, macos,
474+
# windows-runners — they all ship a usable npm via
475+
# actions/setup-node).
476+
run: npm install -g pnpm@10
477+
444478
- name: Setup Python
445479
if: matrix.suite == 'e2e_pypi'
446480
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -483,7 +517,7 @@ jobs:
483517
strategy:
484518
fail-fast: false
485519
matrix:
486-
ecosystem: [npm, pypi, gem, cargo, golang, maven, composer, nuget]
520+
ecosystem: [npm, pypi, gem, cargo, golang, maven, composer, nuget, deno]
487521
steps:
488522
- name: Checkout
489523
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ once_cell = "=1.21.3"
2828
qbsdiff = "=1.4.4"
2929
tar = "=0.4.45"
3030
flate2 = "=1.1.9"
31+
fs2 = "=0.4.3"
3132
wiremock = "=0.6.5"
3233
portable-pty = "=0.9.0"
3334
testcontainers = "=0.27.3"

crates/socket-patch-cli/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ golang = ["socket-patch-core/golang"]
3434
maven = ["socket-patch-core/maven"]
3535
composer = ["socket-patch-core/composer"]
3636
nuget = ["socket-patch-core/nuget"]
37+
deno = ["socket-patch-core/deno"]
3738
# Enables the Docker-driven real-package e2e test suite under
3839
# `tests/docker_e2e_*.rs`. Tests in this suite require either a running
3940
# Docker daemon OR `SOCKET_PATCH_TEST_HOST=1` (host-toolchain mode).
@@ -49,3 +50,8 @@ base64 = { workspace = true }
4950
reqwest = { workspace = true }
5051
tempfile = { workspace = true }
5152
serial_test = { workspace = true }
53+
# Used by `tests/e2e_safety_lock.rs` to externally hold the same
54+
# `.socket/apply.lock` the binary takes, then spawn the binary and
55+
# assert the lock_held exit-code contract. Same crate the binary
56+
# uses internally (`socket-patch-core::patch::apply_lock`).
57+
fs2 = { workspace = true }

crates/socket-patch-cli/src/args.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ pub struct GlobalArgs {
146146
)]
147147
pub yes: bool,
148148

149+
/// Seconds to wait for `<.socket>/apply.lock` before giving up.
150+
/// Default (`None`) and `0` both mean a single non-blocking try
151+
/// — failing immediately if another process holds the lock. A
152+
/// positive value retries with a 100 ms backoff until the lock
153+
/// frees or the budget elapses. Only meaningful for the mutating
154+
/// subcommands (`apply`, `rollback`, `repair`, `remove`); other
155+
/// commands accept it silently.
156+
#[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")]
157+
pub lock_timeout: Option<u64>,
158+
159+
/// Force-remove `<.socket>/apply.lock` before attempting
160+
/// acquisition. Use when you are certain no other socket-patch
161+
/// process is running (e.g. a previous run crashed in a way that
162+
/// stripped the OS lock but left the file). Emits a
163+
/// `lock_broken` warning event in the JSON envelope so the
164+
/// action is auditable. Only meaningful for mutating
165+
/// subcommands; other commands accept it silently.
166+
#[arg(long = "break-lock", env = "SOCKET_BREAK_LOCK", default_value_t = false)]
167+
pub break_lock: bool,
168+
149169
/// Emit verbose debug logs to stderr.
150170
#[arg(long = "debug", env = "SOCKET_DEBUG", default_value_t = false)]
151171
pub debug: bool,
@@ -235,6 +255,8 @@ impl Default for GlobalArgs {
235255
silent: false,
236256
dry_run: false,
237257
yes: false,
258+
lock_timeout: None,
259+
break_lock: false,
238260
debug: false,
239261
no_telemetry: false,
240262
}

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ use socket_patch_core::api::blob_fetcher::{
44
get_missing_blobs, DownloadMode,
55
};
66
use socket_patch_core::api::client::get_api_client_with_overrides;
7-
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
7+
use socket_patch_core::crawlers::{
8+
detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager,
9+
};
810
use socket_patch_core::manifest::operations::read_manifest;
911
use socket_patch_core::patch::apply::{
1012
apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus,
1113
};
14+
15+
use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
1216
use socket_patch_core::utils::purl::strip_purl_qualifiers;
1317
use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed};
1418
use std::collections::{HashMap, HashSet};
1519
use std::path::{Path, PathBuf};
20+
use std::time::Duration;
1621
use tempfile::TempDir;
1722

1823
use crate::args::{apply_env_toggles, GlobalArgs};
@@ -129,6 +134,11 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent
129134
.map(AppliedVia::from_core),
130135
})
131136
.collect();
137+
// Sidecar data is NOT attached here — it's surfaced at the
138+
// envelope level under `Envelope.sidecars[]` by the run loop.
139+
// See `Envelope::record_sidecar`. Keeping events clean of
140+
// sidecar info means each event describes only the apply
141+
// action; sidecar reporting is a separate, JOIN-able list.
132142
PatchEvent::new(PatchAction::Applied, purl).with_files(files)
133143
}
134144

@@ -154,6 +164,74 @@ pub async fn run(args: ApplyArgs) -> i32 {
154164
return 0;
155165
}
156166

167+
// Serialize against concurrent socket-patch runs targeting the same
168+
// `.socket/` directory. The guard releases on function return; see
169+
// `socket_patch_core::patch::apply_lock`.
170+
let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
171+
let acquired = match acquire_or_emit(
172+
socket_dir,
173+
Command::Apply,
174+
args.common.json,
175+
args.common.silent,
176+
args.common.dry_run,
177+
Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
178+
args.common.break_lock,
179+
) {
180+
Ok(acquired) => acquired,
181+
Err(code) => return code,
182+
};
183+
let _lock = acquired.guard;
184+
let lock_was_broken = acquired.broke_lock;
185+
186+
// Package-manager layout detection. yarn-berry PnP keeps packages
187+
// inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` —
188+
// the npm crawler can't reach them and rewriting zips is a
189+
// different operation entirely. Refuse with a clear pointer to
190+
// `yarn patch`. pnpm gets an informational event; the CoW guard
191+
// in `apply_file_patch` does the substantive safety work.
192+
let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
193+
match pkg_manager {
194+
NpmPkgManager::YarnBerryPnP => {
195+
if args.common.json {
196+
let mut env = Envelope::new(Command::Apply);
197+
env.dry_run = args.common.dry_run;
198+
env.mark_error(EnvelopeError::new(
199+
"yarn_pnp_unsupported",
200+
"yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
201+
));
202+
println!("{}", env.to_pretty_json());
203+
} else if !args.common.silent {
204+
eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
205+
eprintln!(
206+
" Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
207+
);
208+
eprintln!(" Use `yarn patch <pkg>` instead.");
209+
}
210+
return 1;
211+
}
212+
NpmPkgManager::Pnpm => {
213+
if !args.common.json && !args.common.silent {
214+
eprintln!(
215+
"Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
216+
);
217+
}
218+
// Non-fatal — CoW handles the safety. JSON consumers see
219+
// the layout-detected info in the apply envelope's
220+
// existing events (no separate event added here yet).
221+
}
222+
NpmPkgManager::Bun => {
223+
if !args.common.json && !args.common.silent {
224+
eprintln!(
225+
"Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
226+
);
227+
}
228+
// Same shape as pnpm: bun hard-links from its global
229+
// install cache by default. The CoW guard handles the
230+
// safety; this is informational only.
231+
}
232+
_ => {}
233+
}
234+
157235
match apply_patches_inner(&args, &manifest_path).await {
158236
Ok((success, results, unmatched)) => {
159237
let patched_count = results
@@ -164,8 +242,18 @@ pub async fn run(args: ApplyArgs) -> i32 {
164242
if args.common.json {
165243
let mut env = Envelope::new(Command::Apply);
166244
env.dry_run = args.common.dry_run;
245+
if lock_was_broken {
246+
env.record(lock_broken_event(socket_dir));
247+
}
167248
for result in &results {
168249
env.record(result_to_event(result, args.common.dry_run));
250+
// Sidecar records live on the envelope, not on
251+
// individual events. Consumers iterate
252+
// `envelope.sidecars[]` and JOIN against
253+
// `events[]` by `purl` for per-package context.
254+
if let Some(ref sidecar) = result.sidecar {
255+
env.record_sidecar(sidecar.clone());
256+
}
169257
}
170258
// Manifest entries that targeted in-scope ecosystems but
171259
// had no installed package on disk — emit one Skipped
@@ -705,6 +793,7 @@ mod tests {
705793
files_patched: vec!["package/index.js".to_string()],
706794
applied_via,
707795
error: None,
796+
sidecar: None,
708797
}
709798
}
710799

@@ -779,6 +868,7 @@ mod tests {
779868
],
780869
applied_via,
781870
error: None,
871+
sidecar: None,
782872
};
783873

784874
let event = result_to_event(&result, false);

0 commit comments

Comments
 (0)