From f41b2d6ba1721211f77c276de802914c8311fc60 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 28 Jul 2026 08:27:14 -0400 Subject: [PATCH 01/16] fix: bug-fix + test-harness sweep across CLI, crawlers, setup and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes - scan --prune now exempts manifest entries whose ecosystem this run never crawled: an unknown `pkg:/` (a newer CLI's ecosystem in a committed, shared manifest) and the runtime-gated maven/nuget crawlers with their gate off. Absence from a crawl that never looked is not evidence of removal, so pruning them silently deleted a teammate's patch plus its blobs. - get: thread --api-token/--api-url/--org/--proxy-url into the nested apply. They were dropped, so a token supplied purely as a flag fell through to the token-less public proxy — against the wrong host, with the wrong client. - redirect: write the managed `[registries.…]` block into the legacy extensionless `.cargo/config` when a project carries it. Cargo reads that spelling in preference to `config.toml`, so the block was landing in a file cargo ignores while the run still reported the dep redirected. - rollback: fall back to dropping the go.mod `replace` + `.socket/go-patches/` copy for a local-mode go patch whose module the crawler cannot find. - crawlers: composer reads `installed.json` through `open_regular_file` (a planted FIFO no longer hangs scan/apply forever); maven treats an empty MAVEN_REPO_LOCAL/M2_HOME as unset; nuget matches `nuget.config` / `packages.config` case-insensitively; python scans the macOS `osx_framework_user` user-install root; ruby's local-mode fallback returns every gem home `gem env` reports (not just `gemdir`) and accepts the alternate `gems.rb`/`gems.locked` Bundler spelling. - setup: `--exclude` trims CSV whitespace and covers the excluded directory's whole subtree; `finalize_gem` forwards an absolutized --manifest-path. - update: non-fatal advisories ride the envelope's run-level `warnings[]`, so a `--json` run no longer silently swallows a managed-install override. - vex: git-config parsing matches git itself (case-insensitive names, BOM tolerance, whitespace before a quoted subsection). - lib: `--update=` (inline `=` spelling) is recognized, and a `--update` after `--` is correctly left as an escaped operand. Test harness / CI - New [profile.ci-release] (release minus the full-LTO link) for test-release, and dependency opt-levels raised in the dev profile — the self_update fixtures gzip and sha256 a multi-MB binary per test at opt-level 0. Workspace members stay at opt-level 0, so llvm-cov line fidelity and debug experience are unchanged. - CI: per-job timeout-minutes, and a concurrency group that supersedes stale runs while never cancelling main (main is the only rust-cache writer). - The two wall-bound real-package-manager redirect capstones are #[ignore]-gated out of the serial `test` job and relocated to the parallel e2e matrix, which runs `-- --ignored` on all three OSes. Same coverage, off the critical path. - Drop the unused testcontainers dev-dependency (-1001 Cargo.lock lines). - Many new invariant/e2e suites: scan, apply, remove, setup, vendor, vex, crawlers, in-process redirect, CLI config fallback. Known-RED tests, gated with #[ignore] and a reason Each of these is a correct test for a real bug whose production fix is NOT in this change. They are gated rather than deleted so the finding is not lost: apply_lock waiter/orphaned-inode (two simultaneous holders of the "exclusive" apply lock), apply's manifest_unreadable fail-closed arm, the telemetry --api-url/--proxy-url env mirror (on-prem token egress), composer.json setup mode preservation (one-line fix: use atomic_write_bytes_preserving_mode), pnpm `node-linker=pnp` detection, pnpm workspace flow-sequence parsing, apply's cached-package-archive fallback, remove's ledger-generation match, and the yarn-PnP refusal scoping (its counter-guard stays live). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 43 +- Cargo.lock | 1059 +---------------- Cargo.toml | 59 +- crates/socket-patch-cli/CLI_CONTRACT.md | 8 +- crates/socket-patch-cli/Cargo.toml | 1 - crates/socket-patch-cli/src/args.rs | 72 ++ crates/socket-patch-cli/src/commands/apply.rs | 2 +- crates/socket-patch-cli/src/commands/get.rs | 61 +- crates/socket-patch-cli/src/commands/list.rs | 104 +- .../socket-patch-cli/src/commands/lock_cli.rs | 4 +- .../socket-patch-cli/src/commands/rollback.rs | 229 +++- .../src/commands/scan/discovery.rs | 16 +- .../socket-patch-cli/src/commands/scan/gc.rs | 68 ++ .../src/commands/scan/hosted.rs | 26 +- .../socket-patch-cli/src/commands/scan/mod.rs | 67 +- .../src/commands/scan/vendor_flow.rs | 9 +- crates/socket-patch-cli/src/commands/setup.rs | 57 +- .../socket-patch-cli/src/commands/update.rs | 47 +- .../socket-patch-cli/src/commands/vendor.rs | 168 ++- crates/socket-patch-cli/src/commands/vex.rs | 13 +- .../src/ecosystem_dispatch.rs | 16 + crates/socket-patch-cli/src/lib.rs | 130 +- .../socket-patch-cli/src/update_notifier.rs | 24 +- .../tests/apply_invariants.rs | 53 + .../socket-patch-cli/tests/apply_network.rs | 112 ++ .../tests/cli_config_fallback.rs | 228 ++++ crates/socket-patch-cli/tests/cli_sigpipe.rs | 3 +- .../tests/common/update_fixture.rs | 10 +- .../tests/e2e_redirect_npm_build.rs | 2 + .../tests/e2e_redirect_rush_sim.rs | 3 + .../tests/e2e_safety_yarn_pnp.rs | 327 ++++- crates/socket-patch-cli/tests/e2e_vex.rs | 117 ++ .../tests/get_nested_apply_api_flags_e2e.rs | 251 ++++ .../tests/get_update_summary_e2e.rs | 164 +++ .../tests/in_process_redirect.rs | 213 ++++ .../socket-patch-cli/tests/in_process_scan.rs | 90 ++ .../tests/in_process_vendor.rs | 29 + .../tests/remove_invariants.rs | 146 +++ .../socket-patch-cli/tests/scan_invariants.rs | 190 +++ .../socket-patch-cli/tests/scan_vendor_e2e.rs | 114 ++ .../tests/scan_vendor_step_error_e2e.rs | 232 ++++ .../tests/self_update_channels_e2e.rs | 63 +- .../socket-patch-cli/tests/self_update_e2e.rs | 62 +- .../tests/self_update_failures_e2e.rs | 5 +- .../tests/setup_contract_gaps.rs | 139 +++ .../tests/setup_invariants.rs | 56 + .../tests/update_notifier_e2e.rs | 35 +- crates/socket-patch-core/src/api/types.rs | 5 +- .../src/composer_setup/mod.rs | 40 + .../src/crawlers/composer_crawler.rs | 104 +- .../src/crawlers/maven_crawler.rs | 99 +- .../src/crawlers/nuget_crawler.rs | 83 +- .../src/crawlers/pkg_managers.rs | 64 + .../src/crawlers/python_crawler.rs | 21 + .../src/crawlers/ruby_crawler.rs | 96 +- crates/socket-patch-core/src/gem_setup/mod.rs | 110 +- .../socket-patch-core/src/gem_setup/update.rs | 309 ++++- .../src/package_json/detect.rs | 176 ++- .../src/package_json/find.rs | 72 ++ .../socket-patch-core/src/patch/apply_lock.rs | 70 ++ .../src/patch/redirect/mod.rs | 88 +- .../socket-patch-core/src/update/channel.rs | 8 +- .../socket-patch-core/src/update/download.rs | 19 +- crates/socket-patch-core/src/update/mod.rs | 3 +- .../socket-patch-core/src/update/release.rs | 23 +- crates/socket-patch-core/src/update/state.rs | 10 +- crates/socket-patch-core/src/update/swap.rs | 20 +- crates/socket-patch-core/src/vex/product.rs | 222 +++- .../tests/crawler_cargo_e2e.rs | 54 + .../tests/crawler_python_e2e.rs | 51 + .../tests/crawler_ruby_e2e.rs | 184 +++ .../tests/proxy_batch_e2e.rs | 54 + scripts/optimize-test-perf.config.ts | 190 +++ 73 files changed, 5767 insertions(+), 1335 deletions(-) create mode 100644 crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs create mode 100644 crates/socket-patch-cli/tests/get_update_summary_e2e.rs create mode 100644 crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs create mode 100644 scripts/optimize-test-perf.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b14fd5..75e9587b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,17 @@ on: permissions: contents: read +# Supersede stale runs on force-push / rapid PR updates. The `main` guard is +# load-bearing: main runs are the ONLY rust-cache writers (save-if), so they +# must never be cancelled mid-save. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: clippy: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -56,6 +64,7 @@ jobs: # ubuntu-latest runner. lint-ecosystems: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -140,6 +149,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 35 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -198,6 +208,7 @@ jobs: test-release: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -224,7 +235,10 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run tests (release) - run: cargo test --workspace --all-features --release + # `ci-release` = [profile.release] minus the full-LTO link (see the + # profile's comment in Cargo.toml). Same opt-level/debug-assertion + # semantics this job exists to validate; ~23m of LTO relinking gone. + run: cargo test --workspace --all-features --profile ci-release coverage: # Code coverage via cargo-llvm-cov (LLVM source-based instrumentation). @@ -233,6 +247,7 @@ jobs: # report-only so contributors get visibility without flaky CI when # coverage shifts naturally with test edits. runs-on: ubuntu-latest + timeout-minutes: 35 permissions: contents: read steps: @@ -328,6 +343,7 @@ jobs: # container ships fails to load. ubuntu-22.04's older glibc is # the highest base that's forward-compatible with debian:12. runs-on: ubuntu-22.04 + timeout-minutes: 30 permissions: contents: read strategy: @@ -442,6 +458,7 @@ jobs: # summed line-by-line so a line covered by ANY test counts. needs: [coverage, coverage-docker] runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read steps: @@ -495,6 +512,7 @@ jobs: dispatch-tests: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -589,7 +607,28 @@ jobs: # `windows-sys`). - os: windows-latest suite: e2e_safety_pnpm + # Wall-bound real-package-manager redirect capstones (~150s and + # ~70s of network installs + bootstrap resolutions — profile- + # insensitive, measured identical in debug and release). They ran + # inside the serial `test` job on every OS; #[ignore]-gated out of + # it and relocated here so they still run on every PR and every + # OS, but in parallel off the critical path. They use the runner's + # default node/corepack, exactly as they did inside `test` — no + # setup-node step, no version change. + - os: ubuntu-latest + suite: e2e_redirect_npm_build + - os: macos-latest + suite: e2e_redirect_npm_build + - os: windows-latest + suite: e2e_redirect_npm_build + - os: ubuntu-latest + suite: e2e_redirect_rush_sim + - os: macos-latest + suite: e2e_redirect_rush_sim + - os: windows-latest + suite: e2e_redirect_rush_sim runs-on: ${{ matrix.os }} + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -684,6 +723,7 @@ jobs: # ---------------------------------------------------------------------- e2e-docker: runs-on: ubuntu-latest + timeout-minutes: 35 permissions: contents: read strategy: @@ -754,6 +794,7 @@ jobs: # ---------------------------------------------------------------------- setup-matrix: runs-on: ubuntu-latest + timeout-minutes: 45 continue-on-error: true permissions: contents: read diff --git a/Cargo.lock b/Cargo.lock index 21d1b409..307a5c45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "0.6.21" @@ -92,55 +83,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "astral-tokio-tar" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" -dependencies = [ - "filetime", - "futures-core", - "libc", - "portable-atomic", - "rustc-hash", - "tokio", - "tokio-stream", - "xattr", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -153,49 +95,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "base64" version = "0.22.1" @@ -223,89 +122,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bollard" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" -dependencies = [ - "async-stream", - "base64", - "bitflags 2.11.0", - "bollard-buildkit-proto", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "home", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-rustls", - "hyper-util", - "hyperlocal", - "log", - "num", - "pin-project-lite", - "rand 0.9.4", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.18", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-buildkit-proto" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" -dependencies = [ - "prost", - "prost-types", - "tonic", - "tonic-prost", - "ureq", -] - -[[package]] -name = "bollard-stubs" -version = "1.52.1-rc.29.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" -dependencies = [ - "base64", - "bollard-buildkit-proto", - "bytes", - "prost", - "serde", - "serde_json", - "serde_repr", - "time", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -379,19 +195,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", + "rand_core", ] [[package]] @@ -453,22 +257,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -531,40 +319,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "deadpool" version = "0.12.3" @@ -583,16 +337,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - [[package]] name = "dialoguer" version = "0.11.0" @@ -627,29 +371,12 @@ dependencies = [ "syn", ] -[[package]] -name = "docker_credential" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" -dependencies = [ - "base64", - "serde", - "serde_json", -] - [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.15.0" @@ -678,33 +405,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "ferroid" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" -dependencies = [ - "portable-atomic", - "rand 0.10.1", - "web-time", -] - [[package]] name = "filedescriptor" version = "0.8.3" @@ -885,18 +591,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -906,8 +600,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", + "rand_core", "wasip2", "wasip3", "wasm-bindgen", @@ -925,19 +619,13 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap", "slab", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.15.5" @@ -971,15 +659,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.0" @@ -1048,21 +727,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", - "winapi", -] - [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1080,19 +744,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1116,45 +767,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -1242,12 +854,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1269,17 +875,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -1327,15 +922,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -1409,24 +995,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1460,76 +1034,6 @@ dependencies = [ "libc", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1567,12 +1071,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1596,57 +1094,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "parse-display" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" -dependencies = [ - "parse-display-derive", - "regex", - "regex-syntax", -] - -[[package]] -name = "parse-display-derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "regex-syntax", - "structmeta", - "syn", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1695,21 +1148,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1729,38 +1167,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" -dependencies = [ - "prost", -] - [[package]] name = "qbsdiff" version = "1.4.4" @@ -1802,7 +1208,7 @@ dependencies = [ "bytes", "getrandom 0.4.2", "lru-slab", - "rand 0.10.1", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1838,12 +1244,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1852,42 +1252,13 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "getrandom 0.3.4", + "chacha20", + "getrandom 0.4.2", + "rand_core", ] [[package]] @@ -1902,7 +1273,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1934,26 +1305,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "regex" version = "1.12.3" @@ -2060,7 +1411,6 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -2069,18 +1419,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2141,39 +1479,6 @@ dependencies = [ "sdd", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2186,29 +1491,6 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "self-replace" version = "1.5.0" @@ -2262,7 +1544,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.0", + "indexmap", "itoa", "memchr", "serde", @@ -2270,17 +1552,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2293,38 +1564,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serial2" version = "0.2.37" @@ -2458,7 +1697,6 @@ dependencies = [ "socket-patch-core", "tar", "tempfile", - "testcontainers", "tokio", "uuid", "wiremock", @@ -2518,29 +1756,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "structmeta" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" -dependencies = [ - "proc-macro2", - "quote", - "structmeta-derive", - "syn", -] - -[[package]] -name = "structmeta-derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" @@ -2611,37 +1826,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "testcontainers" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" -dependencies = [ - "astral-tokio-tar", - "async-trait", - "bollard", - "bytes", - "docker_credential", - "either", - "etcetera", - "ferroid", - "futures", - "http", - "itertools", - "log", - "memchr", - "parse-display", - "pin-project-lite", - "serde", - "serde_json", - "serde_with", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "url", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2682,37 +1866,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -2776,17 +1929,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -2815,7 +1957,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap 2.13.0", + "indexmap", "toml_datetime", "toml_parser", "toml_writer", @@ -2837,46 +1979,6 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - [[package]] name = "tower" version = "0.5.3" @@ -2885,15 +1987,11 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2933,21 +2031,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -2999,33 +2085,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -3036,15 +2095,8 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3193,7 +2245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap", "wasm-encoder", "wasmparser", ] @@ -3206,7 +2258,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap", "semver", ] @@ -3270,65 +2322,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3563,7 +2562,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap", "prettyplease", "syn", "wasm-metadata", @@ -3594,7 +2593,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap", "log", "serde", "serde_derive", @@ -3613,7 +2612,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap", "log", "semver", "serde", @@ -3662,26 +2661,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" @@ -3750,7 +2729,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap", "memchr", "typed-path", "zopfli", diff --git a/Cargo.toml b/Cargo.toml index 7633c1e9..14fc800b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,6 @@ semver = "=1.0.27" self-replace = "=1.5.0" wiremock = "=0.6.5" portable-pty = "=0.9.0" -testcontainers = "=0.27.3" base64 = "=0.22.1" serial_test = "=3.4.0" @@ -48,3 +47,61 @@ serial_test = "=3.4.0" strip = true lto = true opt-level = "s" + +# CI-only profile for the test-release job. Inherits the shipped profile's +# semantics (opt-level = "s", debug-assertions off, overflow-checks off) — +# which is what test-release exists to validate (commit b96a13f) — but drops +# the full-LTO link: ~23m of that job's ~29m was LTO-relinking 159 test +# binaries, and LTO relinks are structurally uncacheable. release.yml still +# builds the real full-LTO [profile.release] for every shipped target. +[profile.ci-release] +inherits = "release" +lto = false +strip = "none" # unstripped test binaries => usable backtraces on failure + +# Test-execution speed: `cargo test` builds dependencies with the dev +# profile; at opt-level 0 the hash/compression/bsdiff hot loops are 10-100x +# slower (the self_update fixture family measured 403s debug vs ~2s release +# on CI run 30289993021 — it gzips and sha256-hashes the multi-MB debug CLI +# binary per test). Workspace members are NOT matched by "*" — they stay +# opt-level 0, so incremental compile speed, debugging, and llvm-cov line +# fidelity (reports filter to workspace crates) are unchanged. Dependencies +# keep debug-assertions and overflow-checks ON — only codegen opt changes. +[profile.dev.package."*"] +opt-level = 1 + +# The measured hot path gets full optimization: archive building +# (flate2/zip over multi-MB payloads), SHA hashing in fixtures and in the +# CLI's own verify paths, and qbsdiff bspatch in every apply test. +[profile.dev.package.sha2] +opt-level = 3 +[profile.dev.package.sha1] +opt-level = 3 +[profile.dev.package.digest] +opt-level = 3 +[profile.dev.package.block-buffer] +opt-level = 3 +[profile.dev.package.cpufeatures] +opt-level = 3 +[profile.dev.package.crc32fast] +opt-level = 3 +[profile.dev.package.flate2] +opt-level = 3 +[profile.dev.package.miniz_oxide] +opt-level = 3 +[profile.dev.package.zlib-rs] +opt-level = 3 +[profile.dev.package.adler2] +opt-level = 3 +[profile.dev.package.simd-adler32] +opt-level = 3 +[profile.dev.package.zip] +opt-level = 3 +[profile.dev.package.qbsdiff] +opt-level = 3 +[profile.dev.package.bzip2] +opt-level = 3 +[profile.dev.package.libbz2-rs-sys] +opt-level = 3 +[profile.dev.package.suffix_array] +opt-level = 3 diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 41277b52..99393525 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -86,7 +86,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -102,7 +102,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml`, `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Mode ledgers (contract surfaces).** Each committable mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write these files, so path + schema are part of the contract: @@ -605,7 +605,7 @@ Synopsis and behavior: | Invocation | Behavior | |---|---| | `--update` | Resolve the latest release; install it if newer than the running version. Already-newest (including a dev build newer than any release): informational no-op, exit 0. `latest` never downgrades. | -| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` and the gem/composer launchers honor); a malformed version is a usage error (exit 2). | +| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. The inline `--update=3.4.0` spelling is equivalent. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` and the gem/composer launchers honor); a malformed version is a usage error (exit 2). | | `--update --force` | Reinstall/downgrade even when already at the target version, and proceed past a managed-install refusal (with a warning that the owning manager's next upgrade will overwrite the binary). Env: `SOCKET_FORCE`. | | `--update --dry-run` | **Check-only**: one metadata request, zero downloads, zero mutation, exit 0 — and always the `verified`/`update_check` event shape, whether or not an update exists. `--json` details carry `{current, latest, updateAvailable, target, asset, path}` — the cheap scriptable "is an update available" probe. | | `--update --offline` | Refused up front (strict airgap, before any client exists), exit 1. `--force` does **not** bypass it. | @@ -624,7 +624,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf **Pipeline order** (each step gates the next; a failure at any point leaves the installed binary untouched): fetch `SHA256SUMS` → fetch the archive (`socket-patch-.tar.gz`/`.zip`, explicit timeouts, size caps) → verify the SHA-256 **before** extraction → extract the single expected member → stage as an executable sibling **in the install directory** (`EACCES` here is the permissions preflight → exit 1 with a sudo hint; system temp is never used, so `noexec` mounts don't matter) → run the staged binary's `--version` self-check (against real GitHub the reported version must equal the release tag; under a `SOCKET_UPDATE_BASE_URL` override a mismatch only warns) → one atomic rename over the install path (mode-preserving; a **setuid/setgid** target — or, on Linux, one carrying **file capabilities** (`setcap`) — is refused, since an unprivileged swap cannot restore those grants; Windows uses the rename-dance via `self-replace`). Concurrent updates are single-flighted per environment by an advisory lock at `/update.lock` (`errorCode: update_in_progress`; the OS releases a dead holder's lock, so there is no stale-lock state). Two updaters whose state dirs diverge (e.g. different `$HOME`s targeting one shared `/usr/local/bin`) are not serialized, but every path to the destination is a whole-file rename and stage cleanup is age-gated — the worst case is duplicated work, never a torn binary. -**Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. +**Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Non-fatal advisories ride the run-level `warnings[]` (`{code, detail}`, omitted when empty) — human runs print the same text to stderr as `Warning: `, and `--json` (which silences stderr) carries them here instead so an override is never silent: `managed_install_override` (a `--force` run replaced a package-manager-owned binary that manager's next upgrade will overwrite) and `update_warning` (a non-fatal note from the update engine, today the relaxed version self-check under a `SOCKET_UPDATE_BASE_URL` override). Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. **Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh and the launcher wrappers): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index acea76b2..16d3651b 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -67,7 +67,6 @@ zip = { workspace = true } hex = { workspace = true } wiremock = { workspace = true } portable-pty = { workspace = true } -testcontainers = { workspace = true } base64 = { workspace = true } reqwest = { workspace = true } tempfile = { workspace = true } diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 87726782..32a7b552 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -548,6 +548,78 @@ mod tests { }); } + /// `--api-url` / `--proxy-url` must reach the telemetry sender, which + /// resolves its endpoint from the env only + /// (`socket_cli_config::resolve_api_base_url` reads `SOCKET_API_URL`, + /// `env_compat::proxy_url_from_env` reads `SOCKET_PROXY_URL`) and never + /// sees the parsed flags. Without the mirror, + /// `socket-patch scan --api-url https://socket.internal --api-token + /// --org acme` POSTs the event — `Authorization: Bearer ` header + /// included — to the default `https://api.socket.dev`, egressing an + /// on-prem token to the very host the operator redirected away from. + #[test] + #[serial_test::serial] + #[ignore = "RED: documents a real token-egress bug — `apply_env_toggles` does \ + not mirror --api-url/--proxy-url into the env, and telemetry \ + resolves its endpoint from the env only, so an on-prem run POSTs \ + its Bearer token to the default api.socket.dev. The mirror was \ + not part of this change."] + fn apply_env_toggles_mirrors_api_and_proxy_urls_for_telemetry() { + with_clean_socket_env(|| { + // Guard against a vacuous pass: the resolver must start at the + // built-in default (the cargo `[env]` `SOCKET_NO_CONFIG=1` keeps + // a developer's real socket-cli config out of the chain). + assert_eq!( + socket_patch_core::utils::socket_cli_config::resolve_api_base_url(), + socket_patch_core::constants::DEFAULT_SOCKET_API_URL, + ); + + let args = GlobalArgs { + api_url: Some("https://socket.internal.example".to_string()), + proxy_url: Some("https://proxy.internal.example".to_string()), + ..GlobalArgs::default() + }; + apply_env_toggles(&args); + assert_eq!( + socket_patch_core::utils::socket_cli_config::resolve_api_base_url(), + "https://socket.internal.example", + "--api-url must reach the telemetry endpoint resolver", + ); + assert_eq!( + std::env::var("SOCKET_PROXY_URL").as_deref(), + Ok("https://proxy.internal.example"), + "--proxy-url must reach the tokenless telemetry endpoint resolver", + ); + }); + } + + /// The URL mirror must not invent an override: `None` (no flag, no env + /// var) has to stay unset so `get_api_client_with_overrides` / + /// `resolve_api_base_url` still fall through env → socket-cli config → + /// default, and `--api-url ""` keeps meaning "unset" exactly as + /// [`GlobalArgs::api_client_overrides`] already treats it. + #[test] + #[serial_test::serial] + fn apply_env_toggles_url_mirror_skips_unset_and_empty() { + with_clean_socket_env(|| { + apply_env_toggles(&GlobalArgs::default()); + assert!(std::env::var("SOCKET_API_URL").is_err()); + assert!(std::env::var("SOCKET_PROXY_URL").is_err()); + + let blank = GlobalArgs { + api_url: Some(String::new()), + proxy_url: Some(String::new()), + ..GlobalArgs::default() + }; + apply_env_toggles(&blank); + assert!( + std::env::var("SOCKET_API_URL").is_err(), + "an empty --api-url must not be mirrored as a blank override", + ); + assert!(std::env::var("SOCKET_PROXY_URL").is_err()); + }); + } + /// `scrub_empty_env_vars` removes exactly-empty `SOCKET_*` flag vars /// (the `VAR=` blank-without-unsetting idiom) — global and local — and /// nothing else: set, non-empty values — even whitespace-only ones, diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index f0301341..7fceb3ec 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -474,7 +474,7 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus> /// files) means nothing can disqualify the variant. Mirrors the /// representative pick in core's /// [`select_installed_variants`](socket_patch_core::patch::apply::select_installed_variants). -fn representative_file( +pub(crate) fn representative_file( files: &HashMap, ) -> Option<(&String, &PatchFileInfo)> { files diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 51fb82a1..1f5fe18e 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -808,14 +808,27 @@ async fn filter_to_installed_releases( (kept, warnings) } -/// Build the API client for a download run, defaulting the override org -/// slug to the caller's `--org` when no explicit override was given. -async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::client::ApiClient { +/// The API-client overrides for a download run: the caller's CLI flags with +/// the override org slug defaulted to `--org` when none was given. +/// +/// Shared by the client built here AND by the nested `apply` step, which +/// constructs its own client and must resolve to the same endpoint/token — +/// see [`run_nested_apply`]. +fn resolved_api_overrides( + params: &DownloadParams, +) -> socket_patch_core::api::client::ApiClientEnvOverrides { let mut overrides = params.api_overrides.clone(); if overrides.org_slug.is_none() { overrides.org_slug = params.org.clone(); } - get_api_client_with_overrides(overrides).await.0 + overrides +} + +/// Build the API client for a download run. +async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::client::ApiClient { + get_api_client_with_overrides(resolved_api_overrides(params)) + .await + .0 } /// Download and apply a set of selected patches. @@ -1014,6 +1027,19 @@ async fn warn_on_vendored_uuid_drift( /// the read-only cargo-redirect verifier stays off and embedded VEX is /// opt-in on the top-level command only, never on this internal /// invocation. +/// +/// `api` carries the caller's API-client flags and is NOT optional: apply +/// builds its own clients from the `GlobalArgs` handed to it (its telemetry +/// client, and `fetch_stage`'s artifact fetcher), and those only ever see +/// this struct. Leaving the fields at their `GlobalArgs::default()` `None` +/// dropped `--api-url` / `--api-token` / `--org` / `--proxy-url` on the +/// floor, so a token supplied purely as a CLI flag fell through to env → +/// socket-cli config → the token-less public proxy. That breaks the flow +/// for real: a patch view that omits `blobContent` for a file (`Option` on +/// the wire, which is why `--download-mode diff` exists) leaves `get` with +/// no blob to write, and the nested apply must download it — with the wrong +/// client, against the wrong host. +#[allow(clippy::too_many_arguments)] async fn run_nested_apply( cwd: &Path, manifest_path: &Path, @@ -1022,6 +1048,7 @@ async fn run_nested_apply( quiet: bool, download_mode: String, strict: bool, + api: socket_patch_core::api::client::ApiClientEnvOverrides, ) -> bool { // Apply re-resolves a relative manifest path against ITS `--cwd` // (`resolved_manifest_path`), but ours is already cwd-resolved — @@ -1039,6 +1066,10 @@ async fn run_nested_apply( silent: quiet, download_mode, strict, + api_url: api.api_url, + api_token: api.api_token, + org: api.org_slug, + proxy_url: api.proxy_url, ..crate::args::GlobalArgs::default() }, force: false, @@ -1101,10 +1132,19 @@ pub async fn download_and_apply_patches( eprintln!("\nDownloading {} patch(es)...", selected.len()); } + // `patches_added` and `patches_updated` are DISJOINT — one patch lands in + // exactly one of them, matching the per-patch `action` vocabulary + // (CLI_CONTRACT.md: `added` | `updated` | ...) and the single-UUID flow's + // summary in `save_and_apply_patch`. `patches_downloaded` is their sum: + // the JSON `downloaded` / `applied` counts cover both (a replacement was + // fetched and applied just like a new record), and it gates the apply + // step. Counting an update in `patches_added` too made the human summary + // print `Added: 1` AND `Updated: 1` for the one entry it had swapped. let mut patches_added = 0; let mut patches_skipped = 0; let mut patches_failed = 0; let mut patches_updated = 0; + let mut patches_downloaded = 0; let mut downloaded_patches: Vec = Vec::new(); for search_result in &selected { @@ -1180,6 +1220,7 @@ pub async fn download_and_apply_patches( }) } _ => { + patches_added += 1; if !params.json && !params.silent { eprintln!(" [add] {}", patch.purl); } @@ -1196,7 +1237,7 @@ pub async fn download_and_apply_patches( // round-trip to the API. merge_metadata(&mut action_record, patch_event_metadata(&patch)); downloaded_patches.push(action_record); - patches_added += 1; + patches_downloaded += 1; } Ok(None) => { if !params.json && !params.silent { @@ -1268,7 +1309,7 @@ pub async fn download_and_apply_patches( // Auto-apply unless --save-only let mut apply_succeeded = false; - if !params.save_only && patches_added > 0 { + if !params.save_only && patches_downloaded > 0 { if !params.json && !params.silent { eprintln!("\nApplying patches..."); } @@ -1280,6 +1321,7 @@ pub async fn download_and_apply_patches( params.json || params.silent, params.download_mode.clone(), params.strict, + resolved_api_overrides(params), ) .await; } @@ -1290,15 +1332,15 @@ pub async fn download_and_apply_patches( // alongside a non-zero exit code misleads JSON consumers (the scan // wrapper recomputes status from the exit code for exactly this // reason, but `get` surfaces this envelope directly). - let apply_failed = !apply_succeeded && patches_added > 0 && !params.save_only; + let apply_failed = !apply_succeeded && patches_downloaded > 0 && !params.save_only; let (status, exit_code) = run_outcome(patches_failed > 0, apply_failed); let mut result_json = serde_json::json!({ "status": status, "found": selected.len(), - "downloaded": patches_added, + "downloaded": patches_downloaded, "skipped": patches_skipped, "failed": patches_failed, - "applied": if apply_succeeded { patches_added } else { 0 }, + "applied": if apply_succeeded { patches_downloaded } else { 0 }, "updated": patches_updated, "patches": downloaded_patches, }); @@ -1917,6 +1959,7 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { quiet, args.common.download_mode.clone(), args.common.strict, + args.common.api_client_overrides(), ) .await; } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 6d01c02e..8f3b07b3 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,6 +1,7 @@ use clap::Args; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::utils::socket_cli_config; use socket_patch_core::utils::telemetry::track_patch_listed; use crate::args::{apply_env_toggles, GlobalArgs}; @@ -80,6 +81,62 @@ fn build_list_envelope(manifest: &PatchManifest) -> Envelope { env } +/// Resolve the credentials the `patch_listed` telemetry event is attributed +/// to: `--api-token` / `--org` (clap already folds in `SOCKET_API_TOKEN` / +/// `SOCKET_ORG_SLUG` and their promoted `SOCKET_CLI_*` aliases), then the +/// socket-cli `config.json` written by `socket login`. +/// +/// The config layer is part of the contract for both settings ("Persisted +/// configuration" in CLI_CONTRACT.md), and +/// `telemetry::resolve_telemetry_endpoint` only uses the org-scoped +/// `/v0/orgs//telemetry` endpoint when BOTH a token and a slug reach +/// it. Passing the raw flag values here skipped the config layer, so a +/// caller authenticated by `socket login` alone had every `list` reported +/// anonymously to the public patch proxy — while `apply`/`repair`/`remove`/ +/// `rollback` (which take theirs from `get_api_client_with_overrides`) +/// reported to that caller's org. With an on-prem `apiBaseUrl` that also +/// broke the "telemetry can never target a different host than the client" +/// property, sending the event off to `patches-api.socket.dev` instead. +/// +/// The API client is deliberately NOT built to get these: `list` is a purely +/// local read, and constructing one would add the org-slug auto-resolve +/// round-trip and the "No SOCKET_API_TOKEN set" advisory to a command that +/// needs neither. Only the two credential lookups are mirrored — including +/// the `SOCKET_NO_API_TOKEN` veto over *ambient* tokens (`main` scrubs the +/// env var for the flag layer; core applies the same veto to the config +/// layer) and the `--debug` echo naming the resolution source. +pub(crate) fn telemetry_credentials(common: &GlobalArgs) -> (Option, Option) { + let api_token = common + .api_token + .clone() + .filter(|t| !t.is_empty()) + .or_else(|| { + if socket_cli_config::no_api_token_veto() { + return None; + } + socket_cli_config::load() + .and_then(|c| c.api_token.clone()) + .inspect(|_| { + if common.debug { + eprintln!( + "[socket-patch debug] api token: from socket-cli config \ + (`socket login`)" + ); + } + }) + }); + let org_slug = common.org.clone().filter(|s| !s.is_empty()).or_else(|| { + socket_cli_config::load() + .and_then(|c| c.default_org.clone()) + .inspect(|slug| { + if common.debug { + eprintln!("[socket-patch debug] org slug: `{slug}` from socket-cli config"); + } + }) + }); + (api_token, org_slug) +} + /// Emit the top-level envelope for `list` in error states. Used for the /// "manifest not found" and "manifest unreadable" paths so they share /// the same JSON shape as a successful list. @@ -112,12 +169,8 @@ pub async fn run(args: ListArgs) -> i32 { let mut patch_entries: Vec<_> = manifest.patches.iter().collect(); patch_entries.sort_by(|a, b| a.0.cmp(b.0)); let patches_count = patch_entries.len(); - track_patch_listed( - patches_count, - args.common.api_token.as_deref(), - args.common.org.as_deref(), - ) - .await; + let (api_token, org_slug) = telemetry_credentials(&args.common); + track_patch_listed(patches_count, api_token.as_deref(), org_slug.as_deref()).await; if args.common.json { println!("{}", build_list_envelope(&manifest).to_pretty_json()); @@ -417,6 +470,45 @@ mod tests { assert_eq!(paths, vec!["z/a.js", "z/b.js"]); } + // -- Telemetry credential resolution --------------------------------- + // The socket-cli `config.json` layer is exercised end-to-end (it is read + // once per process, so it needs a subprocess) by + // `tests/cli_config_fallback.rs::list_telemetry_follows_socket_cli_login`. + // These pin the two layers above it, which need no fixture. + + /// Explicit values — the flag, or the env var clap folds into the same + /// field — are used verbatim, never overridden by a lower layer. + #[test] + fn telemetry_credentials_prefer_explicit_values() { + let common = GlobalArgs { + api_token: Some("sktsec_flag_api".to_string()), + org: Some("flag-org".to_string()), + ..GlobalArgs::default() + }; + assert_eq!( + telemetry_credentials(&common), + ( + Some("sktsec_flag_api".to_string()), + Some("flag-org".to_string()) + ) + ); + } + + /// Empty means "unset" repo-wide, so an empty value must never be + /// forwarded: `Some("")` would build a malformed `/v0/orgs//telemetry` + /// URL and an empty `Bearer ` header. + #[test] + fn telemetry_credentials_treat_empty_as_unset() { + let common = GlobalArgs { + api_token: Some(String::new()), + org: Some(String::new()), + ..GlobalArgs::default() + }; + let (api_token, org_slug) = telemetry_credentials(&common); + assert_ne!(api_token.as_deref(), Some("")); + assert_ne!(org_slug.as_deref(), Some("")); + } + #[test] fn ordering_is_deterministic_across_builds() { // Two independent builds of the same manifest must be byte-identical. diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 07f295bc..8354c4e1 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -160,8 +160,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let _first = acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); - let code = acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO) - .unwrap_err(); + let code = + acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap_err(); assert_eq!(code, 1); } diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 15bc6646..fe0d136c 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -653,7 +653,26 @@ async fn rollback_patches_inner( ) .await; - if all_packages.is_empty() { + // Local-redirect rollback (local-mode go) drops a project-local redirect + // and reads nothing out of the ecosystem's package store, so — unlike an + // in-place restore — it must NOT depend on the crawler finding the package + // there. A directory `replace` makes go skip downloading the replaced + // module entirely, so a clone of a repo that committed `go.mod` + + // `.socket/go-patches/` + `.socket/manifest.json` (the documented golang + // workflow) has no module-cache copy for discovery to find. Without this + // fallback the redirect silently survived the rollback: `rollback` + // reported success while the build kept linking the patched copy, and + // `remove` (which delegates here) then deleted the manifest record, + // leaving an active patch nothing tracks. Scoped to `scoped_manifest` so + // `--ecosystems` still applies. + let undiscovered_redirects: Vec = scoped_manifest + .patches + .keys() + .filter(|purl| is_local_redirect(purl, &args.common) && !all_packages.contains_key(*purl)) + .cloned() + .collect(); + + if all_packages.is_empty() && undiscovered_redirects.is_empty() { if !args.common.silent && !args.common.json { println!("No packages found that match patches to rollback"); } @@ -755,6 +774,33 @@ async fn rollback_patches_inner( } } + // Redirects the crawler never saw (see `undiscovered_redirects` above): + // roll the redirect back from the manifest alone. `package_path` is the + // project root — what gets dropped is the `go.mod` directive + the + // project-local copy, not anything under a package store. + for purl in &undiscovered_redirects { + let Some(patch) = scoped_manifest.patches.get(purl) else { + continue; + }; + let Some(result) = try_rollback_local_go(purl, &args.common.cwd, patch, &args.common).await + else { + continue; + }; + if !result.success { + has_errors = true; + // Errors print even under --silent — same contract as the + // in-place loop above. + if !args.common.json { + eprintln!( + "Failed to rollback {}: {}", + purl, + result.error.as_deref().unwrap_or("unknown error") + ); + } + } + results.push(result); + } + Ok((!has_errors, results, vendored_skipped)) } @@ -1365,6 +1411,187 @@ mod tests { ); } + /// Regression: a local-GO rollback must NOT depend on the module still + /// sitting in the Go module cache. A directory `replace` makes go skip the + /// download of the replaced module entirely, so on a fresh clone of a repo + /// that committed `go.mod` + `.socket/go-patches/` + `.socket/manifest.json` + /// (the documented golang workflow) the cache holds no copy of the module — + /// the crawler finds nothing and the redirect rollback was skipped + /// altogether. `rollback` (and `remove`, which delegates here) then reported + /// success while leaving the `replace` directive + patched copy in place, so + /// the build kept linking patched bytes — for `remove`, with the manifest + /// record deleted, i.e. an active patch nothing tracks. + #[tokio::test] + async fn rollback_drops_local_go_redirect_when_module_cache_has_no_copy() { + use socket_patch_core::patch::go_mod_edit::{ + ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, + }; + + // A module path no real module cache can hold. + const MODULE: &str = "github.com/socket-patch-test/never-cached"; + const VERSION: &str = "v1.4.2"; + const PURL: &str = "pkg:golang/github.com/socket-patch-test/never-cached@v1.4.2"; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write( + root.join("go.mod"), + format!("module myproj\n\ngo 1.21\n\nrequire {MODULE} {VERSION}\n"), + ) + .await + .unwrap(); + assert!( + ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, false) + .await + .unwrap(), + "fixture must install a socket-owned replace" + ); + let copy_dir = root + .join(GO_PATCHES_DIR) + .join(format!("{MODULE}@{VERSION}")); + tokio::fs::create_dir_all(©_dir).await.unwrap(); + tokio::fs::write(copy_dir.join("errors.go"), b"// patched\n") + .await + .unwrap(); + + let mut patches = HashMap::new(); + patches.insert( + PURL.to_string(), + record_with_file("uuid-go", "errors.go", "go_before"), + ); + let manifest = PatchManifest { + patches, + setup: None, + }; + let socket = root.join(".socket"); + tokio::fs::create_dir_all(&socket).await.unwrap(); + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()) + .await + .unwrap(); + + // `--offline`: the redirect rollback reads no blobs, so it must not + // need the network either. + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let (success, results, _vendored) = rollback_patches( + &common, + &manifest_path, + None, + false, // dry_run + true, // silent + Some(vec!["golang".to_string()]), + ) + .await + .expect("rollback must not error"); + + assert!(success, "local-go redirect rollback must succeed"); + assert_eq!( + results.len(), + 1, + "the local-go redirect must be rolled back even though the module \ + cache holds no copy of the module, got {results:?}" + ); + assert!( + results[0] + .files_rolled_back + .contains(&"errors.go".to_string()), + "the patched file must be reported rolled back, got {:?}", + results[0].files_rolled_back + ); + assert!( + read_replace_entries(root) + .await + .iter() + .all(|e| !(e.module == MODULE && e.socket_owned())), + "socket-owned replace directive must be dropped" + ); + assert!( + !copy_dir.exists(), + "patched copy under .socket/go-patches must be removed" + ); + } + + /// The undiscovered-redirect fallback must stay scoped: a local-go PURL + /// filtered out by `--ecosystems` must not be rolled back behind the + /// filter's back. + #[tokio::test] + async fn undiscovered_local_go_redirect_respects_ecosystem_filter() { + use socket_patch_core::patch::go_mod_edit::{ + ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, + }; + + const MODULE: &str = "github.com/socket-patch-test/never-cached"; + const VERSION: &str = "v1.4.2"; + const PURL: &str = "pkg:golang/github.com/socket-patch-test/never-cached@v1.4.2"; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write( + root.join("go.mod"), + format!("module myproj\n\ngo 1.21\n\nrequire {MODULE} {VERSION}\n"), + ) + .await + .unwrap(); + assert!( + ensure_replace_entry(root, MODULE, VERSION, GO_PATCHES_DIR, false) + .await + .unwrap() + ); + let copy_dir = root + .join(GO_PATCHES_DIR) + .join(format!("{MODULE}@{VERSION}")); + tokio::fs::create_dir_all(©_dir).await.unwrap(); + + let mut patches = HashMap::new(); + patches.insert( + PURL.to_string(), + record_with_file("uuid-go", "errors.go", "go_before"), + ); + let manifest = PatchManifest { + patches, + setup: None, + }; + let socket = root.join(".socket"); + tokio::fs::create_dir_all(&socket).await.unwrap(); + let manifest_path = socket.join("manifest.json"); + tokio::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()) + .await + .unwrap(); + + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; + let (success, results, _vendored) = rollback_patches( + &common, + &manifest_path, + None, + false, + true, + Some(vec!["npm".to_string()]), // golang out of scope + ) + .await + .expect("rollback must not error"); + assert!(success); + assert!( + results.is_empty(), + "golang is out of scope — nothing may be rolled back, got {results:?}" + ); + assert!( + read_replace_entries(root) + .await + .iter() + .any(|e| e.module == MODULE && e.socket_owned()), + "an out-of-scope redirect must survive" + ); + assert!(copy_dir.exists(), "an out-of-scope copy must survive"); + } + // --- Before-blob gate `--ecosystems` scoping -------------------------- // // Twin of apply's (fixed) "offline guard unscoped" bug: the gate must diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 5a47bfdc..666fec4e 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -190,7 +190,21 @@ pub(super) fn detect_updates( }; let mut updates = Vec::new(); for pkg in packages { - let Some(existing) = manifest.patches.get(&pkg.purl) else { + // Manifest keys are written verbatim from the *patch* purl, which + // the API serves percent-encoded (`pkg:npm/%40scope/...`) and, for + // artifact-pinned ecosystems, qualified (`?artifact_id=...`); the + // batch *package* purl is the crawler's literal spelling. Bridge + // both divergences like the lockfile-only partition does: exact hit + // first, then a normalized qualifier-stripped comparison. + let existing = manifest.patches.get(&pkg.purl).or_else(|| { + let want = normalize_purl(strip_purl_qualifiers(&pkg.purl)); + manifest + .patches + .iter() + .find(|(k, _)| normalize_purl(strip_purl_qualifiers(k)) == want) + .map(|(_, v)| v) + }); + let Some(existing) = existing else { continue; }; // The candidate is the top-ranked patch — the one the apply path diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index b707e561..77926ea4 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -271,6 +271,15 @@ pub(super) fn print_gc_vendored_line(gc: &GcSummary) { /// crawler purls carry the literal `@scope` — comparing the raw strings /// would make every encoded scoped entry look prunable and `--prune`/ /// `--sync` would GC the very patch it just downloaded. +/// +/// Entries the crawl never even looked for are exempt too +/// (`crawl_covers_purl`): the runtime-gated maven/nuget crawlers with their +/// gate off, and any `pkg:/` this build has no crawler for. The +/// manifest is a committed, shared file, so a newer CLI's ecosystem can +/// legitimately appear in it — "absent from the crawl" then says nothing +/// about whether the package is installed, and pruning would silently +/// delete a teammate's patch (plus its blobs). Same fail-safe reasoning as +/// capturing `scanned_purls` before the `--ecosystems` filter. fn detect_prunable( manifest: &PatchManifest, scanned_purls: &HashSet, @@ -288,6 +297,7 @@ fn detect_prunable( !scanned_bases.contains(base.as_ref()) && !vendored.contains(p.as_str()) && !vendored.contains(strip_purl_qualifiers(p)) + && crate::ecosystem_dispatch::crawl_covers_purl(p.as_str()) }) .cloned() .collect() @@ -424,6 +434,64 @@ mod tests { assert_eq!(out, vec!["pkg:npm/%40scope/x@1.0.0".to_string()]); } + #[test] + fn detect_prunable_keeps_entries_of_uncrawled_ecosystems() { + // A manifest key whose `pkg:/` this build has no crawler for + // (a newer CLI's ecosystem in a COMMITTED manifest, read by an older + // binary) is never looked for by the crawl, so its absence from + // `scanned_purls` says nothing about whether it is installed. + // Pruning it silently deletes a teammate's patch (plus its blobs) + // from the shared manifest. + let m = manifest_with(&[ + ("pkg:hex/plug@1.14.0", "uuid-a"), + ("pkg:npm/gone@1.0.0", "uuid-b"), + ]); + let out = detect_prunable(&m, &scanned(&[]), &no_vendored()); + assert_eq!( + out, + vec!["pkg:npm/gone@1.0.0".to_string()], + "only the crawled-ecosystem orphan may prune; got {out:?}" + ); + } + + #[test] + fn detect_prunable_keeps_runtime_gated_ecosystem_entries() { + // Maven/NuGet crawlers only run under their experimental opt-in + // env gates, so with the gate OFF their packages are invisible to + // the crawl — "absent" must not mean "uninstalled". (An ambient + // opt-in makes them genuinely crawled, which is a different + // scenario; skip rather than mutate the shared process env.) + let m = manifest_with(&[ + ("pkg:maven/com.example/lib@1.0.0", "uuid-a"), + ("pkg:nuget/Some.Package@1.0.0", "uuid-b"), + ("pkg:npm/gone@1.0.0", "uuid-c"), + ]); + // Mirror `ecosystem_dispatch::env_truthy` exactly — the gate opens + // only on `1`/`true` (any case), so a merely-PRESENT but falsy + // `SOCKET_EXPERIMENTAL_MAVEN=0` leaves the ecosystem uncrawled and + // therefore exempt. Testing presence instead of truthiness made this + // expectation disagree with the code under test and fail spuriously. + let gate_open = |name: &str| { + std::env::var(name) + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + }; + let mut expected = vec!["pkg:npm/gone@1.0.0".to_string()]; + if gate_open("SOCKET_EXPERIMENTAL_MAVEN") { + expected.push("pkg:maven/com.example/lib@1.0.0".to_string()); + } + if gate_open("SOCKET_EXPERIMENTAL_NUGET") { + expected.push("pkg:nuget/Some.Package@1.0.0".to_string()); + } + expected.sort(); + let mut out = detect_prunable(&m, &scanned(&[]), &no_vendored()); + out.sort(); + assert_eq!( + out, expected, + "a runtime-gated ecosystem that was never crawled must not prune" + ); + } + #[test] fn detect_prunable_exempts_qualified_variant_of_vendored_base() { // The ledger key set carries qualifier-stripped bases (see diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e3fff04e..19dcdfe7 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -25,6 +25,11 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "Cargo.toml", "Cargo.lock", ".cargo/config.toml", + // The LEGACY extensionless spelling: cargo reads `.cargo/config` in + // preference to `config.toml` when both exist, so the rewriter must see + // it (it wires the managed registry into whichever one is present) — + // otherwise the `[registries.…]` block lands in a file cargo ignores. + ".cargo/config", "composer.lock", "nuget.config", "packages.lock.json", @@ -83,7 +88,12 @@ pub(super) async fn run_redirect( .await { Ok(s) => s, - Err(code) => return code, + // Hosted mode has no discovery envelope to fold the message into at + // this point (it builds its `redirect` result further down) and its + // other bail-outs — e.g. the reference resolve below — report on + // stderr the same way. `discover_selected` already printed the + // message; behavior here is unchanged. + Err((code, _message)) => return code, }; let mut skipped: Vec = Vec::new(); @@ -498,8 +508,14 @@ pub(super) async fn run_redirect( confirmed.len(), rewritten.len() ); + // Human output prints the bare strings — `Value`'s `Display` + // would JSON-quote them (`skipped "pkg:npm/x" ("forbidden")`). for s in &skipped { - eprintln!(" skipped {} ({})", s["purl"], s["reason"]); + eprintln!( + " skipped {} ({})", + s["purl"].as_str().unwrap_or_default(), + s["reason"].as_str().unwrap_or_default() + ); } // Same warning set as the JSON envelope, same order: the // rewriter's own warnings first (e.g. `no package-lock.json`), @@ -508,13 +524,13 @@ pub(super) async fn run_redirect( eprintln!(" warning: {}", w.detail); } for w in &record_warnings { - eprintln!(" warning: {}", w["detail"]); + eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } for w in &migration_warnings { - eprintln!(" warning: {}", w["detail"]); + eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } for w in &rush_warnings { - eprintln!(" warning: {}", w["detail"]); + eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } if let Some(statements) = vex_statements { eprintln!( diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 9643f7fa..7305f7e0 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -345,13 +345,16 @@ async fn embed_vex_human( /// with the failure on stderr. Passes `is_json = false` to /// `select_patches`: scan-driven workflows have no "specify --id" option, /// so non-TTY runs auto-select the newest patch rather than erroring with -/// `selection_required`. `Err` carries the exit code. +/// `selection_required`. `Err` carries the exit code AND the message: the +/// JSON callers must fold it into their envelope (every `--json` +/// invocation emits exactly one JSON object — see CLI_CONTRACT.md), so +/// the stderr line alone is not enough. async fn discover_selected( api_client: &socket_patch_core::api::client::ApiClient, org_slug: Option<&str>, packages: &[BatchPackagePatches], can_access_paid_patches: bool, -) -> Result, i32> { +) -> Result, (i32, String)> { let mut all_search_results: Vec = Vec::new(); let mut error_count = 0usize; let mut last_error: Option = None; @@ -369,13 +372,26 @@ async fn discover_selected( } if error_count > 0 && error_count == packages.len() { let err = last_error.unwrap_or_else(|| "all patch-detail queries failed".to_string()); - eprintln!("Error: all {error_count} patch-detail queries failed: {err}"); - return Err(1); + let message = format!("all {error_count} patch-detail queries failed: {err}"); + eprintln!("Error: {message}"); + return Err((1, message)); } if all_search_results.is_empty() { return Ok(Vec::new()); } select_patches(&all_search_results, can_access_paid_patches, false) + .map_err(|code| (code, "patch selection failed".to_string())) +} + +/// Fold a [`discover_selected`] failure into a JSON caller's `result` and +/// print it. The discovery counts already in `result` stay — they were +/// computed from the (successful) batch phase — while `status`/`error` +/// mirror the all-batches-failed envelope so JSON consumers see one +/// consistent scan-error schema instead of empty stdout. +fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { + result["status"] = serde_json::json!("error"); + result["error"] = serde_json::json!(message); + println!("{}", serde_json::to_string_pretty(result).unwrap()); } /// The `DownloadParams` every scan-driven download shares. Only the output @@ -907,7 +923,10 @@ pub async fn run(mut args: ScanArgs) -> i32 { .await { Ok(s) => s, - Err(code) => return code, + Err((code, message)) => { + emit_discovery_error_json(&mut result, &message); + return code; + } }; // Vendor-owned purls are skipped BEFORE download (any uuid); @@ -1061,13 +1080,24 @@ pub async fn run(mut args: ScanArgs) -> i32 { if !args.common.silent { println!("\nNo patches available for installed packages."); } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + // Vendored mode still has work to do on an empty discovery: the + // committed manifest is re-vendored wholesale, which is how a + // fresh clone (or a wiped `.socket/vendor/`) gets its artifacts + // back. The JSON arm states this outright — "the vendor step + // still runs when zero patches were downloaded (re-vendor after a + // wipe)" — and `selected.is_empty() && !vendor` below encodes the + // same rule; without this the interactive arm never reaches it. + if !vendor { + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } } // The whole table + summary section is presentational only (nothing // computed inside is consumed downstream), so `--silent` skips it - // wholesale. - if !args.common.silent { + // wholesale — as does an empty discovery, which vendored mode now + // falls through with (an all-header, no-row table plus a "0 package(s)" + // summary is noise, not information). + if !args.common.silent && !all_packages_with_patches.is_empty() { let mut updates_available = 0usize; // Canonical set of PURLs with a newer patch available, computed once via @@ -1225,14 +1255,21 @@ pub async fn run(mut args: ScanArgs) -> i32 { }; if downloadable_count == 0 { - if !args.common.silent { + // The paid-plan nudge only makes sense when the API DID return + // patches; with an empty discovery (vendored mode falls through + // the guard above) there is no gated catalog to point at. + if !args.common.silent && !all_packages_with_patches.is_empty() { println!("\nNo downloadable patches (paid subscription required)."); } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + // Same reason as above: vendored mode re-vendors the committed + // manifest regardless of what discovery turned up. + if !vendor { + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } } // Fetch full PatchSearchResult for each package that has patches - if show_progress { + if show_progress && !all_packages_with_patches.is_empty() { eprint!("\nFetching patch details..."); } @@ -1260,11 +1297,15 @@ pub async fn run(mut args: ScanArgs) -> i32 { } } - if show_progress { + if show_progress && !all_packages_with_patches.is_empty() { eprintln!(); } - if all_search_results.is_empty() { + // Empty details are a failure only when there WERE packages to fetch + // details for. Vendored mode now reaches here with nothing discovered + // (see the two guards above) and must fall through to the vendor step + // rather than report a fetch failure that never happened. + if all_search_results.is_empty() && !all_packages_with_patches.is_empty() { eprintln!("Could not fetch patch details."); return 1; } diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index c96da68c..13de4149 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -23,7 +23,9 @@ use crate::commands::vendor::{ use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; -use super::{discover_selected, download_params, embed_vex_into_json, ScanArgs}; +use super::{ + discover_selected, download_params, embed_vex_into_json, emit_discovery_error_json, ScanArgs, +}; /// Dry-run preview for `scan --vendor`: classify each selected patch /// against the vendor ledger without touching disk or the network beyond @@ -175,7 +177,10 @@ async fn run_vendor_json_path( .await { Ok(s) => s, - Err(code) => return code, + Err((code, message)) => { + emit_discovery_error_json(result, &message); + return code; + } }; if args.common.dry_run { diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 7322b970..b16e920c 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -209,10 +209,17 @@ fn eco_in_scope(common: &GlobalArgs, names: &[&str]) -> bool { } } -/// Normalize a workspace-member / exclude path for comparison: forward slashes, -/// no leading `./`, no trailing slash. +/// Normalize a workspace-member / exclude path for comparison: trimmed, +/// forward slashes, no leading `./`, no trailing slash. +/// +/// The trim is load-bearing for the CSV spellings of `--exclude`: clap splits +/// `--exclude "packages/a, packages/b"` (and `SOCKET_SETUP_EXCLUDE=a, b`, the +/// idiomatic CI-YAML form) on the comma only, so the second value arrives with +/// a leading space. Untrimmed it matches no member — the exclusion silently +/// does nothing — and the unmatchable spelling is then persisted into +/// `.socket/manifest.json`, where every later run and every clone inherits it. fn normalize_rel_path(p: &str) -> String { - let p = p.replace('\\', "/"); + let p = p.trim().replace('\\', "/"); let p = p.strip_prefix("./").unwrap_or(&p); p.trim_end_matches('/').to_string() } @@ -236,7 +243,15 @@ fn is_member_excluded(manifest_path: &Path, cwd: &Path, excludes: &[String]) -> if rel.is_empty() { return false; } - excludes.iter().any(|e| normalize_rel_path(e) == rel) + excludes.iter().any(|e| { + let e = normalize_rel_path(e); + // An exclusion covers the named directory AND everything below it. The + // walk finds nested manifests (`tools/inner/package.json`, and members + // of a member that is itself a workspace root), and those lie *inside* + // the excluded member — an exact-match-only test wired install hooks + // into a subtree the user asked setup to keep out of. + !e.is_empty() && (rel == e || rel.starts_with(&format!("{e}/"))) + }) } /// The exclude set in effect for this run: the persisted `setup.exclude` list @@ -734,8 +749,27 @@ async fn finalize_gem(common: &GlobalArgs) -> Vec { } }; let root = common.cwd.display().to_string(); + // Forward the manifest location: the nested `apply` re-resolves a relative + // `--manifest-path` against ITS OWN `--cwd`, so hand it the absolutized, + // already-cwd-resolved path (the `get::run_nested_apply` rule). Dropping + // the flag made this run read the default `.socket/manifest.json`, so a + // project whose patches live anywhere else materialized nothing here — + // silently, since a missing manifest is a clean exit-0 no-op for `apply`. + let manifest = common.resolved_manifest_path(); + let manifest = std::path::absolute(&manifest).unwrap_or(manifest); + let manifest = manifest.display().to_string(); match tokio::process::Command::new(&exe) - .args(["apply", "--offline", "--ecosystems", "gem", "--cwd", &root, "--silent"]) + .args([ + "apply", + "--offline", + "--ecosystems", + "gem", + "--cwd", + &root, + "--manifest-path", + &manifest, + "--silent", + ]) .output() .await { @@ -1494,10 +1528,19 @@ async fn run_setup(args: &SetupArgs) -> i32 { composer_present, npm_pm, ); + // Attribute the event through the same layered credential chain as every + // other command — flag / env / socket-cli `config.json` — not the raw flag + // values. `setup` builds no API client (it is a purely local edit), so the + // config layer has to be consulted explicitly, exactly as `list` does: + // otherwise a caller authenticated by `socket login` alone reports + // anonymously to the public patch proxy, which with an on-prem + // `apiBaseUrl` also sends the event to a different host than the one the + // client would talk to. + let (telemetry_token, telemetry_org) = crate::commands::list::telemetry_credentials(common); track_patch_setup( &telemetry_manager, - common.api_token.as_deref(), - common.org.as_deref(), + telemetry_token.as_deref(), + telemetry_org.as_deref(), ) .await; diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index ea20a199..e8892531 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -16,7 +16,7 @@ use socket_patch_core::update::{ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::lock_cli::error_envelope; -use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent}; +use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, RunWarning}; use crate::output; /// The target triple this binary was compiled for, embedded by `build.rs`. @@ -77,9 +77,32 @@ fn fail(args: &UpdateArgs, code: &str, message: &str) -> i32 { 1 } +/// Record a non-fatal advisory: stderr for humans, `warnings[]` on the +/// envelope for machines. `--json` suppresses the stderr line (stdout is +/// the machine channel and stderr must stay clean), so a warning that only +/// ever went to stderr would vanish entirely for JSON consumers — the +/// managed-install override in particular is the "your package manager +/// will silently revert this" signal, and a silent override is the bug +/// class the channel suite exists to catch. Same stderr-or-envelope +/// split `vendor`/`remove` use for their run-level advisories (the +/// rendered stderr line keeps update's own `Warning: ` wording). +fn note_warning(warnings: &mut Vec, quiet: bool, code: &str, detail: String) { + if !quiet { + eprintln!("Warning: {detail}"); + } + warnings.push(RunWarning { + code: code.to_string(), + detail, + }); +} + pub async fn run(args: UpdateArgs) -> i32 { apply_env_toggles(&args.common); let quiet = args.common.json || args.common.silent; + // Advisories collected as the run proceeds; attached to whichever + // envelope is emitted (the managed-install one lands long before the + // envelope exists). + let mut warnings: Vec = Vec::new(); // 1. Offline gate first — strict airgap refuses before any client // exists, and --force does not bypass it (matching scan/get). @@ -100,13 +123,16 @@ pub async fn run(args: UpdateArgs) -> i32 { let channel = detect_channel(&install_path, &ChannelEnv::from_env()); if channel != InstallChannel::Standalone { if args.force { - if !quiet { - eprintln!( - "Warning: this install is managed by {} — its next upgrade will overwrite \ + note_warning( + &mut warnings, + quiet, + "managed_install_override", + format!( + "this install is managed by {} — its next upgrade will overwrite \ the updated binary.", channel_label(channel) - ); - } + ), + ); } else { return fail( &args, @@ -183,6 +209,7 @@ pub async fn run(args: UpdateArgs) -> i32 { "path": install_path.display().to_string(), })), ); + env.warnings = warnings; println!("{}", env.to_pretty_json()); } else if !args.common.silent { println!("{msg}"); @@ -210,6 +237,7 @@ pub async fn run(args: UpdateArgs) -> i32 { "latest": target_version.to_string(), })), ); + env.warnings = warnings; println!("{}", env.to_pretty_json()); } else if !args.common.silent { println!("{msg}"); @@ -250,10 +278,8 @@ pub async fn run(args: UpdateArgs) -> i32 { } }; - if !quiet { - for warning in &outcome.warnings { - eprintln!("Warning: {warning}"); - } + for warning in &outcome.warnings { + note_warning(&mut warnings, quiet, "update_warning", warning.clone()); } if args.common.json { @@ -273,6 +299,7 @@ pub async fn run(args: UpdateArgs) -> i32 { "target": UPDATE_TARGET, })), ); + env.warnings = warnings; println!("{}", env.to_pretty_json()); } else if !args.common.silent { println!( diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 09ea79de..03310205 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -36,7 +36,7 @@ use std::path::Path; use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; -use crate::commands::apply::{result_to_event, variant_matches_installed}; +use crate::commands::apply::{representative_file, result_to_event, variant_matches_installed}; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; @@ -321,15 +321,27 @@ pub async fn run(args: VendorArgs) -> i32 { // Same lock as apply/rollback: vendor mutates the same lockfiles and // `.socket/` tree, so a separate lock would allow an apply↔vendor race. - let _lock = match acquire_or_emit( - &socket_dir, - Command::Vendor, - args.common.json, - args.common.dry_run, - Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), - ) { - Ok(guard) => guard, - Err(code) => return code, + // + // The lock file lives INSIDE `.socket/`, and `acquire` creates the file + // but never its parent. `--revert` skipped the manifest check above, so + // it is the one path that can reach here with no `.socket/` dir at all — + // the documented clean no-op ("a missing ledger is an empty ledger"). + // Locking first would turn that into a `lock_io` failure, so skip it: + // with no `.socket/` there is no ledger to read and nothing to write, + // hence nothing to serialize against. + let _lock = if args.revert && tokio::fs::metadata(&socket_dir).await.is_err() { + None + } else { + match acquire_or_emit( + &socket_dir, + Command::Vendor, + args.common.json, + args.common.dry_run, + Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), + ) { + Ok(guard) => Some(guard), + Err(code) => return code, + } }; let mut env = Envelope::new(Command::Vendor); @@ -883,7 +895,12 @@ pub(crate) async fn vendor_records( let probe_applicable = is_variant_eco && !matches!(Ecosystem::from_purl(candidate), Some(Ecosystem::Maven)); if probe_applicable && !force { - let first = match record.files.iter().next() { + // The representative must be a file that MODIFIES existing + // content: a new file (empty beforeHash) verifies `Ready` + // against any environment, so it can neither identify nor + // disqualify a variant. Same deterministic pick as apply / + // core's `select_installed_variants`. + let first = match representative_file(&record.files) { Some((f, info)) => Some(verify_file_patch(pkg_path, f, info).await.status), None => None, }; @@ -1431,12 +1448,133 @@ mod dispatch_tests { .await; // The backend itself may refuse (nothing is installed in the // fixture) — the gate just must not be what stops it. - match outcome { - Some(VendorOutcome::Refused { code, .. }) => assert_ne!( + if let Some(VendorOutcome::Refused { code, .. }) = outcome { + assert_ne!( code, "vendor_service_unsupported_ecosystem", "maven has a service backend; the dispatch gate must admit it" - ), - _ => {} + ); + } + } +} + +#[cfg(test)] +mod variant_probe_tests { + use super::*; + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + use socket_patch_core::manifest::schema::PatchFileInfo; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const WHEEL: &str = "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl"; + const SDIST: &str = "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0.tar.gz"; + + fn record(files: &[(&str, &str, &str)]) -> PatchRecord { + PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files: files + .iter() + .map(|(name, before, after)| { + ( + (*name).to_string(), + PatchFileInfo { + before_hash: (*before).to_string(), + after_hash: (*after).to_string(), + }, + ) + }) + .collect(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + /// The release-variant probe must never pick a NEW file (empty + /// `beforeHash`) as the representative that decides whether a variant + /// describes the installed distribution: a new file verifies `Ready` + /// against *any* environment, so it can neither identify nor + /// disqualify a variant. + /// + /// Fixture: an installed wheel of `foo@1.0.0` (its `foo/__init__.py` + /// matches the wheel variant's `beforeHash`) plus a manifest sdist + /// variant that is NOT installed — it patches `setup.py` (absent from + /// the wheel install → `NotFound`) and adds one new file. With the + /// representative taken from `HashMap::iter().next()` the sdist's new + /// file comes up first roughly half the time, `Ready` admits the + /// not-installed variant, and `vendor` attempts to vendor it — the + /// same nondeterminism that was fixed in core's + /// `select_installed_variants` and in `apply`'s variant loop. + #[tokio::test] + async fn variant_probe_never_picks_a_new_file_as_representative() { + let tmp = tempfile::tempdir().unwrap(); + let site = tmp.path().join("site-packages"); + tokio::fs::create_dir_all(site.join("foo-1.0.0.dist-info")) + .await + .unwrap(); + tokio::fs::write( + site.join("foo-1.0.0.dist-info").join("METADATA"), + "Name: foo\nVersion: 1.0.0\n", + ) + .await + .unwrap(); + tokio::fs::create_dir_all(site.join("foo")).await.unwrap(); + let installed = b"print('hi')\n"; + tokio::fs::write(site.join("foo").join("__init__.py"), installed) + .await + .unwrap(); + let before = compute_git_sha256_from_bytes(installed); + let elsewhere = compute_git_sha256_from_bytes(b"setup(name='foo')\n"); + let after = compute_git_sha256_from_bytes(b"patched\n"); + + let common = GlobalArgs { + cwd: tmp.path().to_path_buf(), + // Aim the pypi crawler at the fixture site-packages: hermetic, + // and no real interpreter needed. + global_prefix: Some(site.clone()), + ecosystems: Some(vec!["pypi".to_string()]), + dry_run: true, + offline: true, + json: true, + silent: true, + ..GlobalArgs::default() + }; + let sources = PatchSources { + blobs_path: tmp.path(), + packages_path: None, + diffs_path: None, + mem_blobs: None, + }; + + // `HashMap` iteration order is randomized per instance, so build a + // fresh `records` map (and hence fresh per-record `files` maps) + // every round. + for round in 0..32 { + let mut records: HashMap = HashMap::new(); + records.insert( + WHEEL.to_string(), + record(&[("foo/__init__.py", &before, &after)]), + ); + records.insert( + SDIST.to_string(), + record(&[ + // Sorts before `setup.py`, so a lex-only representative + // pick would still be caught. + ("aaa_added_by_the_sdist.py", "", &after), + ("setup.py", &elsewhere, &after), + ]), + ); + + let mut env = Envelope::new(Command::Vendor); + vendor_records(&common, &records, &sources, false, false, &mut env, None).await; + + assert!( + !env.events.iter().any(|e| e.purl.as_deref() == Some(SDIST)), + "round {round}: the sdist variant is not the installed distribution \ + (its only discriminating file, setup.py, is absent) — vendor must not \ + act on it; events: {:?}", + env.events + ); } } } diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 969a75e8..ae5d8b5f 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -414,9 +414,13 @@ async fn generate_vex( // Build the document. let opts = BuildOptions { product_id, + // Same "empty means unset" rule as the product override above: the + // document `@id` is a required field with no `skip_serializing_if`, + // so `--doc-id "$UNSET_VAR"` emitted a literal `"@id": ""`. doc_id: params .doc_id .clone() + .filter(|d| !d.trim().is_empty()) .unwrap_or_else(|| format!("urn:uuid:{}", uuid::Uuid::new_v4())), author: "Socket".to_string(), tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))), @@ -582,7 +586,14 @@ async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGe /// Pick the product PURL from an explicit override or by filesystem /// auto-detect. async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Result { - if let Some(p) = product { + // An empty (or whitespace-only) override means "unset" — the semantics + // `scrub_empty_env_vars` already gives the `SOCKET_VEX_PRODUCT=` twin and + // `api_client_overrides` gives `--api-url ""`. Without the filter, + // `--product "$UNSET_VAR"` sailed through to `BuildOptions::product_id`, + // and `Product::id` is `skip_serializing_if = "String::is_empty"` — so the + // run wrote a spec-invalid document whose statements claim `not_affected` + // about a product carrying NO identifier at all, and exited 0. + if let Some(p) = product.filter(|p| !p.trim().is_empty()) { return Ok(p.to_string()); } let detect = detect_product(&common.cwd).await; diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 0ec9e392..d2b44ea4 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -61,6 +61,22 @@ fn env_truthy(name: &str) -> bool { .unwrap_or(false) } +/// Whether [`crawl_all_ecosystems`] actually visits this PURL's ecosystem +/// in THIS process. Maven and NuGet sit behind runtime opt-in gates, and an +/// unrecognized `pkg:/` (a newer CLI's ecosystem in a committed +/// manifest) has no crawler at all — for those, absence from the crawl +/// carries no information about whether the package is installed. Callers +/// that read "not in the crawl" as "no longer installed" (scan's prune GC) +/// must not judge them. +pub fn crawl_covers_purl(purl: &str) -> bool { + match Ecosystem::from_purl(purl) { + Some(Ecosystem::Maven) => maven_runtime_enabled(), + Some(Ecosystem::Nuget) => nuget_runtime_enabled(), + Some(_) => true, + None => false, + } +} + /// Partition PURLs by ecosystem, filtering by the `--ecosystems` flag if set. pub fn partition_purls( purls: &[String], diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index c843d6ae..ff44743f 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -154,12 +154,40 @@ pub fn parse_with_uuid_fallback(argv: Vec) -> Result { // (including its errors) is surfaced; anywhere else a genuine // rewrite failure falls back to the original error, mirroring // the UUID shortcut below. - if let Some(pos) = argv.iter().skip(1).position(|a| a == "--update") { - let pos = pos + 1; // undo the skip(1) offset + // + // `--` ends the option list, so only a `--update` before it is + // the flag — after it the token is an escaped operand and the + // original error stands. + let opts_end = argv + .iter() + .skip(1) + .position(|a| a == "--") + .map_or(argv.len(), |i| i + 1); + // Both spellings clap gives a value-taking long flag are + // recognized: the space form (`--update 3.4.0`, whose VERSION + // reaches the synthesized subcommand as its positional) and the + // inline `--update=3.4.0`, whose value is spliced in where the + // flag token was. Without the inline arm clap rejects it as + // "unexpected value '3.4.0' for '--update'" — the root flag is a + // bool, so the `=` form never reaches the VERSION at all. + let update_flag = argv + .iter() + .enumerate() + .take(opts_end) + .skip(1) + .find_map(|(i, a)| match a.strip_prefix("--update") { + Some("") => Some((i, None)), + Some(rest) => rest.strip_prefix('=').map(|v| (i, Some(v))), + None => None, + }); + if let Some((pos, inline_version)) = update_flag { let mut new_args = Vec::with_capacity(argv.len() + 1); new_args.push(argv[0].clone()); new_args.push("self-update".to_string()); new_args.extend_from_slice(&argv[1..pos]); + if let Some(version) = inline_version { + new_args.push(version.to_string()); + } new_args.extend_from_slice(&argv[pos + 1..]); return match Cli::try_parse_from(&new_args) { Ok(cli) => Ok(cli), @@ -513,19 +541,14 @@ mod tests { fn update_flag_is_position_independent() { // The flag needn't come first: every other arg is preserved in // order around the dropped `--update` token. - let cli = - parse_with_uuid_fallback(argv(&["socket-patch", "--json", "--update"])).unwrap(); + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "--json", "--update"])).unwrap(); match cli.command { Commands::SelfUpdate(args) => assert!(args.common.json), _ => panic!("expected Commands::SelfUpdate"), } - let cli = parse_with_uuid_fallback(argv(&[ - "socket-patch", - "--update", - "--force", - "--silent", - ])) - .unwrap(); + let cli = + parse_with_uuid_fallback(argv(&["socket-patch", "--update", "--force", "--silent"])) + .unwrap(); match cli.command { Commands::SelfUpdate(args) => { assert!(args.force); @@ -586,6 +609,86 @@ mod tests { assert!(err.to_string().contains("self-update"), "{err}"); } + #[test] + fn update_flag_accepts_the_inline_equals_version() { + // `--update ` is what the flag's own help advertises, and + // clap accepts `--flag=value` for every value-taking long flag. But + // the root `--update` is a `bool`, so clap rejects the `=` spelling + // outright — "unexpected value '3.4.0' for '--update' found; no more + // were expected", i.e. "this flag takes no value at all". The VERSION + // only exists on the synthesized subcommand, so the rewrite has to + // recognize `--update=` itself. + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "--update=3.4.0"])).unwrap(); + match cli.command { + Commands::SelfUpdate(args) => assert_eq!(args.pin_version.as_deref(), Some("3.4.0")), + _ => panic!("expected Commands::SelfUpdate"), + } + } + + #[test] + fn update_inline_equals_version_normalizes_and_keeps_neighbours() { + // Same leading-`v` normalization as the space form, and the inline + // value is spliced in where the flag token was, so the args on either + // side keep both their order and their meaning. + let cli = parse_with_uuid_fallback(argv(&[ + "socket-patch", + "--json", + "--update=v3.4.0", + "--force", + ])) + .unwrap(); + match cli.command { + Commands::SelfUpdate(args) => { + assert_eq!(args.pin_version.as_deref(), Some("3.4.0")); + assert!(args.common.json, "--json before the flag must survive"); + assert!(args.force, "--force after the flag must survive"); + } + _ => panic!("expected Commands::SelfUpdate"), + } + } + + #[test] + fn update_inline_equals_garbage_version_is_a_usage_error() { + // The inline form validates its VERSION exactly like the space form: + // a usage error naming the bad value (exit 2), never a silent + // fall-through to a latest-release install. + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "--update=latest"])) { + Ok(_) => panic!("expected parse to fail"), + Err(e) => e, + }; + assert!(err.use_stderr()); + assert_eq!(err.exit_code(), 2); + assert!(err.to_string().contains("not a valid version"), "{err}"); + } + + #[test] + fn update_after_a_double_dash_is_an_operand_not_the_flag() { + // `--` ends the option list, so a following `--update` is an escaped + // operand. The root command takes no positional, so this is a usage + // error — it must NOT be rewritten into a binary-replacing + // self-update. (`socket-patch list -- --update` already errors, only + // because the rewrite happens to fail there; the root form has to + // fail for the right reason.) + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "--", "--update"])) { + Ok(_) => panic!("`--update` after `--` is an operand, not the flag"), + Err(e) => e, + }; + assert!(err.use_stderr(), "a usage error, not a display request"); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn double_dash_after_the_update_flag_still_rewrites() { + // Counter-guard for the test above: only a `--` that PRECEDES the + // flag ends the option list, so `--update -- 3.4.0` still pins. + let cli = + parse_with_uuid_fallback(argv(&["socket-patch", "--update", "--", "3.4.0"])).unwrap(); + match cli.command { + Commands::SelfUpdate(args) => assert_eq!(args.pin_version.as_deref(), Some("3.4.0")), + _ => panic!("expected Commands::SelfUpdate"), + } + } + #[test] fn root_help_documents_the_update_flag() { let err = match parse_with_uuid_fallback(argv(&["socket-patch", "--help"])) { @@ -594,7 +697,10 @@ mod tests { }; assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); let help = err.to_string(); - assert!(help.contains("--update"), "root help must advertise --update"); + assert!( + help.contains("--update"), + "root help must advertise --update" + ); assert!( !help.contains("self-update"), "the internal subcommand stays hidden from root help" diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs index 1eb06a0c..f6be28c7 100644 --- a/crates/socket-patch-cli/src/update_notifier.rs +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -120,7 +120,9 @@ fn in_ci() -> bool { if !ci.is_empty() && !matches!(ci.trim().to_ascii_lowercase().as_str(), "0" | "false") { return true; } - !std::env::var("GITHUB_ACTIONS").unwrap_or_default().is_empty() + !std::env::var("GITHUB_ACTIONS") + .unwrap_or_default() + .is_empty() } impl GuardCtx { @@ -295,19 +297,30 @@ pub async fn finish(notifier: Option) { } let now = core_update::unix_now(); if !core_update::notice_is_due(notifier.last_notified_at, now) { - debug_log(notifier.debug, "update pending but notice already shown today"); + debug_log( + notifier.debug, + "update pending but notice already shown today", + ); return; } eprintln!( "{}", - format_notice(¤t, &latest, upgrade_command(), output::stderr_is_tty()) + format_notice( + ¤t, + &latest, + upgrade_command(), + output::stderr_is_tty() + ) ); let mut state = core_update::load_state(); state.last_notified_at = Some(now); if let Err(e) = core_update::save_state(&state).await { - debug_log(notifier.debug, &format!("could not persist notice time: {e}")); + debug_log( + notifier.debug, + &format!("could not persist notice time: {e}"), + ); } } @@ -332,7 +345,8 @@ mod tests { fn guard_precedence_table() { // (mutation, expected outcome) — the full precedence contract in // one table. e2e spot-checks a subset of rows end-to-end. - let cases: &[(&str, fn(&mut GuardCtx), Result<(), SkipReason>)] = &[ + type GuardCase = (&'static str, fn(&mut GuardCtx), Result<(), SkipReason>); + let cases: &[GuardCase] = &[ ("all open", |_| {}, Ok(())), ("opt-out", |c| c.opted_out = true, Err(SkipReason::OptedOut)), ( diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index 08afa4bf..fbb3c554 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -509,3 +509,56 @@ fn apply_with_no_socket_dir_silent_emits_nothing() { "non-silent no-manifest run must print the skip message; got {loud_stdout:?}" ); } + +/// Regression: only a genuine NotFound means "no `.socket/` set up". Any +/// other stat error — an unreadable `.socket/` (root-owned directory, +/// restrictive ACL), a plain file where `.socket/` should be, a symlink +/// loop — used to take the very same `status: noManifest` / exit-0 path. +/// A project whose patches were never even read then reported "nothing to +/// apply", which the install hook and CI both read as success. Fail closed. +#[cfg(unix)] +#[test] +#[ignore = "RED: apply's manifest probe is `tokio::fs::metadata(..).is_err()`, so \ + an UNREADABLE manifest is reported as the clean `noManifest` no-op \ + (exit 0) exactly like a missing one — the install hook and CI both \ + read that as success. The fail-closed fix was not part of this change."] +fn apply_with_unreadable_socket_dir_fails_closed() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_project(tmp.path()); + let socket = tmp.path().join(".socket"); + let manifest = socket.join("manifest.json"); + let restore = || { + let _ = std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)); + }; + + // Drop the search bit: the manifest is still there, apply just cannot + // stat it. + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o000)) + .expect("chmod .socket"); + // In-test control: as root (or on a filesystem that ignores mode bits) + // the stat still succeeds and there is no fail-open case to exercise. + if std::fs::metadata(&manifest).is_ok() { + restore(); + eprintln!("SKIP: .socket/ still readable after chmod 000 (running as root?)"); + return; + } + + let (code, stdout) = run_apply(tmp.path(), &[]); + restore(); + + assert_ne!( + code, 0, + "an unreadable manifest must not report success; stdout=\n{stdout}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope must be valid JSON"); + assert_ne!( + v["status"], "noManifest", + "\"cannot read\" is not \"not set up\"; envelope: {v}" + ); + assert_eq!( + v["error"]["code"], "manifest_unreadable", + "expected the manifest_unreadable envelope error; envelope: {v}" + ); +} diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index db60c7d7..14f4eff6 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -875,3 +875,115 @@ async fn offline_apply_with_token_makes_zero_network_requests() { mock server saw: {hits:?}" ); } + +// --------------------------------------------------------------------------- +// Failed downloads must not condemn patches that already have a local source. +// --------------------------------------------------------------------------- + +/// Write a `.socket/packages/.tar.gz` carrying `entries`. +fn write_package_archive(packages: &Path, uuid: &str, entries: &[(&str, &[u8])]) { + use std::io::Write as _; + std::fs::create_dir_all(packages).expect("create packages dir"); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + std::fs::File::create(packages.join(format!("{uuid}.tar.gz"))).unwrap(), + flate2::Compression::default(), + )); + for (name, bytes) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, name, *bytes).unwrap(); + } + builder + .into_inner() + .unwrap() + .finish() + .unwrap() + .flush() + .unwrap(); +} + +/// A cached `.socket/packages/.tar.gz` is a complete source for the +/// patch: the same tree applies fine under `--offline`. Going online must +/// not make it FAIL — but the stage step used to bail whenever the +/// (default) diff fetch and the blob fallback both reported failures, +/// without checking whether any patch was actually left without a source. +/// A server that serves no archives and no longer has the blob (GC'd, +/// entitlement change, dead network) therefore turned a fully satisfiable +/// apply into a whole-run abort. +#[tokio::test] +#[ignore = "RED: a transient fetch failure still aborts the whole run even when \ + `.socket/packages/.tar.gz` already satisfies the apply. The \ + cache-fallback fix was not part of this change."] +async fn apply_online_uses_cached_package_archive_when_downloads_fail() { + let before = b"pkgcache before\n"; + let after = b"pkgcache after\n"; + let before_hash = git_sha256(before); + let after_hash = git_sha256(after); + let uuid = "44444444-4444-4444-8444-444444444444"; + + // Nothing is served: diff, package and blob endpoints all 404 (wiremock + // default for unmounted routes), i.e. every download attempt fails. + let mock = MockServer::start().await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "pkgcache", "1.0.0", "index.js", before); + let socket = tmp.path().join(".socket"); + write_manifest_with_patch( + &socket, + "pkg:npm/pkgcache@1.0.0", + uuid, + &before_hash, + &after_hash, + ); + // The only local source: a package archive holding the patched bytes + // (what `repair --download-mode package` leaves behind). No blobs. + write_package_archive( + &socket.join("packages"), + uuid, + &[("package/index.js", after)], + ); + + let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); + assert_eq!( + code, 0, + "a cached package archive is a usable source; failed downloads for \ + artifacts we don't need must not abort the run; \ + stdout={stdout}\nstderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!( + v["summary"]["applied"], 1, + "the patch must apply from the cached package archive; stdout={stdout}" + ); + assert_eq!(v["summary"]["failed"], 0, "stdout={stdout}"); + + // The patched bytes came from the archive. + let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap(); + assert_eq!(content, after, "file must carry the patched content"); + + // Keep the test honest: the downloads really were attempted and really + // did fail (otherwise this would pass for the wrong reason). + let requests = mock.received_requests().await.unwrap_or_default(); + let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"); + assert!( + requests.iter().any(|r| r.url.path() == blob_path), + "the (404ing) blob fetch must have been attempted; got {:?}", + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() + ); + + // Apply stays read-only against the persistent cache. + let blobs_dir = socket.join("blobs"); + if blobs_dir.exists() { + let entries: Vec<_> = std::fs::read_dir(&blobs_dir).unwrap().collect(); + assert!( + entries.is_empty(), + "apply must not write to .socket/blobs/; found {entries:?}" + ); + } +} diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs index ddf9713c..ea23e17c 100644 --- a/crates/socket-patch-cli/tests/cli_config_fallback.rs +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -415,3 +415,231 @@ async fn missing_config_is_silent() { ); assert!(out.stderr.contains(PROXY_NOTICE)); } + +// --------------------------------------------------------------------------- +// `list` — the config layer must reach the commands that only fire telemetry +// --------------------------------------------------------------------------- + +/// An empty manifest, so `list` exits 0 without needing anything but the +/// telemetry call under test. +fn write_empty_manifest(root: &Path) { + let dir = root.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("manifest.json"), r#"{ "patches": {} }"#).unwrap(); +} + +/// `socket-patch list` against `project`, with the same hermetic env as +/// [`scan_cmd`]: every ambient `SOCKET_*` scrubbed, the data dir pointed at +/// the fixture, the config layer re-enabled, telemetry off by default. +fn list_cmd(project: &Path, data_dir: &Path) -> Command { + let mut cmd = Command::new(BINARY); + cmd.arg("list").arg("--cwd").arg(project); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + cmd.env(DATA_DIR_VAR, data_dir); + cmd.env("SOCKET_NO_CONFIG", "0"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // The scrub above also dropped the workspace-level notifier guard; keep + // the passive update check off so the only request a run can make is the + // telemetry POST under test. + cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); + cmd +} + +/// `list` fires `patch_listed`, and that event must be attributed to the +/// caller's org like every other command's. The credentials come from the +/// same layered chain as client construction — flag / env / socket-cli +/// `config.json` — so a caller authenticated by `socket login` alone POSTs +/// to `/v0/orgs//telemetry` on the config `apiBaseUrl`, never to the +/// anonymous public proxy. +/// +/// Regression: `list` handed the tracker its raw `--api-token` / `--org` +/// flag values, skipping the config layer entirely (`apply` / `repair` / +/// `remove` / `rollback` resolve theirs through +/// `get_api_client_with_overrides`). A `socket login` user's every `list` +/// was therefore reported anonymously — and, with an on-prem `apiBaseUrl`, +/// to the public Socket proxy instead of their own host. The `scan` twin is +/// `config_default_org_skips_auto_resolve_and_telemetry_follows`. +#[tokio::test] +async fn list_telemetry_follows_socket_cli_login() { + let server = MockServer::start().await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_manifest(project.path()); + write_config( + data.path(), + &serde_json::json!({ + "apiToken": token('c'), + "apiBaseUrl": server.uri(), + "defaultOrg": "cfg-org" + }), + ); + + let mut cmd = list_cmd(project.path(), data.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "0"); + // Pin the anonymous fallback at the fixture too, so a run that skips the + // config layer is caught by the assertions below instead of escaping to + // the real public proxy. + cmd.env("SOCKET_PROXY_URL", server.uri()); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + + let reqs = server.received_requests().await.unwrap_or_default(); + let paths: Vec<&str> = reqs.iter().map(|r| r.url.path()).collect(); + let telemetry = reqs + .iter() + .find(|r| r.url.path() == "/v0/orgs/cfg-org/telemetry") + .unwrap_or_else(|| { + panic!( + "`list` telemetry must POST to the org endpoint resolved from the \ + socket-cli login; requests seen: {paths:?}" + ) + }); + assert_eq!( + telemetry + .headers + .get("authorization") + .map(|v| v.to_str().unwrap_or_default().to_string()) + .as_deref(), + Some(format!("Bearer {}", token('c')).as_str()), + "list telemetry must carry the config token" + ); + assert!( + !paths.contains(&"/patch/telemetry"), + "list must not report anonymously when a login is configured; \ + requests seen: {paths:?}" + ); +} + +/// The veto still wins for `list`: `SOCKET_NO_API_TOKEN` suppresses the +/// *ambient* config token, so the event falls back to the anonymous proxy +/// rather than authenticating off a login the user asked to be ignored. +#[tokio::test] +async fn list_telemetry_honors_no_api_token_veto() { + let server = MockServer::start().await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_empty_manifest(project.path()); + write_config( + data.path(), + &serde_json::json!({ + "apiToken": token('c'), + "apiBaseUrl": server.uri(), + "defaultOrg": "cfg-org" + }), + ); + + let mut cmd = list_cmd(project.path(), data.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "0"); + cmd.env("SOCKET_NO_API_TOKEN", "1"); + cmd.env("SOCKET_PROXY_URL", server.uri()); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + + let reqs = server.received_requests().await.unwrap_or_default(); + let paths: Vec<&str> = reqs.iter().map(|r| r.url.path()).collect(); + assert!( + paths.contains(&"/patch/telemetry"), + "a vetoed run must report to the anonymous proxy; requests seen: {paths:?}" + ); + assert!( + !paths.contains(&"/v0/orgs/cfg-org/telemetry"), + "SOCKET_NO_API_TOKEN must veto the config token for telemetry too; \ + requests seen: {paths:?}" + ); +} + +// --------------------------------------------------------------------------- +// `setup` — the other command that only fires telemetry +// --------------------------------------------------------------------------- + +/// `socket-patch setup --json --yes` against `project`, with the same hermetic +/// env as [`list_cmd`]. `--json` also skips the confirmation prompt. +fn setup_cmd(project: &Path, data_dir: &Path) -> Command { + let mut cmd = Command::new(BINARY); + cmd.args(["setup", "--json", "--yes", "--cwd"]).arg(project); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + cmd.env(DATA_DIR_VAR, data_dir); + cmd.env("SOCKET_NO_CONFIG", "0"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); + cmd +} + +/// `setup` fires `patch_setup`, and — like `list` — it builds no API client, so +/// it has to consult the socket-cli config layer itself. A caller +/// authenticated by `socket login` alone must have the event POSTed to +/// `/v0/orgs//telemetry` on the config `apiBaseUrl`, never anonymously +/// to the public proxy. +/// +/// Regression: `setup` handed the tracker its raw `--api-token` / `--org` flag +/// values (both `None` here), so the event went out unauthenticated to +/// `patches-api.socket.dev` — a different host than the one the caller's +/// client talks to, which for an on-prem `apiBaseUrl` egresses the event +/// entirely. Exact twin of `list_telemetry_follows_socket_cli_login`. +#[tokio::test] +async fn setup_telemetry_follows_socket_cli_login() { + let server = MockServer::start().await; + let data = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + // A plain npm project: setup has real work to do, so telemetry fires. + write_empty_project(project.path()); + write_config( + data.path(), + &serde_json::json!({ + "apiToken": token('c'), + "apiBaseUrl": server.uri(), + "defaultOrg": "cfg-org" + }), + ); + + let mut cmd = setup_cmd(project.path(), data.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "0"); + // Pin the anonymous fallback at the fixture too, so a run that skips the + // config layer is caught here instead of escaping to the real proxy. + cmd.env("SOCKET_PROXY_URL", server.uri()); + let out = run(cmd); + assert_eq!(out.code, Some(0), "stderr:\n{}", out.stderr); + assert!( + std::fs::read_to_string(project.path().join("package.json")) + .unwrap() + .contains("socket-patch"), + "control: the run must actually have configured the project, or the \ + telemetry event under test never fires" + ); + + let reqs = server.received_requests().await.unwrap_or_default(); + let paths: Vec<&str> = reqs.iter().map(|r| r.url.path()).collect(); + let telemetry = reqs + .iter() + .find(|r| r.url.path() == "/v0/orgs/cfg-org/telemetry") + .unwrap_or_else(|| { + panic!( + "`setup` telemetry must POST to the org endpoint resolved from \ + the socket-cli login; requests seen: {paths:?}" + ) + }); + assert_eq!( + telemetry + .headers + .get("authorization") + .map(|v| v.to_str().unwrap_or_default().to_string()) + .as_deref(), + Some(format!("Bearer {}", token('c')).as_str()), + "setup telemetry must carry the config token" + ); + assert!( + !paths.contains(&"/patch/telemetry"), + "setup must not report anonymously when a login is configured; \ + requests seen: {paths:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_sigpipe.rs b/crates/socket-patch-cli/tests/cli_sigpipe.rs index 2be3188c..caf99ee2 100644 --- a/crates/socket-patch-cli/tests/cli_sigpipe.rs +++ b/crates/socket-patch-cli/tests/cli_sigpipe.rs @@ -30,8 +30,7 @@ fn closed_stdout_pipe_is_not_a_panic() { let dir = tempfile::tempdir().expect("tempdir"); let socket = dir.path().join(".socket"); std::fs::create_dir_all(&socket).expect("create .socket"); - std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#) - .expect("write manifest"); + std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); // Build a pipe and close the read end BEFORE the child spawns, so the // child's first stdout write hits EPIPE deterministically (piping to a diff --git a/crates/socket-patch-cli/tests/common/update_fixture.rs b/crates/socket-patch-cli/tests/common/update_fixture.rs index 3ba4de9c..e40b9b70 100644 --- a/crates/socket-patch-cli/tests/common/update_fixture.rs +++ b/crates/socket-patch-cli/tests/common/update_fixture.rs @@ -70,7 +70,9 @@ pub struct StagedInstall { } pub fn sha256_file(p: &Path) -> String { - hex::encode(Sha256::digest(std::fs::read(p).expect("read file for hashing"))) + hex::encode(Sha256::digest( + std::fs::read(p).expect("read file for hashing"), + )) } fn real_binary() -> PathBuf { @@ -293,7 +295,11 @@ impl FakeRelease { } pub async fn received_request_count(&self) -> usize { - self.server.received_requests().await.unwrap_or_default().len() + self.server + .received_requests() + .await + .unwrap_or_default() + .len() } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs index 180a3389..cb0683dc 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs @@ -411,6 +411,7 @@ fn fresh_checkout_npm_ci(fx: &RedirectFixture) -> (PathBuf, Output) { // multi_thread: the CLI/npm subprocesses block a worker thread while wiremock // keeps serving the API + tarball routes on the others. #[tokio::test(flavor = "multi_thread")] +#[ignore = "wall-bound real-npm install (~150s); runs on all 3 OSes as an e2e CI matrix leg"] async fn npm_redirect_fresh_checkout_npm_ci_installs_patched_bytes_and_vex_verifies() { let Some(fx) = redirect_scanned_project("main", false).await else { return; @@ -478,6 +479,7 @@ async fn npm_redirect_fresh_checkout_npm_ci_installs_patched_bytes_and_vex_verif /// install. This is what makes the redirect safe to commit: a compromised or /// swapped hosted artifact cannot slip past the pin. #[tokio::test(flavor = "multi_thread")] +#[ignore = "wall-bound real-npm install (~150s); runs on all 3 OSes as an e2e CI matrix leg"] async fn npm_redirect_tampered_hosted_tarball_fails_fresh_npm_ci() { let Some(fx) = redirect_scanned_project("tampered", true).await else { return; diff --git a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs index c39cb457..06e02f97 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs @@ -322,6 +322,7 @@ fn simulate_rush_install(root: &Path, store: &Path) -> Output { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] +#[ignore = "wall-bound real-rush/pnpm install (~70s); runs on all 3 OSes as an e2e CI matrix leg"] async fn rush_hosted_scan_then_simulated_pnpm_install_lands_patched_bytes() { if !has_corepack_pm("pnpm@9") { println!("SKIP e2e_redirect_rush_sim: `corepack pnpm@9` unavailable"); @@ -386,6 +387,7 @@ async fn rush_hosted_scan_then_simulated_pnpm_install_lands_patched_bytes() { /// integrity check. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] +#[ignore = "wall-bound real-rush/pnpm install (~70s); runs on all 3 OSes as an e2e CI matrix leg"] async fn rush_hosted_tampered_tarball_fails_simulated_install() { if !has_corepack_pm("pnpm@9") { println!("SKIP e2e_redirect_rush_sim (tampered): `corepack pnpm@9` unavailable"); @@ -446,6 +448,7 @@ async fn rush_hosted_tampered_tarball_fails_simulated_install() { /// default; set `RUSH_E2E=1` to opt in. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] +#[ignore = "wall-bound real-rush/pnpm install (~70s); runs on all 3 OSes as an e2e CI matrix leg (tier-2 RUSH_E2E=1 self-skip unchanged)"] async fn rush_hosted_real_rush_update_install() { if std::env::var("RUSH_E2E").as_deref() != Ok("1") { println!("SKIP e2e_redirect_rush_sim: set RUSH_E2E=1 to run the real-rush tier-2 leg"); diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index 7dae2416..0d37431c 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -21,7 +21,7 @@ mod common; use common::{ assert_run_ok, envelope_error_code, envelope_error_message, git_sha256, json_string, - parse_json_envelope, run, write_blob, write_minimal_manifest, PatchEntry, + parse_json_envelope, run, run_with_env, write_blob, write_minimal_manifest, PatchEntry, }; const PURL: &str = "pkg:npm/dummy@1.0.0"; @@ -492,3 +492,328 @@ fn synthetic_manifest_is_discovered_by_cli() { "list should surface our synthetic manifest entry (purl + uuid).\nenvelope: {env}" ); } + +// ── refusal scope ──────────────────────────────────────────────────────────── +// +// The refusal above is about ONE thing: the packages this run would patch +// live inside `.yarn/cache/*.zip` instead of the project's `node_modules`. +// It must therefore only fire for runs that actually crawl `--cwd`'s +// `node_modules` for an npm patch. The three tests below pin the runs it +// must NOT hijack — each was exit-1 / `yarn_pnp_unsupported` / +// nothing-patched before the scope fix — followed by the control proving +// the refusal itself still fires. + +/// Regression: a `--global-prefix` (or `--global`) apply patches a +/// completely different tree — the global npm prefix — so `--cwd`'s PnP +/// markers say nothing about what it will touch. The detector ran +/// unconditionally against `--cwd`, so running global-mode apply from +/// inside any PnP checkout refused and patched nothing. +#[test] +#[ignore = "RED: apply::run calls detect_npm_pkg_manager(&args.common.cwd) \ + unconditionally, so the yarn_pnp_unsupported refusal is not scoped to \ + runs that actually touch npm packages. The scoping fix was not part of \ + this change (see pnp_project_still_refuses_when_an_npm_patch_is_in_scope \ + for the counter-guard, which stays live)."] +fn global_prefix_apply_is_not_refused_from_a_pnp_cwd() { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + + // `.socket/` (manifest + after-blob) lives in the PnP checkout — that's + // what `--cwd` resolves — while the package to patch lives in the + // "global" tree `--global-prefix` points at. + let socket = dir.path().join(".socket"); + let before_hash = git_sha256(ORIGINAL_BYTES); + let after_hash = git_sha256(PATCHED_BYTES); + write_minimal_manifest( + &socket, + PURL, + UUID, + &[PatchEntry { + file_name: "package/index.js", + before_hash: &before_hash, + after_hash: &after_hash, + }], + ); + write_blob(&socket, &after_hash, PATCHED_BYTES); + + let global_root = dir.path().join("global-node-modules"); + let pkg = global_root.join("dummy"); + std::fs::create_dir_all(&pkg).expect("create global dummy dir"); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"dummy","version":"1.0.0"}"#, + ) + .expect("write global package.json"); + let index = pkg.join("index.js"); + std::fs::write(&index, ORIGINAL_BYTES).expect("write global index.js"); + + let (code, stdout, stderr) = run( + dir.path(), + &[ + "apply", + "--json", + "--offline", + "--global-prefix", + global_root.to_str().unwrap(), + ], + ); + let env = parse_json_envelope(&stdout); + assert_ne!( + envelope_error_code(&env), + Some("yarn_pnp_unsupported"), + "a --global-prefix run never crawls the PnP checkout's node_modules, so the \ + cwd's layout must not refuse it.\nenvelope: {env}\nstderr:\n{stderr}" + ); + // Non-vacuous: the global copy must actually be patched, so a fix that + // merely swallowed the refusal (and then patched nothing) still fails. + assert_eq!( + code, 0, + "global-prefix apply should run to completion.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + json_string(&env, "status"), + Some("success"), + "global-prefix apply should report success.\nenvelope: {env}" + ); + assert_eq!( + env.get("summary") + .and_then(|s| s.get("applied")) + .and_then(|v| v.as_u64()), + Some(1), + "global-prefix apply should patch exactly the one global package.\nenvelope: {env}" + ); + assert_eq!( + std::fs::read(&index).unwrap(), + PATCHED_BYTES, + "the global tree's copy must carry the patched bytes" + ); +} + +const PY_PURL: &str = "pkg:pypi/dummypkg@1.0.0"; +const PY_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const PY_ORIGINAL_BYTES: &[u8] = b"def f():\n return 'before'\n"; +const PY_PATCHED_BYTES: &[u8] = b"def f():\n return 'after'\n"; + +/// Synthetic venv `site-packages` path for `cwd` — no real python needed: +/// the crawler globs `.venv/lib/python3.*/site-packages` (unix) or +/// `.venv/Lib/site-packages` (Windows). +fn site_packages_dir(cwd: &Path) -> PathBuf { + if cfg!(windows) { + cwd.join(".venv").join("Lib").join("site-packages") + } else { + cwd.join(".venv") + .join("lib") + .join("python3.11") + .join("site-packages") + } +} + +/// Stage an installed pypi distribution matching [`PY_PURL`] in a synthetic +/// venv under `cwd`: the patch target at its ORIGINAL bytes plus the +/// `.dist-info/METADATA` the crawler resolves name@version from. Returns the +/// path to the patch target. Pair with a staged after-blob for a fully +/// offline-appliable pypi patch. +fn stage_applicable_pypi_package(cwd: &Path) -> PathBuf { + let site_packages = site_packages_dir(cwd); + std::fs::create_dir_all(site_packages.join("dummypkg")).expect("create dummypkg dir"); + let py_file = site_packages.join("dummypkg").join("__init__.py"); + std::fs::write(&py_file, PY_ORIGINAL_BYTES).expect("write __init__.py"); + let dist_info = site_packages.join("dummypkg-1.0.0.dist-info"); + std::fs::create_dir_all(&dist_info).expect("create dist-info"); + std::fs::write( + dist_info.join("METADATA"), + "Metadata-Version: 2.1\nName: dummypkg\nVersion: 1.0.0\n\nSynthetic fixture.\n", + ) + .expect("write METADATA"); + py_file +} + +/// One manifest patch record, shaped like `write_minimal_manifest`'s but +/// composable so a manifest can carry two ecosystems at once. +fn patch_record(uuid: &str, file: &str, before: &str, after: &str) -> serde_json::Value { + let mut files = serde_json::Map::new(); + files.insert( + file.to_string(), + serde_json::json!({ "beforeHash": before, "afterHash": after }), + ); + serde_json::json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": files, + "vulnerabilities": {}, + "description": "synthetic test patch", + "license": "MIT", + "tier": "free", + }) +} + +/// Manifest carrying BOTH the npm patch of [`stage_applicable_package`] and +/// a pypi patch, i.e. the polyglot repo shape (JS frontend + python +/// backend). Overwrites the single-patch manifest that helper wrote. +fn write_polyglot_manifest(socket_dir: &Path, py_before: &str, py_after: &str) { + let mut patches = serde_json::Map::new(); + patches.insert( + PURL.to_string(), + patch_record( + UUID, + "package/index.js", + &git_sha256(ORIGINAL_BYTES), + &git_sha256(PATCHED_BYTES), + ), + ); + patches.insert( + PY_PURL.to_string(), + patch_record(PY_UUID, "dummypkg/__init__.py", py_before, py_after), + ); + std::fs::write( + socket_dir.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .expect("write polyglot manifest.json"); +} + +/// Regression: a polyglot repo (yarn-berry PnP frontend + a python venv) +/// running `apply --ecosystems pypi` never looks at `node_modules` at all, +/// so the PnP layout is irrelevant — yet the unconditional detector refused +/// the whole command, leaving the perfectly appliable pypi patch unapplied. +#[test] +#[ignore = "RED: same unscoped yarn_pnp_unsupported refusal — a pypi-only apply in \ + a PnP checkout is refused even though no npm package is involved."] +fn non_npm_ecosystem_apply_is_not_refused_in_a_pnp_project() { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + // The npm side: installed, patchable, and deliberately OUT of scope — + // asserted untouched below so the ecosystems filter stays honest. + let index = stage_applicable_package(dir.path()); + + let socket = dir.path().join(".socket"); + let py_before = git_sha256(PY_ORIGINAL_BYTES); + let py_after = git_sha256(PY_PATCHED_BYTES); + write_polyglot_manifest(&socket, &py_before, &py_after); + write_blob(&socket, &py_after, PY_PATCHED_BYTES); + let py_file = stage_applicable_pypi_package(dir.path()); + + // An ambient VIRTUAL_ENV would send the crawler to the developer's own + // venv instead of this fixture's; blank it for the child. + let (code, stdout, stderr) = run_with_env( + dir.path(), + &["apply", "--json", "--offline", "--ecosystems", "pypi"], + &[("VIRTUAL_ENV", "")], + ); + let env = parse_json_envelope(&stdout); + assert_ne!( + envelope_error_code(&env), + Some("yarn_pnp_unsupported"), + "an --ecosystems pypi run never touches node_modules, so the PnP layout must \ + not refuse it.\nenvelope: {env}\nstderr:\n{stderr}" + ); + assert_eq!( + code, 0, + "the pypi patch is fully appliable offline.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + env.get("summary") + .and_then(|s| s.get("applied")) + .and_then(|v| v.as_u64()), + Some(1), + "exactly the one in-scope pypi patch should apply.\nenvelope: {env}" + ); + assert_eq!( + std::fs::read(&py_file).unwrap(), + PY_PATCHED_BYTES, + "the site-packages file must carry the patched bytes" + ); + // The out-of-scope npm patch must NOT have been applied — otherwise this + // test would pass for the wrong reason (npm crawled after all). + assert_eq!( + std::fs::read(&index).unwrap(), + ORIGINAL_BYTES, + "the npm patch was filtered out by --ecosystems and must stay unapplied" + ); +} + +/// The same scope bug without any `--ecosystems` flag — the realistic +/// shape, since almost nobody passes one: a repo whose manifest carries +/// only non-npm patches (python backend) but whose frontend is yarn-berry +/// PnP. No npm patch is in scope, so `node_modules` is never crawled and +/// the PnP layout is irrelevant; the unconditional detector refused the +/// whole command anyway. +#[test] +#[ignore = "RED: same unscoped yarn_pnp_unsupported refusal — a PnP project whose \ + manifest holds no npm patches has its other ecosystems' patches \ + refused too."] +fn pnp_project_with_no_npm_patches_still_applies_its_other_patches() { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + + let socket = dir.path().join(".socket"); + let py_before = git_sha256(PY_ORIGINAL_BYTES); + let py_after = git_sha256(PY_PATCHED_BYTES); + // pypi ONLY — no npm patch anywhere in the manifest. + write_minimal_manifest( + &socket, + PY_PURL, + PY_UUID, + &[PatchEntry { + file_name: "dummypkg/__init__.py", + before_hash: &py_before, + after_hash: &py_after, + }], + ); + write_blob(&socket, &py_after, PY_PATCHED_BYTES); + let py_file = stage_applicable_pypi_package(dir.path()); + + let (code, stdout, stderr) = run_with_env( + dir.path(), + &["apply", "--json", "--offline"], + &[("VIRTUAL_ENV", "")], + ); + let env = parse_json_envelope(&stdout); + assert_ne!( + envelope_error_code(&env), + Some("yarn_pnp_unsupported"), + "with no npm patch in the manifest the PnP layout is irrelevant and must not \ + refuse the run.\nenvelope: {env}\nstderr:\n{stderr}" + ); + assert_eq!( + code, 0, + "the pypi patch is fully appliable offline.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read(&py_file).unwrap(), + PY_PATCHED_BYTES, + "the site-packages file must carry the patched bytes" + ); +} + +/// Control for the two tests above: an in-scope npm patch in the SAME +/// polyglot manifest still refuses. Without this, scoping the detector down +/// to nothing at all would leave every positive test in this file passing +/// only because they happen to use npm-only manifests. +#[test] +fn pnp_project_still_refuses_when_an_npm_patch_is_in_scope() { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + let index = stage_applicable_package(dir.path()); + let py_before = git_sha256(PY_ORIGINAL_BYTES); + let py_after = git_sha256(PY_PATCHED_BYTES); + write_polyglot_manifest(&dir.path().join(".socket"), &py_before, &py_after); + + let (code, stdout, stderr) = run(dir.path(), &["apply", "--json"]); + assert_eq!( + code, 1, + "an in-scope npm patch in a PnP checkout must still refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_json_envelope(&stdout); + assert_eq!( + envelope_error_code(&env), + Some("yarn_pnp_unsupported"), + "the refusal must still fire for the npm patch.\nenvelope: {env}" + ); + assert_no_work_done(&env); + assert_eq!( + std::fs::read(&index).unwrap(), + ORIGINAL_BYTES, + "the refusal must still be a pre-apply bail" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index c9da9be3..ba1a7340 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -630,6 +630,123 @@ fn auto_detect_uses_package_json() { ); } +// ────────────────────────────────────────────────────────────────────── +// empty overrides mean "unset" +// +// `--product ""` / `--doc-id ""` is what `--product "$PRODUCT"` collapses +// to when the variable is unset — the everyday CI shape. The env twins +// (`SOCKET_VEX_PRODUCT=` / `SOCKET_VEX_DOC_ID=`) already mean "unset": +// `scrub_empty_env_vars` drops exactly-empty flag vars before clap runs. +// The flags must agree, because the failure is SILENT: `Product::id` is +// `skip_serializing_if = "String::is_empty"`, so a blank product emits a +// product object with no identifier at all, and a blank doc id emits +// `"@id": ""` — both spec-invalid documents, written with exit 0. +// ────────────────────────────────────────────────────────────────────── + +/// Fixture for the two tests below: a package.json for product auto-detect +/// plus a one-patch manifest. Returns the PURL auto-detect must produce. +fn scaffold_autodetect_project(cwd: &Path) -> &'static str { + std::fs::write( + cwd.join("package.json"), + r#"{"name":"my-app","version":"7.7.7"}"#, + ) + .unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/x@1.0.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + "a".repeat(64).as_str(), + "b".repeat(64).as_str(), + "GHSA-empty-override", + &["CVE-EMPTY"], + ), + ); + write_manifest(cwd, &manifest); + "pkg:npm/my-app@7.7.7" +} + +#[test] +fn empty_product_flag_falls_back_to_auto_detect() { + // Whitespace-only is the same class: `--product " "` would serialize a + // blank-but-present `@id`, equally unusable downstream. + for blank in ["", " "] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let detected = scaffold_autodetect_project(cwd); + + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--no-verify", + "--product", + blank, + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "--product {blank:?} must fall back to auto-detect, not fail. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let doc: Value = serde_json::from_slice(&out.stdout).expect("VEX JSON on stdout"); + let product = &doc["statements"][0]["products"][0]["@id"]; + assert!( + product.is_string(), + "--product {blank:?} must not emit a product with no identifier \ + (an unidentifiable `not_affected` claim): {doc}" + ); + assert_eq!( + product, detected, + "--product {blank:?} must mean \"unset\" and defer to auto-detect, \ + matching the scrubbed SOCKET_VEX_PRODUCT= twin: {doc}" + ); + } +} + +#[test] +fn empty_doc_id_flag_falls_back_to_generated_uuid() { + for blank in ["", " "] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + scaffold_autodetect_project(cwd); + + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--no-verify", + "--product", + "pkg:npm/app@1.0.0", + "--doc-id", + blank, + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "--doc-id {blank:?} must fall back to a generated id, not fail. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let doc: Value = serde_json::from_slice(&out.stdout).expect("VEX JSON on stdout"); + let id = doc["@id"] + .as_str() + .expect("document @id is a required field"); + assert!( + id.starts_with("urn:uuid:") && id.len() > "urn:uuid:".len(), + "--doc-id {blank:?} must mean \"unset\" and generate a random urn:uuid, \ + matching the scrubbed SOCKET_VEX_DOC_ID= twin; got {id:?}" + ); + } +} + // ────────────────────────────────────────────────────────────────────── // verify-mode tests — lay down patched files on disk and exercise the // hash-check pipeline. We bypass ecosystem-crawler resolution by writing diff --git a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs new file mode 100644 index 00000000..587e6545 --- /dev/null +++ b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs @@ -0,0 +1,251 @@ +//! `get` must forward its API-client flags into the nested `apply` step. +//! +//! `get` drives `apply` in-process (`get.rs::run_nested_apply`). That step +//! builds its OWN `ApiClient` from the `GlobalArgs` it is handed +//! (`apply.rs` → `fetch_stage::stage_patch_sources` → +//! `get_api_client_with_overrides(common.api_client_overrides())`), so any +//! `--api-url` / `--api-token` / `--org` / `--proxy-url` the caller passed on +//! the COMMAND LINE has to be threaded through. Regression guard: the nested +//! `ApplyArgs` was built from `GlobalArgs::default()`, whose api fields are +//! all `None` — the nested apply silently fell back to env-var / config / +//! built-in-default resolution and never saw the user's flags. +//! +//! Reachable whenever the patch view does not embed `blobContent` for every +//! file (the manifest records the hashes; the bytes are fetched on demand): +//! `get` writes no blob, and the nested apply has to download it. Both `get` +//! call sites are covered — the direct-UUID path (`save_and_apply_patch`) and +//! the search path (`download_and_apply_patches`). +//! +//! Hermetic by construction: the *env* API/proxy URLs point at a dead local +//! port, so a run that ignores the flags fails on a refused connection rather +//! than reaching the real socket.dev. + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const PKG: &str = "nested-api-pkg"; +const PURL: &str = "pkg:npm/nested-api-pkg@1.0.0"; +const PURL_ENCODED: &str = "pkg%3Anpm%2Fnested-api-pkg%401.0.0"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"patched\n"; + +/// A dead local port. Whatever the nested apply resolves from the ENV must +/// not be reachable — that is what makes "the flags were ignored" show up as +/// a failure instead of a silent success against the same mock. +const DEAD_URL: &str = "http://127.0.0.1:1"; + +fn install_npm_package(root: &std::path::Path) { + std::fs::write( + root.join("package.json"), + r#"{"name":"nested-apply-root","version":"0.0.0","private":true}"#, + ) + .expect("write root package.json"); + let pkg_dir = root.join("node_modules").join(PKG); + std::fs::create_dir_all(&pkg_dir).expect("create pkg dir"); + std::fs::write( + pkg_dir.join("package.json"), + format!(r#"{{"name":"{PKG}","version":"1.0.0"}}"#), + ) + .expect("write pkg package.json"); + std::fs::write(pkg_dir.join("index.js"), BEFORE).expect("write pkg file"); +} + +/// Mount the patch view WITHOUT `blobContent`: the manifest gets the +/// before/after hashes, but the patched bytes are only obtainable from the +/// blob endpoint — i.e. by the nested apply's own API client. +async fn mount_view_without_blob(mock: &MockServer, before_hash: &str, after_hash: &str) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "nested-apply api-flag fixture", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +async fn mount_blob(mock: &MockServer, after_hash: &str) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/blob/{after_hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(AFTER.to_vec())) + .mount(mock) + .await; +} + +async fn mount_search(mock: &MockServer) { + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/{PURL_ENCODED}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "nested-apply api-flag fixture", + "license": "MIT", + "tier": "free", + "vulnerabilities": {}, + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; +} + +/// Env that pins every ambient API source at a dead port, so the ONLY +/// working route to the mock is the flags the test passes on the argv. +/// +/// `SOCKET_API_TOKEN` is deliberately ABSENT (`common::run_with_env` scrubs +/// the whole `SOCKET_*` surface first, and this list does not add it back): +/// the token is the discriminator. A nested client that never sees +/// `--api-token` takes the token-less public-proxy branch and resolves its +/// base from `SOCKET_PROXY_URL` — the dead port — no matter what any other +/// layer does with the URL. +fn dead_env<'a>() -> Vec<(&'a str, &'a str)> { + vec![ + ("SOCKET_API_URL", DEAD_URL), + ("SOCKET_PROXY_URL", DEAD_URL), + // Pin the slug: an unset one triggers an org auto-resolve + // round-trip, which is not what this test is about. + ("SOCKET_ORG_SLUG", ORG), + ] +} + +async fn assert_blob_was_fetched(mock: &MockServer, after_hash: &str) { + let requests = mock + .received_requests() + .await + .expect("wiremock records requests"); + let want = format!("/v0/orgs/{ORG}/patches/blob/{after_hash}"); + assert!( + requests.iter().any(|r| r.url.path() == want), + "the nested apply must fetch the missing blob through the CLI-flag API client; \ + got requests={:?}", + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() + ); +} + +#[tokio::test] +async fn get_by_uuid_nested_apply_uses_api_flags_not_env() { + let before_hash = common::git_sha256(BEFORE); + let after_hash = common::git_sha256(AFTER); + + let mock = MockServer::start().await; + mount_view_without_blob(&mock, &before_hash, &after_hash).await; + mount_blob(&mock, &after_hash).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + install_npm_package(tmp.path()); + + let uri = mock.uri(); + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ + "get", + UUID, + "--yes", + "--json", + // `file` mode goes straight for the per-file blob endpoint; the + // point here is which CLIENT does the fetch, not which artifact. + "--download-mode", + "file", + "--api-url", + &uri, + "--api-token", + "flag-token", + "--org", + ORG, + ], + &dead_env(), + ); + + assert_eq!( + code, 0, + "get must apply the patch through the flag-configured client; \ + stdout={stdout}\nstderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("valid JSON expected: {e}\nstdout={stdout}")); + assert_eq!(v["status"], "success", "stdout={stdout}"); + assert_eq!(v["applied"], 1, "stdout={stdout}"); + + let patched = tmp.path().join("node_modules").join(PKG).join("index.js"); + assert_eq!( + std::fs::read(&patched).expect("read patched file"), + AFTER, + "the installed file must carry the patched bytes" + ); + assert_blob_was_fetched(&mock, &after_hash).await; +} + +#[tokio::test] +async fn get_by_purl_nested_apply_uses_api_flags_not_env() { + let before_hash = common::git_sha256(BEFORE); + let after_hash = common::git_sha256(AFTER); + + let mock = MockServer::start().await; + mount_search(&mock).await; + mount_view_without_blob(&mock, &before_hash, &after_hash).await; + mount_blob(&mock, &after_hash).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + install_npm_package(tmp.path()); + + let uri = mock.uri(); + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ + "get", + PURL, + "--yes", + "--json", + "--download-mode", + "file", + "--api-url", + &uri, + "--api-token", + "flag-token", + "--org", + ORG, + ], + &dead_env(), + ); + + assert_eq!( + code, 0, + "the search path's nested apply must also use the flag-configured client; \ + stdout={stdout}\nstderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("valid JSON expected: {e}\nstdout={stdout}")); + assert_eq!(v["status"], "success", "stdout={stdout}"); + assert_eq!(v["applied"], 1, "stdout={stdout}"); + + let patched = tmp.path().join("node_modules").join(PKG).join("index.js"); + assert_eq!( + std::fs::read(&patched).expect("read patched file"), + AFTER, + "the installed file must carry the patched bytes" + ); + assert_blob_was_fetched(&mock, &after_hash).await; +} diff --git a/crates/socket-patch-cli/tests/get_update_summary_e2e.rs b/crates/socket-patch-cli/tests/get_update_summary_e2e.rs new file mode 100644 index 00000000..9887db41 --- /dev/null +++ b/crates/socket-patch-cli/tests/get_update_summary_e2e.rs @@ -0,0 +1,164 @@ +//! The human-readable `get` summary must not report one patch twice. +//! +//! `download_and_apply_patches` prints an `Added: / Skipped: / Failed: / +//! Updated:` block after writing the manifest. Regression guard: the "added" +//! tally was bumped for EVERY record it wrote — including the ones classified +//! `Updated` — so replacing an existing manifest entry printed both +//! `Added: 1` and `Updated: 1` for the single patch it had just swapped. +//! +//! The JSON `downloaded` count deliberately covers adds + updates (pinned by +//! `in_process_get_update_count.rs`); only the human `Added:` line is the +//! true-adds count. +//! +//! Runs `--save-only` so the apply step never fires: this is purely about the +//! download bookkeeping. + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const OLD_UUID: &str = "00000000-0000-4000-8000-000000000000"; +const NEW_UUID: &str = "11111111-1111-4111-8111-111111111111"; +const PURL: &str = "pkg:npm/summary-pkg@1.0.0"; +const PURL_ENCODED: &str = "pkg%3Anpm%2Fsummary-pkg%401.0.0"; + +fn seed_manifest(root: &std::path::Path, uuid: &str) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "patches": { + PURL: { + "uuid": uuid, + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "previously recorded", + "license": "MIT", + "tier": "free", + } + } + })) + .unwrap(), + ) + .expect("write manifest"); +} + +async fn mount_mocks(mock: &MockServer) { + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/{PURL_ENCODED}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": NEW_UUID, + "purl": PURL, + "publishedAt": "2024-06-01T00:00:00Z", + "description": "replacement patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {}, + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{NEW_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": NEW_UUID, + "purl": PURL, + "publishedAt": "2024-06-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", + "afterHash": "1111111111111111111111111111111111111111111111111111111111111111", + "blobContent": "cGF0Y2hlZAo=", + } + }, + "vulnerabilities": {}, + "description": "replacement patch", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +/// Run `get --save-only` against `mock`, returning `(code, stdout, stderr)`. +fn run_get(cwd: &std::path::Path, uri: &str) -> (i32, String, String) { + common::run( + cwd, + &[ + "get", + PURL, + "--yes", + "--save-only", + "--api-url", + uri, + "--api-token", + "fake-token-for-tests", + "--org", + ORG, + ], + ) +} + +#[tokio::test] +async fn replacing_a_manifest_entry_is_reported_as_updated_only() { + let mock = MockServer::start().await; + mount_mocks(&mock).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + // The PURL is already recorded under a DIFFERENT uuid → `Updated`. + seed_manifest(tmp.path(), OLD_UUID); + + let (code, stdout, stderr) = run_get(tmp.path(), &mock.uri()); + assert_eq!(code, 0, "save-only update must succeed; stderr={stderr}"); + + assert!( + stderr.contains("Updated: 1"), + "the replacement must be reported as an update; stderr={stderr}" + ); + assert!( + !stderr.contains("Added: 1"), + "a replaced entry must NOT also be counted as an add — one patch, one \ + line; stdout={stdout}\nstderr={stderr}" + ); + + // The manifest really was swapped (so the assertions above aren't + // describing a run that did nothing). + let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + let m: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(m["patches"][PURL]["uuid"], NEW_UUID, "manifest={body}"); +} + +#[tokio::test] +async fn a_brand_new_entry_is_still_reported_as_added() { + // Positive control: the `Added:` line must still count real adds, so a + // fix that simply stopped counting can't pass. + let mock = MockServer::start().await; + mount_mocks(&mock).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + // No manifest at all → `Added`. + let (code, stdout, stderr) = run_get(tmp.path(), &mock.uri()); + assert_eq!(code, 0, "save-only add must succeed; stderr={stderr}"); + + assert!( + stderr.contains("Added: 1"), + "a new entry must be reported as an add; stdout={stdout}\nstderr={stderr}" + ); + assert!( + !stderr.contains("Updated:"), + "a new entry must not report an update; stderr={stderr}" + ); + + let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + let m: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(m["patches"][PURL]["uuid"], NEW_UUID, "manifest={body}"); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 3ee2069d..d2dc7685 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1382,6 +1382,91 @@ async fn redirect_human_mode_prints_rewriter_warnings() { ); } +/// Human-mode `skipped` lines and the record/migration/rush warnings are built +/// as `serde_json::Value`s and were printed with `{}` — `Display` for `Value` +/// emits JSON, so every one of them reached the terminal wrapped in literal +/// double quotes (`skipped "pkg:npm/x@1.0.0" ("forbidden")`, `warning: "…"`), +/// unlike the adjacent `rewrite.warnings` line which prints the bare `String`. +/// Both legs run as subprocesses so stderr can be read back. +#[tokio::test] +#[serial] +async fn redirect_human_mode_warnings_are_not_json_quoted() { + // Leg 1 — a DENIED reference produces a `skipped` line. + let server = MockServer::start().await; + mock_discovery(&server).await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { "status": "forbidden", "url": null, "purl": PURL, "artifacts": [], "registryOverride": null } } + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains(&format!("skipped {PURL} (forbidden)")), + "the skipped line must print the bare purl/reason, not JSON-quoted \ + values; stderr=\n{stderr}" + ); + + // Leg 2 — a GRANTED reference whose patch record cannot be fetched (no + // `view/{uuid}` mock → 404) produces a `record_fetch_failed` warning. + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stdout.contains("Redirected 1 package(s)"), + "anchor: the dep must have been redirected so the record fetch runs; \ + stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stderr.contains(&format!( + "warning: {PURL} redirected, but its patch record could not be fetched" + )), + "the record-fetch warning must print the bare detail string, not a \ + JSON-quoted one; stderr=\n{stderr}" + ); +} + /// The `redirect_rush_repo_state_stale` warning fires exactly when a Rush lock /// was rewritten AND common/config/rush/repo-state.json is present (the file /// that carries pnpmShrinkwrapHash, which an out-of-band lock edit desyncs). @@ -1493,3 +1578,131 @@ async fn rush_stale_warning_requires_an_actual_lock_edit() { "the unrelated lock must be untouched" ); } + +/// Cargo's hosted redirect wires the managed sparse registry into +/// `.cargo/config.toml` — but a project carrying the LEGACY extensionless +/// `.cargo/config` is one cargo READS INSTEAD (it warns about the duplicate +/// and ignores `config.toml`). The redirect never even read that spelling, so +/// the `[registries.socket-patch-]` definition landed in an ignored file +/// while `Cargo.toml` gained `registry = "socket-patch-"` naming it: +/// cargo then fails with "no index found for registry", and the run still +/// reported the dep redirected (its index URL "landed in a file") and attested +/// it to VEX. Same invariant `vendor::cargo_config::config_path` already +/// enforces on the vendor path. +#[tokio::test] +#[serial] +async fn cargo_redirect_writes_the_legacy_dot_cargo_config() { + const CARGO_PURL: &str = "pkg:cargo/serde@1.0.190"; + const CARGO_UUID: &str = "55555555-5555-4555-8555-555555555555"; + const CKSUM: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + let index_url = "sparse+http://patch.test/cargo/idx/"; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": CARGO_PURL, + "patches": [{ + "uuid": CARGO_UUID, "purl": CARGO_PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "cargo legacy-config fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": CARGO_UUID, "purl": CARGO_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + CARGO_UUID: { + "status": "granted", + "url": "http://patch.test/serde-1.0.190.crate", + "purl": CARGO_PURL, + "artifacts": [{ + "kind": "tarball", + "url": "http://patch.test/serde-1.0.190.crate", + "integrity": { "sha256": CKSUM } + }], + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": index_url, + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": CKSUM, + } + } + } + } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{CARGO_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": CARGO_UUID, + "purl": CARGO_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ) + .unwrap(); + // Lockfile-only discovery: the Cargo.lock inventory supplies the purl. + std::fs::write( + tmp.path().join("Cargo.lock"), + "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"91f70896d6720bc714a4a57d22fc91f1db634680e65c8efe13323f1fa38d53f5\"\n", + ) + .unwrap(); + std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap(); + std::fs::write(tmp.path().join(".cargo/config"), "[net]\nretry = 3\n").unwrap(); + + let code = run(redirect_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --redirect should succeed"); + + let legacy = std::fs::read_to_string(tmp.path().join(".cargo/config")).unwrap(); + assert!( + legacy.contains(&format!("[registries.socket-patch-{CARGO_UUID}]")), + "the registry definition must land in the legacy `.cargo/config` — the \ + file cargo actually reads; got:\n{legacy}" + ); + assert!( + legacy.contains("retry = 3"), + "the user's existing config must be preserved: {legacy}" + ); + assert!( + !tmp.path().join(".cargo/config.toml").exists(), + "no shadowed `.cargo/config.toml` may be created beside the legacy file" + ); + let manifest = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + manifest.contains(&format!("registry = \"socket-patch-{CARGO_UUID}\"")), + "anchor: the Cargo.toml dep must name the managed registry: {manifest}" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 21f9f558..44abfa8f 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -1166,6 +1166,96 @@ async fn scan_prune_with_ecosystem_filter_keeps_other_ecosystem() { ); } +// --------------------------------------------------------------------------- +// Regression: --prune must not delete manifest entries of ecosystems this +// build never crawled. +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn scan_prune_keeps_entry_of_uncrawled_ecosystem() { + // `.socket/manifest.json` is a COMMITTED, shared file. A teammate on a + // newer CLI can add a patch for an ecosystem this binary has no crawler + // for (here `pkg:hex/…`; the runtime-gated maven/nuget crawlers behave + // the same way with their gate off). That purl is never looked for, so + // its absence from the crawl says nothing about whether it is installed + // — yet prune treated "not in scanned_purls" as "uninstalled" and + // deleted both the entry and its blob: silent, cross-machine patch loss. + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "live-npm", "1.0.0"); + + let socket = tmp.path().join(".socket"); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + let after_hash = "a".repeat(64); + let blob = blobs.join(&after_hash); + std::fs::write(&blob, vec![0u8; 64]).unwrap(); + std::fs::write( + socket.join("manifest.json"), + format!( + r#"{{ "patches": {{ + "pkg:npm/live-npm@1.0.0": {{ + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, "vulnerabilities": {{}}, + "description": "live npm", "license": "MIT", "tier": "free" + }}, + "pkg:npm/orphan-npm@9.9.9": {{ + "uuid": "22222222-2222-4222-8222-222222222222", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, "vulnerabilities": {{}}, + "description": "orphan npm", "license": "MIT", "tier": "free" + }}, + "pkg:hex/plug@1.14.0": {{ + "uuid": "33333333-3333-4333-8333-333333333333", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "lib/plug.ex": {{ + "beforeHash": "{zeros}", + "afterHash": "{after_hash}" + }} + }}, + "vulnerabilities": {{}}, + "description": "unsupported ecosystem", "license": "MIT", "tier": "free" + }} + }}}}"#, + zeros = "0".repeat(64), + ), + ) + .unwrap(); + + let mut args = default_args(tmp.path()); + args.common.api_url = Some(server.uri()); + args.prune = true; + + assert_eq!(run_scrubbed(args).await, 0); + + let body = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); + let m: serde_json::Value = serde_json::from_str(&body).unwrap(); + let patches = m["patches"].as_object().unwrap(); + + assert!( + !patches.contains_key("pkg:npm/orphan-npm@9.9.9"), + "the genuinely-uninstalled npm orphan must still be pruned; got {m}" + ); + assert!( + patches.contains_key("pkg:npm/live-npm@1.0.0"), + "the installed npm entry must be kept; got {m}" + ); + assert!( + patches.contains_key("pkg:hex/plug@1.14.0"), + "an entry of an ecosystem this build never crawled must NOT be pruned; got {m}" + ); + assert!( + blob.exists(), + "the uncrawled entry's blob must survive the orphan sweep" + ); +} + // --------------------------------------------------------------------------- // Regression: ambient VIRTUAL_ENV must not leak into the scan. // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 65a343c7..ddac1877 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1165,6 +1165,35 @@ fn lock_contention_exits_lock_held() { assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); } +/// `vendor --revert` is documented to work without a manifest, and "a +/// missing ledger is an empty ledger (clean no-op plus the orphan-dir +/// sweep)" (CLI_CONTRACT, "Ownership, state, and reversal"). A project +/// with no `.socket/` directory at all is exactly that case — but the +/// apply lock lives INSIDE `.socket/`, and `apply_lock::acquire` only +/// creates the lock *file*, never its parent. Taking the lock before +/// noticing there is nothing to revert turns the documented no-op into a +/// `lock_io` failure. +#[test] +fn revert_without_a_socket_dir_is_a_clean_no_op() { + let tmp = tempfile::tempdir().expect("tempdir"); + assert!(!tmp.path().join(".socket").exists()); + + let (code, env) = vendor_cli(tmp.path(), &["--revert"]); + assert_eq!( + code, 0, + "an absent ledger is an empty ledger — revert must be a clean no-op: {env:#}" + ); + assert_ne!( + env["error"]["code"], "lock_io", + "revert must not fail on the lock file it would have created inside the \ + missing .socket/ dir: {env:#}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "a no-op revert must not litter a .socket/ dir" + ); +} + // ───────────────────────────────────────────────────────────────────── // 12. JSON envelope shape // ───────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-cli/tests/remove_invariants.rs b/crates/socket-patch-cli/tests/remove_invariants.rs index d9b69b40..c23dfa58 100644 --- a/crates/socket-patch-cli/tests/remove_invariants.rs +++ b/crates/socket-patch-cli/tests/remove_invariants.rs @@ -381,6 +381,152 @@ fn remove_blob_sweep_does_not_inflate_removed_count() { ); } +// --------------------------------------------------------------------------- +// Vendored patches: the wiring must never outlive the manifest entry +// --------------------------------------------------------------------------- + +const VENDORED_PURL: &str = "pkg:npm/__remove_vendored__@1.0.0"; +/// The manifest's current patch generation. +const MANIFEST_UUID: &str = "55555555-5555-4555-8555-555555555555"; +/// The generation that was actually vendored — one behind the manifest. +/// This is the documented `vendor_uuid_mismatch` state: `get` / `scan +/// --apply` refreshed the manifest record while the re-vendor is still +/// pending (repair reports it and declines to cross patch generations). +const LEDGER_UUID: &str = "66666666-6666-4666-8666-666666666666"; + +/// Manifest with a single vendored npm patch. `files: {}` keeps the run +/// offline — the internal rollback needs no before-blobs. +fn write_vendored_manifest(root: &Path, patch_uuid: &str) -> PathBuf { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + let manifest = format!( + r#"{{ + "patches": {{ + "{VENDORED_PURL}": {{ + "uuid": "{patch_uuid}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "synthetic vendored remove test patch", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); + socket +} + +/// Vendor ledger with one npm entry for [`VENDORED_PURL`] at `ledger_uuid` +/// (empty wiring, so the revert is a pure offline artifact-dir delete), +/// plus the artifact dir it names. +fn write_vendored_ledger(root: &Path, ledger_uuid: &str) -> PathBuf { + let vendor = root.join(".socket/vendor"); + let artifact_dir = vendor.join("npm").join(ledger_uuid); + std::fs::create_dir_all(&artifact_dir).expect("create artifact dir"); + std::fs::write(artifact_dir.join("package.tgz"), b"tgz").expect("write artifact"); + let state = format!( + r#"{{ + "version": 1, + "entries": {{ + "{VENDORED_PURL}": {{ + "ecosystem": "npm", + "basePurl": "{VENDORED_PURL}", + "uuid": "{ledger_uuid}", + "artifact": {{ "path": ".socket/vendor/npm/{ledger_uuid}/package.tgz" }}, + "wiring": [] + }} + }} +}}"# + ); + std::fs::write(vendor.join("state.json"), state).expect("write vendor state"); + artifact_dir +} + +/// `remove` must revert the vendoring of every patch it deletes from the +/// manifest: otherwise the lockfile keeps resolving to the committed +/// `.socket/vendor/` artifact after the manifest forgot the patch, so the +/// dependency stays silently patched with no record of it — and the +/// internal rollback can't compensate, because it deliberately skips +/// vendor-owned purls (nothing was patched in the installed tree). +/// +/// The regression: the ledger lookup matched the raw remove identifier +/// only, never the manifest purls actually being deleted. A patch uuid is +/// exactly the identifier that resolves through the manifest but not +/// through the ledger whenever the vendored generation is older than the +/// manifest's — `remove ` matched the manifest entry by its NEW +/// uuid and would have had to match the ledger entry by its OLD one. The +/// revert was skipped in silence and the run still reported success. +/// +/// Fully offline: no files in the record, vendor-owned purl (so the +/// rollback returns before the before-blob gate), empty wiring. +#[test] +#[ignore = "RED: pins a ledger-generation matching fix in remove.rs that was not \ + part of this change."] +fn remove_by_uuid_reverts_vendoring_when_ledger_generation_is_older() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = write_vendored_manifest(tmp.path(), MANIFEST_UUID); + let artifact_dir = write_vendored_ledger(tmp.path(), LEDGER_UUID); + + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &["remove", MANIFEST_UUID, "--json", "--yes"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["summary"]["removed"], 1, "the manifest entry is deleted"); + assert!( + read_manifest(&socket)["patches"] + .as_object() + .expect("patches object") + .is_empty(), + "precondition: the manifest entry really was removed" + ); + + // The crux: the vendoring must be gone too. An emptied ledger is + // deleted outright, so a surviving state.json means a surviving entry. + assert!( + !tmp.path().join(".socket/vendor/state.json").exists(), + "remove must revert the vendoring of the entry it deleted; envelope={v}" + ); + assert!( + !artifact_dir.exists(), + "the vendored artifact must be deleted with the patch; envelope={v}" + ); + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["errorCode"] == "vendor_reverted" + && e["purl"] == VENDORED_PURL + && e["action"] == "removed"), + "expected a vendor_reverted Removed event for the vendored purl: {events:?}" + ); +} + +/// Control for the test above: removing the SAME fixture by PURL already +/// reverted the vendoring, so the by-uuid failure was a matching hole +/// rather than a broken fixture (no artifact, unrevertable wiring, ...). +/// Also pins the in-sync generation case end to end. +#[test] +fn remove_by_purl_reverts_vendoring() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_vendored_manifest(tmp.path(), MANIFEST_UUID); + let artifact_dir = write_vendored_ledger(tmp.path(), LEDGER_UUID); + + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &["remove", VENDORED_PURL, "--json", "--yes"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + !tmp.path().join(".socket/vendor/state.json").exists(), + "removing by purl must revert the vendoring; stdout=\n{stdout}" + ); + assert!(!artifact_dir.exists(), "artifact must be deleted"); +} + // --------------------------------------------------------------------------- // Manifest-path override // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index ad1b1bf5..995862e9 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -383,6 +383,91 @@ async fn scan_update_candidate_is_the_highest_ranked_patch() { ); } +// --------------------------------------------------------------------------- +// Discovery — `updates[]` bridges the two PURL spellings +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn scan_emits_updates_entry_for_scoped_purl_despite_manifest_percent_encoding() { + // Regression: for a SCOPED package the two purl spellings diverge. + // * manifest keys are written verbatim from the *patch* purl, which + // the API serves percent-encoded (`pkg:npm/%40scope/...`) — see + // in_process_vendor.rs `vendor_resolves_percent_encoded_scope_purl`. + // * the batch *package* key comes back in the crawler's literal + // spelling (`pkg:npm/@scope/...`) — the public-proxy path builds it + // from the purls we requested (`assemble_batch_from_individual`). + // `detect_updates` looked the manifest up by the raw batch purl, so a + // scoped package with a newer patch never reached `updates[]` (nor the + // table's `[UPDATE]` marker) — the operator silently kept the old patch. + let mock = MockServer::start().await; + let crawler_purl = "pkg:npm/@scope/left-pad@1.3.0"; + let api_purl = "pkg:npm/%40scope/left-pad@1.3.0"; + let new_uuid = "99999999-9999-4999-8999-999999999999"; + let old_uuid = "11111111-1111-4111-8111-111111111111"; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": crawler_purl, + "patches": [{ + "uuid": new_uuid, + "purl": api_purl, + "tier": "free", + "cveIds": [], + "ghsaIds": [], + "severity": "high", + "title": "Newer patch" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "@scope/left-pad", "1.3.0"); + // Manifest keyed by the ENCODED purl — exactly what `get`/`scan --apply` + // write for a scoped package. + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + format!( + r#"{{ + "patches": {{ + "{api_purl}": {{ + "uuid": "{old_uuid}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "old", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ), + ) + .unwrap(); + + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let updates = v["updates"].as_array().expect("updates array"); + assert_eq!( + updates.len(), + 1, + "the scoped package's newer UUID must be reported; got: {v}" + ); + assert_eq!(updates[0]["purl"], crawler_purl); + assert_eq!(updates[0]["oldUuid"], old_uuid); + assert_eq!(updates[0]["newUuid"], new_uuid); + + let reqs = recorded(&mock).await; + assert_single_batch_carries_purl(&reqs, crawler_purl); +} + // --------------------------------------------------------------------------- // Discovery — no manifest, no `updates` field (nothing to diff against) // --------------------------------------------------------------------------- @@ -927,6 +1012,111 @@ async fn scan_handles_api_500_error_gracefully() { ); } +/// Mount a batch endpoint that reports one patched package, plus a +/// per-package detail endpoint that fails with a 500 for it. This is the +/// "batch phase fine, detail phase totally down" shape that drives +/// `discover_selected` into its all-queries-failed bail. +async fn mount_batch_ok_details_500(mock: &MockServer, purl: &str, uuid: &str) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [{ + "uuid": uuid, + "purl": purl, + "tier": "free", + "cveIds": [], + "ghsaIds": [], + "severity": "high", + "title": "Prototype Pollution" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/pkg%3Anpm%2Fminimist%401.2.2" + ))) + .respond_with(ResponseTemplate::new(500).set_body_string("detail endpoint down")) + .mount(mock) + .await; +} + +/// CONTRACT (CLI_CONTRACT.md, "JSON output shapes"): *every* `--json` +/// invocation emits a single JSON object on stdout. `scan`'s other total +/// failures honor that — `--offline` and the all-batches-failed bail both +/// print `{"status": "error", "error": ...}`. The all-detail-queries-failed +/// bail did not: it returned exit 1 straight out of `discover_selected` +/// with EMPTY stdout, so a bot parsing `scan --json --apply` got a JSON +/// parse error instead of a diagnosable failure envelope. +#[tokio::test] +async fn scan_apply_all_detail_queries_failed_emits_json_error_envelope() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_ok_details_500(&mock, purl, "11111111-1111-4111-8111-111111111111").await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--apply", "--yes"]); + + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "scan --json --apply must emit a JSON envelope even when every \ + patch-detail query fails; err={e}; stdout={stdout:?}; stderr={stderr}" + ) + }); + assert_eq!( + v["status"], "error", + "a total detail-phase failure must be reported as status=error; envelope={v}" + ); + assert!( + v["error"].is_string() && !v["error"].as_str().unwrap().is_empty(), + "the error envelope must carry a diagnosable message; envelope={v}" + ); + assert_ne!(code, 0, "exit code must stay non-zero; envelope={v}"); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "a fully-failed detail phase must not write a manifest" + ); +} + +/// Same contract, vendored mode: `run_vendor_json_path` calls the same +/// `discover_selected` and had the same bare `return code` with no stdout. +#[tokio::test] +async fn scan_vendored_all_detail_queries_failed_emits_json_error_envelope() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_ok_details_500(&mock, purl, "11111111-1111-4111-8111-111111111111").await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), &["--mode", "vendored", "--yes"]); + + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "scan --json --mode vendored must emit a JSON envelope even when every \ + patch-detail query fails; err={e}; stdout={stdout:?}; stderr={stderr}" + ) + }); + assert_eq!( + v["status"], "error", + "a total detail-phase failure must be reported as status=error; envelope={v}" + ); + assert!( + v["error"].is_string() && !v["error"].as_str().unwrap().is_empty(), + "the error envelope must carry a diagnosable message; envelope={v}" + ); + assert_ne!(code, 0, "exit code must stay non-zero; envelope={v}"); +} + // --------------------------------------------------------------------------- // Lifecycle: withdrawn patches and patch updates // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 97962f21..faa6236f 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -289,6 +289,120 @@ async fn scan_vendor_manifest_mode_end_to_end() { ); } +/// A batch endpoint that reports NO available patches for the installed +/// set — the shape a withdrawn patch (or a free account against a +/// paid-only catalog) produces. The by-package / view endpoints are +/// deliberately unmounted: nothing may reach them. +async fn mount_empty_discovery(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; +} + +/// Seed a committed `.socket/manifest.json` plus its afterHash blob — the +/// state a repo has after `scan --vendor` was run and `.socket/vendor/` +/// was later wiped (or never committed). The blob lets the vendor engine +/// stage sources with no download phase and no network. +fn seed_committed_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(AFTER)), AFTER).unwrap(); + let manifest = serde_json::json!({ + "patches": { + PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "Vendor patch", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// CONTRACT (CLI_CONTRACT.md, `scan --vendor`): "The whole manifest is +/// vendored" — and `run_vendor_json_path` says so in code ("the vendor +/// step still runs when zero patches were downloaded (re-vendor after a +/// wipe)"). `scan/mod.rs`'s `selected.is_empty() && !vendor` guard encodes +/// the same intent for the interactive arm. +/// +/// But the interactive arm never reaches that guard on an empty discovery: +/// the earlier `all_packages_with_patches.is_empty()` / +/// `downloadable_count == 0` / `all_search_results.is_empty()` returns fire +/// first and exit before the vendor dispatch. Same fixture, same mock, only +/// `--json` differing must not decide whether the vendor tree gets rebuilt. +#[tokio::test] +async fn scan_vendor_rebuilds_committed_manifest_when_discovery_is_empty() { + let mock = MockServer::start().await; + mount_empty_discovery(&mock).await; + let uri = mock.uri(); + + // --- JSON arm (the documented behavior) --- + let json_tmp = tempfile::tempdir().unwrap(); + write_fixture(json_tmp.path()); + seed_committed_manifest(json_tmp.path()); + let (json_code, json_out, json_err) = run_scan_vendor(json_tmp.path(), &uri, &[]); + let json_tgz = json_tmp + .path() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + assert_eq!(json_code, 0, "stdout={json_out}; stderr={json_err}"); + assert!( + json_tgz.is_file(), + "baseline: scan --json --vendor must re-vendor the committed manifest \ + even when discovery returns no patches; stdout={json_out}; stderr={json_err}" + ); + + // --- Interactive arm (same inputs, no --json) --- + let tty_tmp = tempfile::tempdir().unwrap(); + write_fixture(tty_tmp.path()); + seed_committed_manifest(tty_tmp.path()); + let (tty_code, tty_out, tty_err) = run_cli_env( + tty_tmp.path(), + &[ + "scan", + "--vendor", + "--yes", + "--api-url", + &uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + &[], + ); + let tty_tgz = tty_tmp + .path() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + assert_eq!(tty_code, 0, "stdout={tty_out}; stderr={tty_err}"); + assert!( + tty_tgz.is_file(), + "scan --vendor (interactive) must re-vendor the committed manifest too — \ + --json must not decide whether the vendor step runs; stdout={tty_out}; stderr={tty_err}" + ); + assert!( + tty_tmp.path().join(".socket/vendor/state.json").is_file(), + "the ledger must be written by the interactive arm; stdout={tty_out}; stderr={tty_err}" + ); +} + #[tokio::test] async fn scan_vendor_detached_mode_writes_no_manifest() { // scan --vendor --detached: the ledger (with embedded records) is the diff --git a/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs new file mode 100644 index 00000000..e5c63393 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs @@ -0,0 +1,232 @@ +//! Regression: the vendor step's ERROR returns must still report the work +//! the step already committed to disk. +//! +//! `scan --vendor`'s vendor step (`run_scan_vendor_step`) runs the manifest +//! reconcile — reverting vendored entries whose patch left the manifest, +//! which rewrites lockfiles, deletes `.socket/vendor//` artifacts and +//! rewrites the ledger — BEFORE it stages patch sources. A staging failure +//! (`no_local_source`: a patch view the API would not serve) therefore +//! aborts a run that has already mutated the project, and the `vendor` +//! Envelope holding those `Removed`/`Failed` events is the only record of +//! it. The `vendor` command prints that envelope on the same failure +//! (`vendor::run` emits `env` whatever `run_vendor` returned); scan's JSON +//! arm must not be the one place the events vanish. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +const ORG_SLUG: &str = "test-org"; +/// The manifest patch whose content the mock API refuses to serve. +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +/// A ledger entry with NO manifest patch — the reconcile reverts it. +const DROPPED_PURL: &str = "pkg:npm/gone@9.9.9"; +const DROPPED_UUID: &str = "33333333-3333-4333-8333-333333333333"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"after\n"; + +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// A vendorable npm project: root package.json, a v3 package-lock with a +/// registry-resolved left-pad entry, and the installed package. +fn write_fixture(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "scan-vendor-step-error", "version": "0.0.0" }"#, + ) + .unwrap(); + let lock = serde_json::json!({ + "name": "scan-vendor-step-error", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scan-vendor-step-error", + "version": "0.0.0", + "dependencies": { "left-pad": "^1.3.0" } + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-orig==", + "license": "WTFPL" + } + } + }); + let mut lock_bytes = serde_json::to_vec_pretty(&lock).unwrap(); + lock_bytes.push(b'\n'); + std::fs::write(root.join("package-lock.json"), lock_bytes).unwrap(); + + let pkg = root.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); +} + +/// A committed manifest whose afterHash blob is NOT on disk: the vendor +/// step must fetch the patch view to stage it, and the mock refuses. +fn seed_unstageable_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let manifest = serde_json::json!({ + "patches": { + PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "Vendor patch", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// A ledger holding one entry the manifest does not mention: the vendor +/// step's `reconcile_dropped` reverts it (and rewrites `state.json`) +/// before staging is even attempted. +fn seed_dropped_ledger_entry(root: &Path) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { DROPPED_PURL: { + "ecosystem": "npm", + "basePurl": DROPPED_PURL, + "uuid": DROPPED_UUID, + "artifact": { + "path": format!(".socket/vendor/npm/{DROPPED_UUID}/gone-9.9.9.tgz"), + }, + "wiring": [] + }} + })) + .unwrap(), + ) + .unwrap(); +} + +/// Discovery reports no available patches (so nothing downloads and the +/// run goes straight to the vendor step), and the patch-view endpoint is +/// deliberately unmounted so staging the committed manifest fails. +async fn mount_empty_discovery(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; +} + +fn run_cli(root: &Path, argv: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(argv).current_dir(root); + // Scrub the ambient `SOCKET_*` surface (prefix scrub — fixed lists rot) + // so a developer's shell can't steer the child, then force the telemetry + // kill-switch: telemetry resolves its endpoint from env only, so an + // ambient value would ship this run's events to the LIVE API. + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +#[tokio::test] +async fn scan_vendor_staging_error_still_reports_the_reconcile() { + let mock = MockServer::start().await; + mount_empty_discovery(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + seed_unstageable_manifest(tmp.path()); + seed_dropped_ledger_entry(tmp.path()); + + let (code, stdout, stderr) = run_cli( + tmp.path(), + &[ + "scan", + "--json", + "--vendor", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + ); + + assert_eq!( + code, 1, + "an unstageable manifest must fail the run; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be one JSON object ({e}); stdout={stdout}")); + assert_eq!( + v["error"]["code"], "no_local_source", + "precondition: the run must abort at staging; envelope={v}" + ); + + // Non-vacuous: the reconcile really did run and really did persist — + // the ledger's only entry is gone, so `save_state` deleted state.json. + assert!( + !tmp.path().join(".socket/vendor/state.json").exists(), + "precondition: the reconcile must have reverted the dropped entry \ + and rewritten the ledger; envelope={v}" + ); + + // The point: that on-disk mutation must be visible to the JSON consumer. + let events = v["vendor"]["events"].as_array().unwrap_or_else(|| { + panic!( + "the vendor envelope must survive the staging error — the \ + reconcile already reverted {DROPPED_PURL} on disk; envelope={v}" + ) + }); + assert!( + events.iter().any(|e| e["purl"] == DROPPED_PURL), + "the reconcile's event for {DROPPED_PURL} must be reported; envelope={v}" + ); +} diff --git a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs index 475821ce..f70a19e1 100644 --- a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs @@ -98,7 +98,10 @@ async fn pip_bundled_refuses_with_pip_hint() { &["--update", "--yes"], &[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)], ); - assert_eq!(code, 1, "pip-managed install must refuse.\nstderr:\n{stderr}"); + assert_eq!( + code, 1, + "pip-managed install must refuse.\nstderr:\n{stderr}" + ); assert!( stderr.contains("pip install --upgrade socket-patch"), "refusal must route to pip's own upgrade command: {stderr}" @@ -135,7 +138,10 @@ async fn cargo_install_refuses_with_cargo_hint() { ("CARGO_HOME", &cargo_home), ], ); - assert_eq!(code, 1, "cargo-managed install must refuse.\nstderr:\n{stderr}"); + assert_eq!( + code, 1, + "cargo-managed install must refuse.\nstderr:\n{stderr}" + ); assert!( stderr.contains("cargo install socket-patch-cli"), "refusal must route to cargo's own upgrade command: {stderr}" @@ -262,6 +268,54 @@ async fn force_overrides_channel_refusal() { update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); } +/// The same override, one output mode over: `--json` silences stderr, so +/// the "npm owns this and will overwrite it" advisory has to ride the +/// envelope's `warnings[]` instead. Without it a CI wrapper running +/// `--update --force --json` inside `node_modules` sees a bare +/// `status: "success"` and never learns the swap is ephemeral — the same +/// silent-override bug class [`force_overrides_channel_refusal`] guards in +/// human mode. +#[tokio::test] +async fn force_override_warning_survives_json() { + let install = staged_install_at("node_modules/@socketsecurity/socket-patch-x/bin"); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!( + code, 0, + "--force must proceed past the channel gate.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::json_string(&env, "status"), Some("success")); + let warnings = env["warnings"].as_array().cloned().unwrap_or_default(); + let advisory = warnings + .iter() + .find(|w| w["code"] == "managed_install_override") + .unwrap_or_else(|| { + panic!("a --json override must still report that npm owns this install: {stdout}") + }); + assert!( + advisory["detail"] + .as_str() + .unwrap_or_default() + .contains("managed by npm"), + "the advisory must name the owning manager: {advisory}" + ); + assert!( + !stderr.contains("Warning"), + "--json must route the advisory to the envelope, not stderr: {stderr}" + ); +} + /// Canonicalization pin: the binary physically lives in the node_modules /// shape but is invoked through a plain symlink elsewhere — exactly how /// npm `.bin/` shims exec. Detection must classify the resolved target, @@ -303,7 +357,10 @@ async fn symlinked_invocation_still_detected() { install.assert_binary_intact(); install.assert_only_binary_present(); assert!( - std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink(), "the invocation symlink must be left alone" ); } diff --git a/crates/socket-patch-cli/tests/self_update_e2e.rs b/crates/socket-patch-cli/tests/self_update_e2e.rs index 98506aa2..29c604e5 100644 --- a/crates/socket-patch-cli/tests/self_update_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_e2e.rs @@ -50,7 +50,10 @@ async fn update_force_swaps_binary_end_to_end() { ("SOCKET_API_TOKEN", "secret-canary"), ], ); - assert_eq!(code, 0, "update must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "update must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); assert!( stdout.contains("Updated socket-patch"), "human output must report the update: {stdout}" @@ -81,7 +84,11 @@ async fn update_force_swaps_binary_end_to_end() { ); use std::os::unix::fs::PermissionsExt; assert_eq!( - std::fs::metadata(&install.bin).unwrap().permissions().mode() & 0o777, + std::fs::metadata(&install.bin) + .unwrap() + .permissions() + .mode() + & 0o777, 0o755, "destination mode must be preserved" ); @@ -142,6 +149,51 @@ async fn update_upgrade_branch_swaps() { update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); } +/// The relaxed version self-check's warning must survive `--json`. Human +/// mode prints it to stderr, but `--json` deliberately silences stderr, so +/// without `warnings[]` on the envelope a machine consumer is handed a +/// plain `status: "success"` with no hint that the binary it just +/// installed reports a different version than the release it was fetched +/// as — the one signal that says "this mirror served something other than +/// what you asked for". Regression: the advisories were printed only under +/// `!json && !silent` and dropped on the floor otherwise. +#[tokio::test] +async fn update_json_envelope_carries_version_self_check_warning() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--yes", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + let warnings = env["warnings"].as_array().cloned().unwrap_or_default(); + let advisory = warnings + .iter() + .find(|w| w["code"] == "update_warning") + .unwrap_or_else(|| { + panic!("--json must carry the self-check advisory in warnings[]: {stdout}") + }); + let detail = advisory["detail"].as_str().unwrap_or_default(); + assert!( + detail.contains("9.9.9") && detail.contains(CURRENT), + "the advisory must name both the requested release and what the \ + downloaded binary actually reports: {detail}" + ); + // stdout stays a single machine-readable object; stderr stays clean. + assert!( + !stderr.contains("Warning"), + "--json must route the advisory to the envelope, not stderr: {stderr}" + ); +} + /// `--dry-run` is check-only: one resolve, zero downloads, zero mutation, /// exit 0 — the cheap scriptable "is an update available" probe. #[tokio::test] @@ -165,7 +217,7 @@ async fn update_dry_run_checks_without_downloading() { ); assert_eq!(code, 0); let env = common::parse_json_envelope(&stdout); - assert_eq!(common::json_string(&env, "command").as_deref(), Some("update")); + assert_eq!(common::json_string(&env, "command"), Some("update")); assert_eq!(env["dryRun"], true); let details = &env["events"][0]["details"]; assert_eq!(details["updateAvailable"], true); @@ -260,8 +312,8 @@ async fn update_json_success_envelope_shape() { ); assert_eq!(code, 0, "{stdout}"); let env = common::parse_json_envelope(&stdout); - assert_eq!(common::json_string(&env, "command").as_deref(), Some("update")); - assert_eq!(common::json_string(&env, "status").as_deref(), Some("success")); + assert_eq!(common::json_string(&env, "command"), Some("update")); + assert_eq!(common::json_string(&env, "status"), Some("success")); let actions: Vec<&str> = env["events"] .as_array() .unwrap() diff --git a/crates/socket-patch-cli/tests/self_update_failures_e2e.rs b/crates/socket-patch-cli/tests/self_update_failures_e2e.rs index e2d949ff..8602d712 100644 --- a/crates/socket-patch-cli/tests/self_update_failures_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_failures_e2e.rs @@ -504,7 +504,10 @@ async fn concurrent_update_lock_held() { ); assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); let env = common::parse_json_envelope(&stdout); - assert_eq!(common::envelope_error_code(&env), Some("update_in_progress")); + assert_eq!( + common::envelope_error_code(&env), + Some("update_in_progress") + ); install.assert_binary_intact(); // Release the lock: the very next run must go all the way through. diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index 087499e3..92a55cca 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -465,3 +465,142 @@ fn setup_honors_exclude_for_a_workspace_member() { "the excluded member must not appear among the checked files:\n{stdout}" ); } + +/// Property 9, CSV spelling: `--exclude` is comma-delimited, so +/// `--exclude "packages/a, packages/b"` (and the `SOCKET_SETUP_EXCLUDE=a, b` +/// form CI YAML produces) must exclude BOTH members. +/// +/// Regression: clap splits on the comma only, so the second value reached +/// `normalize_rel_path` as `" packages/b"`. Untrimmed it equalled no member's +/// relative path, so the member was configured anyway — silently, with no +/// warning that an exclusion had missed — and the unmatchable spelling was +/// then persisted under `setup.exclude`, where every later run and every clone +/// inherited a dead entry. +#[test] +fn setup_exclude_tolerates_spaces_in_the_csv_list() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + write( + &proj.path().join("package.json"), + r#"{ "name": "root", "workspaces": ["packages/*"] }"#, + ); + for member in ["a", "b", "keep"] { + write( + &proj.path().join(format!("packages/{member}/package.json")), + &format!(r#"{{ "name": "{member}", "version": "1.0.0" }}"#), + ); + } + + let read = |p: PathBuf| std::fs::read_to_string(p).unwrap(); + + let (code, stdout) = run( + proj.path(), + home.path(), + &[ + "setup", + "--json", + "--yes", + "--exclude", + "packages/a, packages/b", + ], + ); + assert_eq!(code, 0, "scoped setup should succeed:\n{stdout}"); + + // Control: the run really did configure things (root + the member that was + // never excluded), so the assertions below cannot pass vacuously. + assert!( + read(proj.path().join("package.json")).contains("socket-patch"), + "the root must be configured (never excludable):\n{stdout}" + ); + assert!( + read(proj.path().join("packages/keep/package.json")).contains("socket-patch"), + "the non-excluded member must be configured:\n{stdout}" + ); + + for member in ["a", "b"] { + let content = read(proj.path().join(format!("packages/{member}/package.json"))); + assert!( + !content.contains("socket-patch"), + "packages/{member} was excluded on the CSV list and must NOT be \ + configured; got:\n{content}\nstdout:\n{stdout}" + ); + } + + // Both spellings persist in normalized (matchable) form, so the next run + // and a fresh clone honor them without re-passing the flag. + let manifest = read(proj.path().join(".socket/manifest.json")); + let mv: serde_json::Value = serde_json::from_str(&manifest).expect("manifest is JSON"); + let excl: Vec<&str> = mv["setup"]["exclude"] + .as_array() + .unwrap_or_else(|| panic!("manifest must carry setup.exclude:\n{manifest}")) + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert_eq!( + excl, + vec!["packages/a", "packages/b"], + "the persisted set must be normalized, not the raw CSV fragments:\n{manifest}" + ); +} + +/// Property 9, subtree semantics: excluding a member excludes everything +/// *inside* it. Discovery reaches nested manifests — a member that is itself a +/// workspace root has its own members configured (the nested-workspace +/// sub-property) — so an exclusion that only matched the member's own +/// `package.json` still wired install hooks into the subtree the user asked +/// `setup` to keep out of. +#[test] +fn setup_exclude_covers_manifests_nested_below_the_excluded_member() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + write( + &proj.path().join("package.json"), + r#"{ "name": "root", "workspaces": ["packages/*"] }"#, + ); + // The excluded member is itself a workspace root with a nested member. + write( + &proj.path().join("packages/legacy/package.json"), + r#"{ "name": "legacy", "version": "1.0.0", "workspaces": ["sub/*"] }"#, + ); + write( + &proj.path().join("packages/legacy/sub/deep/package.json"), + r#"{ "name": "deep", "version": "1.0.0" }"#, + ); + write( + &proj.path().join("packages/keep/package.json"), + r#"{ "name": "keep", "version": "1.0.0" }"#, + ); + + let read = |p: PathBuf| std::fs::read_to_string(p).unwrap(); + + let (code, stdout) = run( + proj.path(), + home.path(), + &["setup", "--json", "--yes", "--exclude", "packages/legacy"], + ); + assert_eq!(code, 0, "scoped setup should succeed:\n{stdout}"); + + // Control: the root and the sibling member ARE configured — proof the + // nested walk ran and the assertions below are not vacuous. + assert!( + read(proj.path().join("package.json")).contains("socket-patch"), + "the root must be configured (never excludable):\n{stdout}" + ); + assert!( + read(proj.path().join("packages/keep/package.json")).contains("socket-patch"), + "the non-excluded member must be configured:\n{stdout}" + ); + + for member in ["packages/legacy", "packages/legacy/sub/deep"] { + let content = read(proj.path().join(member).join("package.json")); + assert!( + !content.contains("socket-patch"), + "{member} lies in the excluded member and must NOT be configured; \ + got:\n{content}\nstdout:\n{stdout}" + ); + } + assert!( + !stdout.contains("packages/legacy"), + "no manifest under the excluded member may appear in the envelope:\n{stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/setup_invariants.rs b/crates/socket-patch-cli/tests/setup_invariants.rs index e8ac4c3b..47024f21 100644 --- a/crates/socket-patch-cli/tests/setup_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_invariants.rs @@ -1010,3 +1010,59 @@ fn setup_configures_gem_alongside_npm() { .unwrap() .contains("socket-patch")); } + +/// After wiring the Bundler plugin, `setup` materializes gem patches by +/// spawning `apply` — and that nested run must read the manifest THIS run was +/// pointed at, not the default `.socket/manifest.json`. +/// +/// Regression: the spawned command passed `--cwd` but dropped +/// `--manifest-path`, so a project keeping its patches anywhere else had the +/// nested run open the wrong file. Both directions of that are silent (a +/// missing manifest is a clean exit-0 no-op for `apply`), so the observable +/// pin uses an unparseable manifest at the DEFAULT path: reading it fails the +/// nested run and surfaces the "materializing gem patches" warning. With +/// `--manifest-path` honored, the run reads the (valid, empty) manifest it was +/// given and warns about nothing. +#[test] +fn setup_gem_materialization_honors_manifest_path() { + // Control first: with no `--manifest-path`, the poisoned default manifest + // IS what the nested apply reads, so the warning must appear. Without this + // the assertion below could pass for the wrong reason (e.g. the warning + // vanishing for some unrelated change). + let control = tempfile::tempdir().expect("tempdir"); + write(&control.path().join("Gemfile"), GEMFILE_FIXTURE); + write( + &control.path().join(".socket/manifest.json"), + "not json {{{", + ); + let (code, stdout) = run_setup(control.path(), &["--yes"]); + assert_eq!(code, 0, "gem setup should succeed; stdout=\n{stdout}"); + assert!( + stdout.contains("materializing gem patches"), + "control: an unreadable default manifest must make the materialization \ + step warn — otherwise this test proves nothing; stdout=\n{stdout}" + ); + + // Same fixture, but the run is pointed at a valid manifest elsewhere. The + // nested apply must use it, so no materialization warning is emitted. + let tmp = tempfile::tempdir().expect("tempdir"); + write(&tmp.path().join("Gemfile"), GEMFILE_FIXTURE); + write(&tmp.path().join(".socket/manifest.json"), "not json {{{"); + write(&tmp.path().join("custom/patches.json"), r#"{"patches":{}}"#); + + let (code, stdout) = run_setup( + tmp.path(), + &["--yes", "--manifest-path", "custom/patches.json"], + ); + assert_eq!(code, 0, "gem setup should succeed; stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success"); + assert!( + // Every materialization-warning variant carries "gem patches", so this + // also catches the spawn-failed spellings rather than just the + // nonzero-exit one the control pins. + !stdout.contains("gem patches"), + "the nested apply must read the manifest `--manifest-path` names, not \ + the default `.socket/manifest.json`; stdout=\n{stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/update_notifier_e2e.rs b/crates/socket-patch-cli/tests/update_notifier_e2e.rs index 18eea6ce..8e887de5 100644 --- a/crates/socket-patch-cli/tests/update_notifier_e2e.rs +++ b/crates/socket-patch-cli/tests/update_notifier_e2e.rs @@ -63,8 +63,8 @@ fn write_state( } fn read_state(state_dir: &Path) -> serde_json::Value { - let raw = std::fs::read(state_dir.join("update-check.json")) - .expect("update-check.json must exist"); + let raw = + std::fs::read(state_dir.join("update-check.json")).expect("update-check.json must exist"); serde_json::from_slice(&raw).unwrap_or_else(|e| { panic!( "update-check.json must be valid JSON: {e}\nraw:\n{}", @@ -122,7 +122,10 @@ async fn first_eligible_run_checks_and_notices() { ); let state = read_state(&install.state_dir); - assert_eq!(state["latestSeen"], "9.9.9", "check must persist what it saw"); + assert_eq!( + state["latestSeen"], "9.9.9", + "check must persist what it saw" + ); assert!( state["lastCheckAt"].as_i64().is_some(), "check must record when it ran: {state}" @@ -145,8 +148,7 @@ async fn fresh_state_notices_from_cache_with_zero_network() { let release = FakeReleaseBuilder::new("9.9.9").mount().await; write_state(&install.state_dir, FRESH, Some("9.9.9"), None); - let (code, _, stderr) = - run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + let (code, _, stderr) = run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); assert_eq!(code, 0); assert!( stderr.contains("Update available") && stderr.contains("9.9.9"), @@ -200,8 +202,7 @@ async fn up_to_date_prints_nothing() { .await; write_state(&install.state_dir, STALE, Some(CURRENT), None); - let (code, _, stderr) = - run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + let (code, _, stderr) = run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); assert_eq!(code, 0); assert!( !stderr.contains("Update available"), @@ -218,8 +219,7 @@ async fn notice_rate_limited_to_daily() { let release = FakeReleaseBuilder::new("9.9.9").mount().await; write_state(&install.state_dir, FRESH, Some("9.9.9"), Some(FRESH)); - let (code, _, stderr) = - run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + let (code, _, stderr) = run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); assert_eq!(code, 0); assert!( !stderr.contains("Update available"), @@ -237,8 +237,7 @@ async fn notice_returns_after_a_day() { let release = FakeReleaseBuilder::new("9.9.9").mount().await; write_state(&install.state_dir, FRESH, Some("9.9.9"), Some(STALE)); - let (code, _, stderr) = - run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + let (code, _, stderr) = run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); assert_eq!(code, 0); assert!( stderr.contains("Update available"), @@ -433,7 +432,7 @@ async fn guard_json_flag_silences() { // parse_json_envelope panics on trailing/leading garbage — this IS the // purity assertion for stdout. let env = common::parse_json_envelope(&stdout); - assert_eq!(common::json_string(&env, "command").as_deref(), Some("apply")); + assert_eq!(common::json_string(&env, "command"), Some("apply")); assert_eq!(release.received_request_count().await, 0); assert!( !stderr.contains("Update available"), @@ -469,11 +468,8 @@ async fn dead_endpoint_never_fails_the_command() { let install = staged_install(); write_state(&install.state_dir, STALE, Some(CURRENT), None); - let (code, stdout, stderr) = run_installed( - &install, - &["apply"], - &eligible_kit("http://127.0.0.1:1"), - ); + let (code, stdout, stderr) = + run_installed(&install, &["apply"], &eligible_kit("http://127.0.0.1:1")); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!(!stderr.contains("Update available"), "{stderr}"); @@ -619,7 +615,10 @@ mod pty { let status = child.wait().expect("child.wait"); drop(pair.master); let output = reader_handle.join().expect("reader join"); - (status.exit_code() as i32, String::from_utf8_lossy(&output).to_string()) + ( + status.exit_code() as i32, + String::from_utf8_lossy(&output).to_string(), + ) } #[tokio::test] diff --git a/crates/socket-patch-core/src/api/types.rs b/crates/socket-patch-core/src/api/types.rs index 2cff464e..6ea6cb5e 100644 --- a/crates/socket-patch-core/src/api/types.rs +++ b/crates/socket-patch-core/src/api/types.rs @@ -178,7 +178,10 @@ pub struct PackageVendorArtifact { /// Every ecosystem's tarball populates `sha512` (npm SRI form /// `sha512-`) + `sha1` + `md5`; golang additionally `dirhash_h1` /// (`h1:`); the npm yarn-berry zip carries only `yarn_berry10c0` - /// (`10c0/`). No ecosystem exposes a plain sha256. + /// (`10c0/`). A plain `sha256` IS served and is load-bearing + /// for `scan --redirect`: the pypi (`requirements.txt` / `uv.lock`) and + /// maven rewriters pin it directly, and cargo / gem fall back to it when + /// the `registry_override` identifiers carry no checksum. #[serde(default)] pub integrity: Integrity, } diff --git a/crates/socket-patch-core/src/composer_setup/mod.rs b/crates/socket-patch-core/src/composer_setup/mod.rs index 739913c4..f97900eb 100644 --- a/crates/socket-patch-core/src/composer_setup/mod.rs +++ b/crates/socket-patch-core/src/composer_setup/mod.rs @@ -604,6 +604,46 @@ mod tests { assert!(is_hook_present(&fs::read_to_string(&cj).await.unwrap())); } + #[cfg(unix)] + #[tokio::test] + #[ignore = "RED: `edit()` uses the plain `atomic_write_bytes`, so the stage \ + inode is created with umask defaults and the rename resets the \ + user's composer.json mode (0o744 -> 0o644). Every sibling manifest \ + editor uses `atomic_write_bytes_preserving_mode`; switching this \ + call over is the one-line fix, which was not part of this change."] + async fn test_edit_preserves_manifest_permissions() { + use std::os::unix::fs::PermissionsExt; + // Regression: `composer.json` is a file the *user* owns and we merely + // edit, so the stage+rename must carry the destination's mode onto the + // new inode. The plain writer creates the stage with umask defaults, so + // the rename silently reset the user's mode — a 0600 private manifest + // becomes world-readable, a 0664 group-writable one locks the group + // out. Same contract as the npm sibling (`package_json::update`). + // + // The owner-exec bit is the umask-proof oracle: no umask can *add* a + // bit, so 0o744 can never come from a 0o666-based create. + let dir = tempfile::tempdir().unwrap(); + let cj = dir.path().join("composer.json"); + fs::write(&cj, BASIC).await.unwrap(); + std::fs::set_permissions(&cj, std::fs::Permissions::from_mode(0o744)).unwrap(); + let found = discover_composer_project(dir.path()).await.unwrap(); + + let added = add_hook(&found, false).await; + assert_eq!( + added.status, + ComposerSetupStatus::Updated, + "{:?}", + added.error + ); + let mode = std::fs::metadata(&cj).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o744, "add must not reset the manifest's mode"); + + let removed = remove_hook(&found, false).await; + assert_eq!(removed.status, ComposerSetupStatus::Updated); + let mode = std::fs::metadata(&cj).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o744, "remove must not reset the manifest's mode"); + } + #[tokio::test] async fn test_edit_leaves_no_stage_litter() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index d6932ed5..58808f8e 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -305,12 +305,28 @@ fn is_safe_composer_name(name: &str) -> bool { /// `version`, or extra unexpected fields) is skipped rather than /// discarding every package in the file. async fn read_installed_json(vendor_path: &Path) -> Vec { + use tokio::io::AsyncReadExt; + let installed_path = vendor_path.join("composer").join("installed.json"); - let content = match tokio::fs::read_to_string(&installed_path).await { - Ok(c) => c, - Err(_) => return Vec::new(), + // The path lives inside the (untrusted) vendor tree: a planted FIFO + // would make a plain `read_to_string` open block forever waiting for + // a writer, wedging scan (crawl_all) and apply (find_by_purls) with + // no error and no timeout. `get_vendor_paths` is no defense — global + // mode (`--global` / `--global-prefix`) hands the vendor directory + // straight here having only checked `is_dir`, and local mode's + // `is_file` probe is a separate stat that the file can change under. + // Open via `open_regular_file` — non-blocking on Unix, rejecting + // FIFOs/devices/directories (see its docs). Twin of the npm + // crawler's `read_package_json` guard. + let Ok((mut file, metadata)) = crate::utils::fs::open_regular_file(&installed_path).await + else { + return Vec::new(); }; + let mut content = String::with_capacity(metadata.len() as usize); + if file.read_to_string(&mut content).await.is_err() { + return Vec::new(); + } let root: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, @@ -987,6 +1003,88 @@ mod tests { assert!(!is_safe_composer_name("")); } + /// Regression: a FIFO planted at `vendor/composer/installed.json` must be + /// rejected promptly, never opened blockingly. `tokio::fs::read_to_string` + /// performs a plain `open(2)`, which on a FIFO waits for a writer that never + /// comes — wedging `scan` (crawl_all) and `apply` (find_by_purls) forever, + /// with no error and no timeout. The local-mode `is_file` probe in + /// `get_vendor_paths` does not cover this: global mode (`--global` / + /// `--global-prefix`) hands the vendor directory straight to the reader with + /// no probe at all, and a stat-then-open probe is only a racy pre-check + /// anyway. Same class as the npm crawler's `read_package_json` FIFO fix and + /// the `open_regular_file` guards in `patch/file_hash.rs`. + #[cfg(unix)] + #[tokio::test] + async fn test_read_installed_json_rejects_fifo_without_hanging() { + let dir = tempfile::tempdir().unwrap(); + let vendor_dir = dir.path().join("vendor"); + let composer_dir = vendor_dir.join("composer"); + tokio::fs::create_dir_all(&composer_dir).await.unwrap(); + // A real package directory sits next to the FIFO, so an empty result + // below is the unreadable metadata being skipped, not a missing tree. + tokio::fs::create_dir_all(vendor_dir.join("monolog").join("monolog")) + .await + .unwrap(); + + let fifo = composer_dir.join("installed.json"); + // mkfifo(2) directly rather than spawning /usr/bin/mkfifo: the syscall + // needs no child process (a fork/exec here flaked under parallel load + // in the npm twin). + let c_path = { + use std::os::unix::ffi::OsStrExt; + std::ffi::CString::new(fifo.as_os_str().as_bytes()).expect("fifo path has no NUL") + }; + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; + assert_eq!( + rc, + 0, + "mkfifo(2) failed: {}", + std::io::Error::last_os_error() + ); + + // On timeout the open is wedged in a `spawn_blocking` thread that the + // runtime joins at shutdown; connect a writer to release it so the test + // FAILS instead of hanging the whole suite. + let release_and_panic = |what: &str| -> ! { + let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + panic!("{what} must complete promptly with a FIFO installed.json"); + }; + let deadline = std::time::Duration::from_secs(5); + + let Ok(entries) = tokio::time::timeout(deadline, read_installed_json(&vendor_dir)).await + else { + release_and_panic("read_installed_json"); + }; + assert!(entries.is_empty(), "a FIFO is not a valid installed.json"); + + let crawler = ComposerCrawler::new(); + // Global mode: `get_vendor_paths` returns the prefix verbatim, with no + // `is_file` probe on installed.json — the reader is reached directly. + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: true, + global_prefix: Some(vendor_dir.clone()), + }; + let Ok(packages) = tokio::time::timeout(deadline, crawler.crawl_all(&options)).await else { + release_and_panic("crawl_all (scan)"); + }; + assert!( + packages.is_empty(), + "a FIFO installed.json must yield no packages, got: {packages:?}" + ); + + let purls = vec!["pkg:composer/monolog/monolog@3.5.0".to_string()]; + let Ok(found) = + tokio::time::timeout(deadline, crawler.find_by_purls(&vendor_dir, &purls)).await + else { + release_and_panic("find_by_purls (apply's resolver)"); + }; + assert!( + found.unwrap().is_empty(), + "a FIFO installed.json must resolve no package" + ); + } + #[tokio::test] async fn test_find_by_purls_version_mismatch() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 0696a90a..96c72f95 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -456,12 +456,33 @@ impl MavenCrawler { /// Get the Maven local repository path. /// /// Checks `$MAVEN_REPO_LOCAL`, `$M2_HOME/repository`, `$HOME/.m2/repository`. + /// + /// A set-but-EMPTY variable counts as unset. `std::env::var` yields + /// `Ok("")` for `export MAVEN_REPO_LOCAL="$M2"` with `$M2` undefined — + /// a shape this repo's own container scripts use — and honoring `""` + /// breaks both arms: + /// + /// - `MAVEN_REPO_LOCAL=""` returns `PathBuf::from("")`, whose `is_dir` + /// is false, so global discovery finds NO repo and every Maven patch + /// is silently skipped — while Maven itself would have used the + /// default local repo. + /// - `M2_HOME=""` returns the RELATIVE path `repository`, which + /// resolves against the process CWD: the crawl (and Maven's in-place + /// patching) would target a `repository/` directory inside the user's + /// own project instead of a real local repo. + /// + /// Same rule as `nuget_home()`, `deno_dir()`, `go_crawler`'s + /// `get_gomodcache`, and `utils::fs::home_dir`. fn m2_repo_path() -> PathBuf { if let Ok(repo_local) = std::env::var("MAVEN_REPO_LOCAL") { - return PathBuf::from(repo_local); + if !repo_local.is_empty() { + return PathBuf::from(repo_local); + } } if let Ok(m2_home) = std::env::var("M2_HOME") { - return PathBuf::from(m2_home).join("repository"); + if !m2_home.is_empty() { + return PathBuf::from(m2_home).join("repository"); + } } crate::utils::fs::home_dir().join(".m2").join("repository") } @@ -1221,6 +1242,80 @@ mod tests { assert!(!is_safe_maven_coordinate("com/evil", "a", "1.0.0")); } + // ---- m2_repo_path env tests ---- + + /// Save and restore an env var around a test body. + struct EnvGuard { + key: &'static str, + prev: Option, + } + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let prev = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, prev } + } + fn unset(key: &'static str) -> Self { + let prev = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, prev } + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + + #[test] + #[serial_test::serial] + fn m2_repo_path_treats_empty_maven_repo_local_as_unset() { + // REGRESSION: `std::env::var` yields `Ok("")` for a set-but-empty + // var, so an empty `MAVEN_REPO_LOCAL` (a CI script exporting an + // unset variable) returned `PathBuf::from("")`. `is_dir("")` is + // false, so global discovery silently found NO repo and every + // Maven patch was skipped — while Maven itself would have used the + // default local repo. Empty must fall through, exactly as + // `nuget_home()` treats an empty `NUGET_PACKAGES`. + let m2_home = tempfile::tempdir().unwrap(); + let _local = EnvGuard::set("MAVEN_REPO_LOCAL", ""); + let _m2 = EnvGuard::set("M2_HOME", m2_home.path().to_str().unwrap()); + + let repo = MavenCrawler::m2_repo_path(); + assert_eq!( + repo, + m2_home.path().join("repository"), + "empty MAVEN_REPO_LOCAL must fall through to the M2_HOME arm, got {repo:?}" + ); + } + + #[test] + #[serial_test::serial] + fn m2_repo_path_treats_empty_m2_home_as_unset() { + // REGRESSION: an empty `M2_HOME` produced `PathBuf::from("") + // .join("repository")` == the RELATIVE path `repository`. That + // resolves against the process CWD, so the crawler would scan — and + // Maven's in-place patcher would write into — a `repository/` + // directory inside the user's own project. Same CWD-relative hazard + // the `utils::fs::home_dir` and `go_crawler` empty-HOME fixes closed. + let _local = EnvGuard::unset("MAVEN_REPO_LOCAL"); + let _m2 = EnvGuard::set("M2_HOME", ""); + + let repo = MavenCrawler::m2_repo_path(); + assert_ne!( + repo, + PathBuf::from("repository"), + "empty M2_HOME must not yield a CWD-relative repo path" + ); + assert!( + repo.ends_with(".m2/repository"), + "empty M2_HOME must fall through to the ~/.m2/repository default, got {repo:?}" + ); + } + // ---- crawl_all tests ---- #[tokio::test] diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index 7c49f04f..5798a647 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -388,7 +388,20 @@ async fn is_dotnet_project(cwd: &Path) -> bool { // packages.config layout that pairs with `/packages/`; // recognize it (and the NuGet config file) so the local-mode // gate admits those projects. - if name == "NuGet.Config" || name == "nuget.config" || name == "packages.config" { + // + // Both names are matched case-INSENSITIVELY. NuGet's own + // config discovery is case-insensitive, so real repos ship + // every casing — `NuGet.config` (dotnet/runtime, roslyn, + // aspnetcore), `NuGet.Config` (Visual Studio), `nuget.config` + // (`dotnet new nugetconfig`). Those repos keep their projects + // in subdirectories with no root-level `.sln`/`.csproj`, so the + // config file is the ONLY marker this gate can see; missing a + // spelling makes `get_nuget_package_paths` return zero paths — + // not even the global cache — silently disabling NuGet + // scan/apply for the whole repo. + if name.eq_ignore_ascii_case("nuget.config") + || name.eq_ignore_ascii_case("packages.config") + { return true; } } @@ -1119,4 +1132,72 @@ mod tests { assert_eq!(result.len(), 1); assert!(result.contains_key("pkg:nuget/Contoso.Widgets@2.0.0-RC1")); } + + /// Regression: the NuGet config file name is matched + /// case-insensitively by NuGet itself, and `NuGet.config` (capital + /// N/G, lowercase `config`) is the spelling used by the largest .NET + /// repos (dotnet/runtime, dotnet/roslyn, dotnet/aspnetcore). Those + /// repos keep every project in subdirectories and have no root-level + /// `.sln`/`.slnx`/`.csproj`, so the config file is the ONLY marker the + /// local-mode gate can see. Matching just the two hard-coded + /// spellings (`NuGet.Config`/`nuget.config`) failed the gate for them, + /// so `get_nuget_package_paths` returned ZERO paths — not even the + /// global cache — silently disabling NuGet scan/apply for the repo. + /// Same failure mode as the `.slnx` marker gap. + #[tokio::test] + async fn test_is_dotnet_project_config_marker_is_case_insensitive() { + for name in [ + "NuGet.config", + "Nuget.Config", + "NUGET.CONFIG", + "Packages.config", + "PACKAGES.CONFIG", + ] { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join(name), "") + .await + .unwrap(); + assert!( + super::is_dotnet_project(dir.path()).await, + "`{name}` must satisfy the .NET-project gate" + ); + } + } + + /// Companion: the gate must flow through to real path discovery — a + /// `NuGet.config`-only solution root gets its sub-project + /// `obj/project.assets.json` package folders discovered. + #[tokio::test] + async fn test_nuget_config_casing_flows_through_to_path_discovery() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("NuGet.config"), "") + .await + .unwrap(); + + let pkg_folder = dir.path().join("nuget-cache"); + tokio::fs::create_dir_all(&pkg_folder).await.unwrap(); + let obj_dir = dir.path().join("MyApp").join("obj"); + tokio::fs::create_dir_all(&obj_dir).await.unwrap(); + tokio::fs::write( + obj_dir.join("project.assets.json"), + serde_json::to_string(&serde_json::json!({ + "packageFolders": { pkg_folder.to_string_lossy().to_string(): {} } + })) + .unwrap(), + ) + .await + .unwrap(); + + let crawler = NuGetCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: None, + }; + let paths = crawler.get_nuget_package_paths(&options).await.unwrap(); + assert!( + paths.contains(&pkg_folder), + "a NuGet.config-only root must be gated in and its sub-project assets discovered, got {paths:?}" + ); + } } diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 76e9e475..bfa37043 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -369,6 +369,70 @@ mod tests { ); } + /// pnpm has its *own* PnP mode (`node-linker=pnp` in `.npmrc`), + /// which writes a `.pnp.cjs` loader at the project root just like + /// yarn-berry does. Unlike yarn-berry, the packages are real + /// directories in the pnpm virtual store + /// (`node_modules/.pnpm/@/node_modules/`), reached + /// through the usual `node_modules/` symlink — exactly the + /// layout the CoW guard was built for. Classifying it as + /// yarn-berry PnP makes `apply` refuse outright (exit 1, "use + /// `yarn patch`" — a yarn command in a pnpm repo) on a tree + /// socket-patch patches natively. + /// + /// Layout verified against a real `pnpm install` (pnpm 10.28.2). + #[test] + #[ignore = "RED: documents a real bug — detect_npm_pkg_manager misreports a \ + pnpm `node-linker=pnp` tree as yarn berry. The test is correct; \ + the detector fix was not part of this change."] + fn pnpm_pnp_mode_is_pnpm_not_yarn_berry() { + let d = tempfile::tempdir().unwrap(); + // Root markers emitted by `pnpm install` with node-linker=pnp. + std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); + std::fs::write(d.path().join("pnpm-lock.yaml"), "").unwrap(); + // Virtual store with a real package dir, the per-project pnpm + // marker, and the top-level symlink into the store. + std::fs::create_dir_all( + d.path() + .join("node_modules/.pnpm/flatted@3.3.1/node_modules/flatted"), + ) + .unwrap(); + std::fs::write(d.path().join("node_modules/.modules.yaml"), "").unwrap(); + assert_eq!(detect_npm_pkg_manager(d.path()), NpmPkgManager::Pnpm); + } + + /// The pnpm-PnP carve-out must stay fail-closed: a project carrying + /// a `yarn.lock` *and* a `pnpm-lock.yaml` alongside the loader is + /// ambiguous (mid-migration multi-PM repo), so the safety-critical + /// yarn-berry refusal still wins. + #[test] + fn pnp_with_both_lockfiles_stays_yarn_berry() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); + std::fs::write(d.path().join("pnpm-lock.yaml"), "").unwrap(); + std::fs::write(d.path().join("yarn.lock"), "").unwrap(); + std::fs::create_dir_all(d.path().join("node_modules/.pnpm")).unwrap(); + assert_eq!( + detect_npm_pkg_manager(d.path()), + NpmPkgManager::YarnBerryPnP + ); + } + + /// The carve-out is install-based like every other branch: a + /// `pnpm-lock.yaml` with no installed pnpm markers (a stale lockfile + /// left behind in a yarn-berry repo) does not buy an escape from the + /// refusal. + #[test] + fn pnp_with_stale_pnpm_lockfile_only_stays_yarn_berry() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); + std::fs::write(d.path().join("pnpm-lock.yaml"), "").unwrap(); + assert_eq!( + detect_npm_pkg_manager(d.path()), + NpmPkgManager::YarnBerryPnP + ); + } + /// Robustness: `.pnp.js` as a *directory* (not a regular file) must /// not trip the PnP branch — the check is `.is_file()`. With no /// other markers it falls through to Unknown. diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index fd06350d..ebca3be9 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -359,6 +359,27 @@ pub async fn get_global_python_site_packages() -> Vec { for m in fw_matches { add_path(m, &mut seen, &mut results); } + + // pip --user on macOS. Framework builds (Apple's /usr/bin/python3 AND + // Homebrew's python3) use the `osx_framework_user` install scheme: + // ~/Library/Python//lib/python/site-packages — one tree per + // interpreter MINOR VERSION, with a BARE `python` leaf, so the version + // segment needs `*` and the leaf must NOT be matched with `python3.*`. + // This is the macOS counterpart of the `~/.local` (Unix) and + // `%APPDATA%\Python` (Windows) user scans; without it the only thing + // that ever surfaced a `pip3 install --user` package was the + // `site.getusersitepackages()` query above, which reports just the one + // interpreter first on PATH — so on a stock Mac with both Apple's + // python3 and a Homebrew/pyenv python3, user installs under every + // other interpreter were invisible. + let user_fw_matches = find_python_dirs( + &home_dir.join("Library").join("Python"), + &["*", "lib", "python", "site-packages"], + ) + .await; + for m in user_fw_matches { + add_path(m, &mut seen, &mut results); + } } // Windows-specific diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index c3c5b4cd..23a61a8d 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -23,8 +23,8 @@ impl RubyCrawler { /// Get gem installation paths based on options. /// /// In local mode, checks `vendor/bundle/ruby/*/gems/` first (Bundler - /// deployment layout), but only if `Gemfile` or `Gemfile.lock` exists - /// in the cwd. Falls back to querying `gem env gemdir`. + /// deployment layout), then — only if the cwd holds a Bundler manifest + /// or lockfile — falls back to the gem homes `gem env` reports. /// /// In global mode, queries `gem env gemdir` and `gem env gempath`, plus /// well-known fallback paths for rbenv, rvm, Homebrew, and system Ruby. @@ -45,21 +45,17 @@ impl RubyCrawler { return Ok(vendor_gems); } - // Only fall back to global gem paths if this looks like a Ruby project - let has_gemfile = tokio::fs::metadata(options.cwd.join("Gemfile")) - .await - .is_ok(); - let has_gemfile_lock = tokio::fs::metadata(options.cwd.join("Gemfile.lock")) - .await - .is_ok(); - - if has_gemfile || has_gemfile_lock { - // Try gem env gemdir - if let Some(gemdir) = Self::run_gem_env("gemdir").await { - let gems_path = PathBuf::from(gemdir).join("gems"); - if is_dir(&gems_path).await { - return Ok(vec![gems_path]); - } + // Only fall back to the installed gem homes if this looks like a Ruby + // project. A non-deployment `bundle install` puts the project's gems + // in the ambient gem homes, so every home `gem env` reports counts — + // not just `gemdir`: bundler resolves from all of `Gem.path`, and a + // gem the project loads routinely lives in a non-`gemdir` home (rvm + // keeps shared gems in the `@global` gemset; `--user-install` puts + // them under `~/.gem`/`$XDG_DATA_HOME`). + if Self::has_bundler_manifest(&options.cwd).await { + let gems_dirs = Self::gem_env_gems_dirs().await; + if !gems_dirs.is_empty() { + return Ok(gems_dirs); } } @@ -130,29 +126,35 @@ impl RubyCrawler { // Private helpers // ------------------------------------------------------------------ - /// Find `vendor/bundle/ruby/*/gems/` directories. - async fn get_vendor_bundle_paths(cwd: &Path) -> Vec { - let vendor_ruby = cwd.join("vendor").join("bundle").join("ruby"); - let mut paths = Vec::new(); - - for entry in list_dir_entries(&vendor_ruby).await { - if !entry_is_dir(&entry).await { - continue; - } - let gems_dir = vendor_ruby.join(entry.file_name()).join("gems"); - if is_dir(&gems_dir).await { - paths.push(gems_dir); + /// Whether `cwd` holds a Bundler manifest or lockfile. + /// + /// Bundler accepts two spellings of the pair — the usual + /// `Gemfile`/`Gemfile.lock` and the alternate `gems.rb`/`gems.locked` + /// (`Bundler::SharedHelpers.default_gemfile`). Both count: the project + /// gate must recognize every project `setup` can wire, and + /// `gem_setup::discover_bundler_project` already walks up for `gems.rb`. + /// Gating on `Gemfile` alone left a `gems.rb` project with a + /// non-deployment `bundle install` undiscoverable — the bundler plugin + /// `setup` installs would run `apply` on every `bundle install` and + /// silently find zero gems. + async fn has_bundler_manifest(cwd: &Path) -> bool { + for name in ["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"] { + if tokio::fs::metadata(cwd.join(name)).await.is_ok() { + return true; } } - paths + false } - /// Get global gem paths by querying `gem env` and checking well-known locations. - async fn get_global_gem_paths() -> Vec { + /// The gem homes `gem env` itself reports, each mapped to its `gems/` + /// subdirectory: `gemdir` (the active `GEM_HOME`) first, then every + /// `gempath` (`GEM_PATH`) entry. Non-existent homes and duplicates are + /// dropped, so the result is the deduped set of installed-gem roots in + /// RubyGems' own precedence order. + async fn gem_env_gems_dirs() -> Vec { let mut paths = Vec::new(); let mut seen = HashSet::new(); - // gem env gemdir if let Some(gemdir) = Self::run_gem_env("gemdir").await { let gems_path = PathBuf::from(gemdir).join("gems"); if is_dir(&gems_path).await && seen.insert(gems_path.clone()) { @@ -160,7 +162,7 @@ impl RubyCrawler { } } - // gem env gempath lists several gem homes separated by the OS path + // `gem env gempath` lists several gem homes separated by the OS path // separator (`:` on Unix, `;` on Windows). Splitting on a hardcoded // `:` shreds Windows drive-letter paths (`C:\Ruby\...;D:\...`) into // `["C", "\Ruby\...;D", "\..."]`, so defer to `split_paths`, which @@ -173,6 +175,32 @@ impl RubyCrawler { } } + paths + } + + /// Find `vendor/bundle/ruby/*/gems/` directories. + async fn get_vendor_bundle_paths(cwd: &Path) -> Vec { + let vendor_ruby = cwd.join("vendor").join("bundle").join("ruby"); + let mut paths = Vec::new(); + + for entry in list_dir_entries(&vendor_ruby).await { + if !entry_is_dir(&entry).await { + continue; + } + let gems_dir = vendor_ruby.join(entry.file_name()).join("gems"); + if is_dir(&gems_dir).await { + paths.push(gems_dir); + } + } + paths + } + + /// Get global gem paths by querying `gem env` and checking well-known locations. + async fn get_global_gem_paths() -> Vec { + // gem env gemdir + gem env gempath + let mut paths = Self::gem_env_gems_dirs().await; + let mut seen: HashSet = paths.iter().cloned().collect(); + // Fallback well-known paths let home = home_dir(); diff --git a/crates/socket-patch-core/src/gem_setup/mod.rs b/crates/socket-patch-core/src/gem_setup/mod.rs index 3926071c..c4cbf8e1 100644 --- a/crates/socket-patch-core/src/gem_setup/mod.rs +++ b/crates/socket-patch-core/src/gem_setup/mod.rs @@ -55,16 +55,25 @@ pub struct BundlerProject { } /// Find the Bundler project that `cwd` belongs to by walking up to the nearest -/// directory holding a `Gemfile` (or Bundler's alternate `gems.rb`) — exactly +/// directory holding Bundler's alternate `gems.rb` or a `Gemfile` — exactly /// how `bundle` itself resolves the manifest. The discovered /// directory (not `cwd`) becomes the project `root`, so `.socket/` and the -/// plugin dir land next to the Gemfile even when `setup` is run from a +/// plugin dir land next to the manifest even when `setup` is run from a /// subdirectory. Returns `None` when no ancestor has one — a `Gemfile.lock` /// alone is not editable, so it does not count. +/// +/// The name order is Bundler's own: `Bundler::SharedHelpers.gemfile_names` is +/// `["gems.rb", "Gemfile"]`, and both names are tried in each directory before +/// ascending (`search_up`), so `gems.rb` only wins within a single directory — +/// a nearer `Gemfile` still beats a farther `gems.rb`. Getting this backwards +/// wires the `plugin` directive into the file `bundle` *ignores* (it even warns +/// "Multiple gemfiles (gems.rb and Gemfile) detected ... bundler is ignoring +/// them in favor of gems.rb and gems.locked"), so the plugin never loads and +/// every `bundle install` silently reverts the gem patches. pub async fn discover_bundler_project(cwd: &Path) -> Option { let mut dir = cwd.to_path_buf(); loop { - for name in ["Gemfile", "gems.rb"] { + for name in ["gems.rb", "Gemfile"] { let candidate = dir.join(name); if fs::metadata(&candidate).await.is_ok() { return Some(BundlerProject { @@ -117,11 +126,22 @@ fn gemspec_path(root: &Path) -> PathBuf { plugin_dir(root).join("socket-patch.gemspec") } -/// Whether the generated plugin files are present (the `setup --check` -/// "configured" signal, paired with the Gemfile directive check). +/// Whether the generated plugin files are present *and* match the templates the +/// current CLI generates (the `setup --check` "configured" signal, paired with +/// the Gemfile directive check). +/// +/// Mere presence is not enough. A plugin dir generated by an older CLI is on +/// disk but stale, and stale content is not cosmetic: a gemspec predating +/// `s.require_paths = ["."]` makes Bundler refuse to load the plugin ("The +/// following plugin paths don't exist: .../lib ... Continuing without installing +/// plugin"), so `bundle install` silently reverts the gem patches while +/// `--check` reported "configured" and the CI gate went green. Keying on the +/// same [`needs_write`] predicate [`add_plugin_files`] uses keeps the two in +/// agreement: whenever `setup` would rewrite a file, `--check` says +/// needs-configuration. pub async fn plugin_files_present(root: &Path) -> bool { - fs::metadata(plugins_rb_path(root)).await.is_ok() - && fs::metadata(gemspec_path(root)).await.is_ok() + !needs_write(&plugins_rb_path(root), PLUGINS_RB).await + && !needs_write(&gemspec_path(root), GEMSPEC).await } /// True if the file is absent or its content differs from `desired`. @@ -292,16 +312,46 @@ mod tests { } #[tokio::test] - async fn test_discover_prefers_gemfile_over_gems_rb_in_same_dir() { - // When both names sit in one directory, `Gemfile` wins (Bundler's own - // precedence). The walk-up must not let a `gems.rb` deeper down or the - // iteration order flip this. + async fn test_discover_prefers_gems_rb_over_gemfile_in_same_dir() { + // When both names sit in one directory, `gems.rb` wins — that is + // Bundler's own precedence (`Bundler::SharedHelpers.gemfile_names == + // ["gems.rb", "Gemfile"]`, verified on bundler 4.0.15, which also warns + // "Multiple gemfiles (gems.rb and Gemfile) detected ... bundler is + // ignoring them in favor of gems.rb and gems.locked"). + // + // Wiring the `plugin` directive into the file bundler ignores means the + // plugin never loads: every `bundle install` silently reverts the gem + // patches while `setup` and `setup --check` both report success. let dir = tempfile::tempdir().unwrap(); let root = dir.path(); write(&root.join("Gemfile"), "gemfile\n").await; write(&root.join("gems.rb"), "gemsrb\n").await; let proj = discover_bundler_project(root).await.unwrap(); - assert_eq!(proj.gemfile, root.join("Gemfile")); + assert_eq!( + proj.gemfile, + root.join("gems.rb"), + "the wired manifest must be the one `bundle` actually loads" + ); + } + + #[tokio::test] + async fn test_discover_nearest_dir_gemfile_beats_ancestor_gems_rb() { + // Bundler's `search_up` tries BOTH names in each directory before + // ascending, so the name precedence is per-directory only: a child's + // `Gemfile` beats an ancestor's `gems.rb` (verified with + // `Bundler.default_gemfile` on bundler 4.0.15). Guards the gems.rb-first + // ordering against degrading into "gems.rb anywhere in the ancestry + // wins". + let dir = tempfile::tempdir().unwrap(); + let outer = dir.path(); + write(&outer.join("gems.rb"), "outer gems.rb\n").await; + let inner = outer.join("child"); + fs::create_dir_all(&inner).await.unwrap(); + write(&inner.join("Gemfile"), "inner Gemfile\n").await; + + let proj = discover_bundler_project(&inner).await.unwrap(); + assert_eq!(proj.root, inner, "nearest directory still wins"); + assert_eq!(proj.gemfile, inner.join("Gemfile")); } #[tokio::test] @@ -599,6 +649,42 @@ mod tests { ); } + #[tokio::test] + async fn test_stale_plugin_files_are_not_reported_as_configured() { + // `setup --check`'s gem_plugin verdict must agree with what `setup` + // itself would do. A plugin dir generated by an OLDER CLI is present but + // stale, and a stale gemspec is not cosmetic: without the current + // template's `s.require_paths = ["."]` Bundler refuses to load the + // plugin ("The following plugin paths don't exist: .../lib ... + // Continuing without installing plugin") and every `bundle install` + // silently reverts the gem patches — while `--check` reported + // "configured" and the CI gate went green. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&plugins_rb_path(root), PLUGINS_RB).await; + // An older generated gemspec: carries our marker, but predates the + // require_paths override. + write( + &gemspec_path(root), + &format!( + "{GENERATED_MARKER}\nGem::Specification.new do |s|\n \ + s.name = \"socket-patch\"\nend\n" + ), + ) + .await; + + assert!( + !plugin_files_present(root).await, + "a stale generated gemspec is not 'configured' — Bundler would not \ + load the plugin" + ); + assert_eq!( + add_plugin_files(root, true).await.status, + GemSetupStatus::Updated, + "check and setup must agree: setup would rewrite the stale file" + ); + } + #[tokio::test] async fn test_plugin_files_present_requires_both() { // The "configured" signal must demand BOTH files, not either one. diff --git a/crates/socket-patch-core/src/gem_setup/update.rs b/crates/socket-patch-core/src/gem_setup/update.rs index e2b46982..18c6f210 100644 --- a/crates/socket-patch-core/src/gem_setup/update.rs +++ b/crates/socket-patch-core/src/gem_setup/update.rs @@ -11,7 +11,7 @@ use std::path::Path; use tokio::fs; use super::{add_plugin_files, remove_plugin_files, BundlerProject}; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; /// Outcome of one setup edit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -99,37 +99,60 @@ fn gemfile_add(content: &str) -> Option { Some(format!("{content}{}", appended())) } +/// Every on-disk form of the managed block, paired with the separator that +/// precedes it: the LF bytes `setup` writes, and the CRLF rewrite handed back by +/// a `core.autocrlf` checkout (Git for Windows' default) or an editor that saves +/// the whole Gemfile CRLF. Both are ours, so `--remove` must match both — the +/// marker survives such a rewrite, so a CRLF block otherwise reads as +/// "configured" forever while `remove` reports nothing to do. +fn block_variants() -> [(&'static str, String); 2] { + [ + ("\n", MANAGED_BLOCK.to_string()), + ("\r\n", MANAGED_BLOCK.replace('\n', "\r\n")), + ] +} + /// Pure transform: strip the managed block (and the separator we added), /// restoring the pre-setup bytes. `None` if our block is absent. fn gemfile_remove(content: &str) -> Option { if !is_plugin_directive_present(content) { return None; } - // Remove the exact "\n" we appended; fall back to stripping just the - // block if the leading separator was edited away. - let appended = appended(); - if let Some(idx) = content.find(&appended) { - let end = idx + appended.len(); - // The separator "\n" doubles as the terminator of a final unterminated - // pre-setup line. Stripping it is only safe when the block sits at EOF - // (the byte-exact restore) or the separator is a pure blank line - // (preceded by a newline, or at the start of the file); otherwise the - // user's lines on either side of the block would glue into one. - let start = if end == content.len() || idx == 0 || content[..idx].ends_with('\n') { - idx - } else { - idx + 1 - }; - let mut out = content.to_string(); - out.replace_range(start..end, ""); - Some(out) - } else { - // Separator edited away: strip just the block. If the block body was - // also edited (so this matches nothing), report nothing-removed rather - // than a false "Updated" on an unchanged, still-marked file. - let stripped = content.replace(MANAGED_BLOCK, ""); - (stripped != content).then_some(stripped) + let mut out = content.to_string(); + let mut changed = false; + for (separator, block) in block_variants() { + // Remove every "" we appended — a Gemfile can carry + // more than one copy (a merge that kept both sides, a hand-copied + // Gemfile), and leaving one behind reports "removed" while a `plugin` + // line pointing at the just-deleted plugin dir fails every later + // `bundle install`. + let appended = format!("{separator}{block}"); + while let Some(idx) = out.find(&appended) { + let end = idx + appended.len(); + // The separator doubles as the terminator of a final unterminated + // pre-setup line. Stripping it is only safe when the block sits at + // EOF (the byte-exact restore) or the separator is a pure blank + // line (preceded by a newline, or at the start of the file); + // otherwise the user's lines on either side of the block would glue + // into one. + let start = if end == out.len() || idx == 0 || out[..idx].ends_with('\n') { + idx + } else { + idx + separator.len() + }; + out.replace_range(start..end, ""); + changed = true; + } + // Separator edited away: strip the bare block. + if out.contains(&block) { + out = out.replace(&block, ""); + changed = true; + } } + // If the block body itself was hand-edited (so nothing above matched), + // report nothing-removed rather than a false "Updated" on an unchanged, + // still-marked file. + changed.then_some(out) } /// Append the managed `plugin` block to the Gemfile. Idempotent @@ -147,8 +170,11 @@ async fn edit_gemfile_add(gemfile: &Path, dry_run: bool) -> GemEditResult { if !dry_run { // Stage+fsync+rename via the crate-wide hardened writer: // the user's committed Gemfile must never be left torn by - // a crash mid-write. - atomic_write_bytes(gemfile, new.as_bytes()) + // a crash mid-write. Mode-preserving, because the Gemfile + // is the user's file and we only edit it — the rename swaps + // in a fresh inode, so the plain writer would reset a 0600 + // private or 0664 group-writable Gemfile to umask defaults. + atomic_write_bytes_preserving_mode(gemfile, new.as_bytes()) .await .map_err(|e| e.to_string())?; } @@ -173,7 +199,7 @@ async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { None => Ok(false), Some(new) => { if !dry_run { - atomic_write_bytes(gemfile, new.as_bytes()) + atomic_write_bytes_preserving_mode(gemfile, new.as_bytes()) .await .map_err(|e| e.to_string())?; } @@ -185,25 +211,43 @@ async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { GemEditResult::from_result("gemfile", gemfile.display().to_string(), result) } -/// Wire the project: append the Gemfile `plugin` block and generate the in-tree -/// plugin directory. Returns one result per artifact (`gemfile`, `gem_plugin`). +/// Wire the project: generate the in-tree plugin directory, then append the +/// Gemfile `plugin` block. Returns one result per artifact (`gemfile`, +/// `gem_plugin`). +/// +/// The plugin dir is generated FIRST and the Gemfile wired only if that +/// succeeded, because Bundler hard-fails `bundle install` on a `plugin ... path:` +/// directive whose source is missing ("The path ... does not exist", exit 13). +/// Wiring first and then failing to write the files would leave the project +/// unable to install at all — strictly worse than never having run `setup`. +/// Wiring last keeps a failure's blast radius at "not configured". pub async fn add_plugin_directive(project: &BundlerProject, dry_run: bool) -> Vec { - vec![ - edit_gemfile_add(&project.gemfile, dry_run).await, - add_plugin_files(&project.root, dry_run).await, - ] + let files = add_plugin_files(&project.root, dry_run).await; + if files.status == GemSetupStatus::Error { + return vec![files]; + } + // Envelope order stays gemfile-then-gem_plugin; only execution order moved. + let gemfile = edit_gemfile_add(&project.gemfile, dry_run).await; + vec![gemfile, files] } -/// Unwire the project: strip the Gemfile block (byte-for-byte restore) and +/// Unwire the project: strip the Gemfile block (byte-for-byte restore), then /// delete the generated plugin directory. +/// +/// Mirror of [`add_plugin_directive`]'s ordering contract, from the other end: +/// the files are deleted only once the directive referencing them is gone. A +/// failed un-wire that still deleted the plugin dir would leave the Gemfile +/// pointing at a path that no longer exists, breaking every later +/// `bundle install` (exit 13) on a project that installed fine before. pub async fn remove_plugin_directive( project: &BundlerProject, dry_run: bool, ) -> Vec { - vec![ - edit_gemfile_remove(&project.gemfile, dry_run).await, - remove_plugin_files(&project.root, dry_run).await, - ] + let gemfile = edit_gemfile_remove(&project.gemfile, dry_run).await; + if gemfile.status == GemSetupStatus::Error { + return vec![gemfile]; + } + vec![gemfile, remove_plugin_files(&project.root, dry_run).await] } #[cfg(test)] @@ -344,6 +388,62 @@ mod tests { ); } + #[test] + fn test_remove_strips_a_crlf_rewritten_block() { + // Git for Windows' default `core.autocrlf` ("checkout Windows-style, + // commit Unix-style") rewrites the LF block we wrote into CRLF on + // checkout — as does a Windows editor that saves the whole Gemfile + // CRLF. `--remove` must still strip it. Otherwise it reports + // "not_configured" and leaves the `plugin` line behind while + // `remove_plugin_files` DOES delete the generated plugin dir (its + // marker survives the rewrite), so every later `bundle install` dies + // on a plugin path that no longer exists. + let crlf_block = MANAGED_BLOCK.replace('\n', "\r\n"); + let user = "source 'https://rubygems.org'\r\ngem 'colorize', '1.1.0'\r\n"; + let configured = format!("{user}\r\n{crlf_block}"); + assert!(is_plugin_directive_present(&configured)); + let out = + gemfile_remove(&configured).expect("a CRLF-rewritten block is still ours to strip"); + assert_eq!( + out, user, + "the CRLF checkout's pre-setup bytes are restored" + ); + assert!(!is_plugin_directive_present(&out)); + } + + #[test] + fn test_remove_strips_a_crlf_block_without_gluing_later_user_lines() { + // Same CRLF rewrite, but the user added gems AFTER our block and the + // pre-setup file had no final newline (so the separator terminates that + // last line). Stripping the CRLF separator too would glue two `gem` + // lines into one invalid Ruby line. + let crlf_block = MANAGED_BLOCK.replace('\n', "\r\n"); + let configured = format!("gem 'colorize'\r\n{crlf_block}gem 'extra', '2.0'\r\n"); + assert_eq!( + gemfile_remove(&configured).unwrap(), + "gem 'colorize'\r\ngem 'extra', '2.0'\r\n", + "the CRLF separator survives as the previous line's terminator" + ); + } + + #[test] + fn test_remove_strips_every_managed_block() { + // A Gemfile can end up carrying two copies of the block — a merge that + // kept both sides, or a hand-copied Gemfile. `--remove` must strip all + // of them: leaving one behind reports "removed" while a `plugin` line + // pointing at the just-deleted plugin dir survives and fails every + // later `bundle install`. + let added = gemfile_add(GEMFILE).unwrap(); + let doubled = format!("{added}\n{MANAGED_BLOCK}"); + assert!(is_plugin_directive_present(&doubled)); + let out = gemfile_remove(&doubled).unwrap(); + assert!( + !is_plugin_directive_present(&out), + "no managed block may survive `--remove`" + ); + assert_eq!(out, GEMFILE, "both blocks stripped, user bytes restored"); + } + #[test] fn test_closing_marker_alone_is_not_detected_as_present() { // The "<<<" closing line must not satisfy the ">>>" opening marker. @@ -353,7 +453,7 @@ mod tests { #[tokio::test] async fn test_full_roundtrip_via_gems_rb() { - // discover prefers Gemfile, so exercise the gems.rb manifest directly. + // Exercise Bundler's alternate manifest name end to end. let dir = tempfile::tempdir().unwrap(); let gems_rb = dir.path().join("gems.rb"); fs::write(&gems_rb, GEMFILE).await.unwrap(); @@ -461,6 +561,49 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn test_add_preserves_gemfile_permissions() { + use std::os::unix::fs::PermissionsExt; + // The rename swaps in a fresh stage inode created with umask defaults, + // so the plain writer resets the mode of a file the USER owns and we + // merely edit: a 0600 private Gemfile silently becomes world-readable. + let dir = tempfile::tempdir().unwrap(); + let gemfile = dir.path().join("Gemfile"); + fs::write(&gemfile, GEMFILE).await.unwrap(); + std::fs::set_permissions(&gemfile, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let res = edit_gemfile_add(&gemfile, false).await; + assert_eq!(res.status, GemSetupStatus::Updated, "err: {:?}", res.error); + let mode = std::fs::metadata(&gemfile).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "the user's Gemfile mode must survive the edit (got {mode:o})" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_remove_preserves_gemfile_permissions() { + use std::os::unix::fs::PermissionsExt; + // Inverse of the 0600 case: a group-writable Gemfile (shared checkout) + // must not come back 0644, locking the group out. + let dir = tempfile::tempdir().unwrap(); + let gemfile = dir.path().join("Gemfile"); + fs::write(&gemfile, gemfile_add(GEMFILE).unwrap()) + .await + .unwrap(); + std::fs::set_permissions(&gemfile, std::fs::Permissions::from_mode(0o664)).unwrap(); + + let res = edit_gemfile_remove(&gemfile, false).await; + assert_eq!(res.status, GemSetupStatus::Updated, "err: {:?}", res.error); + let mode = std::fs::metadata(&gemfile).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o664, + "group-writable Gemfile stays group-writable (got {mode:o})" + ); + } + #[tokio::test] async fn test_edit_leaves_no_stage_litter() { let dir = tempfile::tempdir().unwrap(); @@ -485,6 +628,92 @@ mod tests { } } + // ── the Gemfile directive must never point at a missing plugin dir ── + // + // Bundler HARD-FAILS `bundle install` on a `plugin ... path:` directive + // whose source directory does not exist: + // + // $ bundle install + // The path `/tmp/x/.socket/bundler-plugin` does not exist. + // $ echo $? + // 13 + // + // (verified on bundler 4.0.15). So a half-applied add — Gemfile wired, files + // not written — is strictly WORSE than never running setup: the project can + // no longer install at all. Same for a half-applied remove: files deleted, + // directive left behind. Both orderings must keep the directive's lifetime + // inside the plugin dir's. + + #[tokio::test] + async fn test_add_leaves_gemfile_unwired_when_plugin_dir_cannot_be_generated() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); + // `.socket` as a regular FILE makes `create_dir_all(".socket/bundler- + // plugin")` fail on every platform — the portable stand-in for a + // read-only checkout / ENOSPC / a clobbered `.socket`. + fs::write(root.join(".socket"), "not a directory\n") + .await + .unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + + let results = add_plugin_directive(&project, false).await; + + assert!( + results.iter().any(|r| r.status == GemSetupStatus::Error), + "the failed plugin-dir generation must surface as an error: {results:?}" + ); + assert_eq!( + fs::read_to_string(root.join("Gemfile")).await.unwrap(), + GEMFILE, + "the Gemfile must NOT be wired to a plugin dir that does not exist — \ + that breaks every `bundle install` (exit 13)" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_remove_keeps_plugin_files_when_gemfile_cannot_be_unwired() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + assert!(add_plugin_directive(&project, false) + .await + .iter() + .all(|r| r.status == GemSetupStatus::Updated)); + + // A read-only project root blocks the Gemfile's stage+rename (the stage + // sibling cannot be created) while leaving `.socket/bundler-plugin/` + // itself writable — so the un-wire fails but the deletes would succeed. + fs::set_permissions(root, std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + + let results = remove_plugin_directive(&project, false).await; + + // Restore before any assertion can unwind, so the tempdir cleans up. + fs::set_permissions(root, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + + assert!( + results.iter().any(|r| r.status == GemSetupStatus::Error), + "the failed Gemfile un-wire must surface as an error: {results:?}" + ); + assert!( + is_plugin_directive_present(&fs::read_to_string(root.join("Gemfile")).await.unwrap()), + "precondition: the directive is still in the Gemfile" + ); + assert!( + super::super::plugin_files_present(root).await, + "the plugin files must SURVIVE a failed un-wire — deleting them while \ + the directive remains breaks every `bundle install` (exit 13)" + ); + } + #[tokio::test] async fn test_full_roundtrip_via_project() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs index ab7de389..0d4521c5 100644 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -1,3 +1,5 @@ +use crate::patch::vendor::common::{detect_indent, serialize_json}; + /// Package manager type for selecting the correct command prefix. #[derive(Debug, Clone, Copy, PartialEq)] pub enum PackageManager { @@ -166,12 +168,19 @@ fn update_package_json_object( /// Strip every socket-patch segment out of a single lifecycle script. /// -/// Scripts are joined with `" && "` (that is exactly how +/// Commands are chained with `&&` (that is exactly how /// [`generate_updated_script`] prepends the patch command), so splitting on -/// the same separator and dropping any segment that is a socket-patch invocation +/// that operator and dropping any segment that is a socket-patch invocation /// reverses the setup edit, whether the command was added to an empty script /// (`""`) or prepended to an existing one (`" && build"`). /// +/// The split ignores the whitespace around `&&`: a hand-wired +/// `"socket-patch apply&&npm run build"` is two commands, and treating it as +/// one patch-containing segment would delete the user's `npm run build` along +/// with the patch invocation. Survivors are re-joined with the canonical +/// `" && "`, so a `&&` that was quoted rather than an operator comes back +/// spaced — cosmetic, and only in scripts that also carry a patch command. +/// /// Returns `(changed, new_value)`: /// - `(false, Some(original))` — no socket-patch segment found; leave as-is. /// - `(true, Some(rest))` — patch segment(s) removed, other commands survive. @@ -183,7 +192,7 @@ fn remove_socket_patch_from_script(script: &str) -> (bool, Option) { return (false, None); } - let segments: Vec<&str> = trimmed.split(" && ").collect(); + let segments: Vec<&str> = trimmed.split("&&").collect(); // `changed` must reflect whether a *socket-patch* segment was removed — not // whether `kept` is merely shorter than `segments`. Filtering also drops @@ -298,6 +307,43 @@ fn remove_package_json_object(package_json: &mut serde_json::Value) -> ScriptRem } } +/// Re-serialize a package.json, keeping the indent unit the file already uses. +/// +/// serde's `to_string_pretty` is hard-wired to 2 spaces, so a 4-space or +/// tab-indented manifest came back reformatted top to bottom — turning a +/// two-key edit into a whole-file diff. The vendor backends already respect the +/// project's formatting when they rewrite package.json / lockfiles; reuse the +/// same helpers so `setup` touches only the lines it means to. +fn serialize_preserving_indent(value: &serde_json::Value, original: &str) -> String { + let indent = detect_indent(strip_bom(original)); + match serialize_json(value, &indent) { + // Always valid UTF-8: serde_json emits escaped ASCII/UTF-8 only. + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + // Serializing a `Value` cannot fail; fall back to the 2-space form. + Err(_) => serde_json::to_string_pretty(value).unwrap_or_default() + "\n", + } +} + +/// Reject a present-but-non-string lifecycle script value (`null` counts as +/// absent, exactly like `"scripts": null`). Overwriting an array/object/number +/// would silently discard whatever the user had there — the same reason a +/// non-object `scripts` is refused rather than clobbered. +fn check_script_values(package_json: &serde_json::Value) -> Result<(), String> { + let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) else { + return Ok(()); + }; + for key in ["postinstall", "dependencies"] { + if let Some(v) = scripts.get(key) { + if !v.is_null() && !v.is_string() { + return Err(format!( + "Invalid package.json: \"scripts.{key}\" is not a string" + )); + } + } + } + Ok(()) +} + /// Parse package.json content and remove socket-patch lifecycle scripts. /// Returns `(modified, new_content, status)`. pub(crate) fn remove_package_json_content( @@ -323,7 +369,7 @@ pub(crate) fn remove_package_json_content( return Ok((false, content.to_string(), status)); } - let new_content = serde_json::to_string_pretty(&package_json).unwrap() + "\n"; + let new_content = serialize_preserving_indent(&package_json, content); Ok((true, new_content, status)) } @@ -350,6 +396,7 @@ pub(crate) fn update_package_json_content( return Err("Invalid package.json: \"scripts\" is not a JSON object".to_string()); } } + check_script_values(&package_json)?; let status = is_setup_configured(&package_json); @@ -368,7 +415,7 @@ pub(crate) fn update_package_json_content( let old_dependencies = status.dependencies_script.clone(); let (_, new_postinstall, new_dependencies) = update_package_json_object(&mut package_json, pm); - let new_content = serde_json::to_string_pretty(&package_json).unwrap() + "\n"; + let new_content = serialize_preserving_indent(&package_json, content); Ok(( true, @@ -805,6 +852,28 @@ mod tests { assert_eq!(new.as_deref(), Some("build")); } + /// `&&` is a shell operator regardless of the whitespace around it, and + /// hand-wired scripts routinely omit the spaces. Splitting only on + /// `" && "` made `"socket-patch apply&&npm run build"` a single segment + /// that merely *contained* a patch pattern, so remove treated the whole + /// script as patch-only and deleted the key — silently taking the user's + /// `npm run build` with it. + #[test] + fn test_remove_script_ampersand_no_spaces_keeps_siblings() { + let (changed, new) = remove_socket_patch_from_script("socket-patch apply&&npm run build"); + assert!(changed); + assert_eq!(new.as_deref(), Some("npm run build")); + + let (changed, new) = remove_socket_patch_from_script("npm run build&& socket-patch apply"); + assert!(changed); + assert_eq!(new.as_deref(), Some("npm run build")); + + let (changed, new) = + remove_socket_patch_from_script("echo a &&socket-patch apply&& echo b && echo c"); + assert!(changed); + assert_eq!(new.as_deref(), Some("echo a && echo b && echo c")); + } + #[test] fn test_remove_script_pnpm_command() { // The pnpm canonical command must be recognized and stripped (it @@ -929,6 +998,103 @@ mod tests { assert!(result.is_err()); } + /// End-to-end shape of the `&&`-without-spaces data loss: the user's + /// `npm run build` must survive `remove`, not be deleted along with the + /// patch command it was chained to. + #[test] + fn test_remove_content_ampersand_no_spaces_keeps_user_script() { + let content = r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm&&npm run build"}}"#; + let (modified, new_content, status) = remove_package_json_content(content).unwrap(); + assert!(modified); + assert_eq!(status.new_postinstall.as_deref(), Some("npm run build")); + let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); + assert_eq!( + parsed["scripts"]["postinstall"], "npm run build", + "the user's command must survive:\n{new_content}" + ); + } + + /// A present-but-non-string lifecycle script is malformed. Overwriting it + /// silently discards the user's value; refuse it the same way a non-object + /// `scripts` is refused. + #[test] + fn test_update_content_non_string_script_errors() { + for body in [ + r#"{"scripts":{"postinstall":["a","b"]}}"#, + r#"{"scripts":{"postinstall":42}}"#, + r#"{"scripts":{"dependencies":{"a":"b"}}}"#, + r#"{"scripts":{"dependencies":true}}"#, + ] { + let result = update_package_json_content(body, PackageManager::Npm); + assert!(result.is_err(), "expected error for {body}"); + assert!( + result.as_ref().unwrap_err().contains("is not a string"), + "unexpected error for {body}: {:?}", + result.unwrap_err() + ); + } + } + + /// `null` stays benign: like `"scripts": null`, a null script value is + /// treated as absent and populated rather than rejected. + #[test] + fn test_update_content_null_script_is_populated() { + let content = r#"{"scripts":{"postinstall":null,"build":"tsc"}}"#; + let (modified, new_content, ..) = + update_package_json_content(content, PackageManager::Npm).unwrap(); + assert!(modified); + let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); + assert!(parsed["scripts"]["postinstall"] + .as_str() + .unwrap() + .contains("socket-patch apply")); + assert_eq!(parsed["scripts"]["build"], "tsc"); + } + + /// Rewriting the manifest must not reformat it: a 4-space or tab-indented + /// package.json re-serialized at serde's fixed 2-space indent turns a + /// two-line edit into a whole-file diff. The vendor backends already + /// respect the project's indent when they rewrite package.json + /// (`detect_indent` + `serialize_json`); `setup` must too. + #[test] + fn test_update_content_preserves_indent() { + for indent in [" ", "\t"] { + let content = format!( + "{{\n{indent}\"name\": \"x\",\n{indent}\"scripts\": {{\n{indent}{indent}\"build\": \"tsc\"\n{indent}}}\n}}\n" + ); + let (modified, new_content, ..) = + update_package_json_content(&content, PackageManager::Npm).unwrap(); + assert!(modified); + assert!( + new_content.contains(&format!("\n{indent}\"name\": \"x\",")), + "indent {indent:?} not preserved at depth 1:\n{new_content}" + ); + assert!( + new_content.contains(&format!("\n{indent}{indent}\"build\": \"tsc\",")), + "indent {indent:?} not preserved at depth 2:\n{new_content}" + ); + // Still valid JSON with the scripts wired. + let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); + assert!(parsed["scripts"]["postinstall"].is_string()); + assert!(parsed["scripts"]["dependencies"].is_string()); + } + } + + #[test] + fn test_remove_content_preserves_indent() { + let content = "{\n \"name\": \"x\",\n \"scripts\": {\n \"build\": \"tsc\",\n \"postinstall\": \"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"\n }\n}\n"; + let (modified, new_content, _) = remove_package_json_content(content).unwrap(); + assert!(modified); + assert!( + new_content.contains("\n \"scripts\": {"), + "indent not preserved:\n{new_content}" + ); + assert!( + new_content.contains("\n \"build\": \"tsc\""), + "indent not preserved:\n{new_content}" + ); + } + #[test] fn test_update_content_pnpm() { let content = r#"{"name": "test"}"#; diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index ed534d17..9d13e200 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -492,6 +492,42 @@ mod tests { assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); } + #[test] + #[ignore = "RED: parse_pnpm_workspace does not support the YAML flow-sequence \ + spelling `packages: [a, b]`. The test is correct; the parser fix \ + was not part of this change."] + fn test_parse_pnpm_flow_sequence() { + // pnpm parses pnpm-workspace.yaml with a real YAML parser, which accepts + // a flow sequence (`packages: ['a/*', "b/*"]`) exactly like the block + // list. The line-based header check only accepted a bare `packages:` + // (optionally plus a comment), so an inline sequence matched no header + // and every pattern was silently dropped — and because + // pnpm-workspace.yaml still marks the project as a pnpm workspace, no + // fallback walk runs: zero members discovered. + assert_eq!( + parse_pnpm_workspace_patterns("packages: ['packages/*', \"apps/*\"]"), + vec!["packages/*", "apps/*"] + ); + // Unquoted items, an empty sequence, and a trailing inline comment. + assert_eq!( + parse_pnpm_workspace_patterns("packages: [packages/*] # globs"), + vec!["packages/*"] + ); + assert!(parse_pnpm_workspace_patterns("packages: []").is_empty()); + } + + #[test] + #[ignore = "RED: same missing flow-sequence support as \ + test_parse_pnpm_flow_sequence, for the quoted-comma case."] + fn test_parse_pnpm_flow_sequence_keeps_quoted_comma() { + // A `,` inside a quoted scalar is part of the value (a brace pattern + // carries one), so it must not split the sequence. + assert_eq!( + parse_pnpm_workspace_patterns("packages: ['{apps,libs}/*', '!**/test/**']"), + vec!["{apps,libs}/*", "!**/test/**"] + ); + } + // ── Group 2: workspace detection + file discovery ──────────────── #[tokio::test] @@ -710,6 +746,42 @@ mod tests { assert!(result.files[1].is_workspace); } + #[tokio::test] + #[ignore = "RED: end-to-end consequence of the missing flow-sequence support \ + — a pnpm workspace declared with `packages: [a, b]` has its \ + members silently skipped by discovery."] + async fn test_find_pnpm_flow_sequence_members_discovered() { + // End-to-end symptom of the flow-sequence gap: the inline + // `packages: [...]` spelling yielded no patterns, so a real pnpm + // workspace reported zero members — and the fallback walk that would + // otherwise have found them is skipped for a pnpm workspace. + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) + .await + .unwrap(); + fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages: ['packages/*']\n", + ) + .await + .unwrap(); + let pkg_a = dir.path().join("packages").join("a"); + fs::create_dir_all(&pkg_a).await.unwrap(); + fs::write(pkg_a.join("package.json"), r#"{"name":"a"}"#) + .await + .unwrap(); + let result = find_package_json_files(dir.path()).await; + assert!(matches!(result.workspace_type, WorkspaceType::Pnpm)); + assert!( + result + .files + .iter() + .any(|f| f.is_workspace && f.path.ends_with("packages/a/package.json")), + "flow-sequence member must be discovered: {:?}", + result.files.iter().map(|f| &f.path).collect::>() + ); + } + #[tokio::test] async fn test_find_nested_skips_node_modules() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs index 5a7c3145..7809bb49 100644 --- a/crates/socket-patch-core/src/patch/apply_lock.rs +++ b/crates/socket-patch-core/src/patch/apply_lock.rs @@ -335,6 +335,76 @@ mod tests { releaser.join().unwrap(); } + /// Regression: a waiter parked in the retry loop must not keep + /// locking the *old* inode across `repair`'s sanctioned lock-file + /// deletion. + /// + /// `repair` drops its guard and then unlinks `apply.lock` as its + /// final housekeeping step. It justifies that with a "residual + /// window of microseconds" between the drop and the unlink — true + /// for a fresh acquire (open, then immediately flock), but false + /// for a waiter: `acquire` used to open the lock file exactly once, + /// *before* the loop, then re-flock that same handle for the whole + /// `--lock-timeout` budget. So a waiter parked for minutes would + /// eventually flock the unlinked, orphaned inode and report success + /// while the next command created a fresh `apply.lock` and locked + /// that — two simultaneous holders of the "exclusive" apply lock, + /// i.e. exactly the concurrent manifest/package-file corruption the + /// lock exists to prevent. Re-opening the path on every retry keeps + /// the waiter honest about whatever file `apply.lock` names now. + #[test] + #[ignore = "RED: documents a real data-corruption bug — `acquire` opens \ + apply.lock once BEFORE the retry loop, so a waiter parked \ + across repair's sanctioned unlink flocks the orphaned inode \ + and returns a SECOND live guard. The fix (re-open the path on \ + every retry) was not part of this change."] + fn waiter_does_not_lock_orphaned_inode_after_lock_file_deleted() { + use std::sync::mpsc; + + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join("apply.lock"); + + // A `repair` run holds the lock; this is the inode the waiter + // will open below. + let repair_guard = acquire(dir.path(), Duration::ZERO).unwrap(); + + // The waiter: a concurrent `apply --lock-timeout 1` that parks + // in the retry loop while repair finishes. + let (started_tx, started_rx) = mpsc::channel(); + let waiter_dir = dir.path().to_path_buf(); + let waiter = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + acquire(&waiter_dir, Duration::from_millis(600)) + }); + + // Let the waiter open the lock file and burn its first + // (contended) attempt, so its handle is on the pre-deletion + // inode. Being late here is harmless — it just means the waiter + // burns another attempt on the same handle. + started_rx.recv().unwrap(); + std::thread::sleep(Duration::from_millis(50)); + + // repair's tail: release the guard, then unlink the lock file. + drop(repair_guard); + std::fs::remove_file(&lock_path).unwrap(); + + // The next mutating command comes along and takes the lock on a + // brand-new inode. + let fresh_guard = acquire(dir.path(), Duration::ZERO).unwrap(); + + // Mutual exclusion: while `fresh_guard` is alive, nobody else + // may hold the apply lock. Under the bug the waiter locks the + // orphaned inode and hands back a second live guard. + let waiter_result = waiter.join().unwrap(); + assert!( + matches!(waiter_result, Err(LockError::Held)), + "waiter must not acquire the apply lock while another holder is live \ + (it locked the orphaned pre-deletion inode): got {:?}", + waiter_result.map(|_| "Ok(guard)") + ); + drop(fresh_guard); + } + /// The retry loop must not overshoot the deadline by a full sleep /// quantum. A 150 ms budget should resolve well under the old /// fixed-100 ms-sleep worst case (~200 ms) — the final sleep is diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 55580a2b..e01f4344 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -393,7 +393,18 @@ fn rewrite_cargo( } let mut cargo_toml = files.get("Cargo.toml").cloned(); let mut cargo_lock = files.get("Cargo.lock").cloned(); - let mut cargo_config = files.get(".cargo/config.toml").cloned().unwrap_or_default(); + // Cargo reads the LEGACY extensionless `.cargo/config` in preference to + // `config.toml` when both exist (it warns about the duplicate), so a + // managed `[registries.…]` block written to `config.toml` there is + // silently inert and the `registry = "socket-patch-…"` this rewriter puts + // in Cargo.toml then names an undefined registry. Same preference + // `vendor::cargo_config::config_path` applies on the vendor path. + let cargo_config_key = if files.contains_key(".cargo/config") { + ".cargo/config" + } else { + ".cargo/config.toml" + }; + let mut cargo_config = files.get(cargo_config_key).cloned().unwrap_or_default(); let (mut toml_changed, mut lock_changed, mut config_changed) = (false, false, false); for dep in &cargo { @@ -434,7 +445,7 @@ fn rewrite_cargo( cargo_config = format!("{cargo_config}{sep}{prefix}{block}"); config_changed = true; result.edits.push(FileEdit { - path: ".cargo/config.toml".into(), + path: cargo_config_key.into(), kind: "redirect_cargo_registry".into(), action: "added".into(), key: Some(reg.clone()), @@ -494,9 +505,7 @@ fn rewrite_cargo( } } if config_changed { - result - .files - .insert(".cargo/config.toml".into(), cargo_config); + result.files.insert(cargo_config_key.into(), cargo_config); } } @@ -3463,6 +3472,75 @@ mod tests { ); } + /// A project carrying the LEGACY extensionless `.cargo/config` must have + /// the managed `[registries.socket-patch-…]` block written into THAT file. + /// When both spellings exist cargo reads `config` (and warns), so a block + /// parked in `config.toml` is silently inert: the `registry = + /// "socket-patch-…"` the rewriter puts in Cargo.toml then names an + /// undefined registry and the build breaks — while the run still reports + /// the dep redirected (the index URL "landed in a file") and attests it. + /// Same invariant the vendor path enforces in `vendor::cargo_config`. + #[test] + fn cargo_legacy_config_is_the_file_edited() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n" + .to_string(), + ); + files.insert( + "Cargo.lock".to_string(), + "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"91f70896d6720bc714a4a57d22fc91f1db634680e65c8efe13323f1fa38d53f5\"\n" + .to_string(), + ); + files.insert( + ".cargo/config".to_string(), + "[net]\nretry = 3\n".to_string(), + ); + + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let written = r.files.get(".cargo/config").unwrap_or_else(|| { + panic!( + "the legacy `.cargo/config` is the file cargo reads; got {:?}", + r.files.keys().collect::>() + ) + }); + assert!( + written.contains("[registries.socket-patch-uuid]"), + "registry definition must land in the legacy config: {written}" + ); + assert!( + written.contains("retry = 3"), + "the user's existing config must be preserved, not clobbered: {written}" + ); + assert!( + !r.files.contains_key(".cargo/config.toml"), + "no shadowed config.toml may be created alongside the legacy config: {:?}", + r.files.keys().collect::>() + ); + assert!( + r.edits + .iter() + .any(|e| e.path == ".cargo/config" && e.kind == "redirect_cargo_registry"), + "the recorded edit must name the file actually written (revert target): {:?}", + r.edits.iter().map(|e| &e.path).collect::>() + ); + } + + /// The default (no legacy file) shape is unchanged: `.cargo/config.toml`. + #[test] + fn cargo_config_toml_is_the_default_target() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.contains_key(".cargo/config.toml")); + assert!(!r.files.contains_key(".cargo/config")); + } + fn gem_override(name: &str, version: &str) -> DepOverride { DepOverride { ecosystem: "gem".into(), diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 21d8573a..67a85577 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -76,7 +76,8 @@ pub fn detect_channel(canonical_exe: &Path, env: &ChannelEnv) -> InstallChannel if has_component(canonical_exe, "node_modules") { return InstallChannel::Npm; } - if has_component(canonical_exe, "site-packages") || has_component(canonical_exe, "dist-packages") + if has_component(canonical_exe, "site-packages") + || has_component(canonical_exe, "dist-packages") { return InstallChannel::Pypi; } @@ -328,10 +329,7 @@ mod tests { InstallChannel::Npm ); assert_eq!( - detect_channel( - Path::new(r"C:\Users\u\.cargo\bin\socket-patch.exe"), - &env - ), + detect_channel(Path::new(r"C:\Users\u\.cargo\bin\socket-patch.exe"), &env), InstallChannel::Cargo ); assert_eq!( diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs index 7808fd78..6e4a842b 100644 --- a/crates/socket-patch-core/src/update/download.rs +++ b/crates/socket-patch-core/src/update/download.rs @@ -156,9 +156,9 @@ fn extract_zip_member(archive: &[u8], member: &str) -> Result, UpdateErr let cursor = std::io::Cursor::new(archive); let mut zip = zip::ZipArchive::new(cursor) .map_err(|e| UpdateError::VerifyFailed(format!("unreadable zip archive: {e}")))?; - let file = zip - .by_name(member) - .map_err(|_| UpdateError::VerifyFailed(format!("archive does not contain a {member} entry")))?; + let file = zip.by_name(member).map_err(|_| { + UpdateError::VerifyFailed(format!("archive does not contain a {member} entry")) + })?; if file.size() > MAX_BINARY_BYTES { return Err(UpdateError::VerifyFailed(format!( "{member} exceeds the {MAX_BINARY_BYTES}-byte cap" @@ -199,9 +199,7 @@ fn stage_binary(dest_dir: &Path, bytes: &[u8]) -> Result { } })?; use std::io::Write; - let write_result = file - .write_all(bytes) - .and_then(|()| file.sync_all()); + let write_result = file.write_all(bytes).and_then(|()| file.sync_all()); drop(file); if let Err(e) = write_result { let _ = std::fs::remove_file(&stage); @@ -320,9 +318,7 @@ async fn sanity_exec( if version_ok { return Ok(None); } - let detail = format!( - "downloaded binary reports {reported:?} instead of version {expected}" - ); + let detail = format!("downloaded binary reports {reported:?} instead of version {expected}"); if strict { Err(UpdateError::VerifyFailed(detail)) } else { @@ -605,7 +601,10 @@ mod tests { std::fs::write(&path, "#!/bin/sh\necho socket-patch 9.9.9\n").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - let held = std::fs::OpenOptions::new().append(true).open(&path).unwrap(); + let held = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); let dropper = std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(150)); drop(held); diff --git a/crates/socket-patch-core/src/update/mod.rs b/crates/socket-patch-core/src/update/mod.rs index 07402be4..ed2a613d 100644 --- a/crates/socket-patch-core/src/update/mod.rs +++ b/crates/socket-patch-core/src/update/mod.rs @@ -24,8 +24,7 @@ pub use release::{ UpdateEndpoints, UpdateTimeouts, }; pub use state::{ - check_is_due, load_state, notice_is_due, save_state, unix_now, UpdateCheckState, - CHECK_INTERVAL, + check_is_due, load_state, notice_is_due, save_state, unix_now, UpdateCheckState, CHECK_INTERVAL, }; pub use swap::resolve_install_path; diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index ba761bf0..d544610d 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -240,9 +240,7 @@ pub fn sha256sums_entry(sums: &str, file: &str) -> Result { /// integrity root, so it needs this exactly as much as the archive leg). /// Overridden bases (wiremock fixtures, mirrors) are plain-`http` loopback /// by design, so there the policy is only hop-count-limited. -pub(crate) fn follow_redirect_policy( - endpoints: &UpdateEndpoints, -) -> reqwest::redirect::Policy { +pub(crate) fn follow_redirect_policy(endpoints: &UpdateEndpoints) -> reqwest::redirect::Policy { if endpoints.is_default() { reqwest::redirect::Policy::custom(|attempt| { if attempt.previous().len() > 10 { @@ -345,9 +343,7 @@ async fn fetch_latest_via_api( let tag = json .get("tag_name") .and_then(|v| v.as_str()) - .ok_or_else(|| { - UpdateError::CheckFailed(format!("GET {url}: response has no tag_name")) - })?; + .ok_or_else(|| UpdateError::CheckFailed(format!("GET {url}: response has no tag_name")))?; parse_release_tag(tag) } @@ -536,10 +532,7 @@ mod tests { #[test] fn sums_digest_compare_is_case_insensitive() { - let sums = format!( - "{} socket-patch-x.tar.gz\n", - DIGEST_A.to_ascii_uppercase() - ); + let sums = format!("{} socket-patch-x.tar.gz\n", DIGEST_A.to_ascii_uppercase()); assert_eq!( sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), DIGEST_A, @@ -561,15 +554,13 @@ mod tests { #[test] fn sums_conflicting_duplicates_refused() { - let sums = format!( - "{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_B} socket-patch-x.tar.gz\n" - ); + let sums = + format!("{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_B} socket-patch-x.tar.gz\n"); let err = sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap_err(); assert!(err.to_string().contains("conflicting"), "{err}"); // Agreeing duplicates are harmless. - let sums = format!( - "{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_A} socket-patch-x.tar.gz\n" - ); + let sums = + format!("{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_A} socket-patch-x.tar.gz\n"); assert_eq!( sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), DIGEST_A diff --git a/crates/socket-patch-core/src/update/state.rs b/crates/socket-patch-core/src/update/state.rs index 087a40f0..19ce4e4b 100644 --- a/crates/socket-patch-core/src/update/state.rs +++ b/crates/socket-patch-core/src/update/state.rs @@ -65,9 +65,8 @@ pub fn state_dir() -> Option { return Some(dir); } let base = if cfg!(windows) { - env_dir("LOCALAPPDATA").or_else(|| { - env_dir("USERPROFILE").map(|p| p.join("AppData").join("Local")) - }) + env_dir("LOCALAPPDATA") + .or_else(|| env_dir("USERPROFILE").map(|p| p.join("AppData").join("Local"))) } else { env_dir("XDG_CACHE_HOME").or_else(|| env_dir("HOME").map(|h| h.join(".cache"))) }; @@ -208,10 +207,7 @@ mod tests { // Env-mutating test: keep it self-contained and restore. let prev = std::env::var_os("SOCKET_UPDATE_STATE_DIR"); std::env::set_var("SOCKET_UPDATE_STATE_DIR", "/tmp/socket-update-test"); - assert_eq!( - state_dir(), - Some(PathBuf::from("/tmp/socket-update-test")) - ); + assert_eq!(state_dir(), Some(PathBuf::from("/tmp/socket-update-test"))); // Empty value means unset (env_non_empty convention) — falls through // to the platform default rather than yielding "". std::env::set_var("SOCKET_UPDATE_STATE_DIR", ""); diff --git a/crates/socket-patch-core/src/update/swap.rs b/crates/socket-patch-core/src/update/swap.rs index b732cec7..d0e910ac 100644 --- a/crates/socket-patch-core/src/update/swap.rs +++ b/crates/socket-patch-core/src/update/swap.rs @@ -63,11 +63,11 @@ pub fn acquire_update_lock() -> Result, UpdateError> { /// exec), and the swap must replace the real file rather than turning a /// symlink into a regular binary. pub fn resolve_install_path() -> Result { - let exe = std::env::current_exe() - .map_err(|e| UpdateError::SwapFailed(format!("cannot determine current executable: {e}")))?; - std::fs::canonicalize(&exe).map_err(|e| { - UpdateError::SwapFailed(format!("cannot canonicalize {}: {e}", exe.display())) - }) + let exe = std::env::current_exe().map_err(|e| { + UpdateError::SwapFailed(format!("cannot determine current executable: {e}")) + })?; + std::fs::canonicalize(&exe) + .map_err(|e| UpdateError::SwapFailed(format!("cannot canonicalize {}: {e}", exe.display()))) } /// Atomically replace `dest` with the staged binary at `staged`. @@ -114,9 +114,8 @@ fn has_file_capabilities(_path: &Path) -> bool { fn swap_binary_inner(staged: &Path, dest: &Path) -> Result<(), UpdateError> { use std::os::unix::fs::PermissionsExt; - let dest_meta = std::fs::metadata(dest).map_err(|e| { - UpdateError::SwapFailed(format!("cannot stat {}: {e}", dest.display())) - })?; + let dest_meta = std::fs::metadata(dest) + .map_err(|e| UpdateError::SwapFailed(format!("cannot stat {}: {e}", dest.display())))?; let mode = dest_meta.permissions().mode(); if mode & 0o6000 != 0 { return Err(UpdateError::SwapFailed(format!( @@ -134,9 +133,8 @@ fn swap_binary_inner(staged: &Path, dest: &Path) -> Result<(), UpdateError> { } // Carry the destination's exact mode onto the staged inode before the // rename so a 0555 install never appears 0755, even briefly. - std::fs::set_permissions(staged, std::fs::Permissions::from_mode(mode)).map_err(|e| { - UpdateError::SwapFailed(format!("cannot set mode on staged binary: {e}")) - })?; + std::fs::set_permissions(staged, std::fs::Permissions::from_mode(mode)) + .map_err(|e| UpdateError::SwapFailed(format!("cannot set mode on staged binary: {e}")))?; std::fs::rename(staged, dest).map_err(|e| { if e.kind() == std::io::ErrorKind::PermissionDenied { UpdateError::PermissionDenied { diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 71b19dd6..b0b40297 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -19,9 +19,10 @@ use std::path::Path; -// npm/Node strip a BOM from package.json and cargo accepts one in Cargo.toml, -// but serde_json and the line scanner both reject it — without this, manifests -// the user's own toolchain accepts yield no PURL. +// npm/Node strip a BOM from package.json, cargo accepts one in Cargo.toml, and +// git reads a BOM'd `.git/config`, but serde_json and the line scanners all +// reject it — without this, files the user's own toolchain accepts yield no +// PURL. use crate::package_json::detect::strip_bom; /// Version-extracting parser for one manifest flavor, keyed by file name in @@ -217,7 +218,10 @@ async fn find_git_config(start: &Path) -> Option { /// a git config file. Returns the trimmed URL, or `None`. fn scan_remote_origin_url(content: &str) -> Option { let mut in_section = false; - for raw in content.lines() { + // git reads a BOM'd config file fine, but `trim()` does not strip + // U+FEFF (it carries no White_Space property), so a BOM would keep + // the very first header from matching `starts_with('[')`. + for raw in strip_bom(content).lines() { let line = raw.trim(); if line.starts_with('[') { // git permits a `;`/`#` comment after the closing bracket; @@ -229,7 +233,7 @@ fn scan_remote_origin_url(content: &str) -> Option { if let Some(close) = line.find(']') { let rest = line[close + 1..].trim_start(); if rest.is_empty() || rest.starts_with('#') || rest.starts_with(';') { - in_section = &line[..=close] == "[remote \"origin\"]"; + in_section = is_remote_origin_header(&line[..=close]); continue; } } @@ -237,27 +241,52 @@ fn scan_remote_origin_url(content: &str) -> Option { if !in_section { continue; } - // Parse `key = value`. Only the EXACT `url` key counts: a + // Parse `key = value`. Only the `url` key counts (matched + // case-insensitively, as git matches variable names): a // `url`-prefixed-but-different key (git permits arbitrary - // config keys, e.g. a custom `urlsuffix`) or a malformed - // `url ...` line without an `=` must be SKIPPED, not abort - // the scan — otherwise a later, valid `url = ...` line in the - // same section would never be read. + // config keys, e.g. a custom `urlsuffix`), a malformed + // `url ...` line without an `=`, or an empty/commented-out + // value must be SKIPPED, not abort the scan — otherwise a + // later, valid `url = ...` line in the same section would + // never be read. (git resolves `url =` followed by a real + // `url = ...` to the real one.) let Some((key, value)) = line.split_once('=') else { continue; }; - if key.trim() != "url" { + if !key.trim().eq_ignore_ascii_case("url") { continue; } let value = parse_git_config_value(value); if value.is_empty() { - return None; + continue; } return Some(value); } None } +/// Does `header` — a `[...]` line, brackets included — name the +/// `[remote "origin"]` section? +/// +/// git matches SECTION names case-insensitively but subsection names +/// case-SENSITIVELY, and skips any run of spaces/tabs between the two. +/// Verified against `git config -f`, which also rejects +/// `[remote"origin"]`, `[ remote "origin"]` and `[remote "origin" ]` +/// as `bad config line`, so those stay non-matches here. +fn is_remote_origin_header(header: &str) -> bool { + let Some(inner) = header.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else { + return false; + }; + let Some((section, subsection)) = inner.split_once('"') else { + return false; + }; + section.ends_with([' ', '\t']) + && section + .trim_end_matches([' ', '\t']) + .eq_ignore_ascii_case("remote") + && subsection == "origin\"" +} + /// Reduce the raw right-hand side of a git config `key = value` line /// to the value git itself reports (verified against `git config -f`): /// `#`/`;` begin a comment outside double quotes — no preceding @@ -1522,6 +1551,175 @@ mod tests { assert!(scan_remote_origin_url(cfg).is_none()); } + // ── Regression: an empty `url` must not abort the section scan ── + // git resolves `url = ` followed by a real `url = ...` to the real + // one (verified: `git remote get-url origin` prints only the real + // url). Aborting on the empty value violated the same "skip the + // bad line, keep scanning" invariant the malformed-line fix above + // established, and silently dropped the repo's identity. + + #[test] + fn scan_origin_url_empty_url_does_not_abort_scan() { + let cfg = "[remote \"origin\"]\n\turl = \n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + /// Same shape, but the dead first value is a comment-only one. + #[test] + fn scan_origin_url_comment_only_url_does_not_abort_scan() { + let cfg = "[remote \"origin\"]\n\turl = ; disabled\n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + // ── Regression: UTF-8 BOM on `.git/config` ──────────────────── + // git reads a BOM'd config fine (verified: `git config -f` on a + // BOM'd file still reports remote.origin.url), but `trim()` does + // NOT strip U+FEFF — it has no White_Space property — so the + // leading `[remote "origin"]` header failed `starts_with('[')` and + // the section never opened. Same policy as the package.json / + // Cargo.toml BOM fix: a file the user's own toolchain accepts must + // not silently yield no PURL. + + #[test] + fn scan_origin_url_tolerates_leading_bom() { + let cfg = "\u{feff}[remote \"origin\"]\n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + #[tokio::test] + async fn detect_git_remote_with_bom_config() { + let dir = tempfile::tempdir().unwrap(); + let git_dir = dir.path().join(".git"); + tokio::fs::create_dir_all(&git_dir).await.unwrap(); + tokio::fs::write( + git_dir.join("config"), + "\u{feff}[remote \"origin\"]\n\turl = git@github.com:owner/bom-repo.git\n", + ) + .await + .unwrap(); + + let r = detect_product(dir.path()).await; + assert_eq!(r.purl.as_deref(), Some("pkg:github/owner/bom-repo")); + } + + // ── Regression: git config name case-insensitivity + header spacing ── + // Verified against `git config -f`: SECTION names and VARIABLE + // names are case-insensitive, subsection names are NOT, and any run + // of whitespace may separate the section from its quoted + // subsection. The exact byte comparisons rejected configs git + // itself accepts, so a hand-edited `.git/config` lost the repo's + // identity and detection silently fell through to a package + // manifest (or to no product at all). + + #[test] + fn scan_origin_url_section_name_is_case_insensitive() { + let cfg = "[Remote \"origin\"]\n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + let cfg = "[REMOTE \"origin\"]\n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + /// Subsection names stay case-SENSITIVE, matching git: a + /// `[remote "ORIGIN"]` section defines `remote.ORIGIN.url`, and + /// `git config --get remote.origin.url` reports nothing for it. + #[test] + fn scan_origin_url_subsection_name_stays_case_sensitive() { + let cfg = "[remote \"ORIGIN\"]\n\turl = git@github.com:foo/bar.git\n"; + assert!(scan_remote_origin_url(cfg).is_none()); + } + + /// git skips any run of whitespace between the section name and + /// its quoted subsection. + #[test] + fn scan_origin_url_header_extra_whitespace_before_subsection() { + let cfg = "[remote \"origin\"]\n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + let cfg = "[remote\t\"origin\"]\n\turl = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + /// Variable names are case-insensitive too (`URL`/`UrL` both + /// resolve `remote.origin.url` for git). + #[test] + fn scan_origin_url_key_name_is_case_insensitive() { + let cfg = "[remote \"origin\"]\n\tURL = git@github.com:foo/bar.git\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + /// Header shapes git itself rejects as `bad config line` stay + /// rejected — the relaxation must not over-match. Verified: all + /// three make `git config -f` fail outright. + #[test] + fn scan_origin_url_malformed_headers_stay_rejected() { + for cfg in [ + "[remote \"origin\" ]\n\turl = git@github.com:foo/bar.git\n", + "[ remote \"origin\"]\n\turl = git@github.com:foo/bar.git\n", + "[remote\"origin\"]\n\turl = git@github.com:foo/bar.git\n", + ] { + assert!( + scan_remote_origin_url(cfg).is_none(), + "malformed header should not open the origin section: {cfg:?}" + ); + } + } + + /// A case-varied header must also still CLOSE an open origin + /// section — otherwise the next remote's url is misattributed. + #[test] + fn scan_origin_url_case_varied_foreign_header_closes_section() { + let cfg = + "[remote \"origin\"]\n[Remote \"upstream\"]\n\turl = git@github.com:other/repo.git\n"; + assert!(scan_remote_origin_url(cfg).is_none()); + } + + /// End-to-end: a `.git/config` with a case-varied header still + /// wins over the package manifest, as the priority chain promises. + #[tokio::test] + async fn detect_git_remote_with_case_varied_header() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("package.json"), + r#"{"name":"from-pkg","version":"1.0.0"}"#, + ) + .await + .unwrap(); + let git_dir = dir.path().join(".git"); + tokio::fs::create_dir_all(&git_dir).await.unwrap(); + tokio::fs::write( + git_dir.join("config"), + "[Remote \"origin\"]\n\tURL = git@github.com:owner/from-git.git\n", + ) + .await + .unwrap(); + + let r = detect_product(dir.path()).await; + assert_eq!(r.purl.as_deref(), Some("pkg:github/owner/from-git")); + } + #[tokio::test] async fn multi_manifest_all_unparseable_emits_no_warning() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs index a7dbf84c..37825dad 100644 --- a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs @@ -157,6 +157,60 @@ async fn cargo_home_fallback_to_home_dot_cargo() { ); } +/// Regression: a set-but-EMPTY `CARGO_HOME` must be treated as unset, the +/// same way cargo itself resolves it (`home::cargo_home` filters an empty +/// value before falling back to `$HOME/.cargo`). Honoring `""` made +/// `cargo_home()` return an empty `PathBuf`, so the registry root became +/// the CWD-RELATIVE path `registry/src` — the crawler then probed a +/// directory inside the user's project instead of the real crate cache and +/// silently discovered nothing (every crate reported not-installed). +/// +/// Empty-valued env vars are routine in containers (`ENV CARGO_HOME=`), +/// `CARGO_HOME= socket-patch scan` one-liners, and CI templates that +/// interpolate an undefined variable. Twin of the guards already in +/// go/nuget/deno/composer and `utils::fs::home_dir`. +#[tokio::test] +#[serial_test::serial] +async fn empty_cargo_home_falls_back_to_home_dot_cargo() { + let tmp = tempfile::tempdir().unwrap(); + let stamp_dir = tmp + .path() + .join(".cargo") + .join("registry") + .join("src") + .join("index.crates.io-1949cf8c6b5b557f"); + tokio::fs::create_dir_all(&stamp_dir).await.unwrap(); + + let prev_cargo = std::env::var("CARGO_HOME").ok(); + let prev_home = std::env::var("HOME").ok(); + std::env::set_var("CARGO_HOME", ""); + std::env::set_var("HOME", tmp.path()); + + let crawler = CargoCrawler; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let paths = crawler.get_crate_source_paths(&opts).await.unwrap(); + + match prev_cargo { + Some(v) => std::env::set_var("CARGO_HOME", v), + None => std::env::remove_var("CARGO_HOME"), + } + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + + assert_eq!( + paths, + vec![stamp_dir], + "empty CARGO_HOME must fall back to $HOME/.cargo, not the \ + CWD-relative `registry/src`; got {paths:?}" + ); +} + // ── find_by_purls ────────────────────────────────────────────── #[tokio::test] diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index f80a9bdf..42acf868 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -335,6 +335,57 @@ async fn get_global_python_site_packages_discovers_anaconda() { ); } +/// macOS `pip install --user` uses the framework "user" install scheme: +/// `~/Library/Python//lib/python/site-packages` — one tree per +/// interpreter MINOR VERSION, with a BARE `python` leaf (not `python3.X`). +/// Both Apple's `/usr/bin/python3` and Homebrew's `python3` are framework +/// builds and use it, so a stock Mac has several of these trees. +/// +/// The well-known scan covers pip --user on Linux (`~/.local`) and Windows +/// (`%APPDATA%\Python`) but had no macOS entry, so the only thing that ever +/// surfaced such a package was the `site.getusersitepackages()` query — which +/// reports at most the ONE interpreter first on PATH. Everything +/// `pip3 install --user`ed under any other interpreter was invisible to +/// global discovery. +/// +/// Two versions are staged deliberately: the runtime-query arm can only ever +/// contribute the host interpreter's own version, so requiring BOTH to surface +/// keeps the test honest whatever Python the host happens to run. +#[cfg(target_os = "macos")] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_macos_user_site() { + let tmp = tempfile::tempdir().unwrap(); + let mut staged = Vec::new(); + for ver in ["3.9", "3.12"] { + let sp = tmp + .path() + .join("Library") + .join("Python") + .join(ver) + .join("lib") + .join("python") + .join("site-packages"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + staged.push(sp); + } + + let prev_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", tmp.path()); + let result = get_global_python_site_packages().await; + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } + + for sp in &staged { + assert!( + result.iter().any(|p| p == sp), + "macOS pip --user site-packages {} must surface; got {result:?}", + sp.display() + ); + } +} + // ── uv-tools and uv-python discovery ────────────────────────── /// `uv tool install ` on macOS installs into diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 2eccfab5..2b0b4bbd 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -65,6 +65,40 @@ fn install_fake_gem(bin_dir: &Path, gemdir: &Path) { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); } +/// Like [`install_fake_gem`], but also answers `gem env gempath` with the +/// OS-separated list of gem homes in `gempath` — the multi-home reality of +/// rvm gemsets (`@global` + app set) and `--user-install`. +#[cfg(unix)] +fn install_fake_gem_with_gempath(bin_dir: &Path, gemdir: &Path, gempath: &str) { + use std::os::unix::fs::PermissionsExt; + let script = format!( + "#!/bin/sh\nif [ \"$1\" = env ] && [ \"$2\" = gemdir ]; then\n printf '%s\\n' \"{}\"\n exit 0\nfi\nif [ \"$1\" = env ] && [ \"$2\" = gempath ]; then\n printf '%s\\n' \"{}\"\n exit 0\nfi\nexit 1\n", + gemdir.display(), + gempath + ); + let bin = bin_dir.join("gem"); + std::fs::write(&bin, script).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +/// Swap `PATH` for the duration of `f`, restoring the previous value (or +/// unsetting it when it started unset) even though `f` returns a value. +#[cfg(unix)] +async fn with_path(bin_dir: &Path, f: F) -> T +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + let prev = std::env::var("PATH").ok(); + std::env::set_var("PATH", bin_dir); + let out = f().await; + match prev { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + out +} + // ── find_by_purls ────────────────────────────────────────────── #[tokio::test] @@ -349,6 +383,156 @@ async fn get_gem_paths_with_gemfile_lock_only_returns_gemdir() { ); } +/// Bundler accepts `gems.rb` as the alternate spelling of `Gemfile` +/// (`Bundler::SharedHelpers.default_gemfile`), and +/// `gem_setup::discover_bundler_project` already walks up for it — so +/// `setup` will wire a `gems.rb` project with the bundler plugin that runs +/// `apply` on every `bundle install`. The crawler's project gate must +/// recognize the same spelling; otherwise that project's non-deployment +/// install (no vendor/bundle) yields zero gem paths and every scan/apply +/// there is a silent no-op. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn get_gem_paths_with_gems_rb_manifest_returns_gemdir() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("gems.rb"), + b"source 'https://rubygems.org'\n", + ) + .await + .unwrap(); + + let gemdir = tempfile::tempdir().unwrap(); + let gems = gemdir.path().join("gems"); + tokio::fs::create_dir_all(&gems).await.unwrap(); + + let bin = tempfile::tempdir().unwrap(); + install_fake_gem(bin.path(), gemdir.path()); + + let crawler = RubyCrawler; + let paths = with_path(bin.path(), || async { + crawler.get_gem_paths(&options_at(tmp.path())).await + }) + .await + .unwrap(); + + assert_eq!( + paths, + vec![gems.clone()], + "gems.rb (Bundler's alternate manifest) must trigger `gem env gemdir`; got {paths:?}" + ); +} + +/// Same for `gems.locked`, the lock half of the alternate pair — mirrors +/// `Gemfile.lock` alone being enough. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn get_gem_paths_with_gems_locked_only_returns_gemdir() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("gems.locked"), b"GEM\n") + .await + .unwrap(); + + let gemdir = tempfile::tempdir().unwrap(); + let gems = gemdir.path().join("gems"); + tokio::fs::create_dir_all(&gems).await.unwrap(); + + let bin = tempfile::tempdir().unwrap(); + install_fake_gem(bin.path(), gemdir.path()); + + let crawler = RubyCrawler; + let paths = with_path(bin.path(), || async { + crawler.get_gem_paths(&options_at(tmp.path())).await + }) + .await + .unwrap(); + + assert_eq!( + paths, + vec![gems.clone()], + "gems.locked alone must trigger `gem env gemdir`; got {paths:?}" + ); +} + +/// Local mode must scan every gem home `gem env gempath` reports, not just +/// `gemdir`. Bundler resolves a project's gems from all of `Gem.path`, so a +/// dependency installed into a non-`gemdir` home — rvm's shared `@global` +/// gemset, or `~/.gem`/`$XDG_DATA_HOME` under `--user-install` — is still +/// loaded by the project and must be discoverable (and patchable). Global +/// mode already walks both; local mode stopping at `gemdir` silently missed +/// those gems. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn get_gem_paths_local_includes_every_gempath_home() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("Gemfile"), + b"source 'https://rubygems.org'\n", + ) + .await + .unwrap(); + + // Two gem homes: the active one (`gemdir`) and a second one that only + // `gempath` names — e.g. rvm's `@global` gemset. + let home_a = tempfile::tempdir().unwrap(); + let home_b = tempfile::tempdir().unwrap(); + let gems_a = home_a.path().join("gems"); + let gems_b = home_b.path().join("gems"); + tokio::fs::create_dir_all(gems_a.join("rails-7.1.0").join("lib")) + .await + .unwrap(); + tokio::fs::create_dir_all(gems_b.join("rake-13.2.1").join("lib")) + .await + .unwrap(); + + // `gempath` repeats the gemdir home first, as real RubyGems does — the + // result must stay deduped and keep gemdir's precedence. + let gempath = std::env::join_paths([home_a.path(), home_b.path()]).unwrap(); + let bin = tempfile::tempdir().unwrap(); + install_fake_gem_with_gempath(bin.path(), home_a.path(), gempath.to_str().unwrap()); + + let crawler = RubyCrawler; + let (paths, decoy, crawled) = with_path(bin.path(), || async { + let paths = crawler + .get_gem_paths(&options_at(tmp.path())) + .await + .unwrap(); + // Control: the gate still holds with `gem env` answerable — a + // non-Ruby cwd must not pull in the ambient gem homes. + let non_ruby = tempfile::tempdir().unwrap(); + let decoy = crawler + .get_gem_paths(&options_at(non_ruby.path())) + .await + .unwrap(); + let crawled = crawler.crawl_all(&options_at(tmp.path())).await; + (paths, decoy, crawled) + }) + .await; + + assert_eq!( + paths, + vec![gems_a.clone(), gems_b.clone()], + "local mode must return gemdir's gems/ then every other gempath home's, deduped; got {paths:?}" + ); + assert!( + decoy.is_empty(), + "non-Ruby cwd must still yield no gem paths; got {decoy:?}" + ); + // End-to-end: the gem that lives only in the second home is crawled. + let purls: Vec<&str> = crawled.iter().map(|p| p.purl.as_str()).collect(); + assert!( + purls.contains(&"pkg:gem/rake@13.2.1"), + "a gem installed only in a non-gemdir GEM_PATH home must be discovered; got {purls:?}" + ); + assert!( + purls.contains(&"pkg:gem/rails@7.1.0"), + "the gemdir home's gem must still be discovered; got {purls:?}" + ); +} + // ── global gem discovery ─────────────────────────────────────── #[tokio::test] diff --git a/crates/socket-patch-core/tests/proxy_batch_e2e.rs b/crates/socket-patch-core/tests/proxy_batch_e2e.rs index 9ac8f409..e1708ae6 100644 --- a/crates/socket-patch-core/tests/proxy_batch_e2e.rs +++ b/crates/socket-patch-core/tests/proxy_batch_e2e.rs @@ -264,3 +264,57 @@ async fn proxy_batch_429_surfaces_as_rate_limited_without_fallback() { "429 must be RateLimited; got: {err:?}" ); } + +#[tokio::test] +async fn proxy_batch_fallback_preserves_requested_purl_order() { + // Regression: the per-package fallback runs each chunk's GETs + // concurrently and used to collect them in *completion* order, so the + // assembled `packages` list was ordered by whichever response came back + // first. That order is user-visible — `scan --json` emits `packages` + // verbatim and the human table / interactive picker index it — so two + // identical scans of an unchanged project could disagree. The order must + // follow the requested PURLs, matching what the batch endpoint returns. + // + // The mock makes the race deterministic: the FIRST requested PURL is + // served with a delay, so completion order is the exact reverse of + // request order. + let first = "pkg:npm/alpha@1.0.0"; + let second = "pkg:npm/omega@1.0.0"; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/patch/batch")) + .respond_with(ResponseTemplate::new(405)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(r"^/patch/by-package/.*alpha.*$")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(by_package_response_body()) + .set_delay(std::time::Duration::from_millis(400)), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(r"^/patch/by-package/.*omega.*$")) + .respond_with(ResponseTemplate::new(200).set_body_json(by_package_response_body())) + .expect(1) + .mount(&server) + .await; + + let client = proxy_client(&server.uri()); + let resp = client + .search_patches_batch(None, &[first.to_string(), second.to_string()]) + .await + .expect("legacy proxy must degrade to per-package GETs"); + + let order: Vec<&str> = resp.packages.iter().map(|p| p.purl.as_str()).collect(); + assert_eq!( + order, + vec![first, second], + "assembled packages must follow the requested PURL order, not response-completion order" + ); +} diff --git a/scripts/optimize-test-perf.config.ts b/scripts/optimize-test-perf.config.ts new file mode 100644 index 00000000..257fe29d --- /dev/null +++ b/scripts/optimize-test-perf.config.ts @@ -0,0 +1,190 @@ +/** + * optimize-test-perf.config.ts — test-suite performance sweep for study-crates.ts. + * + * Runs one session per test file and asks it to make the file's tests FASTER + * without reducing what they cover or how hard they fail: + * + * npx tsx scripts/study-crates.ts --tests \ + * --prompt-file scripts/optimize-test-perf.config.ts + * + * (Use `--target all` to also visit src files and their inline #[cfg(test)] + * modules, `--crate`/`--filter` to narrow scope, and `--dry-run` to preview.) + * + * IMPORTANT: keep the default --concurrency 1. Sessions may touch shared + * harness modules (tests/common/mod.rs and friends) and they benchmark + * wall-clock time — parallel sessions would both race on shared files and + * corrupt each other's timing measurements. + * + * Framing + * ------- + * Speed is the only allowed win, and coverage/quality regressions are the only + * forbidden cost. The prompt therefore forces three proof obligations on every + * session that changes anything: + * 1. Inventory proof: `cargo test ... -- --list` output identical before and + * after (no test deleted, merged, ignored, or renamed away). + * 2. Strength proof: for every test whose setup or exercised path changed, a + * sabotage (RED) check — temporarily break the guarded behavior, confirm + * the optimized test still fails, restore. + * 3. Timing proof: warm before/after wall-clock numbers, measured after a + * throwaway first run (fresh macOS binaries can stall ~90s in dyld — that + * stall is not test time). + * Sessions that can't find a meaningful win are told to say "already fast" and + * stop — a no-op is a valid, desirable outcome. + * + * FileCtx fields available (see study-crates.ts): + * file repo-relative POSIX path, e.g. "crates/socket-patch-core/tests/diff_e2e.rs" + * abspath absolute path on disk + * crate crate dir name, e.g. "socket-patch-core" + * name basename, e.g. "diff_e2e.rs" + * stem basename without extension, e.g. "diff_e2e" + * relInCrate path within the crate's tests/ (or src/) dir + * isTest true when discovered under tests/ + */ + +import type { FileCtx } from "./study-crates.ts"; + +export const model = "claude-opus-5"; + +// Every ecosystem is compiled in unconditionally — the old per-ecosystem +// feature gates (`cargo`, `golang`, `maven`, …) no longer exist, and naming +// them makes cargo abort with "none of the selected packages contains these +// features". The default feature set is already exactly what we want here: +// all nine ecosystems, minus the cfg-gated `docker-e2e`/`setup-e2e` suites +// that `--all-features` would drag in. +const FEATURES = ""; + +export default function render(ctx: FileCtx): string { + const featureFlag = FEATURES ? ` --features ${FEATURES}` : ""; + const isHarness = + /(^|\/)(common|setup_matrix_common|helpers?|support|fixtures?)(\/|$)/.test( + ctx.relInCrate, + ) || ctx.name === "mod.rs"; + + const runCmd = ctx.isTest + ? `cargo test -p ${ctx.crate} --test ${ctx.stem}${featureFlag}` + : `cargo test -p ${ctx.crate} --lib${featureFlag} `; + + const lines: string[] = [ + `You are optimizing the RUNTIME PERFORMANCE of the tests in a single file.`, + `Treat this as your only task and stay within this one file (plus, only when`, + `unavoidable, the shared harness modules it pulls in).`, + ``, + `Target file: ${ctx.file}`, + `Crate: ${ctx.crate}`, + ``, + ctx.isTest + ? `This is an integration-test file.` + : `This is a source file — your scope is ONLY its inline #[cfg(test)]` + + ` module(s). Do not change any production code in the file.`, + ``, + `## Goal`, + `Make these tests finish faster while covering exactly as much and failing`, + `exactly as hard as they do today. Speed is the only allowed win; any loss`, + `of coverage, assertion strength, isolation, or determinism is a regression`, + `and disqualifies the change. If the file is already fast or has no safe`, + `win, say so plainly and change nothing — a no-op is a good outcome.`, + ``, + `## Method`, + `1. Baseline. Build and run the tests, then run them AGAIN and record the`, + ` second run's wall-clock time (freshly built binaries on macOS can stall`, + ` ~90s in dyld on first launch — that stall is launch overhead, not test`, + ` time; never count it or "fix" it):`, + ` ${runCmd}`, + ` (If a feature in the list doesn't exist for this crate, drop it and`, + ` note that. Report exactly what you ran.)`, + ` Also snapshot the test inventory:`, + ` ${runCmd} -- --list`, + `2. Profile before touching anything. Find where the time actually goes —`, + ` time individual tests with name filters if needed. Do not optimize on`, + ` suspicion; every change must chase a measured cost.`, + `3. Optimize. Legitimate wins, roughly in order of typical payoff here:`, + ` * Fixed sleeps and generous timeouts on paths that could poll: replace`, + ` sleep-then-assert with poll-until-condition under a deadline. Keep the`, + ` deadline as generous as the old timeout — the win is the common case`, + ` finishing early, not a tighter limit that flakes on slow CI.`, + ` * Expensive setup repeated per test (building fixture trees, spawning`, + ` helper processes, compiling anything): compute once and share via`, + ` OnceLock/lazy ONLY if the shared value is genuinely immutable and`, + ` read-only afterwards. Anything a test mutates — temp dirs, env vars,`, + ` lockfiles, cwd — must stay per-test.`, + ` * Redundant work inside one test: re-running a process to check a second`, + ` property observable from the first run's output; re-copying a fixture`, + ` tree the test never mutates; needlessly large fixture payloads whose`, + ` size provably doesn't matter to any assertion (be conservative — size`, + ` is sometimes the point, e.g. chunking/streaming boundaries).`, + ` * Waiting out a full timeout on expected-failure paths where the failure`, + ` is detectable immediately.`, + `4. Prove coverage is intact:`, + ` * Inventory proof: rerun \`-- --list\` and diff against the snapshot —`, + ` it must be IDENTICAL. No test deleted, merged, renamed, or #[ignore]d.`, + ` * Strength proof (sabotage / RED check): for EVERY test whose setup or`, + ` exercised code path you changed, temporarily sabotage the behavior it`, + ` guards (break the prod code path or the fixture it validates), confirm`, + ` the optimized test FAILS, then restore cleanly. A pure timing change`, + ` (sleep -> poll with same deadline) needs this too — polling loops are`, + ` a classic place to accidentally accept the pre-condition state.`, + ` * Stability proof: run the optimized tests 3 times back to back — all`, + ` green. Shared-fixture and parallelism changes are the classic source`, + ` of new flakes; one green run proves nothing. (Network blips can make`, + ` pip-fixture tests fail spuriously — rerun before diagnosing.)`, + `5. Measure the win: report warm before/after wall-clock. If the total`, + ` saving is under ~20% of the file's runtime AND under ~2 seconds, revert`, + ` everything and report "already fast" — churn is not worth micro-wins.`, + ``, + `## Hard constraints`, + `* Never delete, merge, #[ignore], or skip a test, and never weaken, remove,`, + ` or loosen an assertion. Every assertion that exists today must still run`, + ` and still be at least as strict.`, + `* Never reduce case counts, matrix dimensions, or iteration counts that`, + ` contribute coverage. If a loop looks arbitrarily large, that is a finding`, + ` to REPORT, not a change to make — you cannot prove locally which`, + ` iteration would have caught tomorrow's bug.`, + `* Never swap a real code path for a mock/stub/fake to save time. Faking`, + ` out the thing under test is a coverage loss even when every assertion`, + ` still passes.`, + `* Never remove serialization, locks, or env scrubbing that guard shared`, + ` state. Tests here mutate process env and global fixtures; env races have`, + ` caused real flakes in this repo. If a serial guard looks unnecessary,`, + ` report it — do not remove it.`, + `* Never relax hermeticity: per-test temp dirs stay per-test, env setup and`, + ` scrub lists stay intact, and no test may start depending on another`, + ` test's leftovers or on execution order.`, + `* Do not modify production/source code${ctx.isTest ? `` : ` outside the #[cfg(test)] module`},`, + ` CI configuration, cargo profiles, or global test-runner settings. If a`, + ` prod-side change (e.g. an injectable clock) would unlock a big win,`, + ` describe it in your summary instead of making it.`, + `* Timing changes must not tighten any deadline below its current value on`, + ` the failure path — faster when green, never flakier when slow.`, + `* If any proof step fails or is impractical for a change, revert that`, + ` change. Fail closed: no proof, no optimization.`, + ]; + + if (isHarness) { + lines.push( + ``, + `## Note: this is a shared test harness / setup module`, + `${ctx.relInCrate} is scaffolding other test files depend on, so a win`, + `here multiplies across the suite — and so does a regression. Any change`, + `to shared setup must keep its per-caller semantics identical (same env`, + `scrubbing, same fresh-state guarantees per invocation). After editing,`, + `run EVERY test target in this crate that imports this module, not just`, + `one, and apply the sabotage check through at least one representative`, + `consumer. Prefer additive opt-in fast paths (a cached read-only variant`, + `alongside the existing builder) over changing what existing callers get.`, + ); + } + + lines.push( + ``, + `## Report`, + `End with a concise summary (3-6 bullets): where the time was going, each`, + `optimization applied, warm before/after wall-clock, the three proofs`, + `(inventory diff clean, which tests you sabotage-checked, 3x-green), and`, + `any speed-up opportunities you deliberately left on the table because they`, + `would need prod changes or risk coverage. If you changed nothing, one`, + `bullet saying the file is already fast — with the measured runtime — is a`, + `complete, successful report.`, + ); + + return lines.join("\n"); +} From 6efe9b15ca205d91eba12712a5fd0dfd965b9d40 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:55:15 -0700 Subject: [PATCH 02/16] fix(scan,crawler): surface the pre-failure vendor reconcile in JSON; treat empty CARGO_HOME as unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more fixes the sweep's own RED regression tests were pinning: * scan --vendor --json: reconcile_dropped mutates the on-disk ledger BEFORE staging, but a staging failure returned Err without the envelope — the JSON consumer saw only the error object and never learned entries had been reverted on disk. The step error now carries the envelope built so far and the JSON fold attaches it as `vendor`. (Human mode prints no per-event lines even on success; unchanged.) * cargo crawler: CARGO_HOME="" hit PathBuf::from("") and resolved registry/src against the CWD, silently crawling nothing. Empty now means unset (env_non_empty convention), falling back to ~/.cargo. Pinned by scan_vendor_staging_error_still_reports_the_reconcile and empty_cargo_home_falls_back_to_home_dot_cargo. Co-Authored-By: Claude Fable 5 --- .../src/commands/scan/vendor_flow.rs | 46 ++++++++++++++----- .../src/crawlers/cargo_crawler.rs | 11 +++-- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 13de4149..94b71b7b 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -61,19 +61,23 @@ async fn preview_vendor_json(cwd: &Path, selected: &[PatchSearchResult]) -> serd /// [`download_patch_records`]; no manifest involvement at all). /// /// `Ok((has_errors, envelope))` on a run that reached the engine; -/// `Err((code, message))` for the lock/stage/manifest failures the -/// caller folds into its own output shape (scan's ad-hoc JSON can't use -/// `acquire_or_emit`, which prints an Envelope). +/// `Err((code, message, envelope))` for the lock/stage/manifest failures +/// the caller folds into its own output shape (scan's ad-hoc JSON can't +/// use `acquire_or_emit`, which prints an Envelope). The error carries +/// the envelope built so far when the failure happened AFTER +/// `reconcile_dropped` ran — the reconcile mutates the on-disk ledger, +/// and its events must survive the error fold or the JSON consumer +/// never learns about the mutation. async fn run_scan_vendor_step( common: &GlobalArgs, manifest_path: &Path, socket_dir: &Path, detached_records: Option<&HashMap>, -) -> Result<(bool, Envelope), (&'static str, String)> { +) -> Result<(bool, Envelope), (&'static str, String, Option>)> { // The download phase created `.socket/` already in every flow that // reaches here, but `acquire` deliberately refuses to mkdir. if let Err(e) = tokio::fs::create_dir_all(socket_dir).await { - return Err(("socket_dir_unwritable", e.to_string())); + return Err(("socket_dir_unwritable", e.to_string(), None)); } let guard = apply_lock::acquire( socket_dir, @@ -83,8 +87,9 @@ async fn run_scan_vendor_step( apply_lock::LockError::Held => ( "lock_held", "another socket-patch process is operating in this directory".to_string(), + None, ), - apply_lock::LockError::Io { .. } => ("lock_io", e.to_string()), + apply_lock::LockError::Io { .. } => ("lock_io", e.to_string(), None), })?; let mut env = Envelope::new(EnvelopeCommand::Vendor); @@ -111,7 +116,7 @@ async fn run_scan_vendor_step( drop(guard); return Ok((false, env)); } - Err(e) => return Err(("invalid_manifest", e.to_string())), + Err(e) => return Err(("invalid_manifest", e.to_string(), None)), }; // Same placement as the `vendor` command: dropped entries // are reverted even when zero in-scope patches remain. @@ -123,12 +128,15 @@ async fn run_scan_vendor_step( match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd).await { Ok(MemStageOutcome::Ready(s)) => s, Ok(MemStageOutcome::Unavailable) => { + // The reconcile above may have already reverted dropped + // entries on disk — hand its envelope to the error fold. return Err(( "no_local_source", "patch artifacts unavailable (offline or download failure)".to_string(), - )) + Some(Box::new(env)), + )); } - Err(e) => return Err(("stage_failed", e)), + Err(e) => return Err(("stage_failed", e, Some(Box::new(env)))), }; let sources = staged.as_patch_sources(); has_errors |= @@ -280,7 +288,7 @@ async fn run_vendor_json_path( serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); i32::from(has_errors) } - Err((code, message)) => { + Err((code, message, venv)) => { track_patch_vendor_failed( &message, args.common.dry_run, @@ -288,6 +296,13 @@ async fn run_vendor_json_path( telemetry_org, ) .await; + // A pre-failure reconcile already mutated the ledger on disk; + // its envelope (events included) must reach the JSON consumer + // even though the run aborts here. + if let Some(venv) = venv { + result["vendor"] = + serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); + } result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!({ "code": code, @@ -378,7 +393,10 @@ async fn run_vendor_interactive_path( .await; i32::from(has_errors) } - Err((code, message)) => { + // Human mode prints no per-event lines even on success, so the + // carried envelope has no human rendering to feed — JSON mode is + // where the reconcile events must survive (see the JSON fold above). + Err((code, message, _venv)) => { track_patch_vendor_failed( &message, args.common.dry_run, @@ -534,7 +552,11 @@ fn boxed_scan_vendor_step<'a>( socket_dir: &'a Path, detached_records: Option<&'a HashMap>, ) -> std::pin::Pin< - Box> + 'a>, + Box< + dyn std::future::Future< + Output = Result<(bool, Envelope), (&'static str, String, Option>)>, + > + 'a, + >, > { Box::pin(run_scan_vendor_step( common, diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 13ef51ed..0e024417 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -395,12 +395,15 @@ impl CargoCrawler { Some((name.to_string(), version.to_string())) } - /// Get `CARGO_HOME`, defaulting to `$HOME/.cargo`. + /// Get `CARGO_HOME`, defaulting to `$HOME/.cargo`. An empty value means + /// unset (the env_non_empty convention) — `PathBuf::from("")` would + /// otherwise resolve `registry/src` against the CWD and silently crawl + /// nothing. fn cargo_home() -> PathBuf { - if let Ok(cargo_home) = std::env::var("CARGO_HOME") { - return PathBuf::from(cargo_home); + match std::env::var("CARGO_HOME") { + Ok(v) if !v.trim().is_empty() => PathBuf::from(v), + _ => crate::utils::fs::home_dir().join(".cargo"), } - crate::utils::fs::home_dir().join(".cargo") } } From 8aa9a229e69b61039e9b3e05eb5b6c7e218688c1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:28:06 -0700 Subject: [PATCH 03/16] refactor(core): promote patch/vendor to a top-level vendor module Pure move, no logic changes. patch/ was 63% of the crate but only ~11% patch engine; the vendoring + lockfile-rewriting subsystem it contained (34 files, ~47% of core) is the crate's real center of mass and now lives at crate::vendor. The npm-family strays move with it (bun_lock_text, go_mod_edit), and the project-local Go replace-redirect backend joins the other rewiring code as patch::redirect::golang_local. Old `patch::*` paths keep compiling through re-export shims for external consumers of the published crate; internal references are repointed in the follow-up commit. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-core/src/lib.rs | 1 + crates/socket-patch-core/src/patch/mod.rs | 13 +++++++++---- .../{go_redirect.rs => redirect/golang_local.rs} | 6 +++--- crates/socket-patch-core/src/patch/redirect/mod.rs | 1 + .../src/{patch => }/vendor/berry_zip.rs | 0 .../src/{patch => }/vendor/bun_lock.rs | 0 .../src/{patch => vendor}/bun_lock_text.rs | 0 .../src/{patch => }/vendor/cargo.rs | 0 .../src/{patch => }/vendor/cargo_config.rs | 0 .../src/{patch => }/vendor/cargo_lock.rs | 0 .../src/{patch => }/vendor/common.rs | 0 .../src/{patch => }/vendor/composer_lock.rs | 0 .../socket-patch-core/src/{patch => }/vendor/gem.rs | 0 .../src/{patch => vendor}/go_mod_edit.rs | 0 .../src/{patch => }/vendor/golang.rs | 0 .../src/{patch => }/vendor/lock_inventory.rs | 0 .../src/{patch => }/vendor/maven_repo.rs | 0 .../socket-patch-core/src/{patch => }/vendor/mod.rs | 2 ++ .../src/{patch => }/vendor/npm_common.rs | 0 .../src/{patch => }/vendor/npm_flavor.rs | 0 .../src/{patch => }/vendor/npm_lock.rs | 0 .../src/{patch => }/vendor/npm_pack.rs | 0 .../src/{patch => }/vendor/nuget_feed.rs | 0 .../src/{patch => }/vendor/path.rs | 0 .../src/{patch => }/vendor/pnpm_lock.rs | 0 .../src/{patch => }/vendor/pypi.rs | 0 .../src/{patch => }/vendor/pypi_pdm.rs | 0 .../src/{patch => }/vendor/pypi_pipenv.rs | 0 .../src/{patch => }/vendor/pypi_poetry.rs | 0 .../src/{patch => }/vendor/pypi_requirements.rs | 0 .../src/{patch => }/vendor/pypi_uv.rs | 0 .../src/{patch => }/vendor/pypi_wheel.rs | 0 .../src/{patch => }/vendor/registry_fetch.rs | 0 .../src/{patch => }/vendor/service_fetch.rs | 0 .../src/{patch => }/vendor/state.rs | 0 .../src/{patch => }/vendor/toml_surgery.rs | 0 .../src/{patch => }/vendor/verify.rs | 0 .../src/{patch => }/vendor/yarn_berry_lock.rs | 0 .../src/{patch => }/vendor/yarn_classic_lock.rs | 0 .../src/{patch => }/vendor/yarn_layering_tests.rs | 0 40 files changed, 16 insertions(+), 7 deletions(-) rename crates/socket-patch-core/src/patch/{go_redirect.rs => redirect/golang_local.rs} (99%) rename crates/socket-patch-core/src/{patch => }/vendor/berry_zip.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/bun_lock.rs (100%) rename crates/socket-patch-core/src/{patch => vendor}/bun_lock_text.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/cargo.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/cargo_config.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/cargo_lock.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/common.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/composer_lock.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/gem.rs (100%) rename crates/socket-patch-core/src/{patch => vendor}/go_mod_edit.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/golang.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/lock_inventory.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/maven_repo.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/mod.rs (99%) rename crates/socket-patch-core/src/{patch => }/vendor/npm_common.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/npm_flavor.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/npm_lock.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/npm_pack.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/nuget_feed.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/path.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pnpm_lock.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi_pdm.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi_pipenv.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi_poetry.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi_requirements.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi_uv.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/pypi_wheel.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/registry_fetch.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/service_fetch.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/state.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/toml_surgery.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/verify.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/yarn_berry_lock.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/yarn_classic_lock.rs (100%) rename crates/socket-patch-core/src/{patch => }/vendor/yarn_layering_tests.rs (100%) diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 9697e185..dfcd00b6 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -10,4 +10,5 @@ pub mod patch; pub mod pth_hook; pub mod update; pub mod utils; +pub mod vendor; pub mod vex; diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 28d4d66c..b5aa2376 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -1,17 +1,22 @@ pub mod apply; pub mod apply_lock; -pub(crate) mod bun_lock_text; // Ungated: the vendor backends (npm/pypi/gem are unconditional) stage their // patched copies with `fresh_copy`/`remove_tree`, not just the golang redirect. pub mod copy_tree; pub mod cow; pub mod diff; pub(crate) mod file_hash; -pub mod go_mod_edit; -pub mod go_redirect; pub mod package; pub(crate) mod path_safety; pub mod redirect; pub mod rollback; pub mod sidecars; -pub mod vendor; + +// Moved modules — these re-exports keep the old `patch::*` paths compiling +// for external consumers of the published crate. Internal code must import +// the new canonical paths (`crate::vendor::*`, `redirect::golang_local`); +// CI greps reject new uses of the old ones. Drop these aliases at 4.0. +pub(crate) use crate::vendor::bun_lock_text; +pub use crate::vendor::go_mod_edit; +pub use crate::vendor; +pub use redirect::golang_local as go_redirect; diff --git a/crates/socket-patch-core/src/patch/go_redirect.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs similarity index 99% rename from crates/socket-patch-core/src/patch/go_redirect.rs rename to crates/socket-patch-core/src/patch/redirect/golang_local.rs index 69a1c313..f84dfea1 100644 --- a/crates/socket-patch-core/src/patch/go_redirect.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -36,12 +36,12 @@ use crate::patch::vendor::common::{ }; use crate::utils::purl::{build_golang_purl, parse_golang_purl, strip_purl_qualifiers}; -use super::copy_tree::{fresh_copy, remove_tree}; -use super::go_mod_edit::{ +use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::vendor::go_mod_edit::{ self, read_replace_entries, read_required_versions, replace_target_path, ReplaceOwner, GO_PATCHES_DIR, }; -use super::path_safety; +use crate::patch::path_safety; /// A discrepancy between the committed redirect artifacts and the manifest, /// reported by [`verify_go_redirect_state`]. diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e01f4344..ae986174 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -23,6 +23,7 @@ use serde_json::{json, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::patch::vendor::yarn_berry_lock::yarnrc_compression_level; +pub mod golang_local; mod state; pub use state::{load_redirect_state, RedirectState, REDIRECT_STATE_REL}; diff --git a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs b/crates/socket-patch-core/src/vendor/berry_zip.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/berry_zip.rs rename to crates/socket-patch-core/src/vendor/berry_zip.rs diff --git a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/bun_lock.rs rename to crates/socket-patch-core/src/vendor/bun_lock.rs diff --git a/crates/socket-patch-core/src/patch/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs similarity index 100% rename from crates/socket-patch-core/src/patch/bun_lock_text.rs rename to crates/socket-patch-core/src/vendor/bun_lock_text.rs diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/cargo.rs rename to crates/socket-patch-core/src/vendor/cargo.rs diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/cargo_config.rs rename to crates/socket-patch-core/src/vendor/cargo_config.rs diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/cargo_lock.rs rename to crates/socket-patch-core/src/vendor/cargo_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/common.rs rename to crates/socket-patch-core/src/vendor/common.rs diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/composer_lock.rs rename to crates/socket-patch-core/src/vendor/composer_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/gem.rs rename to crates/socket-patch-core/src/vendor/gem.rs diff --git a/crates/socket-patch-core/src/patch/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs similarity index 100% rename from crates/socket-patch-core/src/patch/go_mod_edit.rs rename to crates/socket-patch-core/src/vendor/go_mod_edit.rs diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/golang.rs rename to crates/socket-patch-core/src/vendor/golang.rs diff --git a/crates/socket-patch-core/src/patch/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/lock_inventory.rs rename to crates/socket-patch-core/src/vendor/lock_inventory.rs diff --git a/crates/socket-patch-core/src/patch/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/maven_repo.rs rename to crates/socket-patch-core/src/vendor/maven_repo.rs diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs similarity index 99% rename from crates/socket-patch-core/src/patch/vendor/mod.rs rename to crates/socket-patch-core/src/vendor/mod.rs index d040c8bd..39f447be 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -47,12 +47,14 @@ pub mod state; mod berry_zip; pub mod bun_lock; +pub(crate) mod bun_lock_text; pub mod cargo; pub mod cargo_config; pub(crate) mod cargo_lock; pub(crate) mod common; pub mod composer_lock; pub mod gem; +pub mod go_mod_edit; pub mod golang; pub mod lock_inventory; pub mod maven_repo; diff --git a/crates/socket-patch-core/src/patch/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/npm_common.rs rename to crates/socket-patch-core/src/vendor/npm_common.rs diff --git a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/npm_flavor.rs rename to crates/socket-patch-core/src/vendor/npm_flavor.rs diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/npm_lock.rs rename to crates/socket-patch-core/src/vendor/npm_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs b/crates/socket-patch-core/src/vendor/npm_pack.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/npm_pack.rs rename to crates/socket-patch-core/src/vendor/npm_pack.rs diff --git a/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/nuget_feed.rs rename to crates/socket-patch-core/src/vendor/nuget_feed.rs diff --git a/crates/socket-patch-core/src/patch/vendor/path.rs b/crates/socket-patch-core/src/vendor/path.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/path.rs rename to crates/socket-patch-core/src/vendor/path.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs rename to crates/socket-patch-core/src/vendor/pnpm_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi.rs rename to crates/socket-patch-core/src/vendor/pypi.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs rename to crates/socket-patch-core/src/vendor/pypi_pdm.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs rename to crates/socket-patch-core/src/vendor/pypi_pipenv.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/vendor/pypi_poetry.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs rename to crates/socket-patch-core/src/vendor/pypi_poetry.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs rename to crates/socket-patch-core/src/vendor/pypi_requirements.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi_uv.rs rename to crates/socket-patch-core/src/vendor/pypi_uv.rs diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs b/crates/socket-patch-core/src/vendor/pypi_wheel.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs rename to crates/socket-patch-core/src/vendor/pypi_wheel.rs diff --git a/crates/socket-patch-core/src/patch/vendor/registry_fetch.rs b/crates/socket-patch-core/src/vendor/registry_fetch.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/registry_fetch.rs rename to crates/socket-patch-core/src/vendor/registry_fetch.rs diff --git a/crates/socket-patch-core/src/patch/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/service_fetch.rs rename to crates/socket-patch-core/src/vendor/service_fetch.rs diff --git a/crates/socket-patch-core/src/patch/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/state.rs rename to crates/socket-patch-core/src/vendor/state.rs diff --git a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs b/crates/socket-patch-core/src/vendor/toml_surgery.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/toml_surgery.rs rename to crates/socket-patch-core/src/vendor/toml_surgery.rs diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/verify.rs rename to crates/socket-patch-core/src/vendor/verify.rs diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs rename to crates/socket-patch-core/src/vendor/yarn_berry_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs rename to crates/socket-patch-core/src/vendor/yarn_classic_lock.rs diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs similarity index 100% rename from crates/socket-patch-core/src/patch/vendor/yarn_layering_tests.rs rename to crates/socket-patch-core/src/vendor/yarn_layering_tests.rs From be8f63164c51aec196f4fc18db474bcd3882f1a6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:29:25 -0700 Subject: [PATCH 04/16] refactor(core): repoint internal imports to the canonical moved-module paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical: patch::vendor → vendor, patch::go_mod_edit → vendor::go_mod_edit, patch::bun_lock_text → vendor::bun_lock_text, patch::go_redirect → patch::redirect::golang_local, across both crates and tests. The patch::* re-export shims stay for external consumers of the published core crate, but #[deprecated] on a pub use emits no warnings (rust-lang/rust#30827), so a CI grep now rejects new internal uses of the alias paths. The bun_lock_text shim is dropped outright: it was pub(crate) before the move, so no external consumer could name it. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 15 +++++++++++++ crates/socket-patch-cli/src/args.rs | 2 +- crates/socket-patch-cli/src/commands/apply.rs | 11 +++++----- .../src/commands/fetch_stage.rs | 7 ++---- crates/socket-patch-cli/src/commands/get.rs | 14 +++++------- .../socket-patch-cli/src/commands/remove.rs | 2 +- .../socket-patch-cli/src/commands/repair.rs | 6 ++--- .../src/commands/repair_vendor.rs | 10 ++++----- .../socket-patch-cli/src/commands/rollback.rs | 15 ++++++------- .../src/commands/scan/discovery.rs | 4 ++-- .../socket-patch-cli/src/commands/scan/mod.rs | 3 +-- .../src/commands/scan/vendor_flow.rs | 2 +- .../socket-patch-cli/src/commands/vendor.rs | 10 ++++----- crates/socket-patch-cli/src/commands/vex.rs | 22 ++++++++++--------- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 18 ++++++++------- .../tests/in_process_vendor.rs | 4 ++-- .../tests/setup_contract_gaps.rs | 2 +- .../src/crawlers/composer_crawler.rs | 2 +- .../src/package_json/detect.rs | 2 +- .../socket-patch-core/src/patch/copy_tree.rs | 2 +- crates/socket-patch-core/src/patch/mod.rs | 3 +-- .../src/patch/redirect/golang_local.rs | 6 ++--- .../src/patch/redirect/mod.rs | 6 ++--- .../socket-patch-core/src/pth_hook/detect.rs | 2 +- crates/socket-patch-core/src/pth_hook/edit.rs | 4 ++-- .../socket-patch-core/src/vendor/bun_lock.rs | 8 +++---- crates/socket-patch-core/src/vendor/cargo.rs | 4 ++-- crates/socket-patch-core/src/vendor/common.rs | 2 +- .../src/vendor/composer_lock.rs | 4 ++-- crates/socket-patch-core/src/vendor/gem.rs | 4 ++-- .../src/vendor/go_mod_edit.rs | 2 +- crates/socket-patch-core/src/vendor/golang.rs | 14 ++++++------ .../src/vendor/lock_inventory.rs | 2 +- .../src/vendor/maven_repo.rs | 2 +- crates/socket-patch-core/src/vendor/mod.rs | 2 +- .../src/vendor/npm_flavor.rs | 2 +- .../socket-patch-core/src/vendor/npm_lock.rs | 2 +- .../src/vendor/nuget_feed.rs | 2 +- crates/socket-patch-core/src/vendor/pypi.rs | 4 ++-- .../socket-patch-core/src/vendor/pypi_pdm.rs | 2 +- .../src/vendor/pypi_pipenv.rs | 2 +- .../src/vendor/pypi_poetry.rs | 2 +- .../src/vendor/pypi_requirements.rs | 2 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 2 +- .../src/vendor/service_fetch.rs | 12 +++++----- crates/socket-patch-core/src/vendor/verify.rs | 2 +- .../src/vendor/yarn_layering_tests.rs | 10 ++++----- crates/socket-patch-core/src/vex/verify.rs | 6 ++--- 48 files changed, 139 insertions(+), 129 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75e9587b..62eb3b67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,21 @@ jobs: - name: Run clippy run: cargo clippy --workspace --all-features -- -D warnings + # Moved-module aliases (patch::vendor → vendor, patch::go_mod_edit → + # vendor::go_mod_edit, patch::go_redirect → patch::redirect::golang_local) + # exist only for external consumers of the published core crate. + # #[deprecated] on a `pub use` re-export emits no warnings + # (rust-lang/rust#30827), so the compiler cannot pressure internal code + # off the old paths — this grep is the guard instead. + - name: Reject internal uses of moved-module alias paths + run: | + if grep -rn --include='*.rs' \ + -e 'patch::vendor' -e 'patch::go_mod_edit' \ + -e 'patch::go_redirect' -e 'patch::bun_lock_text' \ + crates; then + echo '::error::use the canonical module paths (crate::vendor, patch::redirect::golang_local); the patch::* aliases exist only for external consumers' + exit 1 + fi # Lint the out-of-workspace packaging artifacts for the ecosystems whose setup # / CLI-distribution we added: the RubyGems CLI launcher gem + the Bundler diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 32a7b552..b4737d85 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -20,7 +20,7 @@ use clap::Args; use socket_patch_core::api::client::ApiClientEnvOverrides; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::patch::vendor::VendorSource; +use socket_patch_core::vendor::VendorSource; /// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token. /// diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 7fceb3ec..57065497 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -8,7 +8,7 @@ use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRec use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, MismatchPolicy, PatchSources, VerifyStatus, }; -use socket_patch_core::patch::go_redirect::{ +use socket_patch_core::patch::redirect::golang_local::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, }; use socket_patch_core::utils::purl::parse_golang_purl; @@ -268,7 +268,7 @@ async fn try_local_go_apply( version, pkg_path, &common.cwd, - socket_patch_core::patch::go_mod_edit::GO_PATCHES_DIR, + socket_patch_core::vendor::go_mod_edit::GO_PATCHES_DIR, &patch.files, sources, Some(&patch.uuid), @@ -339,12 +339,12 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { let mut checked: usize = 0; { - use socket_patch_core::patch::go_redirect::Drift as GoDrift; + use socket_patch_core::patch::redirect::golang_local::Drift as GoDrift; if go_in_local_scope(&args.common) { // Vendored modules are excluded: their replace directives point at // `.socket/vendor/golang/` (the verify engine skips Vendor-owned // entries) and their state is audited by `vendor`, not `--check`. - let vendored = socket_patch_core::patch::vendor::load_state(&args.common.cwd) + let vendored = socket_patch_core::vendor::load_state(&args.common.cwd) .await .map(|s| { s.entries @@ -1002,8 +1002,7 @@ async fn apply_patches_inner( // by ledger key, resolved base purl, or qualifier-stripped key so // release-variant manifest keys (pypi `?artifact_id=`…) hit too; // unreadable state degrades to "nothing vendored" (fail-open). - let vendored_purls = - socket_patch_core::patch::vendor::vendored_purl_keys(&args.common.cwd).await; + let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; let is_vendored = |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); let (mut results, mut matched_manifest_purls, vendored_bases) = diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index b081b31c..cad91c43 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -353,11 +353,8 @@ pub(crate) async fn stage_vendor_sources_in_memory( // The committed vendor artifact IS the patched content: harvest its // afterHash blobs into memory so in-sync re-runs and fresh clones of // already-vendored projects stage with no network and no disk blobs. - mem = socket_patch_core::patch::vendor::harvest_artifact_blobs( - project_root, - &manifest.patches, - ) - .await; + mem = socket_patch_core::vendor::harvest_artifact_blobs(project_root, &manifest.patches) + .await; if !mem.is_empty() { to_fetch.retain(|(purl, _)| { manifest.patches.get(*purl).is_none_or(|record| { diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 1f5fe18e..efa5adb1 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -869,7 +869,7 @@ pub(crate) async fn download_patch_records( let (selected, narrow_warnings) = filter_to_installed_releases(selected, params, &api_client).await; - let vendor_state = socket_patch_core::patch::vendor::load_state(¶ms.cwd) + let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd) .await .unwrap_or_default(); @@ -882,11 +882,9 @@ pub(crate) async fn download_patch_records( for search_result in &selected { // Idempotency: a detached entry already at this uuid carries its // own record — no view fetch needed. - let existing = socket_patch_core::patch::vendor::lookup_entry( - &vendor_state.entries, - &search_result.purl, - ) - .filter(|e| e.detached && e.uuid == search_result.uuid); + let existing = + socket_patch_core::vendor::lookup_entry(&vendor_state.entries, &search_result.purl) + .filter(|e| e.detached && e.uuid == search_result.uuid); if let Some(record) = existing.and_then(|e| e.record.clone()) { if !params.json && !params.silent { eprintln!(" [skip] {} (already vendored)", search_result.purl); @@ -993,7 +991,7 @@ async fn warn_on_vendored_uuid_drift( downloaded_patches: &[serde_json::Value], warnings: &mut Vec, ) { - let Ok(vendor_state) = socket_patch_core::patch::vendor::load_state(cwd).await else { + let Ok(vendor_state) = socket_patch_core::vendor::load_state(cwd).await else { return; }; if vendor_state.entries.is_empty() { @@ -1006,7 +1004,7 @@ async fn warn_on_vendored_uuid_drift( if !matches!(rec["action"].as_str(), Some("added" | "updated")) { continue; } - let entry = socket_patch_core::patch::vendor::lookup_entry(&vendor_state.entries, purl); + let entry = socket_patch_core::vendor::lookup_entry(&vendor_state.entries, purl); if let Some(entry) = entry.filter(|e| e.uuid != uuid) { let w = format!( "{purl} is vendored at patch {} but the manifest now records {uuid}; \ diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 31f627ec..2f3358eb 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -2,10 +2,10 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::patch::vendor::{load_state, save_state, VendorEntry, VendorState}; use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; use socket_patch_core::utils::purl::purl_matches_identifier; use socket_patch_core::utils::telemetry::{track_patch_remove_failed, track_patch_removed}; +use socket_patch_core::vendor::{load_state, save_state, VendorEntry, VendorState}; use std::path::Path; use std::time::Duration; diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index c75d11e3..243956c1 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -83,7 +83,7 @@ pub async fn run(args: RepairArgs) -> i32 { let state_file = args .common .cwd - .join(socket_patch_core::patch::vendor::VENDOR_STATE_REL); + .join(socket_patch_core::vendor::VENDOR_STATE_REL); let has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok() || !crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd) .await @@ -272,7 +272,7 @@ async fn repair_inner( // packages` — repair must not re-litter them (or fail trying). The // cleanup phase below still uses the FULL manifest, so it never sweeps // sources an in-place apply may need for rollback. - let vendor_state = socket_patch_core::patch::vendor::load_state(&args.common.cwd) + let vendor_state = socket_patch_core::vendor::load_state(&args.common.cwd) .await .unwrap_or_default(); // Lockfile vendor references count as vendored even before the ledger @@ -290,7 +290,7 @@ async fn repair_inner( .iter() .filter(|(purl, rec)| { !referenced_uuids.contains(&rec.uuid) - && socket_patch_core::patch::vendor::lookup_entry(&vendor_state.entries, purl) + && socket_patch_core::vendor::lookup_entry(&vendor_state.entries, purl) .is_none_or(|e| e.uuid != rec.uuid) }) .map(|(k, v)| (k.clone(), v.clone())) diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 734057c4..c5843751 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -26,14 +26,14 @@ use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; -use socket_patch_core::patch::vendor::state::VendorArtifact; -use socket_patch_core::patch::vendor::{ - self, check_vendored_artifact, file_sha256_hex, load_state, lock_inventory, parse_vendor_path, - registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, -}; use socket_patch_core::utils::purl::{ normalize_purl, percent_decode_purl_component, strip_purl_qualifiers, }; +use socket_patch_core::vendor::state::VendorArtifact; +use socket_patch_core::vendor::{ + self, check_vendored_artifact, file_sha256_hex, load_state, lock_inventory, parse_vendor_path, + registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, +}; use socket_patch_core::vex::time::now_rfc3339; use crate::args::GlobalArgs; diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index fe0d136c..3a76d9df 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -97,8 +97,8 @@ async fn try_rollback_local_go( patch: &PatchRecord, common: &GlobalArgs, ) -> Option { - use socket_patch_core::patch::go_mod_edit::{ReplaceOwner, GO_PATCHES_DIR}; - use socket_patch_core::patch::go_redirect::remove_go_redirect; + use socket_patch_core::patch::redirect::golang_local::remove_go_redirect; + use socket_patch_core::vendor::go_mod_edit::{ReplaceOwner, GO_PATCHES_DIR}; if !is_local_go(purl, common) { return None; } @@ -511,8 +511,7 @@ async fn rollback_patches_inner( // `vendor --revert` undoes it wholesale. Matching mirrors apply's // ledger-key / base-purl / qualifier-stripped triple; unreadable state // degrades to "nothing vendored". - let vendored_keys = - socket_patch_core::patch::vendor::vendored_purl_keys(&args.common.cwd).await; + let vendored_keys = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; let is_vendored = |p: &str| vendored_keys.contains(p) || vendored_keys.contains(strip_purl_qualifiers(p)); let (vendored_targets, patches_to_rollback): (Vec<_>, Vec<_>) = patches_to_rollback @@ -1246,7 +1245,7 @@ mod tests { /// kept using the patched copy. #[tokio::test] async fn try_rollback_local_go_drops_redirect_and_copy() { - use socket_patch_core::patch::go_mod_edit::{ + use socket_patch_core::vendor::go_mod_edit::{ ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, }; @@ -1331,7 +1330,7 @@ mod tests { /// that mutated nothing. #[tokio::test] async fn try_rollback_local_go_dry_run_reports_no_files_rolled_back() { - use socket_patch_core::patch::go_mod_edit::{ + use socket_patch_core::vendor::go_mod_edit::{ ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, }; @@ -1423,7 +1422,7 @@ mod tests { /// record deleted, i.e. an active patch nothing tracks. #[tokio::test] async fn rollback_drops_local_go_redirect_when_module_cache_has_no_copy() { - use socket_patch_core::patch::go_mod_edit::{ + use socket_patch_core::vendor::go_mod_edit::{ ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, }; @@ -1520,7 +1519,7 @@ mod tests { /// filter's back. #[tokio::test] async fn undiscovered_local_go_redirect_respects_ecosystem_filter() { - use socket_patch_core::patch::go_mod_edit::{ + use socket_patch_core::vendor::go_mod_edit::{ ensure_replace_entry, read_replace_entries, GO_PATCHES_DIR, }; diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 666fec4e..a8404c61 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -39,7 +39,7 @@ pub(super) async fn lockfile_supplement( common: &GlobalArgs, crawled: &[socket_patch_core::crawlers::types::CrawledPackage], ) -> LockfileSupplement { - use socket_patch_core::patch::vendor::lock_inventory; + use socket_patch_core::vendor::lock_inventory; let mut out = LockfileSupplement::default(); if common.global || common.global_prefix.is_some() { @@ -99,7 +99,7 @@ pub(super) async fn vendored_ledger_supplement( if common.global || common.global_prefix.is_some() { return Vec::new(); } - let Ok(state) = socket_patch_core::patch::vendor::load_state(&common.cwd).await else { + let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await else { return Vec::new(); }; let crawled_norm: HashSet = crawled diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 7305f7e0..4b203e58 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -560,8 +560,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { // exemption (a vendored package is consumed from the committed // artifact, so "absent from the crawl" is its normal state, not // grounds for pruning) and the vendored-skip in the apply path. - let vendored_purls = - socket_patch_core::patch::vendor::vendored_purl_keys(&args.common.cwd).await; + let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; // Filter by --ecosystems if provided let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 94b71b7b..0121538e 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -8,8 +8,8 @@ use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply_lock; -use socket_patch_core::patch::vendor::{load_state, lookup_entry}; use socket_patch_core::utils::telemetry::track_patch_vendor_failed; +use socket_patch_core::vendor::{load_state, lookup_entry}; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::time::Duration; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 03310205..7d31b3f2 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -23,13 +23,13 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; use socket_patch_core::patch::copy_tree::remove_tree; -use socket_patch_core::patch::vendor::{ +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::telemetry::{track_patch_vendor_failed, track_patch_vendored}; +use socket_patch_core::vendor::{ self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, registry_fetch, save_state, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, VendorSource, VendorState, VendorWarning, }; -use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{track_patch_vendor_failed, track_patch_vendored}; use socket_patch_core::vex::time::now_rfc3339; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -1400,7 +1400,7 @@ pub(crate) async fn run_vendor_gc( #[cfg(test)] mod dispatch_tests { use super::*; - use socket_patch_core::patch::vendor::VendorSource; + use socket_patch_core::vendor::VendorSource; /// Fail-closed `--vendor-source=service` must not refuse maven at the /// dispatch gate: the maven backend has a full service path (prebuilt @@ -1582,7 +1582,7 @@ mod variant_probe_tests { #[cfg(test)] mod gc_tests { use super::*; - use socket_patch_core::patch::vendor::state::VendorArtifact; + use socket_patch_core::vendor::state::VendorArtifact; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index ae5d8b5f..a71eb37c 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -314,14 +314,14 @@ async fn generate_vex( // not whether this run hashed it. The committed ledger is as // trustworthy as the manifest beside it, and reading it hashes // nothing. An unreadable ledger degrades to "nothing vendored". - let entries = socket_patch_core::patch::vendor::load_state(&common.cwd) + let entries = socket_patch_core::vendor::load_state(&common.cwd) .await .map(|state| state.entries) .unwrap_or_default(); let vendored = manifest .patches .keys() - .filter(|purl| socket_patch_core::patch::vendor::lookup_entry(&entries, purl).is_some()) + .filter(|purl| socket_patch_core::vendor::lookup_entry(&entries, purl).is_some()) .cloned() .collect(); VerifyOutcome { @@ -537,7 +537,7 @@ pub(crate) async fn generate_vex_from_manifest_path( /// still fails closed per-entry downstream, and `load_vendor_context` /// already warns about the unreadable state. async fn augment_with_detached(common: &GlobalArgs, mut manifest: PatchManifest) -> PatchManifest { - if let Ok(state) = socket_patch_core::patch::vendor::load_state(&common.cwd).await { + if let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await { for (key, entry) in state.entries { if !entry.detached { continue; @@ -633,7 +633,7 @@ pub(crate) async fn load_vendor_context( common: &GlobalArgs, manifest: &PatchManifest, ) -> Option { - let entries = match socket_patch_core::patch::vendor::load_state(&common.cwd).await { + let entries = match socket_patch_core::vendor::load_state(&common.cwd).await { Ok(state) => state.entries, Err(e) => { if !common.silent { @@ -665,13 +665,15 @@ pub(crate) async fn load_vendor_context( async fn synthesize_go_patches( common: &GlobalArgs, manifest: &PatchManifest, - entries: &HashMap, + entries: &HashMap, ) -> HashMap { - use socket_patch_core::patch::go_mod_edit::{ - read_replace_entries, ReplaceOwner, GO_PATCHES_DIR, + use socket_patch_core::patch::redirect::golang_local::{ + are_safe_redirect_coords, copy_dir_for, }; - use socket_patch_core::patch::go_redirect::{are_safe_redirect_coords, copy_dir_for}; use socket_patch_core::utils::purl::build_golang_purl; + use socket_patch_core::vendor::go_mod_edit::{ + read_replace_entries, ReplaceOwner, GO_PATCHES_DIR, + }; let mut go_patches = HashMap::new(); for entry in read_replace_entries(&common.cwd).await { @@ -687,7 +689,7 @@ async fn synthesize_go_patches( } // Explicit vendor entries take precedence over the synthesis // (vendor may have taken over an apply redirect). - if socket_patch_core::patch::vendor::lookup_entry(entries, &purl).is_some() { + if socket_patch_core::vendor::lookup_entry(entries, &purl).is_some() { continue; } // SECURITY: module/version come from a committed (tamper-able) @@ -813,7 +815,7 @@ mod tests { /// an out-of-tree path into the go-patches verification map. #[test] fn go_redirect_coord_guard_matches_core_rules() { - use socket_patch_core::patch::go_redirect::are_safe_redirect_coords; + use socket_patch_core::patch::redirect::golang_local::are_safe_redirect_coords; assert!(are_safe_redirect_coords("github.com/foo/bar", "v1.4.2")); assert!(are_safe_redirect_coords("gopkg.in/inf.v0", "v0.9.1")); diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 20379496..fc130a15 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -30,7 +30,7 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, }; -use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; +use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; /// Canonical-grammar patch UUID — the vendored-artifact verifier validates /// the uuid path level, so fixtures must use the real shape. @@ -486,13 +486,15 @@ fn golang_go_patches_redirect_attested_without_module_cache() { .unwrap(); tokio::runtime::Runtime::new() .unwrap() - .block_on(socket_patch_core::patch::go_mod_edit::ensure_replace_entry( - cwd, - module, - version, - socket_patch_core::patch::go_mod_edit::GO_PATCHES_DIR, - false, - )) + .block_on( + socket_patch_core::vendor::go_mod_edit::ensure_replace_entry( + cwd, + module, + version, + socket_patch_core::vendor::go_mod_edit::GO_PATCHES_DIR, + false, + ), + ) .expect("write go.mod replace"); // The patched copy dir the redirect points at. diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index ddac1877..2b263cce 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1008,7 +1008,7 @@ async fn remove_detached_only_purl_reverts() { /// exact state `vendor` persists) so the test needs no full go vendor run. #[tokio::test] async fn vendored_golang_purl_skipped_by_apply() { - use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; + use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; const MODULE: &str = "github.com/foo/bar"; const VERSION: &str = "v1.4.2"; @@ -1088,7 +1088,7 @@ async fn vendored_golang_purl_skipped_by_apply() { pipenv: None, }, ); - socket_patch_core::patch::vendor::save_state(root, &state) + socket_patch_core::vendor::save_state(root, &state) .await .expect("seed state.json"); diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index 92a55cca..b2a87779 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -195,7 +195,7 @@ const VENDOR_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; /// ledger entry binding the purl to it, and a manifest record whose /// afterHash is the hash of `patched`. fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: &[u8]) { - use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; + use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; write( &proj.join("package.json"), diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 58808f8e..63904a9f 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -270,7 +270,7 @@ async fn get_composer_home() -> Option { /// `dev-main`, `1.0.x-dev`) are returned untouched. /// /// Also used by the composer vendor backend -/// (`patch::vendor::composer_lock`) to match lock versions against PURL +/// (`vendor::composer_lock`) to match lock versions against PURL /// versions through the same normalization. pub(crate) fn normalize_version(version: &str) -> &str { let mut chars = version.chars(); diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs index 0d4521c5..62e0b15d 100644 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -1,4 +1,4 @@ -use crate::patch::vendor::common::{detect_indent, serialize_json}; +use crate::vendor::common::{detect_indent, serialize_json}; /// Package manager type for selecting the correct command prefix. #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/crates/socket-patch-core/src/patch/copy_tree.rs b/crates/socket-patch-core/src/patch/copy_tree.rs index 8e34933b..a53725e1 100644 --- a/crates/socket-patch-core/src/patch/copy_tree.rs +++ b/crates/socket-patch-core/src/patch/copy_tree.rs @@ -1,5 +1,5 @@ //! Shared tree-copy helpers used by the Go `replace`-redirect backend -//! ([`crate::patch::go_redirect`]) and the vendor backends. They materialise a +//! ([`crate::patch::redirect::golang_local`]) and the vendor backends. They materialise a //! project-local **patched copy** of a package by copying its pristine source //! out of a read-only registry/module cache into a writable dir under //! `.socket/`, then patching the copy in place. diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index b5aa2376..e7a41373 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -16,7 +16,6 @@ pub mod sidecars; // for external consumers of the published crate. Internal code must import // the new canonical paths (`crate::vendor::*`, `redirect::golang_local`); // CI greps reject new uses of the old ones. Drop these aliases at 4.0. -pub(crate) use crate::vendor::bun_lock_text; -pub use crate::vendor::go_mod_edit; pub use crate::vendor; +pub use crate::vendor::go_mod_edit; pub use redirect::golang_local as go_redirect; diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index f84dfea1..6642db70 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -31,17 +31,17 @@ use crate::patch::apply::{ MismatchPolicy, PatchSources, }; use crate::patch::file_hash::compute_file_git_sha256; -use crate::patch::vendor::common::{ +use crate::utils::purl::{build_golang_purl, parse_golang_purl, strip_purl_qualifiers}; +use crate::vendor::common::{ already_patched_result, copy_matches_after_hashes, synthesized_result, }; -use crate::utils::purl::{build_golang_purl, parse_golang_purl, strip_purl_qualifiers}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; +use crate::patch::path_safety; use crate::vendor::go_mod_edit::{ self, read_replace_entries, read_required_versions, replace_target_path, ReplaceOwner, GO_PATCHES_DIR, }; -use crate::patch::path_safety; /// A discrepancy between the committed redirect artifacts and the manifest, /// reported by [`verify_go_redirect_state`]. diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ae986174..0ff40eb5 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::patch::vendor::yarn_berry_lock::yarnrc_compression_level; +use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; mod state; @@ -1078,7 +1078,7 @@ fn rewrite_bun_lock( overrides: &[DepOverride], result: &mut RewriteResult, ) { - use crate::patch::bun_lock_text::{ + use crate::vendor::bun_lock_text::{ check_lock_version, decode_json_string, parse_packages_section, }; @@ -1653,7 +1653,7 @@ fn rewrite_nuget( /// options (`require: false`, `group: :test`, …) that must survive the move /// into the source block. Empty when the line carries none; bails to empty on /// an unparseable tail (unbalanced quote), matching the previous behavior. -/// Shared with the vendor backend's Gemfile rewrite (`patch::vendor::gem`), +/// Shared with the vendor backend's Gemfile rewrite (`vendor::gem`), /// which has the same drop-the-options failure mode. pub(crate) fn gem_line_trailing_options(tail: &str) -> String { let mut rest = tail.trim_start(); diff --git a/crates/socket-patch-core/src/pth_hook/detect.rs b/crates/socket-patch-core/src/pth_hook/detect.rs index 9e72b24d..b025adb1 100644 --- a/crates/socket-patch-core/src/pth_hook/detect.rs +++ b/crates/socket-patch-core/src/pth_hook/detect.rs @@ -97,7 +97,7 @@ pub async fn detect_python_pm(cwd: &Path) -> PythonPackageManager { } /// True if a `[prefix]` or `[prefix.*]` table header appears in the TOML text. -/// Also used by the pypi vendor flavor router (`patch::vendor::pypi`). +/// Also used by the pypi vendor flavor router (`vendor::pypi`). pub(crate) fn has_table(content: &str, prefix: &str) -> bool { content.lines().any(|line| { let l = line.trim(); diff --git a/crates/socket-patch-core/src/pth_hook/edit.rs b/crates/socket-patch-core/src/pth_hook/edit.rs index fc297d1e..587090a8 100644 --- a/crates/socket-patch-core/src/pth_hook/edit.rs +++ b/crates/socket-patch-core/src/pth_hook/edit.rs @@ -17,8 +17,8 @@ use tokio::fs; use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; use super::detect::{deps_contain_hook, HOOK_DEP}; -use crate::patch::vendor::common::detect_eol; use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::vendor::common::detect_eol; /// Which manifest format a path is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -242,7 +242,7 @@ fn pyproject_remove(content: &str) -> Result, String> { /// Ensure `parent[key]` is a table, creating it if absent. Errors if present /// but a non-table. Also used by the vendor backends' TOML editing -/// (`patch::vendor::cargo_config`, `patch::vendor::pypi_uv`). +/// (`vendor::cargo_config`, `vendor::pypi_uv`). pub(crate) fn ensure_table<'a>( parent: &'a mut Table, key: &str, diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index f5b5ba19..7acb22ac 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -33,12 +33,12 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::patch::bun_lock_text::{ +use crate::patch::copy_tree::remove_tree; +use crate::utils::fs::atomic_write_bytes; +use crate::vendor::bun_lock_text::{ check_lock_version, decode_json_string, packages_bounds, parse_entry_line, parse_packages_section, split_name_spec, BunEntry, }; -use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes; use super::common::{already_patched_result, refused}; use super::npm_common::{done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack}; @@ -409,7 +409,7 @@ fn revert_one_record( // ───────────────────────── vendor-specific classification ───────────────── // The conservative line grammar (`BunEntry`, `parse_*`, `scan_*`, …) lives in -// `crate::patch::bun_lock_text`; this module keeps only the vendor tuple +// `crate::vendor::bun_lock_text`; this module keeps only the vendor tuple // classification that decides which parsed entries to rewrite. /// What a matching entry's tuple looks like. diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index b0a2d487..86c630e5 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -711,7 +711,7 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -1585,7 +1585,7 @@ mod tests { // Both the service path AND the local-build fallback are exercised. use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::{VendorServiceConfig, VendorSource}; + use crate::vendor::{VendorServiceConfig, VendorSource}; fn sri_sha512(bytes: &[u8]) -> String { use base64::Engine as _; diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 4838f474..0383643c 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -1,4 +1,4 @@ -//! Leaf helpers shared by the vendor backends (and [`crate::patch::go_redirect`]). +//! Leaf helpers shared by the vendor backends (and [`crate::patch::redirect::golang_local`]). //! //! Each backend used to carry a private, byte-identical copy of these; they //! are hoisted here so the shapes stay in lockstep. diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index bb5d7fd1..0f7b393f 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -751,7 +751,7 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ApplyResult, VerifyStatus}; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -1372,7 +1372,7 @@ mod tests { // ─────────────── service-download path (Tier B: composer) ─────────────── use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::{VendorServiceConfig, VendorSource}; + use crate::vendor::{VendorServiceConfig, VendorSource}; fn sri_sha512(bytes: &[u8]) -> String { use base64::Engine as _; diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 43f6dfb2..3f3ee42b 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -1563,7 +1563,7 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::VerifyStatus; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -2912,7 +2912,7 @@ mod tests { // and the local-build fallback are exercised. use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::VendorSource; + use crate::vendor::VendorSource; /// A valid path-source stub (no native extensions). const SERVICE_STUB: &[u8] = b"# -*- encoding: utf-8 -*-\n# stub: rack 3.2.6 ruby lib\n\nGem::Specification.new do |s|\n s.name = \"rack\".freeze\n s.version = \"3.2.6\".freeze\n s.require_paths = [\"lib\".freeze]\nend\n"; diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index 181569de..4facc9a6 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -28,7 +28,7 @@ //! patched bytes build cleanly under the default `-mod=readonly`. The directive //! is keyed by *module + version*: a stale pin (the graph resolved a different //! version) is silently ignored and the build links the UNPATCHED module — -//! hence the version cross-check in [`crate::patch::go_redirect`]. +//! hence the version cross-check in [`crate::patch::redirect::golang_local`]. use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 547a9af0..bb090387 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -1,7 +1,7 @@ //! The golang vendor backend: committable `replace`-directive vendoring. //! //! Wraps the project-local Go redirect engine -//! ([`crate::patch::go_redirect`]) with a vendor copy base: the patched module +//! ([`crate::patch::redirect::golang_local`]) with a vendor copy base: the patched module //! copy lands under `.socket/vendor/golang//@/` //! and the `go.mod` `replace` points at it ([`ReplaceOwner::Vendor`]). A //! directory `replace` target bypasses the module cache, sumdb, and `go.sum` @@ -21,13 +21,13 @@ use std::path::Path; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{MismatchPolicy, PatchSources}; use crate::patch::copy_tree::remove_tree; -use crate::patch::go_mod_edit::{ - self, read_replace_entries, replace_target_path, ReplaceOwner, GO_PATCHES_DIR, -}; -use crate::patch::go_redirect::{ +use crate::patch::redirect::golang_local::{ apply_go_redirect, are_safe_redirect_coords, copy_dir_for, ensure_module_go_mod, }; use crate::utils::purl::{parse_golang_purl, strip_purl_qualifiers}; +use crate::vendor::go_mod_edit::{ + self, read_replace_entries, replace_target_path, ReplaceOwner, GO_PATCHES_DIR, +}; use super::common::{ already_patched_result, copy_matches_after_hashes, done, failed_result, refused, @@ -574,7 +574,7 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; use crate::patch::apply::ApplyResult; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -1157,7 +1157,7 @@ mod tests { // the `h1:` dirhash), extracts it into the copy dir, and wires the replace. use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::{VendorServiceConfig, VendorSource}; + use crate::vendor::{VendorServiceConfig, VendorSource}; fn sri_sha512(bytes: &[u8]) -> String { use base64::Engine as _; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index be498302..fa1584b8 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -23,9 +23,9 @@ use std::path::Path; use serde_json::Value; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::patch::bun_lock_text; use crate::patch::path_safety; use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; +use crate::vendor::bun_lock_text; use super::npm_common::is_safe_npm_name; use super::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor}; diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 9c162997..f36c0603 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -1187,7 +1187,7 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const PURL: &str = "pkg:maven/org.apache.commons/commons-text@1.10.0"; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 39f447be..74b382b8 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -40,7 +40,7 @@ //! this Socket-vendored, by which patch" recoverable from the lockfile //! string alone ([`path`]). //! -//! [`ReplaceOwner::Vendor`]: crate::patch::go_mod_edit::ReplaceOwner +//! [`ReplaceOwner::Vendor`]: crate::vendor::go_mod_edit::ReplaceOwner pub mod path; pub mod state; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 1867dddc..b813251e 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -400,7 +400,7 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; use std::collections::HashMap; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 243f685c..139e1e61 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -1822,7 +1822,7 @@ mod tests { // patch.socket.dev two-step (package-reference POST + serve GET). use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::{VendorServiceConfig, VendorSource}; + use crate::vendor::{VendorServiceConfig, VendorSource}; const SERVE_PATH: &str = "/patch/npm/left-pad/1.3.0/grant-tok/uuid/left-pad-1.3.0.tgz"; diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 58109415..83e87ef1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -1326,7 +1326,7 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; use serde_json::json; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 3a075191..4bf6ff27 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -869,7 +869,7 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; - use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -1615,7 +1615,7 @@ wheels = [ // local-build fallback are exercised. use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::{VendorServiceConfig, VendorSource}; + use crate::vendor::{VendorServiceConfig, VendorSource}; const WHEEL_NAME: &str = "six-1.16.0-py2.py3-none-any.whl"; diff --git a/crates/socket-patch-core/src/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs index d72e0ff0..f1543cf9 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pdm.rs @@ -481,7 +481,7 @@ fn rewrite_target_package_unit( #[cfg(test)] mod tests { use super::*; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const REL_WHEEL: &str = diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index c957def3..10a7509f 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -468,7 +468,7 @@ fn to_canonical_json(value: &Value) -> String { #[cfg(test)] mod tests { use super::*; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const REL_WHEEL: &str = diff --git a/crates/socket-patch-core/src/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/vendor/pypi_poetry.rs index 5fee31c1..2306793a 100644 --- a/crates/socket-patch-core/src/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/vendor/pypi_poetry.rs @@ -399,7 +399,7 @@ fn rewrite_target_package_unit( #[cfg(test)] mod tests { use super::*; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const REL_WHEEL: &str = diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index b06f898d..51aba8c0 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -754,7 +754,7 @@ fn parse_requirement_line(text: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const REL_WHEEL: &str = diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index cbcc04dc..a7c853aa 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -1030,7 +1030,7 @@ fn add_manifest_override( #[cfg(test)] mod tests { use super::*; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; const REL_WHEEL: &str = diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index e4b536b3..e2e0bbd5 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -9,10 +9,10 @@ //! extract it into the vendor directory) and the build-vs-service policy. use crate::api::client::{SecondaryArtifact, VendorServiceOutcome}; -use crate::patch::vendor::lock_inventory::LockIntegrity; -use crate::patch::vendor::registry_fetch::{artifact_matches_integrity, verify_go_h1}; -use crate::patch::vendor::VendorServiceConfig; -use crate::patch::vendor::{ +use crate::vendor::lock_inventory::LockIntegrity; +use crate::vendor::registry_fetch::{artifact_matches_integrity, verify_go_h1}; +use crate::vendor::VendorServiceConfig; +use crate::vendor::{ common::{refused, service_offline_conflict}, VendorOutcome, VendorWarning, }; @@ -251,8 +251,8 @@ pub(crate) async fn fetch_verified_secondary( mod tests { use super::*; use crate::api::client::{ApiClient, ApiClientOptions}; - use crate::patch::vendor::npm_pack::PackedTarball; - use crate::patch::vendor::VendorSource; + use crate::vendor::npm_pack::PackedTarball; + use crate::vendor::VendorSource; use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index 0fb826ea..44bcda9b 100644 --- a/crates/socket-patch-core/src/vendor/verify.rs +++ b/crates/socket-patch-core/src/vendor/verify.rs @@ -309,7 +309,7 @@ fn verify_member_map( mod tests { use super::*; use crate::manifest::schema::PatchFileInfo; - use crate::patch::vendor::state::VendorArtifact; + use crate::vendor::state::VendorArtifact; use flate2::write::GzEncoder; use std::io::Write; diff --git a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs index 8f19a98a..fb31cfe1 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -39,12 +39,12 @@ use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::{PatchFileInfo, PatchRecord}; use crate::patch::apply::PatchSources; use crate::patch::redirect::{rewrite_registry_redirect, DepOverride, Integrity}; -use crate::patch::vendor::lock_inventory::{inventory_npm_lock, LockIntegrity}; -use crate::patch::vendor::npm_flavor::NpmLockFlavor; -use crate::patch::vendor::yarn_berry_lock::{revert_yarn_berry, vendor_yarn_berry}; -use crate::patch::vendor::yarn_classic_lock::{revert_yarn_classic, vendor_yarn_classic}; -use crate::patch::vendor::{RevertOutcome, VendorEntry, VendorOutcome}; use crate::utils::uri::encode_uri_component; +use crate::vendor::lock_inventory::{inventory_npm_lock, LockIntegrity}; +use crate::vendor::npm_flavor::NpmLockFlavor; +use crate::vendor::yarn_berry_lock::{revert_yarn_berry, vendor_yarn_berry}; +use crate::vendor::yarn_classic_lock::{revert_yarn_classic, vendor_yarn_classic}; +use crate::vendor::{RevertOutcome, VendorEntry, VendorOutcome}; /// Canonical-grammar patch uuid (the vendor path layer validates the shape /// fail-closed, so fixtures must use the real grammar). diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index f4e23f0e..cf63f2e6 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -17,8 +17,8 @@ use std::path::{Path, PathBuf}; use crate::manifest::schema::{PatchManifest, PatchRecord}; use crate::patch::apply::{verify_file_patch, VerifyStatus}; -use crate::patch::vendor::state::{lookup_entry, VendorEntry}; -use crate::patch::vendor::verify::verify_vendored_patch_record; +use crate::vendor::state::{lookup_entry, VendorEntry}; +use crate::vendor::verify::verify_vendored_patch_record; /// One entry per manifest PURL that did NOT pass verification. The /// `reason` is a short snake_case tag the CLI can route on (matches @@ -899,7 +899,7 @@ mod tests { // ── Vendored-patch awareness (`applied_patches_with_vendor`) ── - use crate::patch::vendor::state::{VendorArtifact, VendorEntry}; + use crate::vendor::state::{VendorArtifact, VendorEntry}; /// Canonical-grammar patch UUID — `verify_vendored_patch_record` /// validates the uuid path level, so vendor fixtures must use a real From d439d07dfa96a4c88d6c3a8d84b36a4dc200a761 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:30:48 -0700 Subject: [PATCH 05/16] refactor(core): extract shared TOML helpers into utils::toml_edit_ext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_table / has_table were defined inside the pypi setup backend (pth_hook) but consumed by vendor::cargo_config, vendor::pypi and vendor::pypi_uv — the pypi hook module owned the crate's generic structured-TOML seam. Move both (verbatim) to utils::toml_edit_ext and repoint the five callers. Unblocks folding pth_hook under a future setup/ umbrella without dragging vendor dependencies along. Co-Authored-By: Claude Fable 5 --- .../socket-patch-core/src/pth_hook/detect.rs | 23 +--------- crates/socket-patch-core/src/pth_hook/edit.rs | 20 +-------- crates/socket-patch-core/src/utils/mod.rs | 1 + .../src/utils/toml_edit_ext.rs | 44 +++++++++++++++++++ .../src/vendor/cargo_config.rs | 2 +- crates/socket-patch-core/src/vendor/pypi.rs | 2 +- .../socket-patch-core/src/vendor/pypi_uv.rs | 2 +- 7 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/toml_edit_ext.rs diff --git a/crates/socket-patch-core/src/pth_hook/detect.rs b/crates/socket-patch-core/src/pth_hook/detect.rs index b025adb1..eb3c9a26 100644 --- a/crates/socket-patch-core/src/pth_hook/detect.rs +++ b/crates/socket-patch-core/src/pth_hook/detect.rs @@ -2,6 +2,8 @@ use std::path::Path; +use crate::utils::toml_edit_ext::has_table; + /// The dependency `setup` adds (PEP 508 form, used for `requirements.txt` and /// PEP 621 `[project].dependencies`): the `socket-patch[hook]` extra, which /// pulls both the socket-patch CLI and the socket-patch-hook wheel (the `.pth` @@ -96,27 +98,6 @@ pub async fn detect_python_pm(cwd: &Path) -> PythonPackageManager { PythonPackageManager::Pip } -/// True if a `[prefix]` or `[prefix.*]` table header appears in the TOML text. -/// Also used by the pypi vendor flavor router (`vendor::pypi`). -pub(crate) fn has_table(content: &str, prefix: &str) -> bool { - content.lines().any(|line| { - let l = line.trim(); - let Some(rest) = l.strip_prefix('[') else { - return false; - }; - // Tolerate array-of-tables (`[[..]]`) by dropping a second opening - // bracket, then take everything up to the closing `]` so a trailing - // inline comment (`[tool.uv] # note`) or interior padding - // (`[ tool.uv ]`) — both valid TOML — doesn't defeat the match. - let rest = rest.trim_start_matches('['); - let Some(end) = rest.find(']') else { - return false; - }; - let header = rest[..end].trim(); - header == prefix || header.starts_with(&format!("{prefix}.")) - }) -} - /// True if the given manifest text already declares the hook dependency, in any /// form. Space- and case-insensitive so `socket-patch [hook]` / `Socket-Patch` /// are recognised. diff --git a/crates/socket-patch-core/src/pth_hook/edit.rs b/crates/socket-patch-core/src/pth_hook/edit.rs index 587090a8..1c8d6b92 100644 --- a/crates/socket-patch-core/src/pth_hook/edit.rs +++ b/crates/socket-patch-core/src/pth_hook/edit.rs @@ -18,6 +18,7 @@ use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; use super::detect::{deps_contain_hook, HOOK_DEP}; use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::toml_edit_ext::ensure_table; use crate::vendor::common::detect_eol; /// Which manifest format a path is. @@ -240,25 +241,6 @@ fn pyproject_remove(content: &str) -> Result, String> { Ok(if changed { Some(doc.to_string()) } else { None }) } -/// Ensure `parent[key]` is a table, creating it if absent. Errors if present -/// but a non-table. Also used by the vendor backends' TOML editing -/// (`vendor::cargo_config`, `vendor::pypi_uv`). -pub(crate) fn ensure_table<'a>( - parent: &'a mut Table, - key: &str, - implicit: bool, -) -> Result<&'a mut Table, String> { - if !parent.contains_key(key) { - let mut t = Table::new(); - t.set_implicit(implicit); - parent.insert(key, Item::Table(t)); - } - parent - .get_mut(key) - .and_then(Item::as_table_mut) - .ok_or_else(|| format!("`{key}` is not a table")) -} - fn pep621_add(doc: &mut DocumentMut) -> Result { let root = doc.as_table_mut(); let project = ensure_table(root, "project", false)?; diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index fc52ea80..deac02b6 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -9,4 +9,5 @@ pub mod purl; pub(crate) mod serde; pub mod socket_cli_config; pub mod telemetry; +pub(crate) mod toml_edit_ext; pub mod uri; diff --git a/crates/socket-patch-core/src/utils/toml_edit_ext.rs b/crates/socket-patch-core/src/utils/toml_edit_ext.rs new file mode 100644 index 00000000..dd05a900 --- /dev/null +++ b/crates/socket-patch-core/src/utils/toml_edit_ext.rs @@ -0,0 +1,44 @@ +//! Small structured-TOML helpers shared by every module that edits or sniffs +//! TOML (`pth_hook`, `vendor::cargo_config`, `vendor::pypi`, +//! `vendor::pypi_uv`). Extracted from `pth_hook` so the pypi setup backend no +//! longer owns the crate's generic TOML seam. + +use toml_edit::{Item, Table}; + +/// Ensure `parent[key]` is a table, creating it if absent. Errors if present +/// but a non-table. +pub(crate) fn ensure_table<'a>( + parent: &'a mut Table, + key: &str, + implicit: bool, +) -> Result<&'a mut Table, String> { + if !parent.contains_key(key) { + let mut t = Table::new(); + t.set_implicit(implicit); + parent.insert(key, Item::Table(t)); + } + parent + .get_mut(key) + .and_then(Item::as_table_mut) + .ok_or_else(|| format!("`{key}` is not a table")) +} + +/// True if a `[prefix]` or `[prefix.*]` table header appears in the TOML text. +pub(crate) fn has_table(content: &str, prefix: &str) -> bool { + content.lines().any(|line| { + let l = line.trim(); + let Some(rest) = l.strip_prefix('[') else { + return false; + }; + // Tolerate array-of-tables (`[[..]]`) by dropping a second opening + // bracket, then take everything up to the closing `]` so a trailing + // inline comment (`[tool.uv] # note`) or interior padding + // (`[ tool.uv ]`) — both valid TOML — doesn't defeat the match. + let rest = rest.trim_start_matches('['); + let Some(end) = rest.find(']') else { + return false; + }; + let header = rest[..end].trim(); + header == prefix || header.starts_with(&format!("{prefix}.")) + }) +} diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index d7dcb56b..e5585b67 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -30,8 +30,8 @@ use std::path::{Path, PathBuf}; use tokio::fs; use toml_edit::{DocumentMut, InlineTable, Item, Table, Value}; -use crate::pth_hook::edit::ensure_table; use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::toml_edit_ext::ensure_table; /// Project-relative root of the vendor backend's committed crate copies. An /// entry whose `path` is under this prefix is socket-owned. diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 4bf6ff27..9c6576f1 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -13,9 +13,9 @@ use sha2::{Digest as _, Sha256}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; -use crate::pth_hook::detect::has_table; use crate::utils::fs::atomic_write_bytes; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; +use crate::utils::toml_edit_ext::has_table; use super::common::{already_patched_result, done, refused, service_offline_conflict}; use super::path::vendor_uuid_dir_rel; diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index a7c853aa..93132918 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -717,7 +717,7 @@ fn ensure_table<'a>( ) -> Result<&'a mut Table, (&'static str, String)> { let mut table: &mut Table = doc.as_table_mut(); for key in path { - table = crate::pth_hook::edit::ensure_table(table, key, true).map_err(|_| { + table = crate::utils::toml_edit_ext::ensure_table(table, key, true).map_err(|_| { ( "pypi_uv_lock_parse_failed", format!( From e84bda36f83d60108d567e5a4449b6441f2fa09a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:37:16 -0700 Subject: [PATCH 06/16] refactor(core): dissolve the utils/ misfiles into their domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical moves with compat re-exports left in utils/ for external consumers of the published crate (internal paths repointed, CI grep-guarded like the vendor promotion): * utils/telemetry.rs -> src/telemetry.rs — a 1k-LOC subsystem, not a leaf helper * utils/cleanup_blobs.rs -> manifest/cleanup_blobs.rs — imports manifest::operations/schema; it is manifest-domain blob GC * utils/date.rs -> api/date.rs — parses the API's RFC-2822 wire dates * utils/fuzzy_match.rs -> crawlers/fuzzy_match.rs — depends on crawlers::types utils/ keeps the genuine leaves: fs, env_compat, http, process, purl, serde, socket_cli_config, toml_edit_ext, uri. Also: vendor/state.rs flavor docstring gains the missing yarn-berry (npm_flavor emits and revert-routes it; the doc list had drifted). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 +++- crates/socket-patch-cli/src/args.rs | 14 +++++++------- crates/socket-patch-cli/src/commands/apply.rs | 2 +- crates/socket-patch-cli/src/commands/get.rs | 4 ++-- crates/socket-patch-cli/src/commands/list.rs | 2 +- crates/socket-patch-cli/src/commands/remove.rs | 4 ++-- crates/socket-patch-cli/src/commands/repair.rs | 8 ++++---- crates/socket-patch-cli/src/commands/rollback.rs | 2 +- crates/socket-patch-cli/src/commands/scan/gc.rs | 8 ++++---- crates/socket-patch-cli/src/commands/scan/mod.rs | 4 ++-- .../src/commands/scan/vendor_flow.rs | 2 +- crates/socket-patch-cli/src/commands/setup.rs | 2 +- crates/socket-patch-cli/src/commands/vendor.rs | 2 +- crates/socket-patch-cli/src/commands/vex.rs | 2 +- .../tests/e2e_hosted_production.rs | 4 ++-- crates/socket-patch-cli/tests/scan_vendor_e2e.rs | 2 +- .../socket-patch-core/src/{utils => api}/date.rs | 0 crates/socket-patch-core/src/api/mod.rs | 1 + crates/socket-patch-core/src/api/ranking.rs | 4 ++-- crates/socket-patch-core/src/api/types.rs | 2 +- .../src/{utils => crawlers}/fuzzy_match.rs | 0 crates/socket-patch-core/src/crawlers/mod.rs | 1 + crates/socket-patch-core/src/lib.rs | 1 + .../src/{utils => manifest}/cleanup_blobs.rs | 0 crates/socket-patch-core/src/manifest/mod.rs | 1 + .../socket-patch-core/src/{utils => }/telemetry.rs | 0 crates/socket-patch-core/src/utils/mod.rs | 13 +++++++++---- crates/socket-patch-core/src/vendor/state.rs | 4 ++-- crates/socket-patch-core/src/vex/time.rs | 2 +- crates/socket-patch-core/tests/fuzzy_match_e2e.rs | 4 ++-- .../tests/telemetry_helpers_e2e.rs | 4 ++-- 31 files changed, 57 insertions(+), 46 deletions(-) rename crates/socket-patch-core/src/{utils => api}/date.rs (100%) rename crates/socket-patch-core/src/{utils => crawlers}/fuzzy_match.rs (100%) rename crates/socket-patch-core/src/{utils => manifest}/cleanup_blobs.rs (100%) rename crates/socket-patch-core/src/{utils => }/telemetry.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62eb3b67..71c67399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,8 +66,10 @@ jobs: if grep -rn --include='*.rs' \ -e 'patch::vendor' -e 'patch::go_mod_edit' \ -e 'patch::go_redirect' -e 'patch::bun_lock_text' \ + -e 'utils::telemetry' -e 'utils::cleanup_blobs' \ + -e 'utils::date' -e 'utils::fuzzy_match' \ crates; then - echo '::error::use the canonical module paths (crate::vendor, patch::redirect::golang_local); the patch::* aliases exist only for external consumers' + echo '::error::use the canonical module paths (crate::vendor, patch::redirect::golang_local, crate::telemetry, manifest::cleanup_blobs, api::date, crawlers::fuzzy_match); the old-path aliases exist only for external consumers' exit 1 fi diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index b4737d85..6485d585 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -317,7 +317,7 @@ impl GlobalArgs { /// flags are off. /// /// `offline` matters most: the telemetry kill-switch -/// (`socket_patch_core::utils::telemetry::is_telemetry_disabled`) honors the +/// (`socket_patch_core::telemetry::is_telemetry_disabled`) honors the /// strict-airgap contract by reading `SOCKET_OFFLINE` from the env, so /// without this mirror a bare `--offline` flag (or a truthy spelling like /// `SOCKET_OFFLINE=yes` that core's `"1" | "true"` match doesn't recognize) @@ -503,7 +503,7 @@ mod tests { } /// `--offline` promises "never contact the network", but the telemetry - /// kill-switch (`socket_patch_core::utils::telemetry::is_telemetry_disabled`) + /// kill-switch (`socket_patch_core::telemetry::is_telemetry_disabled`) /// reads the `SOCKET_OFFLINE` env var directly — it never sees the parsed /// flag. `apply_env_toggles` must therefore mirror `--offline` into the /// env exactly like `--debug` / `--no-telemetry`, or an airgapped @@ -520,7 +520,7 @@ mod tests { apply_env_toggles(&args); assert_eq!(std::env::var("SOCKET_OFFLINE").as_deref(), Ok("1")); assert!( - socket_patch_core::utils::telemetry::is_telemetry_disabled(), + socket_patch_core::telemetry::is_telemetry_disabled(), "--offline must disable telemetry (strict airgap: never contact the network)", ); }); @@ -541,7 +541,7 @@ mod tests { assert!(cli.common.offline, "SOCKET_OFFLINE=yes parses as offline"); apply_env_toggles(&cli.common); assert!( - socket_patch_core::utils::telemetry::is_telemetry_disabled(), + socket_patch_core::telemetry::is_telemetry_disabled(), "SOCKET_OFFLINE=yes must disable telemetry like SOCKET_OFFLINE=1", ); }); @@ -1082,7 +1082,7 @@ mod tests { }; // Guard against a vacuous pass: the gate must start open. - assert!(!socket_patch_core::utils::telemetry::is_telemetry_disabled()); + assert!(!socket_patch_core::telemetry::is_telemetry_disabled()); let tmp = tempfile::tempdir().unwrap(); rt.block_on(crate::commands::list::run( @@ -1091,7 +1091,7 @@ mod tests { }, )); assert!( - socket_patch_core::utils::telemetry::is_telemetry_disabled(), + socket_patch_core::telemetry::is_telemetry_disabled(), "`list --offline --no-telemetry` must mirror the toggles into the \ env — its telemetry kill-switch reads only SOCKET_OFFLINE / \ SOCKET_TELEMETRY_DISABLED", @@ -1115,7 +1115,7 @@ mod tests { }, )); assert!( - socket_patch_core::utils::telemetry::is_telemetry_disabled(), + socket_patch_core::telemetry::is_telemetry_disabled(), "`setup --offline --no-telemetry` must mirror the toggles into the env", ); }); diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 57065497..c3df30d9 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -11,9 +11,9 @@ use socket_patch_core::patch::apply::{ use socket_patch_core::patch::redirect::golang_local::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, }; +use socket_patch_core::telemetry::{track_patch_applied, track_patch_apply_failed}; use socket_patch_core::utils::purl::parse_golang_purl; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index efa5adb1..88f04173 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -7,15 +7,15 @@ use socket_patch_core::api::ranking::{cmp_search_results, severity_order}; use socket_patch_core::api::types::{ PatchResponse, PatchSearchResult, SearchResponse, VulnerabilityResponse, }; +use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; use socket_patch_core::patch::apply::select_installed_variants; -use socket_patch_core::utils::fuzzy_match::fuzzy_match_packages; +use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use socket_patch_core::utils::purl::{is_purl, normalize_purl, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 8f3b07b3..1475d748 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,8 +1,8 @@ use clap::Args; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::telemetry::track_patch_listed; use socket_patch_core::utils::socket_cli_config; -use socket_patch_core::utils::telemetry::track_patch_listed; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 2f3358eb..96cd7de8 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,10 +1,10 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::manifest::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; +use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; use socket_patch_core::utils::purl::purl_matches_identifier; -use socket_patch_core::utils::telemetry::{track_patch_remove_failed, track_patch_removed}; use socket_patch_core::vendor::{load_state, save_state, VendorEntry, VendorState}; use std::path::Path; use std::time::Duration; diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 243956c1..e5722178 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -4,12 +4,12 @@ use socket_patch_core::api::blob_fetcher::{ DownloadMode, }; use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::operations::read_manifest; -use socket_patch_core::patch::apply::PatchSources; -use socket_patch_core::utils::cleanup_blobs::{ +use socket_patch_core::manifest::cleanup_blobs::{ cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, }; -use socket_patch_core::utils::telemetry::{track_patch_repair_failed, track_patch_repaired}; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::patch::apply::PatchSources; +use socket_patch_core::telemetry::{track_patch_repair_failed, track_patch_repaired}; use std::path::Path; use std::time::Duration; diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 3a76d9df..b96deb36 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -8,8 +8,8 @@ use socket_patch_core::patch::apply::select_installed_variants; use socket_patch_core::patch::rollback::{ rollback_package_patch, RollbackResult, VerifyRollbackStatus, }; +use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use socket_patch_core::utils::purl::strip_purl_qualifiers; -use socket_patch_core::utils::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 77926ea4..094e2d6e 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -2,11 +2,11 @@ //! orphan blob/diff/package-archive sweeps, in both mutating (apply) and //! read-only (preview) forms. -use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::utils::cleanup_blobs::{ +use socket_patch_core::manifest::cleanup_blobs::{ cleanup_unused_archives, cleanup_unused_blobs, CleanupResult, }; +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use std::collections::HashSet; use std::path::Path; @@ -86,7 +86,7 @@ impl GcSummary { /// Compute GC actions without performing them. `dry_run = true` for the /// preview path; `dry_run = false` for the apply path. The cleanup helpers -/// from `socket_patch_core::utils::cleanup_blobs` natively support dry-run, +/// from `socket_patch_core::manifest::cleanup_blobs` natively support dry-run, /// so the same function works for both. async fn run_gc( manifest: &PatchManifest, diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 4b203e58..40da341e 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -14,8 +14,8 @@ use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::telemetry::{track_patch_scan_failed, track_patch_scanned}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{track_patch_scan_failed, track_patch_scanned}; use std::collections::HashSet; use std::io::IsTerminal; use std::path::Path; @@ -1546,7 +1546,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { if gc.pruned.len() == 1 { "y" } else { "ies" }, total, if total == 1 { "" } else { "s" }, - socket_patch_core::utils::cleanup_blobs::format_bytes(gc.total_bytes()), + socket_patch_core::manifest::cleanup_blobs::format_bytes(gc.total_bytes()), ); } if !args.common.silent { diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 0121538e..75ae65bf 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -8,7 +8,7 @@ use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply_lock; -use socket_patch_core::utils::telemetry::track_patch_vendor_failed; +use socket_patch_core::telemetry::track_patch_vendor_failed; use socket_patch_core::vendor::{load_state, lookup_entry}; use std::collections::{HashMap, HashSet}; use std::path::Path; diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index b16e920c..805bb8b9 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -19,7 +19,7 @@ use socket_patch_core::pth_hook::edit::{ add_hook_dependency, pyproject_contains_hook, remove_hook_dependency, ManifestKind, PthEditResult, PthStatus, }; -use socket_patch_core::utils::telemetry::track_patch_setup; +use socket_patch_core::telemetry::track_patch_setup; use socket_patch_core::vex::applied_patches_with_vendor; use std::io::{self, Write}; use std::path::{Path, PathBuf}; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 7d31b3f2..644ee129 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -23,8 +23,8 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; use socket_patch_core::patch::copy_tree::remove_tree; +use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{track_patch_vendor_failed, track_patch_vendored}; use socket_patch_core::vendor::{ self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, registry_fetch, save_state, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, VendorSource, diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index a71eb37c..179fc4f3 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -20,7 +20,7 @@ use clap::Args; use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated}; +use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vex::{ build_document, detect_product, BuildOptions, Document, FailedPatch, VendorContext, VerifyOutcome, diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index fb4c4216..1356509e 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -793,7 +793,7 @@ async fn canary_published_at_is_a_patch_date_not_a_package_date() { // PyPI stamps ISO-8601; the patch API stamps RFC 2822. They cannot be // compared as strings, so compare the calendar DATE via the same parser // the ranking uses. - use socket_patch_core::utils::date::parse_timestamp_secs; + use socket_patch_core::api::date::parse_timestamp_secs; let upload_days: std::collections::HashSet = uploads .iter() .filter_map(|u| parse_timestamp_secs(u)) @@ -803,7 +803,7 @@ async fn canary_published_at_is_a_patch_date_not_a_package_date() { let Some(secs) = parse_timestamp_secs(published) else { panic!( "production publishedAt {published:?} (patch {uuid}) does not parse — \ - utils::date must handle every format the API emits" + api::date must handle every format the API emits" ); }; assert!( diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index faa6236f..d10a5446 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -664,7 +664,7 @@ async fn scan_vendor_flag_conflicts_are_clap_errors() { /// No invocation in this suite may emit telemetry. Telemetry resolves its /// endpoint from `SOCKET_API_URL` / `SOCKET_PROXY_URL` env ONLY (the -/// `--api-url` flag is invisible to it — utils::telemetry), so the +/// `--api-url` flag is invisible to it — telemetry), so the /// unhardened harness sent every successful run's `patch_vendored` event to /// the LIVE `/v0/orgs/test-org/telemetry` with the fake bearer token. Seed /// the child env with a reachable endpoint (worst case for the kill-switch) diff --git a/crates/socket-patch-core/src/utils/date.rs b/crates/socket-patch-core/src/api/date.rs similarity index 100% rename from crates/socket-patch-core/src/utils/date.rs rename to crates/socket-patch-core/src/api/date.rs diff --git a/crates/socket-patch-core/src/api/mod.rs b/crates/socket-patch-core/src/api/mod.rs index b27a33bc..ab918e7a 100644 --- a/crates/socket-patch-core/src/api/mod.rs +++ b/crates/socket-patch-core/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod blob_fetcher; pub mod client; +pub mod date; pub mod ranking; pub mod types; diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index cba276d9..71ce8b55 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -45,8 +45,8 @@ use std::cmp::{Ordering, Reverse}; +use crate::api::date::parse_timestamp_secs; use crate::api::types::{BatchPatchInfo, PatchSearchResult}; -use crate::utils::date::parse_timestamp_secs; /// Severity ordering for sorting: **most severe = lowest number**. /// @@ -117,7 +117,7 @@ struct RankKey<'a> { /// package's release date. Unparseable or absent timestamps collapse /// to 0 and therefore sort last: the right treatment for a date we /// cannot trust, and the reason this is epoch seconds rather than the - /// raw string (see [`crate::utils::date`]). + /// raw string (see [`crate::api::date`]). patch_published: Reverse, /// `false` sorts first, so paid leads. A tiebreak only: it can never /// override severity or recency. diff --git a/crates/socket-patch-core/src/api/types.rs b/crates/socket-patch-core/src/api/types.rs index 6ea6cb5e..ceca4dd0 100644 --- a/crates/socket-patch-core/src/api/types.rs +++ b/crates/socket-patch-core/src/api/types.rs @@ -39,7 +39,7 @@ pub struct PatchResponse { /// `Fri, 27 Mar 2026 19:12:42 GMT` (verified across npm, PyPI, cargo /// and gem), while this repo's fixtures use RFC 3339. Never compare /// these as raw strings; route through - /// [`crate::utils::date::parse_timestamp_secs`], which handles both. + /// [`crate::api::date::parse_timestamp_secs`], which handles both. pub published_at: String, pub files: HashMap, pub vulnerabilities: HashMap, diff --git a/crates/socket-patch-core/src/utils/fuzzy_match.rs b/crates/socket-patch-core/src/crawlers/fuzzy_match.rs similarity index 100% rename from crates/socket-patch-core/src/utils/fuzzy_match.rs rename to crates/socket-patch-core/src/crawlers/fuzzy_match.rs diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index 8cdebf90..f95b4b51 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -1,6 +1,7 @@ pub mod cargo_crawler; pub mod composer_crawler; pub mod deno_crawler; +pub mod fuzzy_match; pub mod go_crawler; pub mod maven_crawler; pub mod npm_crawler; diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index dfcd00b6..5358a20d 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod manifest; pub mod package_json; pub mod patch; pub mod pth_hook; +pub mod telemetry; pub mod update; pub mod utils; pub mod vendor; diff --git a/crates/socket-patch-core/src/utils/cleanup_blobs.rs b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs similarity index 100% rename from crates/socket-patch-core/src/utils/cleanup_blobs.rs rename to crates/socket-patch-core/src/manifest/cleanup_blobs.rs diff --git a/crates/socket-patch-core/src/manifest/mod.rs b/crates/socket-patch-core/src/manifest/mod.rs index 93413870..06c3ccf4 100644 --- a/crates/socket-patch-core/src/manifest/mod.rs +++ b/crates/socket-patch-core/src/manifest/mod.rs @@ -1,2 +1,3 @@ +pub mod cleanup_blobs; pub mod operations; pub mod schema; diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs similarity index 100% rename from crates/socket-patch-core/src/utils/telemetry.rs rename to crates/socket-patch-core/src/telemetry.rs diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index deac02b6..cb1f4639 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,13 +1,18 @@ -pub mod cleanup_blobs; -pub mod date; pub mod env_compat; pub mod fs; -pub mod fuzzy_match; pub(crate) mod http; pub mod process; pub mod purl; pub(crate) mod serde; pub mod socket_cli_config; -pub mod telemetry; pub(crate) mod toml_edit_ext; pub mod uri; + +// Moved modules — these re-exports keep the old `utils::*` paths compiling +// for external consumers of the published crate. Internal code must import +// the new canonical paths; CI greps reject new uses of the old ones. Drop +// these aliases at 4.0. +pub use crate::api::date; +pub use crate::crawlers::fuzzy_match; +pub use crate::manifest::cleanup_blobs; +pub use crate::telemetry; diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 2a7a26b9..2333079e 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -205,8 +205,8 @@ pub struct VendorEntry { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub took_over_go_patches: bool, /// Which wiring flavor was used, for the multi-flavor ecosystems — - /// npm: `package-lock` | `yarn-classic` | `pnpm` | `bun` (absent on - /// pre-flavor entries ⇒ `package-lock`); pypi: `uv` | `requirements` | + /// npm: `package-lock` | `yarn-classic` | `yarn-berry` | `pnpm` | `bun` + /// (absent on pre-flavor entries ⇒ `package-lock`); pypi: `uv` | `requirements` | /// `poetry` | `pdm` | `pipenv`. Reverts route on this and fail closed /// on flavors this build has no backend for. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/socket-patch-core/src/vex/time.rs b/crates/socket-patch-core/src/vex/time.rs index e3ffd281..89d57f34 100644 --- a/crates/socket-patch-core/src/vex/time.rs +++ b/crates/socket-patch-core/src/vex/time.rs @@ -31,7 +31,7 @@ fn format_unix_secs_rfc3339(secs: u64) -> String { /// Adapted to operate on a non-negative second count — socket-patch only /// ever stamps "now", so pre-1970 inputs are out of scope. /// -/// Also the date backbone of `utils::telemetry`'s millisecond-precision +/// Also the date backbone of `telemetry`'s millisecond-precision /// timestamps, so this is the single civil-date implementation in the crate. pub(crate) fn unix_to_ymdhms(secs: u64) -> (i32, u32, u32, u32, u32, u32) { let days = (secs / 86_400) as i64; diff --git a/crates/socket-patch-core/tests/fuzzy_match_e2e.rs b/crates/socket-patch-core/tests/fuzzy_match_e2e.rs index 5ddb1068..8e3e4151 100644 --- a/crates/socket-patch-core/tests/fuzzy_match_e2e.rs +++ b/crates/socket-patch-core/tests/fuzzy_match_e2e.rs @@ -1,4 +1,4 @@ -//! Integration coverage for `socket_patch_core::utils::fuzzy_match`. +//! Integration coverage for `socket_patch_core::crawlers::fuzzy_match`. //! //! `fuzzy_match_packages` powers `socket-patch get `'s //! "did you mean…" fallback when the caller's identifier doesn't @@ -7,8 +7,8 @@ use std::path::PathBuf; +use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages; use socket_patch_core::crawlers::types::CrawledPackage; -use socket_patch_core::utils::fuzzy_match::fuzzy_match_packages; fn pkg(name: &str, version: &str, namespace: Option<&str>) -> CrawledPackage { let ns = namespace.map(str::to_string); diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index 76aa379a..827e4673 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -1,4 +1,4 @@ -//! Integration coverage for `utils::telemetry`'s pub helpers +//! Integration coverage for `telemetry`'s pub helpers //! (`is_telemetry_disabled`, `sanitize_error_message`). These are //! exposed for tests + future external callers; the apply/scan //! suites never invoke them directly, so the env-var-branch logic @@ -14,7 +14,7 @@ //! that no other ambient var was secretly carrying the assertion). use serial_test::serial; -use socket_patch_core::utils::telemetry::{is_telemetry_disabled, sanitize_error_message}; +use socket_patch_core::telemetry::{is_telemetry_disabled, sanitize_error_message}; /// Every environment variable that can independently disable telemetry. /// Scrubbing the full set is what makes the per-var causation asserts honest. From 639e3f0b77487a9a37b46ee4ff026ea03231f11a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 11:09:28 -0700 Subject: [PATCH 07/16] refactor(core): unify the per-ecosystem setup backends under setup/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four naming schemes for one concept, now one umbrella: gem_setup -> setup::gem, composer_setup -> setup::composer, pth_hook -> setup::pypi, plus a thin setup::npm alias re-exporting package_json's setup-facing surface. package_json itself stays top-level: it doubles as the crate-wide shared npm-manifest library (crawlers and vendor parse package.json through it), which is exactly why it never fit under a setup umbrella wholesale. Pure git-mv moves — the pypi backend's generic TOML helpers were already extracted to utils::toml_edit_ext, so nothing vendor-shaped rides along. Old top-level paths keep compiling through lib.rs aliases for external consumers of the published crate; internal references are repointed and the CI alias-path grep now rejects the three old paths. The shared per-backend Status enum the four modules' docs describe informally is deliberately NOT introduced here: their status semantics differ subtly (gem template regeneration), and unification is a behavior decision, not motion. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 1 + crates/socket-patch-cli/src/commands/setup.rs | 40 +++++++++---------- .../src/crawlers/ruby_crawler.rs | 2 +- crates/socket-patch-core/src/lib.rs | 12 ++++-- .../{composer_setup => setup/composer}/mod.rs | 2 +- .../src/{gem_setup => setup/gem}/mod.rs | 0 .../gem}/templates/gemspec.tmpl | 0 .../gem}/templates/plugins.rb.tmpl | 0 .../src/{gem_setup => setup/gem}/update.rs | 0 crates/socket-patch-core/src/setup/mod.rs | 22 ++++++++++ crates/socket-patch-core/src/setup/npm.rs | 10 +++++ .../src/{pth_hook => setup/pypi}/detect.rs | 0 .../src/{pth_hook => setup/pypi}/edit.rs | 0 .../src/{pth_hook => setup/pypi}/mod.rs | 0 .../src/utils/toml_edit_ext.rs | 4 +- .../src/vendor/cargo_config.rs | 2 +- .../tests/crawler_ruby_e2e.rs | 2 +- 17 files changed, 68 insertions(+), 29 deletions(-) rename crates/socket-patch-core/src/{composer_setup => setup/composer}/mod.rs (99%) rename crates/socket-patch-core/src/{gem_setup => setup/gem}/mod.rs (100%) rename crates/socket-patch-core/src/{gem_setup => setup/gem}/templates/gemspec.tmpl (100%) rename crates/socket-patch-core/src/{gem_setup => setup/gem}/templates/plugins.rb.tmpl (100%) rename crates/socket-patch-core/src/{gem_setup => setup/gem}/update.rs (100%) create mode 100644 crates/socket-patch-core/src/setup/mod.rs create mode 100644 crates/socket-patch-core/src/setup/npm.rs rename crates/socket-patch-core/src/{pth_hook => setup/pypi}/detect.rs (100%) rename crates/socket-patch-core/src/{pth_hook => setup/pypi}/edit.rs (100%) rename crates/socket-patch-core/src/{pth_hook => setup/pypi}/mod.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c67399..7475edf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,7 @@ jobs: -e 'patch::go_redirect' -e 'patch::bun_lock_text' \ -e 'utils::telemetry' -e 'utils::cleanup_blobs' \ -e 'utils::date' -e 'utils::fuzzy_match' \ + -e 'gem_setup::' -e 'composer_setup::' -e 'pth_hook::' \ crates; then echo '::error::use the canonical module paths (crate::vendor, patch::redirect::golang_local, crate::telemetry, manifest::cleanup_blobs, api::date, crawlers::fuzzy_match); the old-path aliases exist only for external consumers' exit 1 diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 805bb8b9..166e44c7 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -1,7 +1,5 @@ use clap::Args; -use socket_patch_core::composer_setup::{self, ComposerSetupStatus}; use socket_patch_core::crawlers::python_crawler::is_python_project; -use socket_patch_core::gem_setup::{self, GemSetupStatus}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, SetupConfig}; use socket_patch_core::package_json::detect::{is_setup_configured_str, PackageManager}; @@ -12,10 +10,12 @@ use socket_patch_core::package_json::update::{ remove_package_json, update_package_json, RemoveResult, RemoveStatus, UpdateResult, UpdateStatus, }; -use socket_patch_core::pth_hook::detect::{ +use socket_patch_core::setup::composer::{self, ComposerSetupStatus}; +use socket_patch_core::setup::gem::{self, GemSetupStatus}; +use socket_patch_core::setup::pypi::detect::{ deps_contain_hook, detect_python_pm, PythonPackageManager, }; -use socket_patch_core::pth_hook::edit::{ +use socket_patch_core::setup::pypi::edit::{ add_hook_dependency, pyproject_contains_hook, remove_hook_dependency, ManifestKind, PthEditResult, PthStatus, }; @@ -356,17 +356,17 @@ pub(crate) async fn configured_ecosystems( } // gem: the managed plugin directive is present in the Gemfile. - if let Some(project) = gem_setup::discover_bundler_project(&common.cwd).await { + if let Some(project) = gem::discover_bundler_project(&common.cwd).await { if let Ok(content) = tokio::fs::read_to_string(&project.gemfile).await { - if gem_setup::is_plugin_directive_present(&content) { + if gem::is_plugin_directive_present(&content) { set.insert(Ecosystem::Gem); } } } - if let Some(composer_json) = composer_setup::discover_composer_project(&common.cwd).await { + if let Some(composer_json) = composer::discover_composer_project(&common.cwd).await { if let Ok(content) = tokio::fs::read_to_string(&composer_json).await { - if composer_setup::is_hook_present(&content) { + if composer::is_hook_present(&content) { set.insert(Ecosystem::Composer); } } @@ -576,7 +576,7 @@ async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> if !eco_in_scope(common, ECO_GEM) { return SetupOutcome::default(); } - let project = match gem_setup::discover_bundler_project(&common.cwd).await { + let project = match gem::discover_bundler_project(&common.cwd).await { Some(p) => p, None => return SetupOutcome::default(), }; @@ -587,9 +587,9 @@ async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> }; let results = if remove { - gem_setup::remove_plugin_directive(&project, dry_run).await + gem::remove_plugin_directive(&project, dry_run).await } else { - gem_setup::add_plugin_directive(&project, dry_run).await + gem::add_plugin_directive(&project, dry_run).await }; let mut added_paths: Vec = Vec::new(); @@ -647,7 +647,7 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool if !eco_in_scope(common, ECO_COMPOSER) { return SetupOutcome::default(); } - let composer_json = match composer_setup::discover_composer_project(&common.cwd).await { + let composer_json = match composer::discover_composer_project(&common.cwd).await { Some(p) => p, None => return SetupOutcome::default(), }; @@ -658,9 +658,9 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool }; let r = if remove { - composer_setup::remove_hook(&composer_json, dry_run).await + composer::remove_hook(&composer_json, dry_run).await } else { - composer_setup::add_hook(&composer_json, dry_run).await + composer::add_hook(&composer_json, dry_run).await }; let mut added_paths: Vec = Vec::new(); @@ -716,13 +716,13 @@ async fn append_composer_check_entries( if !eco_in_scope(common, ECO_COMPOSER) { return false; } - let composer_json = match composer_setup::discover_composer_project(&common.cwd).await { + let composer_json = match composer::discover_composer_project(&common.cwd).await { Some(p) => p, None => return false, }; let (state, err) = match tokio::fs::read_to_string(&composer_json).await { Ok(content) => { - if composer_setup::is_hook_present(&content) { + if composer::is_hook_present(&content) { (CheckState::Configured, None) } else { (CheckState::NeedsConfiguration, None) @@ -798,13 +798,13 @@ async fn append_gem_check_entries( if !eco_in_scope(common, ECO_GEM) { return false; } - let project = match gem_setup::discover_bundler_project(&common.cwd).await { + let project = match gem::discover_bundler_project(&common.cwd).await { Some(p) => p, None => return false, }; let (state, err) = match tokio::fs::read_to_string(&project.gemfile).await { Ok(content) => { - if gem_setup::is_plugin_directive_present(&content) { + if gem::is_plugin_directive_present(&content) { (CheckState::Configured, None) } else { (CheckState::NeedsConfiguration, None) @@ -813,14 +813,14 @@ async fn append_gem_check_entries( Err(e) => (CheckState::Error, Some(e.to_string())), }; entries.push(("gemfile", project.gemfile.display().to_string(), state, err)); - let dir_state = if gem_setup::plugin_files_present(&project.root).await { + let dir_state = if gem::plugin_files_present(&project.root).await { CheckState::Configured } else { CheckState::NeedsConfiguration }; entries.push(( "gem_plugin", - gem_setup::plugin_dir(&project.root).display().to_string(), + gem::plugin_dir(&project.root).display().to_string(), dir_state, None, )); diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 23a61a8d..8a6fed15 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -132,7 +132,7 @@ impl RubyCrawler { /// `Gemfile`/`Gemfile.lock` and the alternate `gems.rb`/`gems.locked` /// (`Bundler::SharedHelpers.default_gemfile`). Both count: the project /// gate must recognize every project `setup` can wire, and - /// `gem_setup::discover_bundler_project` already walks up for `gems.rb`. + /// `setup::gem::discover_bundler_project` already walks up for `gems.rb`. /// Gating on `Gemfile` alone left a `gems.rb` project with a /// non-deployment `bundle install` undiscoverable — the bundler plugin /// `setup` installs would run `apply` on every `bundle install` and diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 5358a20d..5337ab26 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -1,15 +1,21 @@ pub mod api; -pub mod composer_setup; pub mod constants; pub mod crawlers; -pub mod gem_setup; pub mod hash; pub mod manifest; pub mod package_json; pub mod patch; -pub mod pth_hook; +pub mod setup; pub mod telemetry; pub mod update; pub mod utils; pub mod vendor; pub mod vex; + +// Moved modules — these aliases keep the old top-level paths compiling for +// external consumers of the published crate. Internal code must import the +// canonical `setup::*` paths; CI greps reject new uses of the old ones. +// Drop these aliases at 4.0. +pub use setup::composer as composer_setup; +pub use setup::gem as gem_setup; +pub use setup::pypi as pth_hook; diff --git a/crates/socket-patch-core/src/composer_setup/mod.rs b/crates/socket-patch-core/src/setup/composer/mod.rs similarity index 99% rename from crates/socket-patch-core/src/composer_setup/mod.rs rename to crates/socket-patch-core/src/setup/composer/mod.rs index f97900eb..3e2f8d69 100644 --- a/crates/socket-patch-core/src/composer_setup/mod.rs +++ b/crates/socket-patch-core/src/setup/composer/mod.rs @@ -36,7 +36,7 @@ const HOOK_EVENTS: &[&str] = &["post-install-cmd", "post-update-cmd"]; /// a slightly different flag set still reads as configured. const HOOK_MARKER: &str = "socket-patch apply"; -/// Outcome of one setup edit. Mirrors `gem_setup::GemSetupStatus`. +/// Outcome of one setup edit. Mirrors `setup::gem::GemSetupStatus`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComposerSetupStatus { Updated, diff --git a/crates/socket-patch-core/src/gem_setup/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs similarity index 100% rename from crates/socket-patch-core/src/gem_setup/mod.rs rename to crates/socket-patch-core/src/setup/gem/mod.rs diff --git a/crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl b/crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl similarity index 100% rename from crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl rename to crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl diff --git a/crates/socket-patch-core/src/gem_setup/templates/plugins.rb.tmpl b/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl similarity index 100% rename from crates/socket-patch-core/src/gem_setup/templates/plugins.rb.tmpl rename to crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl diff --git a/crates/socket-patch-core/src/gem_setup/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs similarity index 100% rename from crates/socket-patch-core/src/gem_setup/update.rs rename to crates/socket-patch-core/src/setup/gem/update.rs diff --git a/crates/socket-patch-core/src/setup/mod.rs b/crates/socket-patch-core/src/setup/mod.rs new file mode 100644 index 00000000..c7ed6f1c --- /dev/null +++ b/crates/socket-patch-core/src/setup/mod.rs @@ -0,0 +1,22 @@ +//! Per-ecosystem `setup` backends: the code that wires (and unwires) each +//! ecosystem's auto-re-apply hook into a user's project, consumed by the +//! CLI's `setup` command. +//! +//! One concept, one home — these previously lived as four top-level modules +//! under four naming schemes (`gem_setup`, `composer_setup`, `pth_hook`, +//! plus `package_json`'s setup surface): +//! +//! * [`gem`] — Bundler plugin directive in the Gemfile + generated plugin +//! gem, re-applying gem patches on `bundle install`. +//! * [`composer`] — post-install hook in `composer.json`. +//! * [`pypi`] — the `socket-patch[hook]` dependency whose `.pth` wheel +//! re-applies pypi patches at interpreter startup. +//! * [`npm`] — a thin alias: the npm backend's real home is +//! [`crate::package_json`], which stays top-level because it doubles as +//! the crate-wide shared npm-manifest library (crawlers and vendor read +//! package.json through it too). + +pub mod composer; +pub mod gem; +pub mod npm; +pub mod pypi; diff --git a/crates/socket-patch-core/src/setup/npm.rs b/crates/socket-patch-core/src/setup/npm.rs new file mode 100644 index 00000000..7ba4dd16 --- /dev/null +++ b/crates/socket-patch-core/src/setup/npm.rs @@ -0,0 +1,10 @@ +//! The npm setup backend, by alias. +//! +//! npm's hook wiring lives in [`crate::package_json`] — that module stays +//! top-level because it is also the crate-wide shared npm-manifest library +//! (crawlers and vendor parse package.json through it). This alias exists so +//! `setup::*` enumerates every ecosystem backend in one place. + +pub use crate::package_json::detect::{is_setup_configured_str, PackageManager}; +pub use crate::package_json::find::{detect_package_manager, find_package_json_files}; +pub use crate::package_json::update::{remove_package_json, update_package_json}; diff --git a/crates/socket-patch-core/src/pth_hook/detect.rs b/crates/socket-patch-core/src/setup/pypi/detect.rs similarity index 100% rename from crates/socket-patch-core/src/pth_hook/detect.rs rename to crates/socket-patch-core/src/setup/pypi/detect.rs diff --git a/crates/socket-patch-core/src/pth_hook/edit.rs b/crates/socket-patch-core/src/setup/pypi/edit.rs similarity index 100% rename from crates/socket-patch-core/src/pth_hook/edit.rs rename to crates/socket-patch-core/src/setup/pypi/edit.rs diff --git a/crates/socket-patch-core/src/pth_hook/mod.rs b/crates/socket-patch-core/src/setup/pypi/mod.rs similarity index 100% rename from crates/socket-patch-core/src/pth_hook/mod.rs rename to crates/socket-patch-core/src/setup/pypi/mod.rs diff --git a/crates/socket-patch-core/src/utils/toml_edit_ext.rs b/crates/socket-patch-core/src/utils/toml_edit_ext.rs index dd05a900..5259a4e8 100644 --- a/crates/socket-patch-core/src/utils/toml_edit_ext.rs +++ b/crates/socket-patch-core/src/utils/toml_edit_ext.rs @@ -1,6 +1,6 @@ //! Small structured-TOML helpers shared by every module that edits or sniffs -//! TOML (`pth_hook`, `vendor::cargo_config`, `vendor::pypi`, -//! `vendor::pypi_uv`). Extracted from `pth_hook` so the pypi setup backend no +//! TOML (`setup::pypi`, `vendor::cargo_config`, `vendor::pypi`, +//! `vendor::pypi_uv`). Extracted from the pypi setup backend (now `setup::pypi`) so it no //! longer owns the crate's generic TOML seam. use toml_edit::{Item, Table}; diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index e5585b67..d2557ba1 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -1,7 +1,7 @@ //! Read / write `/.cargo/config.toml` for the cargo vendor //! backend's `[patch.crates-io]` wiring. //! -//! Mirrors the contract style of [`crate::pth_hook::edit`]: pure +//! Mirrors the contract style of [`crate::setup::pypi::edit`]: pure //! `fn(&str) -> Result, String>` transforms (`Some(new)` = //! changed, `None` = already in the desired state) wrapped by async //! read-or-create / write helpers that honour `dry_run` and preserve the diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 2b0b4bbd..ac137863 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -385,7 +385,7 @@ async fn get_gem_paths_with_gemfile_lock_only_returns_gemdir() { /// Bundler accepts `gems.rb` as the alternate spelling of `Gemfile` /// (`Bundler::SharedHelpers.default_gemfile`), and -/// `gem_setup::discover_bundler_project` already walks up for it — so +/// `setup::gem::discover_bundler_project` already walks up for it — so /// `setup` will wire a `gems.rb` project with the bundler plugin that runs /// `apply` on every `bundle install`. The crawler's project gate must /// recognize the same spelling; otherwise that project's non-deployment From 3822626a49bd057a541f73f4ed76b0a21bb53f69 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 11:05:40 -0700 Subject: [PATCH 08/16] refactor(npm): single-source the npm-family file-name knowledge with drift-guard tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm-family package managers spell their shared knowledge across four subsystems, each with its own list — and those lists accept INTENTIONALLY divergent subsets (hosted redirect deliberately omits bun.lockb; the pnpm-lock.yml spelling is setup-detection-only), so a flat shared list cannot serve them. Instead constants::npm_family now holds a structured row table (name x per-consumer role flags) plus the genuinely shared literals, and each consumer keeps its own shape guarded by an equality test against its role: * vendor::npm_flavor: probe families == rows flagged vendor_probe; local PNP_MARKERS deduped onto the shared const (same set the crawler probes — a past-divergence risk, now one definition) * scan::hosted: REDIRECT_CANDIDATE_FILES' npm-family subset == rows flagged redirect_candidate, both directions, so bun.lockb's deliberate absence is pinned as deliberate * package_json::find: detection iterates rows flagged detects_pnpm (behavioral pin per spelling, incl. pnpm-lock.yml) * crawlers::pkg_managers: PnP probe uses the shared PNP_MARKERS * Rush's common/config/rush/pnpm-lock.yaml literal (3 code sites) is now RUSH_COMMON_LOCK_REL Also: apply's package-manager match is exhaustive (a 7th layout must make an explicit appearance instead of falling into the wildcard), and deno.lock's absence from the npm-family lists is recorded as a decision in the table and the hosted candidate list. Not attempted here, deliberately: unifying the three PM enums (they answer different questions), merging redirect's pnpm regex grammar onto vendor's parser (intentionally different version envelopes), and the setup PackageManager Yarn/Bun widening (a product decision on hook commands, its own PR). Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/apply.rs | 5 +- .../src/commands/scan/hosted.rs | 34 ++++- crates/socket-patch-core/src/constants.rs | 116 ++++++++++++++++++ .../src/crawlers/pkg_managers.rs | 6 +- .../src/package_json/find.rs | 30 ++++- .../src/vendor/lock_inventory.rs | 2 +- .../src/vendor/npm_flavor.rs | 19 ++- 7 files changed, 203 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index c3df30d9..03816859 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -650,7 +650,10 @@ pub async fn run(args: ApplyArgs) -> i32 { // install cache by default. The CoW guard handles the // safety; this is informational only. } - _ => {} + // Exhaustive on purpose (no `_`): a new package-manager layout must + // make an explicit appearance here — silence is a decision, not a + // default. + NpmPkgManager::Npm | NpmPkgManager::YarnClassic | NpmPkgManager::Unknown => {} } match apply_patches_inner(&args, &manifest_path).await { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 19dcdfe7..92af24fe 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -47,6 +47,9 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "settings.gradle.kts", "build.gradle", "build.gradle.kts", + // deno.lock is knowingly absent: deno is its own ecosystem and no + // redirect rewriter edits its integrity entries today — recording the + // decision here so the omission reads as deliberate, not forgotten. ]; /// `pkg:/@` → `(type, coordinate, version)`. The @@ -259,7 +262,7 @@ pub(super) async fn run_redirect( let mut rush_warnings: Vec = Vec::new(); let mut rush_lock_keys: Vec = Vec::new(); if args.common.cwd.join("rush.json").is_file() { - let common_lock = "common/config/rush/pnpm-lock.yaml"; + let common_lock = socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL; if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(common_lock)) { files.insert(common_lock.to_string(), content); rush_lock_keys.push(common_lock.to_string()); @@ -553,3 +556,32 @@ pub(super) async fn run_redirect( } vex_code } + +#[cfg(test)] +mod tests { + use super::REDIRECT_CANDIDATE_FILES; + use socket_patch_core::constants::npm_family; + + #[test] + fn redirect_candidates_match_the_shared_npm_family_table() { + // Drift guard, both directions, without classifying the non-npm + // rows: every table row flagged redirect_candidate must be in the + // candidate list, and no npm-family row NOT so flagged may appear + // (bun.lockb's absence is deliberate — run_redirect auto-migrates + // it before rewriting). + for name in npm_family::names_with(|r| r.redirect_candidate) { + assert!( + REDIRECT_CANDIDATE_FILES.contains(&name), + "{name} is flagged redirect_candidate but missing from \ + REDIRECT_CANDIDATE_FILES" + ); + } + for name in npm_family::names_with(|r| !r.redirect_candidate) { + assert!( + !REDIRECT_CANDIDATE_FILES.contains(&name), + "{name} is deliberately NOT a redirect candidate (see the \ + npm_family table) but appears in REDIRECT_CANDIDATE_FILES" + ); + } + } +} diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index 7809be2b..ce198811 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -60,3 +60,119 @@ mod tests { assert_eq!(DEFAULT_PATCH_MANIFEST_PATH, ".socket/manifest.json"); } } + +/// The npm-family package managers' shared file-name knowledge. +/// +/// npm, pnpm, yarn (classic and berry) and bun spell their lockfiles and +/// layout markers across several subsystems — the vendor flavor probe +/// (`vendor::npm_flavor`), the hosted-redirect candidate list (the CLI's +/// `scan::hosted`), the crawler layout probe (`crawlers::pkg_managers`) and +/// setup's PM detection (`package_json::find`). Those sites accept +/// INTENTIONALLY divergent subsets: hosted redirect deliberately omits +/// `bun.lockb` (it auto-migrates it to `bun.lock` before rewriting), and the +/// `pnpm-lock.yml` spelling is accepted only by setup detection. This table +/// encodes each divergence once, visibly, instead of homogenizing them — +/// guard tests beside each consumer assert its list equals the rows flagged +/// for its role, so a new lockfile spelling added in one place fails the +/// other sites' tests instead of drifting silently. +pub mod npm_family { + /// One file-name row and the roles in which consumers accept it. + pub struct FileRow { + pub name: &'static str, + /// `vendor::npm_flavor`'s probe recognizes it (wiring family member). + pub vendor_probe: bool, + /// `scan::hosted` hands it to `rewrite_registry_redirect`. + pub redirect_candidate: bool, + /// `package_json::find::detect_package_manager` treats it as a pnpm + /// marker. + pub detects_pnpm: bool, + } + + pub const FILES: &[FileRow] = &[ + FileRow { + name: "package-lock.json", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + FileRow { + name: "npm-shrinkwrap.json", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + FileRow { + name: "pnpm-lock.yaml", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: true, + }, + // Setup-detection-only spellings: the vendor probe and redirect + // rewriters have never accepted these, and widening them there is a + // behavior change to make deliberately, not by table accident. + FileRow { + name: "pnpm-lock.yml", + vendor_probe: false, + redirect_candidate: false, + detects_pnpm: true, + }, + FileRow { + name: "pnpm-workspace.yaml", + vendor_probe: false, + redirect_candidate: false, + detects_pnpm: true, + }, + FileRow { + name: "yarn.lock", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + // Berry's cache-config gate: read by the redirect rewriters only. + FileRow { + name: ".yarnrc.yml", + vendor_probe: false, + redirect_candidate: true, + detects_pnpm: false, + }, + FileRow { + name: "bun.lock", + vendor_probe: true, + redirect_candidate: true, + detects_pnpm: false, + }, + // The legacy binary lock: the vendor probe knows it (to refuse with + // the migration hint); hosted redirect deliberately does NOT list it + // as a candidate — it auto-migrates to bun.lock first. + FileRow { + name: "bun.lockb", + vendor_probe: true, + redirect_candidate: false, + detects_pnpm: false, + }, + // deno.lock is deliberately absent: deno is its own ecosystem + // (JSR-crawled); no npm-family vendor/redirect/detection path treats + // deno.lock as an npm lock today. Adding it here is a feature + // decision, not a spelling fix. + ]; + + /// The names of every row `pick` flags — consumer guard tests compare + /// their local lists against this. + pub fn names_with(pick: impl Fn(&FileRow) -> bool) -> Vec<&'static str> { + FILES.iter().filter(|r| pick(r)).map(|r| r.name).collect() + } + + /// Yarn Plug'n'Play loader files — any one present means "packages are + /// not on disk" (crawler must refuse; vendor probe refuses). Yarn 3+ + /// emits `.pnp.cjs`, Yarn 2.x emitted `.pnp.js`, newer installs may add + /// the ESM `.pnp.loader.mjs`. + pub const PNP_MARKERS: [&str; 3] = [".pnp.cjs", ".pnp.js", ".pnp.loader.mjs"]; + + /// Rush monorepos keep the single pnpm source-of-truth lock here, + /// relative to the repo root (no root package.json/lock pair). + pub const RUSH_COMMON_LOCK_REL: &str = "common/config/rush/pnpm-lock.yaml"; + + /// The bun.lockb → bun.lock migration command, spliced into every + /// user-facing message that recommends it. + pub const BUN_MIGRATE_CMD: &str = "bun install --save-text-lockfile"; +} diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index bfa37043..896dea1c 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -79,9 +79,9 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { // mean "packages aren't on disk" — refuse rather than silently // fall through to Unknown (a Yarn 2 PnP tree has no // `node_modules/`, so it would otherwise escape the refusal). - if project_root.join(".pnp.cjs").is_file() - || project_root.join(".pnp.js").is_file() - || project_root.join(".pnp.loader.mjs").is_file() + if crate::constants::npm_family::PNP_MARKERS + .iter() + .any(|m| project_root.join(m).is_file()) { return NpmPkgManager::YarnBerryPnP; } diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 9d13e200..90a6df37 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -5,9 +5,11 @@ use super::detect::{strip_bom, PackageManager}; use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries}; /// Detect the package manager based on lockfiles in the project root. -/// Checks for pnpm-lock.yaml, pnpm-lock.yml, and pnpm-workspace.yaml. +/// The accepted pnpm marker spellings (including the `pnpm-lock.yml` +/// variant no other subsystem accepts) live in the shared +/// [`npm_family`](crate::constants::npm_family) table. pub async fn detect_package_manager(start_path: &Path) -> PackageManager { - for name in &["pnpm-lock.yaml", "pnpm-lock.yml", "pnpm-workspace.yaml"] { + for name in crate::constants::npm_family::names_with(|r| r.detects_pnpm) { if fs::metadata(start_path.join(name)).await.is_ok() { return PackageManager::Pnpm; } @@ -528,6 +530,30 @@ mod tests { ); } + #[tokio::test] + async fn detect_package_manager_accepts_every_table_flagged_pnpm_marker() { + // Behavioral pin on the shared npm_family table wiring: every row + // flagged detects_pnpm (including the `pnpm-lock.yml` spelling no + // other subsystem accepts) flips detection to Pnpm; an empty root + // stays Npm. + for name in crate::constants::npm_family::names_with(|r| r.detects_pnpm) { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(name), "").await.unwrap(); + assert!( + matches!( + detect_package_manager(dir.path()).await, + PackageManager::Pnpm + ), + "{name} must flip detection to pnpm" + ); + } + let dir = tempfile::tempdir().unwrap(); + assert!(matches!( + detect_package_manager(dir.path()).await, + PackageManager::Npm + )); + } + // ── Group 2: workspace detection + file discovery ──────────────── #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index fa1584b8..2c1d9b48 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -476,7 +476,7 @@ async fn inventory_rush_pnpm_locks(project_root: &Path) -> Vec { let mut out = Vec::new(); // The single source-of-truth lock. - let common_lock = project_root.join("common/config/rush/pnpm-lock.yaml"); + let common_lock = project_root.join(crate::constants::npm_family::RUSH_COMMON_LOCK_REL); if let Some(entries) = inventory_pnpm_lock_at(&common_lock).await { out.extend(entries); } diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index b813251e..bcc18bd1 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -57,7 +57,7 @@ impl NpmLockFlavor { /// Yarn berry Plug'n'Play loaders: packages live inside `.yarn/cache/` zips, /// so there is nothing on disk to stage and no lockfile entry to rewire. -const PNP_MARKERS: [&str; 3] = [".pnp.cjs", ".pnp.js", ".pnp.loader.mjs"]; +use crate::constants::npm_family::PNP_MARKERS; /// How many head lines the yarn content sniff reads (the v1 header sits in /// the leading comment block; berry's `__metadata:` is the first top-level @@ -397,6 +397,23 @@ pub async fn revert_npm_any( #[cfg(test)] mod tests { + #[test] + fn probe_lockfile_names_match_the_shared_npm_family_table() { + // Drift guard: the probe's wiring families and the shared + // constants::npm_family table must agree on which file names the + // vendor probe recognizes. A new lockfile spelling added in one + // place must show up in the other (and in every other consumer's + // guard test) instead of drifting silently. + let mut from_families: Vec<&str> = LOCKFILE_FAMILIES + .iter() + .flat_map(|(_, names)| names.iter().copied()) + .collect(); + from_families.sort_unstable(); + let mut from_table = crate::constants::npm_family::names_with(|r| r.vendor_probe); + from_table.sort_unstable(); + assert_eq!(from_families, from_table); + } + use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; From 1afb04f214412724090737ee2fd56cc8440c065d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:42:11 -0700 Subject: [PATCH 09/16] ci: stop counting docker-e2e/setup-e2e soft-skips as passing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test and test-release jobs ran `cargo test --workspace --all-features`, which also RUNS the feature-gated docker-e2e and setup-e2e suites. Those tests soft-skip as "ok" when no socket-patch-test images exist — which is always true in these jobs (no images are built there; macOS/Windows have no Docker at all). Every OS leg therefore reported dozens of fake green tests, and a broken skip-guard would disable a whole suite while CI stayed green. Split build from run: --all-features --no-run keeps the compile-rot coverage for the gated suites, the run step uses default features only. The dedicated e2e-docker and setup-matrix jobs remain the places where the gated suites actually execute. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7475edf5..6a49f042 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,7 +222,18 @@ jobs: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run tests - run: cargo test --workspace --all-features + # `--all-features` would also RUN the docker-e2e / setup-e2e suites, + # which soft-skip as "ok" in this job (no images are built here, and + # macOS/Windows have no Docker at all) — dozens of fake greens per OS + # that would hide a broken skip-guard behind a passing checkmark. + # Build them with --all-features (compile rot is real coverage), but + # run only the default-feature suites; the dedicated e2e-docker and + # setup-matrix jobs run the gated suites for real. + shell: bash + run: | + set -euo pipefail + cargo test --workspace --all-features --no-run + cargo test --workspace test-release: runs-on: ubuntu-latest @@ -256,7 +267,13 @@ jobs: # `ci-release` = [profile.release] minus the full-LTO link (see the # profile's comment in Cargo.toml). Same opt-level/debug-assertion # semantics this job exists to validate; ~23m of LTO relinking gone. - run: cargo test --workspace --all-features --profile ci-release + # Build/run split for the same reason as the `test` job: the gated + # docker-e2e / setup-e2e suites only soft-skip here — compile them, + # don't count their skips as passes. + run: | + set -euo pipefail + cargo test --workspace --all-features --profile ci-release --no-run + cargo test --workspace --profile ci-release coverage: # Code coverage via cargo-llvm-cov (LLVM source-based instrumentation). From 882cdb798b21d03b5a6e431923b07e6c795f237b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 10 Aug 2026 10:57:18 -0700 Subject: [PATCH 10/16] fix(setup,scan): fail closed on corrupt manifests, JSON envelopes on every hosted failure; fetch_stage unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coverage gaps from the 2026-08-10 structure review, each with the test that pins it: * setup --exclude persistence clobbered a corrupt manifest: persist_setup_excludes flattened a read/parse error to "no manifest yet" and rewrote the file as a bare setup block, destroying every patch record a merely-corrupt manifest still held. Now fails closed (skip persistence, warn on stderr, manifest bytes untouched). RED test: exclude_persistence_fails_closed_on_corrupt_manifest. * scan --redirect --json emitted empty stdout on every failure exit (discovery-detail failure, reference-resolve failure, file/ledger write failure) — exit 1 with nothing to parse. All four bail-outs now emit the machine-readable error envelope (status/error mirror the success envelope's error fold). RED test: redirect_json_mode_failures_emit_error_envelope. * fetch_stage.rs (the offline-guard-critical download planner) had zero direct tests. In-src unit tests now pin: the read-only-.socket contract, in-place staging when fully cached, the diff-archive disk-vs-vendor staging asymmetry both module docs describe, overlay promotion for late downloads, overlay_dir semantics, and the bad --download-mode hard failure. Co-Authored-By: Claude Fable 5 --- .../src/commands/fetch_stage.rs | 218 ++++++++++++++++++ .../src/commands/scan/hosted.rs | 49 +++- crates/socket-patch-cli/src/commands/setup.rs | 18 +- .../tests/in_process_redirect.rs | 111 +++++++++ .../tests/setup_contract_gaps.rs | 52 +++++ 5 files changed, 439 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index cad91c43..dad11daa 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -438,3 +438,221 @@ pub(crate) async fn stage_vendor_sources_in_memory( mem, })) } + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; + + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + // 64 ascii-hex, the shape `is_valid_blob_hash` accepts. + const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn manifest_with_one_patch() -> PatchManifest { + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: "b".repeat(64), + after_hash: HASH.to_string(), + }, + ); + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/left-pad@1.3.0".to_string(), + PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + manifest + } + + fn offline_args() -> GlobalArgs { + GlobalArgs { + offline: true, + silent: true, + ..GlobalArgs::default() + } + } + + /// Everything cached → read `.socket/` in place: no overlay tempdir, and + /// the returned paths are the persistent cache dirs themselves. + #[tokio::test] + async fn stage_reads_socket_dir_in_place_when_fully_cached() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); + std::fs::write(socket_dir.join("blobs").join(HASH), b"patched").unwrap(); + + let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) + .await + .expect("no hard failure"); + let StageOutcome::Ready(staged) = outcome else { + panic!("fully-cached staging must be Ready"); + }; + assert!(staged._stage.is_none(), "no overlay when nothing to fetch"); + assert_eq!(staged.blobs, socket_dir.join("blobs")); + } + + /// Offline with no usable source → Unavailable, and the read-only + /// contract holds: staging must not create or write `.socket/`. + #[tokio::test] + async fn stage_offline_with_missing_sources_is_unavailable_and_writes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + + let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) + .await + .expect("no hard failure"); + assert!( + matches!(outcome, StageOutcome::Unavailable), + "offline + no local source must be Unavailable" + ); + assert!( + !socket_dir.exists(), + "the stager is read-only against .socket/ — it must not create it" + ); + } + + /// A diff archive alone satisfies the disk stager (the pipeline can apply + /// via the diff path), even with every blob missing. + #[tokio::test] + async fn stage_offline_accepts_diff_archive_as_sole_source() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); + std::fs::write( + socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), + b"x", + ) + .unwrap(); + + let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) + .await + .expect("no hard failure"); + assert!( + matches!(outcome, StageOutcome::Ready(_)), + "a present diff archive is a usable source for the disk stager" + ); + } + + /// The vendor (in-memory) stager documents the opposite policy: a diff + /// archive is NOT sufficient (auto-force can need the full after-blob), + /// so the same fixture that satisfies the disk stager is Unavailable + /// offline here. Pins the asymmetry both module docs describe. + #[tokio::test] + async fn mem_stage_offline_rejects_diff_archive_as_sole_source() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); + std::fs::write( + socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), + b"x", + ) + .unwrap(); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &project_root, + ) + .await + .expect("no hard failure"); + assert!( + matches!(outcome, MemStageOutcome::Unavailable), + "vendor staging must not treat a diff archive as a usable source" + ); + } + + /// An unknown `--download-mode` is a hard setup failure (Err), not a + /// soft Unavailable. + #[tokio::test] + async fn stage_rejects_unknown_download_mode() { + let tmp = tempfile::tempdir().unwrap(); + let args = GlobalArgs { + download_mode: "bogus".to_string(), + silent: true, + ..GlobalArgs::default() + }; + let Err(err) = stage_patch_sources(&args, &manifest_with_one_patch(), tmp.path()).await + else { + panic!("an unparseable download mode is a hard failure"); + }; + assert!( + err.contains("bogus"), + "diagnostic names the bad mode: {err}" + ); + } + + /// `writable_blobs` promotes an in-place (no-overlay) source set to a + /// transient overlay: the returned dir is NOT `.socket/blobs`, existing + /// blobs are pre-seeded into it, and a late download that lands there + /// leaves the persistent cache untouched. + #[tokio::test] + async fn writable_blobs_promotes_to_overlay_and_preserves_cache() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); + std::fs::write(socket_dir.join("blobs").join(HASH), b"cached").unwrap(); + + let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) + .await + .expect("no hard failure"); + let StageOutcome::Ready(mut staged) = outcome else { + panic!("fully-cached staging must be Ready"); + }; + + let writable = staged.writable_blobs().await.expect("overlay created"); + assert_ne!( + writable, + socket_dir.join("blobs"), + "late downloads must never target the persistent cache" + ); + assert!( + writable.join(HASH).exists(), + "the overlay is pre-seeded with the cached blobs" + ); + + std::fs::write(writable.join("late-download"), b"new").unwrap(); + assert!( + !socket_dir.join("blobs").join("late-download").exists(), + "a write into the overlay must not appear in .socket/blobs" + ); + // Stable across calls: a second call reuses the same overlay. + let again = staged.writable_blobs().await.unwrap().to_path_buf(); + assert!(again.join("late-download").exists()); + } + + /// `overlay_dir` mirrors regular files only, and never clobbers a file + /// already present at the destination. + #[tokio::test] + async fn overlay_dir_mirrors_files_skips_dirs_and_existing() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + std::fs::create_dir_all(src.join("subdir")).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join("a"), b"from-src").unwrap(); + std::fs::write(src.join("b"), b"from-src").unwrap(); + std::fs::write(dst.join("b"), b"already-there").unwrap(); + + overlay_dir(&src, &dst).await; + + assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"from-src"); + assert_eq!( + std::fs::read(dst.join("b")).unwrap(), + b"already-there", + "existing destination files are never overwritten" + ); + assert!(!dst.join("subdir").exists(), "directories are not mirrored"); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 92af24fe..8f917fa4 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -65,6 +65,22 @@ fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> { Some((typ.to_string(), name, version.to_string())) } +/// The hosted-mode JSON error envelope, for bail-outs that return before the +/// result envelope at the bottom of [`run_redirect`] is built. A `--json` +/// consumer must always get parseable stdout — `status`/`error` mirror the +/// success envelope's error fold — never empty output plus an exit code. +fn emit_json_error(message: &str) { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": message, + "redirect": { "mode": "hosted" }, + })) + .unwrap() + ); +} + /// `scan --redirect`: resolve hosted-patch references for the selected patches, /// then rewrite ONLY those dependencies' lockfile/registry-config entries to /// point at the hosted vendored patches (the byte-identical counterpart of the @@ -92,11 +108,16 @@ pub(super) async fn run_redirect( { Ok(s) => s, // Hosted mode has no discovery envelope to fold the message into at - // this point (it builds its `redirect` result further down) and its - // other bail-outs — e.g. the reference resolve below — report on - // stderr the same way. `discover_selected` already printed the - // message; behavior here is unchanged. - Err((code, _message)) => return code, + // this point (it builds its `redirect` result further down). + // `discover_selected` already printed the message to stderr; a + // `--json` run additionally gets the machine-readable envelope so + // stdout is never empty on failure. + Err((code, message)) => { + if args.common.json { + emit_json_error(&message); + } + return code; + } }; let mut skipped: Vec = Vec::new(); @@ -114,7 +135,11 @@ pub(super) async fn run_redirect( let references = match api_client.fetch_registry_references(&uuids).await { Ok(r) => r, Err(e) => { - eprintln!("failed to resolve patch references: {e}"); + let message = format!("failed to resolve patch references: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(&message); + } return 1; } }; @@ -391,7 +416,11 @@ pub(super) async fn run_redirect( let _ = std::fs::create_dir_all(parent); } if let Err(e) = std::fs::write(&path, content) { - eprintln!("failed to write {rel}: {e}"); + let message = format!("failed to write {rel}: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(&message); + } return 1; } } @@ -427,7 +456,11 @@ pub(super) async fn run_redirect( vendor_dir.join("redirect-state.json"), format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), ) { - eprintln!("failed to write .socket/vendor/redirect-state.json: {e}"); + let message = format!("failed to write .socket/vendor/redirect-state.json: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(&message); + } return 1; } } diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 166e44c7..2f305518 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -288,7 +288,23 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) { return; } let path = common.resolved_manifest_path(); - let existing = read_manifest(&path).await.ok().flatten(); + // Fail closed on a manifest that exists but cannot be read or parsed: it + // may still hold recoverable patch records, and flattening the error to + // "no manifest yet" would rewrite the file down to a bare setup block — + // destroying them for the sake of persisting an exclude list. Skip + // persistence loudly instead; nothing else in this run needs the file. + let existing = match read_manifest(&path).await { + Ok(existing) => existing, + Err(e) => { + if !common.silent { + eprintln!( + "Warning: not persisting --exclude: cannot read {}: {e}", + path.display() + ); + } + return; + } + }; let mut merged: Vec = excludes.to_vec(); merged.sort(); merged.dedup(); diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d2dc7685..1e8f50cc 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1706,3 +1706,114 @@ async fn cargo_redirect_writes_the_legacy_dot_cargo_config() { "anchor: the Cargo.toml dep must name the managed registry: {manifest}" ); } + +/// `scan --redirect --json` must emit a machine-readable error envelope on +/// stdout for EVERY failure exit, never empty stdout plus an exit code. +/// +/// Regression pin for the long-open hosted-mode JSON gap: the early +/// bail-outs (discovery-detail failure, reference-resolve failure) returned +/// with the message on stderr only, so a `--json` consumer saw exit 1 with +/// nothing to parse. Two legs, one per bail-out. +#[tokio::test] +#[serial] +async fn redirect_json_mode_failures_emit_error_envelope() { + let assert_error_envelope = |out: &std::process::Output, leg: &str| { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "{leg}: failure exit; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("{leg}: --json stdout must be a parseable envelope even on failure ({e}); stdout=\n{stdout}") + }); + assert_eq!( + v["status"], "error", + "{leg}: envelope status; stdout=\n{stdout}" + ); + assert!( + v["error"].as_str().is_some_and(|m| !m.is_empty()), + "{leg}: envelope must carry the error message; stdout=\n{stdout}" + ); + assert_eq!( + v["redirect"]["mode"], "hosted", + "{leg}: envelope must identify the mode; stdout=\n{stdout}" + ); + }; + + // Leg 1 — batch discovery succeeds, every patch-detail query fails → + // `discover_selected` bails with (1, message). + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "redirect fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + assert_error_envelope(&out, "discovery-detail failure"); + + // Leg 2 — discovery + selection succeed, the reference resolve fails. + let server = MockServer::start().await; + mock_discovery(&server).await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + assert_error_envelope(&out, "reference-resolve failure"); +} diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index b2a87779..b59cce77 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -466,6 +466,58 @@ fn setup_honors_exclude_for_a_workspace_member() { ); } +/// `--exclude` persistence must fail closed on a manifest it cannot parse. +/// +/// Regression pin: `persist_setup_excludes` flattened a read/parse error to +/// `None` ("no manifest yet") and rewrote the file as a fresh manifest +/// holding only the setup block — silently destroying every patch record a +/// merely-corrupt (and possibly hand-recoverable) manifest still held. The +/// load-bearing assertion is bytes-unchanged; setup itself still exits 0 +/// (the hooks were written), it just skips persisting and says so on stderr. +#[test] +fn exclude_persistence_fails_closed_on_corrupt_manifest() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + write( + &proj.path().join("package.json"), + r#"{ "name": "root", "version": "1.0.0" }"#, + ); + let manifest_path = proj.path().join(".socket/manifest.json"); + let corrupt = r#"{ "patches": { "pkg:npm/left-pad@1.3.0": TRUNCATED-MID-WRITE"#; + write(&manifest_path, corrupt); + + let mut cmd = Command::new(binary()); + cmd.args(["setup", "--json", "--yes", "--exclude", "packages/b"]) + .current_dir(proj.path()); + for (name, _) in std::env::vars() { + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { + cmd.env_remove(name); + } + } + cmd.env("HOME", home.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run socket-patch"); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert_eq!( + out.status.code(), + Some(0), + "setup itself succeeds (hooks written); only the persistence step is \ + skipped; stderr=\n{stderr}" + ); + let after = std::fs::read_to_string(&manifest_path).expect("manifest still present"); + assert_eq!( + after, corrupt, + "a corrupt manifest must survive `setup --exclude` byte-identical — \ + rewriting it destroys every patch record it may still hold; \ + stderr=\n{stderr}" + ); + assert!( + stderr.contains("not persisting --exclude"), + "skipping persistence must be loud, not silent: {stderr}" + ); +} + /// Property 9, CSV spelling: `--exclude` is comma-delimited, so /// `--exclude "packages/a, packages/b"` (and the `SOCKET_SETUP_EXCLUDE=a, b` /// form CI YAML produces) must exclude BOTH members. From 9b0c9c68d357b72d74033230a47a0c4368b234c8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 11 Aug 2026 09:06:34 -0700 Subject: [PATCH 11/16] fix(ci,test): hermetic composer scans; repair template-lint paths broken by the setup/ move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI reds from the consolidated branch's first run, plus doc-path rot: * e2e_composer's scan tests were designed offline ("the package count is derived from the local crawl") but implicitly called the LIVE public proxy — their exit-0 assumption dated from before the all-batches-failed fix made total API failure exit non-zero, so any production hiccup (like today's patches-api 503 "over capacity" incident) failed the test, coverage and test-release jobs on every OS. Now pinned to an in-test wiremock proxy with the same harness shape as e2e_nuget/e2e_gem (empty no-patch result, env scrub, spawn_blocking, request-count hermeticity guard). e2e_embedded_vex audited too: its scans find zero packages, so no API call fires — left as is. * lint-ecosystems ruby-checked the gem templates at their pre-move path; the setup/ umbrella relocated them to src/setup/gem/templates/ and the Rust-path CI grep could not catch a filesystem path in a workflow. Also: release.yml's gem_setup comment and CLI_CONTRACT.md's src/patch/vendor/ references updated to the new module homes (the only stale non-Rust references a repo-wide sweep found). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 2 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/tests/e2e_composer.rs | 105 ++++++++++++++---- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a49f042..e8bb12e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,8 +94,8 @@ jobs: ( cd gem/socket-patch && ruby -c lib/socket_patch/launcher.rb && ruby -c exe/socket-patch && gem build socket-patch.gemspec ) ( cd gem/socket-patch-bundler && ruby -c plugins.rb && gem build socket-patch-bundler.gemspec ) # The generated-plugin templates are pure Ruby — keep them parseable. - ruby -c crates/socket-patch-core/src/gem_setup/templates/plugins.rb.tmpl - ruby -c crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl + ruby -c crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl + ruby -c crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl - name: PHP — lint the Composer launcher + validate composer.json # composer.json lives at the repo root (Packagist requires the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 221339df..473a4111 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -639,7 +639,7 @@ jobs: # Phase 2 scaffolding (CLI_CONTRACT "gem" support matrix): publish the # `socket-patch-bundler` gem — the published form of the Bundler plugin # that `socket-patch setup` currently wires via an in-tree `git:` - # reference. This gem is NOT yet the active mechanism (gem_setup still + # reference. This gem is NOT yet the active mechanism (setup::gem still # emits the in-tree plugin), so the push is **non-blocking** # (`continue-on-error`). A follow-up switches the generated Gemfile # directive to `plugin "socket-patch-bundler"` and drops diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 99393525..f74b21f3 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -731,7 +731,7 @@ Contract properties: ### Registry override env vars -Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/patch/vendor/registry_fetch.rs`, `src/patch/vendor/maven_repo.rs`). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. +Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/vendor/registry_fetch.rs`, `src/vendor/maven_repo.rs`). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. | Env var | Default | Notes | |---|---|---| diff --git a/crates/socket-patch-cli/tests/e2e_composer.rs b/crates/socket-patch-cli/tests/e2e_composer.rs index 178e97ba..dd929e8d 100644 --- a/crates/socket-patch-cli/tests/e2e_composer.rs +++ b/crates/socket-patch-cli/tests/e2e_composer.rs @@ -2,7 +2,12 @@ //! //! These tests exercise crawling against a temporary directory with a fake //! Composer vendor layout. They do **not** require network access or a real -//! PHP/Composer installation. +//! PHP/Composer installation: every scan's patch lookup is pinned to an +//! in-test wiremock proxy (empty no-patch result), so a live-API outage can +//! never fail them and the discovery counts stay the only thing under test. +//! (They used to call the real public proxy implicitly — green only while +//! production was healthy — and turned red when the all-batches-failed +//! exit-code fix made total API failure exit non-zero.) //! //! # Running //! ```sh @@ -12,6 +17,9 @@ use std::path::PathBuf; use std::process::{Command, Output}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -20,12 +28,62 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -fn run(args: &[&str], cwd: &std::path::Path) -> Output { - Command::new(binary()) - .args(args) - .current_dir(cwd) - .output() - .expect("Failed to run socket-patch binary") +/// Start a mock Socket public proxy answering the scan's `POST /patch/batch` +/// with an empty (no-patch) result, so no scan in this file ever leaves +/// localhost. Same shape as the e2e_nuget/e2e_gem harnesses. +async fn start_proxy() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/patch/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + server +} + +/// Run the binary as a blocking subprocess (off the async runtime so the +/// in-test proxy can service its requests concurrently), pinned to +/// `proxy_url`. `SOCKET_API_TOKEN` is stripped so the binary +/// deterministically takes the public-proxy path, and every other variable +/// that could redirect the API elsewhere or disable it is scrubbed. +async fn run(args: &[&str], cwd: &std::path::Path, proxy_url: &str) -> Output { + let mut args: Vec = args.iter().map(|s| s.to_string()).collect(); + args.extend(["--proxy-url".to_string(), proxy_url.to_string()]); + let cwd = cwd.to_path_buf(); + tokio::task::spawn_blocking(move || { + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + Command::new(binary()) + .args(&arg_refs) + .current_dir(&cwd) + .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_CLI_API_TOKEN") + .env_remove("SOCKET_API_URL") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_PROXY_URL") + .env_remove("SOCKET_PATCH_PROXY_URL") + .env_remove("SOCKET_BATCH_SIZE") + .output() + .expect("Failed to run socket-patch binary") + }) + .await + .expect("socket-patch subprocess task panicked") +} + +/// Regression guard: every scan in a test must have routed its patch lookup +/// through the in-test proxy. Fewer recorded requests than scans means a +/// binary invocation talked to the live API (or skipped the lookup) despite +/// the pinning — exactly the flake this file used to have. +async fn assert_proxy_served_scans(server: &MockServer, scans: usize) { + let requests = server.received_requests().await.unwrap_or_default(); + assert!( + requests.len() >= scans, + "expected all {scans} scan invocations to hit the in-test proxy; \ + recorded only {} request(s)", + requests.len() + ); } /// Run `socket-patch scan --json ...`, assert the process succeeded, and @@ -35,8 +93,13 @@ fn run(args: &[&str], cwd: &std::path::Path) -> Output { /// envelope fails the test loudly instead of slipping past a `.contains()` /// check. Doing this offline is safe: the package *count* is derived from the /// local crawl and is emitted regardless of whether the API query succeeds. -fn scan_json(cwd: &std::path::Path) -> serde_json::Value { - let output = run(&["scan", "--json", "--cwd", cwd.to_str().unwrap()], cwd); +async fn scan_json(cwd: &std::path::Path, proxy_url: &str) -> serde_json::Value { + let output = run( + &["scan", "--json", "--cwd", cwd.to_str().unwrap()], + cwd, + proxy_url, + ) + .await; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -49,8 +112,8 @@ fn scan_json(cwd: &std::path::Path) -> serde_json::Value { } /// Run the human-readable `socket-patch scan` and return combined stdout+stderr. -fn scan_human(cwd: &std::path::Path) -> String { - let output = run(&["scan", "--cwd", cwd.to_str().unwrap()], cwd); +async fn scan_human(cwd: &std::path::Path, proxy_url: &str) -> String { + let output = run(&["scan", "--cwd", cwd.to_str().unwrap()], cwd, proxy_url).await; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -66,8 +129,9 @@ fn scan_human(cwd: &std::path::Path) -> String { // --------------------------------------------------------------------------- /// Verify that `socket-patch scan` discovers packages via Composer 2 installed.json. -#[test] -fn scan_discovers_composer2_packages() { +#[tokio::test] +async fn scan_discovers_composer2_packages() { + let proxy = start_proxy().await; let dir = tempfile::tempdir().unwrap(); let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); @@ -110,7 +174,7 @@ fn scan_discovers_composer2_packages() { // not merely the presence of a `scannedPackages` key (which the envelope // always carries, even when zero packages are found). The Composer 2 // `{"packages": [...]}` parser must surface both packages. - let json = scan_json(&project_dir); + let json = scan_json(&project_dir, &proxy.uri()).await; assert_eq!( json["status"], "success", "scan envelope must report success; got:\n{json:#}" @@ -129,7 +193,7 @@ fn scan_discovers_composer2_packages() { // attributes it to the wrong crawler entirely while "php" leaks in from // an unrelated line. The closing paren after `php` pins the breakdown to // php-only. - let combined = scan_human(&project_dir); + let combined = scan_human(&project_dir, &proxy.uri()).await; assert!( combined.contains("Found 2 packages (2 php)"), "Expected human scan to report exactly 'Found 2 packages (2 php)', got:\n{combined}" @@ -138,11 +202,13 @@ fn scan_discovers_composer2_packages() { !combined.contains("No packages found"), "scan reported no packages despite a populated Composer vendor dir:\n{combined}" ); + assert_proxy_served_scans(&proxy, 2).await; } /// Verify that `socket-patch scan` discovers packages via Composer 1 installed.json (flat array). -#[test] -fn scan_discovers_composer1_packages() { +#[tokio::test] +async fn scan_discovers_composer1_packages() { + let proxy = start_proxy().await; let dir = tempfile::tempdir().unwrap(); let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); @@ -170,7 +236,7 @@ fn scan_discovers_composer1_packages() { // flat-array (top-level `[...]`) form. Asserting the exact count guards // against a regression where only the Composer 2 object form is parsed // (which would silently yield 0 here while the envelope still validates). - let json = scan_json(&project_dir); + let json = scan_json(&project_dir, &proxy.uri()).await; assert_eq!( json["status"], "success", "scan envelope must report success; got:\n{json:#}" @@ -185,7 +251,7 @@ fn scan_discovers_composer1_packages() { // php ecosystem. Assert the contiguous `Found 1 packages (1 php)` string // (see the Composer 2 test for why two independent substrings are too // weak). - let combined = scan_human(&project_dir); + let combined = scan_human(&project_dir, &proxy.uri()).await; assert!( combined.contains("Found 1 packages (1 php)"), "Expected human scan to report exactly 'Found 1 packages (1 php)', got:\n{combined}" @@ -194,4 +260,5 @@ fn scan_discovers_composer1_packages() { !combined.contains("No packages found"), "scan reported no packages despite a populated Composer vendor dir:\n{combined}" ); + assert_proxy_served_scans(&proxy, 2).await; } From 20d35b5257960e4733d723cae29a8effcaed841c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 11 Aug 2026 09:06:34 -0700 Subject: [PATCH 12/16] build: cap dev-profile debuginfo at line tables (macOS target/ bloat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS debug builds use unpacked split-debuginfo: every linked binary — including each of the ~90 e2e test executables — pins its per-codegen-unit .o files in target/debug/deps, and cargo never garbage-collects the generations superseded by lockfile/toolchain bumps; this grew a single worktree's target/ to 99 GB. Line tables keep panic backtraces readable while dropping the bulk of the retained DWARF; for a full-fidelity debugger session, override with CARGO_PROFILE_DEV_DEBUG=full. Applies to the test/bench profiles via inheritance. (Authored during the 2026-08-10 disk-space cleanup; folded into this branch at the owner's request.) Co-Authored-By: Claude Fable 5 --- Cargo.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 14fc800b..b572264c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,3 +105,14 @@ opt-level = 3 opt-level = 3 [profile.dev.package.suffix_array] opt-level = 3 + +# macOS debug builds use unpacked split-debuginfo: every linked binary — +# including each of the ~90 e2e test executables — pins its per-codegen-unit +# .o files in target/debug/deps, and cargo never garbage-collects the +# generations superseded by lockfile/toolchain bumps (this grew target/ to +# 99 GB). Line tables keep panic backtraces readable while dropping the bulk +# of the retained DWARF; for a full-fidelity debugger session, override with +# CARGO_PROFILE_DEV_DEBUG=full. Applies to test/bench profiles via +# inheritance. +[profile.dev] +debug = "line-tables-only" From 5ca6b1f47d80be100622cd9d03b4fe2db9770e52 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 11 Aug 2026 13:42:59 -0700 Subject: [PATCH 13/16] =?UTF-8?q?fix(scan,vendor):=20deterministic=20twin?= =?UTF-8?q?=20updates,=20honest=20carried-envelope=20status,=20dead=20Err?= =?UTF-8?q?=20plumbing=20removed=20=E2=80=94=20with=20the=20pins=20the=20r?= =?UTF-8?q?eview=20demanded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (2026-08-11 ULTRACODE pass over #150): * detect_updates: the qualifier-stripped fallback did HashMap iter().find(), so qualifier TWINS (one package under two artifact-pinned manifest keys, e.g. a pypi wheel+sdist pair) resolved to a per-process-random record. Now: any stale twin means an update; twins scan in sorted-key order and the first differing one names old_uuid — deterministic across runs. New pins: the qualifier-bridge leg (previously only percent-encoding was tested) and the twin cases (stale twin wins over 16 iterations; all-twins- current stays quiet). * scan --vendor --json: the envelope carried into the staging-error fold is now demoted to partialFailure before the carry — a consumer reading .vendor.status inside a "status":"error" result saw the fresh-envelope default "success". Pinned in scan_vendor_step_error_e2e. * stage_vendor_sources_in_memory returns MemStageOutcome directly: it never constructed Err, so the Result wrapper bred statically-dead stage_failed arms in three callers (scan flow, vendor, repair_vendor) — all removed. * Hosted --json write-failure bail-outs (legs 3-4: unwritable lockfile, directory squatting on the revert-ledger path) get the envelope test the 882cdb7 commit message claimed — driven with real filesystem obstructions, cross-platform via set_readonly. * get nested apply: a token-less --proxy-url-only leg. The two authenticated legs could not catch a dropped proxy_url (the client consults it only on the token-less branch); this leg goes red for exactly that regression. Co-Authored-By: Claude Fable 5 --- .../src/commands/fetch_stage.rs | 18 ++-- .../src/commands/repair_vendor.rs | 9 +- .../src/commands/scan/discovery.rs | 97 +++++++++++++++---- .../src/commands/scan/vendor_flow.rs | 11 ++- .../socket-patch-cli/src/commands/vendor.rs | 8 +- .../tests/get_nested_apply_api_flags_e2e.rs | 82 ++++++++++++++++ .../tests/in_process_redirect.rs | 83 ++++++++++++++++ .../tests/scan_vendor_step_error_e2e.rs | 9 ++ 8 files changed, 276 insertions(+), 41 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index dad11daa..2fa5b04f 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -310,13 +310,16 @@ pub(crate) enum MemStageOutcome { /// reproduce); anything else has its full per-file content fetched into /// memory from the patch view endpoint (`blobContent`), preceded by the /// committed-artifact harvest. Offline runs with missing sources are -/// `Unavailable` with the same diagnostics as the disk stager. +/// `Unavailable` with the same diagnostics as the disk stager. Unlike the +/// disk stager there is no hard-failure mode (no download-mode parse, no +/// tempdir), so this returns the outcome directly — every failure is the +/// soft `Unavailable`. pub(crate) async fn stage_vendor_sources_in_memory( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, project_root: &Path, -) -> Result { +) -> MemStageOutcome { let quiet = common.silent || common.json; let blobs = socket_dir.join("blobs"); let diffs = socket_dir.join("diffs"); @@ -370,7 +373,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( if common.offline { let purls: Vec<&str> = to_fetch.iter().map(|(purl, _)| *purl).collect(); report_offline_missing(common, &purls); - return Ok(MemStageOutcome::Unavailable); + return MemStageOutcome::Unavailable; } if !quiet { @@ -427,16 +430,16 @@ pub(crate) async fn stage_vendor_sources_in_memory( eprintln!(" - {}", purl); } } - return Ok(MemStageOutcome::Unavailable); + return MemStageOutcome::Unavailable; } } - Ok(MemStageOutcome::Ready(MemStagedSources { + MemStageOutcome::Ready(MemStagedSources { blobs, diffs, packages, mem, - })) + }) } #[cfg(test)] @@ -565,8 +568,7 @@ mod tests { &socket_dir, &project_root, ) - .await - .expect("no hard failure"); + .await; assert!( matches!(outcome, MemStageOutcome::Unavailable), "vendor staging must not treat a diff archive as a usable source" diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index c5843751..93cc0bb4 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -455,8 +455,8 @@ pub(crate) async fn repair_vendored_artifacts( }; let staged = match stage_vendor_sources_in_memory(common, &synth, socket_dir, &common.cwd).await { - Ok(MemStageOutcome::Ready(s)) => s, - Ok(MemStageOutcome::Unavailable) => { + MemStageOutcome::Ready(s) => s, + MemStageOutcome::Unavailable => { for c in &candidates { fail( env, @@ -477,11 +477,6 @@ pub(crate) async fn repair_vendored_artifacts( } return rebuilt; } - Err(e) => { - env.record(PatchEvent::artifact(PatchAction::Failed).with_error("stage_failed", e)); - env.mark_partial_failure(); - return rebuilt; - } }; let sources = staged.as_patch_sources(); diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index a8404c61..735818ff 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -190,23 +190,6 @@ pub(super) fn detect_updates( }; let mut updates = Vec::new(); for pkg in packages { - // Manifest keys are written verbatim from the *patch* purl, which - // the API serves percent-encoded (`pkg:npm/%40scope/...`) and, for - // artifact-pinned ecosystems, qualified (`?artifact_id=...`); the - // batch *package* purl is the crawler's literal spelling. Bridge - // both divergences like the lockfile-only partition does: exact hit - // first, then a normalized qualifier-stripped comparison. - let existing = manifest.patches.get(&pkg.purl).or_else(|| { - let want = normalize_purl(strip_purl_qualifiers(&pkg.purl)); - manifest - .patches - .iter() - .find(|(k, _)| normalize_purl(strip_purl_qualifiers(k)) == want) - .map(|(_, v)| v) - }); - let Some(existing) = existing else { - continue; - }; // The candidate is the top-ranked patch — the one the apply path // resolves to. Both sides rank with `api::ranking`, so the // `[UPDATE]` marker and the JSON `updates` array track what @@ -229,6 +212,39 @@ pub(super) fn detect_updates( let Some(candidate) = pkg.patches.iter().min_by(|a, b| cmp_batch_infos(a, b)) else { continue; }; + // Manifest keys are written verbatim from the *patch* purl, which + // the API serves percent-encoded (`pkg:npm/%40scope/...`) and, for + // artifact-pinned ecosystems, qualified (`?artifact_id=...`); the + // batch *package* purl is the crawler's literal spelling. Bridge + // both divergences like the lockfile-only partition does: exact hit + // first, then a normalized qualifier-stripped comparison. + // + // Qualifier TWINS (one package recorded under two artifact-pinned + // keys, e.g. a pypi wheel + sdist pair) both match the stripped + // comparison. `manifest.patches` is a HashMap, so a bare `find` + // would pick a per-process-random twin; instead: any stale twin + // means an update is available, so prefer the first twin (in + // sorted-key order, for run-to-run stability) whose uuid differs + // from the candidate, and fall back to the first twin when all + // agree. + let existing = manifest.patches.get(&pkg.purl).or_else(|| { + let want = normalize_purl(strip_purl_qualifiers(&pkg.purl)); + let mut twins: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> = + manifest + .patches + .iter() + .filter(|(k, _)| normalize_purl(strip_purl_qualifiers(k)) == want) + .collect(); + twins.sort_by(|a, b| a.0.cmp(b.0)); + twins + .iter() + .find(|(_, v)| v.uuid != candidate.uuid) + .or_else(|| twins.first()) + .map(|(_, v)| *v) + }); + let Some(existing) = existing else { + continue; + }; if candidate.uuid != existing.uuid { updates.push(UpdateInfo { purl: pkg.purl.clone(), @@ -398,6 +414,53 @@ mod tests { assert_eq!(updates[0].new_uuid, "uuid-b"); } + #[test] + fn detect_updates_bridges_qualified_manifest_keys() { + // Manifest keys for artifact-pinned ecosystems carry qualifiers + // (`?artifact_id=...`); the batch purl is bare. The stripped-purl + // bridge must match them — decode-only would silently drop these + // packages from `updates[]` again. + let m = manifest_with(&[("pkg:pypi/foo@1.0?artifact_id=foo-1.0.tar.gz", "uuid-a")]); + let pkgs = vec![batch_with("pkg:pypi/foo@1.0", &["uuid-b"])]; + let updates = detect_updates(Some(&m), &pkgs); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].old_uuid, "uuid-a"); + assert_eq!(updates[0].new_uuid, "uuid-b"); + } + + #[test] + fn detect_updates_qualifier_twins_are_deterministic_any_stale_wins() { + // One package recorded under two artifact-pinned keys (wheel + + // sdist). `manifest.patches` is a HashMap, so an unordered `find` + // would flip between the twins per process; the contract is: any + // stale twin means an update, `old_uuid` names the stale one, and + // repeated calls agree. + let m = manifest_with(&[ + ( + "pkg:pypi/foo@1.0?artifact_id=foo-1.0-py3-none-any.whl", + "uuid-new", + ), + ("pkg:pypi/foo@1.0?artifact_id=foo-1.0.tar.gz", "uuid-old"), + ]); + let pkgs = vec![batch_with("pkg:pypi/foo@1.0", &["uuid-new"])]; + for _ in 0..16 { + let updates = detect_updates(Some(&m), &pkgs); + assert_eq!(updates.len(), 1, "a stale twin means an update"); + assert_eq!(updates[0].old_uuid, "uuid-old"); + assert_eq!(updates[0].new_uuid, "uuid-new"); + } + + // Both twins current -> no update, regardless of iteration order. + let m = manifest_with(&[ + ( + "pkg:pypi/foo@1.0?artifact_id=foo-1.0-py3-none-any.whl", + "uuid-new", + ), + ("pkg:pypi/foo@1.0?artifact_id=foo-1.0.tar.gz", "uuid-new"), + ]); + assert!(detect_updates(Some(&m), &pkgs).is_empty()); + } + #[test] fn detect_updates_reports_multiple_updates() { let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-c")]); diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 75ae65bf..5534a392 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -126,17 +126,22 @@ async fn run_scan_vendor_step( }; let staged = match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd).await { - Ok(MemStageOutcome::Ready(s)) => s, - Ok(MemStageOutcome::Unavailable) => { + MemStageOutcome::Ready(s) => s, + MemStageOutcome::Unavailable => { // The reconcile above may have already reverted dropped // entries on disk — hand its envelope to the error fold. + // Demote its status first: a fresh Envelope starts at + // Success and a clean reconcile leaves it there, but this + // run is aborting — a consumer reading `.vendor.status` + // inside a `"status":"error"` result must not see + // "success". + env.mark_partial_failure(); return Err(( "no_local_source", "patch artifacts unavailable (offline or download failure)".to_string(), Some(Box::new(env)), )); } - Err(e) => return Err(("stage_failed", e, Some(Box::new(env)))), }; let sources = staged.as_patch_sources(); has_errors |= diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 644ee129..e7403890 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -457,18 +457,14 @@ async fn run_vendor( // writes blobs or temp files (the committed artifact is the patch). let staged = match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd).await { - Ok(MemStageOutcome::Ready(s)) => s, - Ok(MemStageOutcome::Unavailable) => { + MemStageOutcome::Ready(s) => s, + MemStageOutcome::Unavailable => { env.mark_error(EnvelopeError::new( "no_local_source", "patch artifacts unavailable (offline or download failure)", )); return 1; } - Err(e) => { - env.mark_error(EnvelopeError::new("stage_failed", e)); - return 1; - } }; let sources = staged.as_patch_sources(); diff --git a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs index 587e6545..608dd003 100644 --- a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs +++ b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs @@ -249,3 +249,85 @@ async fn get_by_purl_nested_apply_uses_api_flags_not_env() { ); assert_blob_was_fetched(&mock, &after_hash).await; } + +/// Token-less public-proxy leg: with no `--api-token` anywhere, a flag-only +/// `--proxy-url` is the ONLY route to patches — the client consults +/// `proxy_url` exclusively on its token-less branch, so the two +/// authenticated legs above stay green even if `run_nested_apply` drops the +/// `proxy_url` field. This leg goes red for exactly that regression: the +/// nested apply's blob fetch must hit the flag proxy, not the dead env one. +#[tokio::test] +async fn get_by_uuid_nested_apply_uses_proxy_url_flag_when_tokenless() { + let before_hash = common::git_sha256(BEFORE); + let after_hash = common::git_sha256(AFTER); + + let mock = MockServer::start().await; + // Proxy-shaped endpoints: no org scope. + Mock::given(method("GET")) + .and(path(format!("/patch/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "nested-apply proxy-flag fixture", + "license": "MIT", + "tier": "free", + }))) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/patch/blob/{after_hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(AFTER.to_vec())) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + install_npm_package(tmp.path()); + + let uri = mock.uri(); + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ + "get", + UUID, + "--yes", + "--json", + "--download-mode", + "file", + "--proxy-url", + &uri, + ], + &dead_env(), + ); + + assert_eq!( + code, 0, + "token-less get must reach the flag proxy end to end; \ + stdout={stdout}\nstderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("valid JSON expected: {e}\nstdout={stdout}")); + assert_eq!(v["status"], "success", "stdout={stdout}"); + + let requests = mock + .received_requests() + .await + .expect("wiremock records requests"); + let want = format!("/patch/blob/{after_hash}"); + assert!( + requests.iter().any(|r| r.url.path() == want), + "the nested apply's blob fetch must ride the --proxy-url flag \ + (token-less branch); got requests={:?}", + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 1e8f50cc..90f160bd 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1817,3 +1817,86 @@ async fn redirect_json_mode_failures_emit_error_envelope() { .expect("run socket-patch"); assert_error_envelope(&out, "reference-resolve failure"); } + +/// The write-failure bail-outs (legs 3-4 of the four `--json` failure +/// exits) must also emit the machine-readable envelope: a rewritten +/// lockfile that cannot be written back, and a revert ledger that cannot +/// be persisted. Both are driven with real filesystem obstructions so the +/// run reaches the write in question and fails there. (Legs 1-2 — the +/// discovery-detail and reference-resolve failures — are pinned by +/// `redirect_json_mode_failures_emit_error_envelope` above.) +#[tokio::test] +#[serial] +async fn redirect_json_mode_write_failures_emit_error_envelope() { + fn assert_error_envelope(out: &std::process::Output, leg: &str) { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "{leg}: failure exit; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "{leg}: --json stdout must be a parseable envelope even on failure ({e}); \ + stdout=\n{stdout}" + ) + }); + assert_eq!(v["status"], "error", "{leg}: status; stdout=\n{stdout}"); + assert!( + v["error"].as_str().is_some_and(|m| !m.is_empty()), + "{leg}: envelope must carry the error message; stdout=\n{stdout}" + ); + assert_eq!( + v["redirect"]["mode"], "hosted", + "{leg}: envelope must identify the mode; stdout=\n{stdout}" + ); + } + async fn run_leg(tmp: &std::path::Path, server: &MockServer) -> std::process::Output { + scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch") + } + + // Leg 3 — the rewritten lockfile cannot be written back (read-only + // file; the rewriter read it fine moments earlier). + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let lock = tmp.path().join("package-lock.json"); + let mut perms = std::fs::metadata(&lock).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&lock, perms).unwrap(); + let out = run_leg(tmp.path(), &server).await; + assert_error_envelope(&out, "lockfile-write failure"); + + // Leg 4 — the revert ledger cannot be persisted: a DIRECTORY squats on + // `.socket/vendor/redirect-state.json`, so `fs::write` fails after the + // lockfile rewrite succeeded. + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + std::fs::create_dir_all(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + let out = run_leg(tmp.path(), &server).await; + assert_error_envelope(&out, "ledger-write failure"); +} diff --git a/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs index e5c63393..337d96ef 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs @@ -218,6 +218,15 @@ async fn scan_vendor_staging_error_still_reports_the_reconcile() { and rewritten the ledger; envelope={v}" ); + // The carried envelope must not claim the vendor step succeeded: the + // run aborted at staging, so a consumer reading `.vendor.status` inside + // a `"status":"error"` result must see the demoted status, not the + // fresh-envelope default of "success". + assert_eq!( + v["vendor"]["status"], "partialFailure", + "the carried envelope's own status must be demoted; envelope={v}" + ); + // The point: that on-disk mutation must be visible to the JSON consumer. let events = v["vendor"]["events"].as_array().unwrap_or_else(|| { panic!( From 73768f413f46f19602331dc9bd0104b917e05f8e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 11 Aug 2026 13:42:59 -0700 Subject: [PATCH 14/16] fix(setup): skipped --exclude persistence is now machine-visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fail-closed skip on a corrupt manifest only warned on stderr behind !silent: a --json consumer saw a fully-successful setup whose excludes silently evaporate on the next flag-less run, and --silent left no trace at all. persist_setup_excludes now returns the warning and run_setup folds it into the run's warnings channel — human summary line and the --json envelope's warnings array. --silent stays quiet by contract ("errors only") and is now PINNED as a decision: the silent leg asserts suppression AND that fail-closed still holds byte-identically. Known edge left as-is: a corrupt manifest in a project with zero hook files exits through report_no_files before warnings assemble. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/setup.rs | 35 +++++++----- .../tests/setup_contract_gaps.rs | 54 ++++++++++++++++++- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 2f305518..ac5f157a 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -283,9 +283,14 @@ async fn effective_excludes(common: &GlobalArgs, flag: &[String]) -> Vec /// without re-passing `--exclude`. No-op when the set is empty or already /// exactly persisted (keeps the manifest byte-stable). Never called under /// `--dry-run`. -async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) { +/// Returns a warning string when persistence was SKIPPED (fail-closed) — +/// the caller folds it into the run's warnings so it reaches the human +/// summary AND the `--json` envelope; a `--silent`/`--json` automation run +/// must not see a fully-successful setup whose excludes silently evaporate +/// on the next flag-less invocation. +async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) -> Option { if excludes.is_empty() { - return; + return None; } let path = common.resolved_manifest_path(); // Fail closed on a manifest that exists but cannot be read or parsed: it @@ -296,13 +301,11 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) { let existing = match read_manifest(&path).await { Ok(existing) => existing, Err(e) => { - if !common.silent { - eprintln!( - "Warning: not persisting --exclude: cannot read {}: {e}", - path.display() - ); - } - return; + return Some(format!( + "not persisting --exclude: cannot read {}: {e} — the exclude list will \ + need re-passing until the manifest is repaired", + path.display() + )); } }; let mut merged: Vec = excludes.to_vec(); @@ -314,7 +317,7 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) { .map(|s| &s.exclude) == Some(&merged) { - return; // already persisted exactly — don't rewrite + return None; // already persisted exactly — don't rewrite } // Preserve any existing `manual` declarations (property 7) when rewriting. let manual = existing @@ -331,6 +334,7 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) { let _ = tokio::fs::create_dir_all(parent).await; } let _ = write_manifest(&path, &manifest).await; + None } /// Which ecosystems are **actually set up** at `cwd` — i.e. their auto-repatch @@ -1511,9 +1515,11 @@ async fn run_setup(args: &SetupArgs) -> i32 { // Dry-run never writes the manifest. Excluded members are then skipped by // discovery. let excludes = effective_excludes(common, &args.exclude).await; - if !common.dry_run { - persist_setup_excludes(common, &excludes).await; - } + let persist_warning = if !common.dry_run { + persist_setup_excludes(common, &excludes).await + } else { + None + }; let npm_files = discover(args, &excludes).await; let py_plan = plan_python(common).await; // Gem + Composer previews (dry-run); `.present` also tells us each project exists. @@ -1663,6 +1669,9 @@ async fn run_setup(args: &SetupArgs) -> i32 { py_results = edit_python_manifests(plan, false, false).await; warnings = finalize_python(plan, &py_results, &common.cwd).await; } + // A skipped (fail-closed) --exclude persistence rides the same warnings + // channel: human summary line + `--json` envelope `warnings` array. + warnings.extend(persist_warning); // Real gem + composer edits (gem Gemfile `plugin` block + generated plugin // dir; composer.json script-event command). let extra_results = merge_outcomes( diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index b59cce77..02c3f579 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -512,9 +512,59 @@ fn exclude_persistence_fails_closed_on_corrupt_manifest() { rewriting it destroys every patch record it may still hold; \ stderr=\n{stderr}" ); + // Machine-visible marker: the skip rides the envelope's warnings array, + // so `--json` automation cannot mistake this for a fully-persisted run. + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("setup --json must emit valid JSON ({e}); stdout=\n{stdout}")); assert!( - stderr.contains("not persisting --exclude"), - "skipping persistence must be loud, not silent: {stderr}" + v["warnings"].as_array().is_some_and(|w| w.iter().any(|x| x + .as_str() + .is_some_and(|x| x.contains("not persisting --exclude")))), + "the skipped persistence must appear in the --json warnings; stdout=\n{stdout}" + ); +} + +/// The `--silent` contract ("errors only") suppresses the human warning — +/// pinned here so the suppression is a decision, not an accident — while +/// the fail-closed behavior itself must hold identically: exit 0, corrupt +/// bytes untouched. +#[test] +fn exclude_persistence_fails_closed_silently_under_silent() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + write( + &proj.path().join("package.json"), + r#"{ "name": "root", "version": "1.0.0" }"#, + ); + let manifest_path = proj.path().join(".socket/manifest.json"); + let corrupt = r#"{ "patches": { "pkg:npm/left-pad@1.3.0": TRUNCATED-MID-WRITE"#; + write(&manifest_path, corrupt); + + let mut cmd = Command::new(binary()); + cmd.args(["setup", "--silent", "--yes", "--exclude", "packages/b"]) + .current_dir(proj.path()); + for (name, _) in std::env::vars() { + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { + cmd.env_remove(name); + } + } + cmd.env("HOME", home.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert_eq!(out.status.code(), Some(0), "stderr=\n{stderr}"); + let after = std::fs::read_to_string(&manifest_path).expect("manifest still present"); + assert_eq!( + after, corrupt, + "fail-closed must hold under --silent too; stderr=\n{stderr}" + ); + assert!( + !stdout.contains("not persisting") && !stderr.contains("not persisting"), + "--silent is errors-only: the skip warning stays quiet; \ + stdout=\n{stdout}\nstderr=\n{stderr}" ); } From 6b83ba1453c1c75a67ca00e6a7724a68106902e5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 11 Aug 2026 13:42:59 -0700 Subject: [PATCH 15/16] refactor(npm): drop dead BUN_MIGRATE_CMD, wire the Rush message, break the pnpm-pin self-reference, truth the table doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * BUN_MIGRATE_CMD had zero call sites while its doc claimed it was spliced into every message — deleted; the four messages keep their literals (the executed bun argv lives separately anyway, so the const single-sourced nothing). * npm_flavor's vendor_rush_unsupported message now formats RUSH_COMMON_LOCK_REL instead of hardcoding the path twice — the third code site the original commit claimed but did not wire. * find.rs gains a hardcoded pnpm-spelling pin: production code and the guard test iterated the identical names_with(detects_pnpm) expression, so a deleted table row shrank both together while .yml detection silently vanished. * The npm_family module doc now states exactly which consumers are guard-tested and which are behaviorally pinned instead (pkg_managers' own lockfile literals, the probe's decision literals) — it previously promised per-consumer guards it did not have. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-core/src/constants.rs | 18 ++++++++++-------- .../socket-patch-core/src/package_json/find.rs | 15 +++++++++++++++ .../socket-patch-core/src/vendor/npm_flavor.rs | 16 +++++++++------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index ce198811..6f7fa130 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -71,10 +71,16 @@ mod tests { /// INTENTIONALLY divergent subsets: hosted redirect deliberately omits /// `bun.lockb` (it auto-migrates it to `bun.lock` before rewriting), and the /// `pnpm-lock.yml` spelling is accepted only by setup detection. This table -/// encodes each divergence once, visibly, instead of homogenizing them — -/// guard tests beside each consumer assert its list equals the rows flagged -/// for its role, so a new lockfile spelling added in one place fails the -/// other sites' tests instead of drifting silently. +/// encodes each divergence once, visibly, instead of homogenizing them. +/// +/// What is actually guard-tested (equality against the flagged rows): +/// `vendor::npm_flavor`'s wiring-family list, `scan::hosted`'s +/// REDIRECT_CANDIDATE_FILES npm subset, and `package_json::find`'s pnpm +/// markers (plus a hardcoded pin so the table and its consumers cannot +/// shrink together). NOT table-guarded: `crawlers::pkg_managers`' own +/// bun/yarn lockfile literals and `npm_flavor`'s probe decision literals — +/// those are pinned behaviorally by their unit tests instead; only +/// PNP_MARKERS is shared with the crawler. pub mod npm_family { /// One file-name row and the roles in which consumers accept it. pub struct FileRow { @@ -171,8 +177,4 @@ pub mod npm_family { /// Rush monorepos keep the single pnpm source-of-truth lock here, /// relative to the repo root (no root package.json/lock pair). pub const RUSH_COMMON_LOCK_REL: &str = "common/config/rush/pnpm-lock.yaml"; - - /// The bun.lockb → bun.lock migration command, spliced into every - /// user-facing message that recommends it. - pub const BUN_MIGRATE_CMD: &str = "bun install --save-text-lockfile"; } diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 90a6df37..c6bc3d14 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -554,6 +554,21 @@ mod tests { )); } + #[test] + fn pnpm_marker_spellings_are_pinned_by_value() { + // Hardcoded on purpose, breaking the self-reference: production code + // iterates the same names_with(detects_pnpm) expression the guard + // test above does, so a row deleted from the table would shrink code + // and guard together while `.yml` detection silently vanished. This + // list cannot shrink with them. + let mut spellings = crate::constants::npm_family::names_with(|r| r.detects_pnpm); + spellings.sort_unstable(); + assert_eq!( + spellings, + ["pnpm-lock.yaml", "pnpm-lock.yml", "pnpm-workspace.yaml"] + ); + } + // ── Group 2: workspace detection + file discovery ──────────────── #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index bcc18bd1..d601e1ce 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -167,13 +167,15 @@ pub(crate) async fn detect_npm_lock_flavor( if exists("rush.json").await { return Err(( "vendor_rush_unsupported", - "found rush.json: this is a Rush monorepo — its single pnpm lockfile lives at \ - common/config/rush/pnpm-lock.yaml, overrides are declared in \ - common/config/rush/pnpm-config.json (globalOverrides), and `rush install` \ - copies the lock into common/temp and runs pnpm there, so vendor's relative \ - file: specs cannot survive the copy; use `socket-patch scan --mode hosted`, \ - which edits common/config/rush/pnpm-lock.yaml in place" - .to_string(), + format!( + "found rush.json: this is a Rush monorepo — its single pnpm lockfile \ + lives at {lock}, overrides are declared in \ + common/config/rush/pnpm-config.json (globalOverrides), and `rush \ + install` copies the lock into common/temp and runs pnpm there, so \ + vendor's relative file: specs cannot survive the copy; use \ + `socket-patch scan --mode hosted`, which edits {lock} in place", + lock = crate::constants::npm_family::RUSH_COMMON_LOCK_REL + ), )); } From af749cd11a9dc3a66d607912885104bdde701d0f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 11 Aug 2026 13:42:59 -0700 Subject: [PATCH 16/16] fix(vex,setup): vex telemetry uses the layered credential chain; script removal preserves quoted && bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * vex's three track_vex_* sites passed the raw --api-token/--org flags, so a `socket login`-only user attributed vex telemetry anonymously — the exact gap list/setup closed in the sweep (the long-standing "vex.rs telemetry raw-flags twin"). All three now resolve through list::telemetry_credentials (flag / env / socket-cli config.json). * remove_socket_patch_from_script kept survivors via trim+canonical " && " rejoin, which rewrote a && INSIDE a quoted argument of a surviving user command: `grep "a&&b"` came back as `grep "a && b"` — a different pattern, not the "cosmetic" respacing the docstring claimed. Survivors are now spliced out of the original text verbatim (inner spacing included); only seams next to removed segments collapse. All eleven existing removal pins hold unchanged; two new tests pin the quoted-&& and inner-spacing preservation. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/vex.rs | 19 ++++--- .../src/package_json/detect.rs | 57 +++++++++++++++---- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 179fc4f3..51e887ef 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -435,12 +435,8 @@ async fn generate_vex( ) { Some(doc) => doc, None => { - track_vex_failed( - "no_applicable_patches", - common.api_token.as_deref(), - common.org.as_deref(), - ) - .await; + let (token, org) = crate::commands::list::telemetry_credentials(common); + track_vex_failed("no_applicable_patches", token.as_deref(), org.as_deref()).await; return Err(VexGenError { code: "no_applicable_patches", message: "No applied patches with vulnerability metadata to attest.".to_string(), @@ -473,12 +469,13 @@ async fn generate_vex( } }; + let (token, org) = crate::commands::list::telemetry_credentials(common); track_vex_generated( doc.statements.len(), "openvex-0.2.0", if wrote_to_file { "file" } else { "stdout" }, - common.api_token.as_deref(), - common.org.as_deref(), + token.as_deref(), + org.as_deref(), ) .await; @@ -574,8 +571,12 @@ async fn augment_with_redirect( /// Fire `vex_failed` telemetry and build the matching [`VexGenError`]. /// Centralizes the "track then return error" pattern in [`generate_vex`]. +/// Attribution goes through the same layered credential chain as +/// `list`/`setup` (flag / env / socket-cli `config.json`), not the raw +/// flags — a `socket login`-only user must not report anonymously. async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGenError { - track_vex_failed(code, common.api_token.as_deref(), common.org.as_deref()).await; + let (token, org) = crate::commands::list::telemetry_credentials(common); + track_vex_failed(code, token.as_deref(), org.as_deref()).await; VexGenError { code, message, diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs index 62e0b15d..ace73e29 100644 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -177,9 +177,13 @@ fn update_package_json_object( /// The split ignores the whitespace around `&&`: a hand-wired /// `"socket-patch apply&&npm run build"` is two commands, and treating it as /// one patch-containing segment would delete the user's `npm run build` along -/// with the patch invocation. Survivors are re-joined with the canonical -/// `" && "`, so a `&&` that was quoted rather than an operator comes back -/// spaced — cosmetic, and only in scripts that also carry a patch command. +/// with the patch invocation. Surviving segments are kept VERBATIM and +/// re-joined with the bare `&&` separator they were split on: a `&&` inside +/// a quoted argument of a surviving user command also splits here, and +/// canonically respacing it would rewrite the user's bytes +/// (`grep "a&&b"` → `grep "a && b"` greps a different pattern — not +/// cosmetic). Only the seams adjacent to REMOVED segments collapse, and the +/// result's outer edges are trimmed. /// /// Returns `(changed, new_value)`: /// - `(false, Some(original))` — no socket-patch segment found; leave as-is. @@ -202,21 +206,28 @@ fn remove_socket_patch_from_script(script: &str) -> (bool, Option) { // violating this function's documented `(false, ..)`/`(true, ..)` contract. let had_patch = segments.iter().any(|s| script_is_configured(s.trim())); - let kept: Vec<&str> = segments - .iter() - .map(|s| s.trim()) - .filter(|s| !s.is_empty() && !script_is_configured(s)) - .collect(); - if !had_patch { // No socket-patch pattern present — leave the script as-is. return (false, Some(trimmed.to_string())); } + // Keep surviving segments verbatim (inner spacing, quoted `&&` halves + // and all) so the reconstruction reproduces the user's original bytes; + // only removed segments and stray empty segments (double separators) + // drop out. + let kept: Vec<&str> = segments + .iter() + .copied() + .filter(|s| { + let t = s.trim(); + !t.is_empty() && !script_is_configured(t) + }) + .collect(); + if kept.is_empty() { (true, None) } else { - (true, Some(kept.join(" && "))) + (true, Some(kept.join("&&").trim().to_string())) } } @@ -874,6 +885,32 @@ mod tests { assert_eq!(new.as_deref(), Some("echo a && echo b && echo c")); } + /// A `&&` inside a QUOTED argument of a surviving user command also + /// splits at the operator scan, and the old canonical `" && "` rejoin + /// rewrote the user's bytes — `grep "a&&b"` became `grep "a && b"`, + /// which greps a different pattern. Survivors must come back verbatim. + #[test] + fn test_remove_script_preserves_quoted_ampersands_in_survivors() { + let (changed, new) = remove_socket_patch_from_script( + r#"socket-patch apply --silent && grep "a&&b" app.log"#, + ); + assert!(changed); + assert_eq!(new.as_deref(), Some(r#"grep "a&&b" app.log"#)); + + // Same with the patch segment in the middle: the seam next to the + // removed segment collapses to a single `&&`, everything else is + // byte-identical. + let (changed, new) = remove_socket_patch_from_script( + r#"echo start && socket-patch apply && grep "x&&y" out.txt && tail -1"#, + ); + assert!(changed); + assert_eq!( + new.as_deref(), + Some(r#"echo start && grep "x&&y" out.txt && tail -1"#), + "surviving segments keep their original inner spacing too" + ); + } + #[test] fn test_remove_script_pnpm_command() { // The pnpm canonical command must be recognized and stripped (it