From 32aeb77ceae9405edabfb264644efb7b7e44b854 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:19:38 +0000 Subject: [PATCH 1/7] Remove the setup subcommand and its install hooks (v5 WS7) `socket-patch setup` (and --check/--remove/--exclude) is gone, with every install hook it wired: npm postinstall/dependencies scripts, the socket-patch[hook] .pth wheel, the in-tree Bundler plugin + Gemfile block, and Composer post-install/update scripts. `apply` stays; agent mode in CI is `scan --mode agent` once, then `socket-patch apply` after each install. Deleted: commands/setup.rs, core setup/** and the setup-only package_json helpers, the setup tests and setup-matrix suites, the setup-e2e feature, the setup-matrix CI job, tests/setup_matrix and scripts/setup-matrix.sh. vex's install-hook "Property 7" filter goes with it. The socket-patch-hook wheel and socket-patch-bundler gem are dropped from the build and publish workflows (sources kept, frozen, pending an owner decision). Also the plan's small follow-ups: drop the core crate's deprecated re-export aliases (and the CI grep that guarded them), the unused utils::process::tool_command, the vacuous e2e_cargo/e2e_golang CI rows, add the merged 01019627 and 9c2b4925 gem patches to the vendored production e2e, and retire the backtest-poetry "known crawler gap" label. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- .github/workflows/ci.yml | 119 +- .github/workflows/publish-pypi.yml | 24 +- .github/workflows/publish-rubygems.yml | 53 +- .github/workflows/release.yml | 5 +- .github/workflows/vlt-compatibility.yml | 2 - CHANGELOG.md | 96 +- README.md | 180 +- crates/socket-patch-cli/CLI_CONTRACT.md | 282 +- crates/socket-patch-cli/Cargo.toml | 10 - crates/socket-patch-cli/src/args.rs | 36 +- crates/socket-patch-cli/src/commands/apply.rs | 3 +- crates/socket-patch-cli/src/commands/mod.rs | 126 +- .../socket-patch-cli/src/commands/remove.rs | 2 +- .../socket-patch-cli/src/commands/rollback.rs | 2 +- crates/socket-patch-cli/src/commands/setup.rs | 3018 ----------------- crates/socket-patch-cli/src/commands/vex.rs | 280 +- .../src/commands/vex_consumed.rs | 2 +- .../src/commands/vex_sources.rs | 4 +- .../src/ecosystem_dispatch.rs | 37 +- crates/socket-patch-cli/src/json_envelope.rs | 4 +- crates/socket-patch-cli/src/lib.rs | 7 +- crates/socket-patch-cli/src/main.rs | 1 - crates/socket-patch-cli/src/ui/mod.rs | 5 +- crates/socket-patch-cli/src/ui/prompt.rs | 18 - .../tests/apply_invariants.rs | 2 +- .../tests/cli_argv_non_utf8.rs | 4 +- .../tests/cli_config_fallback.rs | 91 - .../socket-patch-cli/tests/cli_global_args.rs | 2 - .../socket-patch-cli/tests/cli_parse_main.rs | 11 +- .../socket-patch-cli/tests/cli_parse_setup.rs | 558 --- .../tests/cli_setup_silent.rs | 308 -- .../tests/covgap_commands_rollback.rs | 3 + .../tests/covgap_commands_scan_mod.rs | 3 - .../tests/covgap_commands_setup.rs | 1979 ----------- .../tests/covgap_commands_vex.rs | 16 +- .../socket-patch-cli/tests/covgap_output.rs | 3 +- .../tests/covgap_setup_composer_mod.rs | 146 - .../tests/covgap_setup_gem_mod.rs | 164 - .../tests/covgap_setup_gem_version.rs | 198 -- .../tests/covgap_setup_pypi_detect.rs | 230 -- .../tests/docker_e2e_cargo.rs | 7 +- .../tests/docker_e2e_composer.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_gem.rs | 7 +- .../tests/docker_e2e_golang.rs | 7 +- .../tests/docker_e2e_maven.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_npm.rs | 17 +- .../tests/docker_e2e_nuget.rs | 7 +- .../socket-patch-cli/tests/docker_e2e_pypi.rs | 7 +- .../tests/e2e_embedded_vex.rs | 23 +- .../tests/e2e_safety_cargo_build.rs | 20 +- .../socket-patch-cli/tests/e2e_safety_lock.rs | 2 +- .../socket-patch-cli/tests/e2e_safety_vlt.rs | 20 +- .../tests/e2e_safety_yarn_pnp.rs | 2 +- .../tests/e2e_vendored_production.rs | 14 + crates/socket-patch-cli/tests/e2e_vex.rs | 40 +- .../tests/e2e_vex_build/deno.rs | 3 - .../tests/e2e_vex_lockfile/deno.rs | 13 +- .../tests/e2e_vex_lockfile/maven.rs | 11 - .../tests/e2e_vex_lockfile/nuget.rs | 7 - .../tests/e2e_vex_redirect.rs | 42 +- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 225 +- crates/socket-patch-cli/tests/e2e_vlt.rs | 284 +- .../tests/ecosystem_dispatch_e2e.rs | 37 +- .../tests/help_text_hygiene.rs | 14 +- .../tests/in_process_redirect.rs | 9 +- .../socket-patch-cli/tests/in_process_scan.rs | 9 +- .../tests/interactive_prompts_e2e.rs | 166 +- .../tests/output_modes_e2e.rs | 47 +- .../tests/setup_contract_gaps.rs | 843 ----- .../tests/setup_invariants.rs | 1427 -------- .../tests/setup_matrix_common/mod.rs | 715 ---- .../tests/setup_matrix_composer.rs | 363 -- .../tests/setup_matrix_deno.rs | 370 -- .../tests/setup_matrix_env_guard.rs | 114 - .../tests/setup_matrix_gem.rs | 1874 ---------- .../tests/setup_matrix_maven.rs | 460 --- .../tests/setup_matrix_monorepo.rs | 329 -- .../tests/setup_matrix_npm.rs | 300 -- .../tests/setup_matrix_nuget.rs | 321 -- .../tests/setup_matrix_pypi.rs | 535 --- .../tests/setup_matrix_static.rs | 91 - .../tests/setup_pth_invariants.rs | 624 ---- .../tests/setup_terminal_output.rs | 273 -- .../tests/vex_terminal_output.rs | 54 +- .../tests/vlt-leg-manifest.json | 65 - crates/socket-patch-core/src/constants.rs | 53 +- .../src/crawlers/npm_crawler.rs | 4 +- .../src/crawlers/ruby_crawler.rs | 37 +- crates/socket-patch-core/src/lib.rs | 9 - .../socket-patch-core/src/manifest/schema.rs | 16 +- .../src/package_json/detect.rs | 1266 ------- .../src/package_json/find.rs | 2093 ------------ .../socket-patch-core/src/package_json/mod.rs | 3 - .../src/package_json/update.rs | 903 ----- crates/socket-patch-core/src/patch/apply.rs | 4 +- crates/socket-patch-core/src/patch/mod.rs | 8 - .../src/patch/redirect/mod.rs | 2 +- .../src/setup/composer/mod.rs | 935 ----- crates/socket-patch-core/src/setup/gem/mod.rs | 2157 ------------ .../src/setup/gem/templates/gemspec.tmpl | 22 - .../src/setup/gem/templates/plugins.rb.tmpl | 306 -- .../socket-patch-core/src/setup/gem/update.rs | 1253 ------- .../src/setup/gem/version.rs | 427 --- crates/socket-patch-core/src/setup/mod.rs | 20 - crates/socket-patch-core/src/setup/npm.rs | 10 - .../src/setup/pypi/detect.rs | 429 --- .../socket-patch-core/src/setup/pypi/edit.rs | 1297 ------- .../socket-patch-core/src/setup/pypi/mod.rs | 20 - crates/socket-patch-core/src/telemetry.rs | 21 +- .../socket-patch-core/src/update/channel.rs | 2 +- crates/socket-patch-core/src/utils/fs.rs | 4 +- crates/socket-patch-core/src/utils/hatch.rs | 10 +- crates/socket-patch-core/src/utils/mod.rs | 9 - crates/socket-patch-core/src/utils/process.rs | 16 +- crates/socket-patch-core/src/utils/serde.rs | 9 + .../src/utils/toml_edit_ext.rs | 4 +- .../src/vendor/npm_common.rs | 2 +- .../socket-patch-core/src/vendor/npm_dir.rs | 2 +- crates/socket-patch-core/src/vendor/pypi.rs | 8 +- .../src/vendor/pypi_hatch.rs | 2 +- .../src/vendor/pypi_requirements.rs | 10 +- .../socket-patch-core/src/vendor/vlt_lock.rs | 4 +- .../src/vendor/vlt_lock_text.rs | 12 +- crates/socket-patch-core/src/vex/product.rs | 2 +- .../tests/crawler_monorepo_gaps.rs | 5 +- .../tests/crawler_npm_e2e.rs | 4 +- .../tests/crawler_ruby_e2e.rs | 11 +- docs/design/configuration.md | 12 +- docs/design/v5-plan.md | 12 + docs/ecosystems.md | 22 +- docs/testing/hatch.md | 2 +- docs/testing/poetry-compatibility.md | 7 +- docs/testing/vendored-production-e2e.md | 2 +- docs/testing/vlt-compatibility.md | 13 +- docs/testing/vlt-coverage.json | 25 - docs/testing/yarn-berry-compatibility.md | 4 - gem/socket-patch-bundler/README.md | 5 + gem/socket-patch/lib/socket_patch/launcher.rb | 2 +- gem/socket-patch/socket-patch.gemspec | 4 +- pypi/socket-patch-hook/README.md | 6 + pypi/socket-patch/pyproject.toml | 9 - pypi/socket-patch/socket_patch/__init__.py | 3 +- scripts/backtest-poetry.py | 45 +- scripts/build-pypi-wheels.py | 114 +- scripts/burn-down-tests.ts | 2 +- scripts/harden-tests.config.ts | 2 +- scripts/optimize-test-perf.config.ts | 4 +- scripts/setup-matrix.sh | 299 -- scripts/tests/test_check_vlt_legs.py | 2 - scripts/tests/test_vlt_coverage.py | 6 +- scripts/version-sync.sh | 20 +- tests/docker/Dockerfile.gem-b1 | 46 - tests/docker/Dockerfile.gem-b4 | 45 - tests/docker/Dockerfile.npm | 11 +- tests/docker/Dockerfile.pypi | 4 +- tests/docker/README.md | 99 - tests/setup_matrix/README.md | 194 -- tests/setup_matrix/matrix.json | 318 -- tests/setup_matrix/results/.gitignore | 3 - tests/setup_matrix/run-case.sh | 883 ----- tests/setup_matrix/shims/npx | 45 - tests/setup_matrix/shims/pnpm | 38 - 162 files changed, 610 insertions(+), 30955 deletions(-) delete mode 100644 crates/socket-patch-cli/src/commands/setup.rs delete mode 100644 crates/socket-patch-cli/tests/cli_parse_setup.rs delete mode 100644 crates/socket-patch-cli/tests/cli_setup_silent.rs delete mode 100644 crates/socket-patch-cli/tests/covgap_commands_setup.rs delete mode 100644 crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs delete mode 100644 crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs delete mode 100644 crates/socket-patch-cli/tests/covgap_setup_gem_version.rs delete mode 100644 crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs delete mode 100644 crates/socket-patch-cli/tests/setup_contract_gaps.rs delete mode 100644 crates/socket-patch-cli/tests/setup_invariants.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_common/mod.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_composer.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_deno.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_env_guard.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_gem.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_maven.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_monorepo.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_npm.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_nuget.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_pypi.rs delete mode 100644 crates/socket-patch-cli/tests/setup_matrix_static.rs delete mode 100644 crates/socket-patch-cli/tests/setup_pth_invariants.rs delete mode 100644 crates/socket-patch-cli/tests/setup_terminal_output.rs delete mode 100644 crates/socket-patch-core/src/package_json/detect.rs delete mode 100644 crates/socket-patch-core/src/package_json/find.rs delete mode 100644 crates/socket-patch-core/src/package_json/mod.rs delete mode 100644 crates/socket-patch-core/src/package_json/update.rs delete mode 100644 crates/socket-patch-core/src/setup/composer/mod.rs delete mode 100644 crates/socket-patch-core/src/setup/gem/mod.rs delete mode 100644 crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl delete mode 100644 crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl delete mode 100644 crates/socket-patch-core/src/setup/gem/update.rs delete mode 100644 crates/socket-patch-core/src/setup/gem/version.rs delete mode 100644 crates/socket-patch-core/src/setup/mod.rs delete mode 100644 crates/socket-patch-core/src/setup/npm.rs delete mode 100644 crates/socket-patch-core/src/setup/pypi/detect.rs delete mode 100644 crates/socket-patch-core/src/setup/pypi/edit.rs delete mode 100644 crates/socket-patch-core/src/setup/pypi/mod.rs delete mode 100755 scripts/setup-matrix.sh delete mode 100644 tests/docker/Dockerfile.gem-b1 delete mode 100644 tests/docker/Dockerfile.gem-b4 delete mode 100644 tests/setup_matrix/README.md delete mode 100644 tests/setup_matrix/matrix.json delete mode 100644 tests/setup_matrix/results/.gitignore delete mode 100755 tests/setup_matrix/run-case.sh delete mode 100755 tests/setup_matrix/shims/npx delete mode 100755 tests/setup_matrix/shims/pnpm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a260c8f..62a04af5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,24 +54,6 @@ 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' \ - -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 - fi # The napi addon is only ever loaded by Node, so cargo's own tests never # exercise its JS loader or the engine/provider boundary. @@ -106,7 +88,7 @@ jobs: run: node --test crates/socket-patch-node/npm/test/smoke.mjs # Lint the out-of-workspace packaging artifacts: the RubyGems CLI launcher - # gem + the Bundler plugin gem (Ruby), and the curl|sh installer. Ruby is + # gem (Ruby), and the curl|sh installer. Ruby is # pre-installed on the ubuntu-latest runner. lint-ecosystems: runs-on: ubuntu-latest @@ -117,13 +99,9 @@ jobs: with: persist-credentials: false - - name: Ruby — syntax-check + build the launcher gem and Bundler plugin + - name: Ruby — syntax-check + build the launcher gem run: | ( 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/setup/gem/templates/plugins.rb.tmpl - ruby -c crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl - name: Python — test native installer harnesses run: python3 -B -m unittest discover -s scripts/tests -v @@ -308,13 +286,13 @@ jobs: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run tests - # `--all-features` would also RUN the docker-e2e / setup-e2e suites, + # `--all-features` would also RUN the docker-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. + # run only the default-feature suites; the dedicated e2e-docker + # job runs the gated suites for real. # # `--no-fail-fast`: without it cargo stops at the first failing test # BINARY, so one bad file hides every later binary's result on that @@ -365,7 +343,7 @@ jobs: # profile's comment in Cargo.toml). Same opt-level/debug-assertion # semantics this job exists to validate; ~23m of LTO relinking gone. # Build/run split for the same reason as the `test` job: the gated - # docker-e2e / setup-e2e suites only soft-skip here — compile them, + # docker-e2e suites only soft-skip here — compile them, # don't count their skips as passes. run: | set -euo pipefail @@ -678,10 +656,10 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-latest - suite: e2e_cargo - - os: ubuntu-latest - suite: e2e_golang + # (No e2e_cargo / e2e_golang rows: neither suite has an + # `#[ignore]`-gated test or needs a real toolchain, so the + # unfiltered `test` job already runs all of them and the rows' + # `--ignored` legs selected zero tests.) - os: ubuntu-latest suite: e2e_maven - os: ubuntu-latest @@ -714,9 +692,6 @@ jobs: - {os: ubuntu-latest, suite: e2e_redirect_composer_build, composer: '2'} - {os: ubuntu-latest, suite: e2e_redirect_composer_build, composer: '2.2'} - {os: ubuntu-latest, suite: e2e_redirect_composer_build, composer: '1'} - # setup-e2e host guards run in no other job (test job = default - # features; setup-matrix job = shell script). - - {os: ubuntu-latest, suite: setup_matrix_composer, test_filter: host_guard} # Real-bundler gem capstones, one leg per bundler era. Boundaries: # 1.17/2.1 merged GEM section, 2.2 separate sections, 2.5 last # pre-CHECKSUMS, 2.6 CHECKSUMS, 4.0.15/4.0.21 before/after the @@ -738,8 +713,6 @@ jobs: - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.3', bundler: '2.7.2'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.4', bundler: '4.0.15'} - {os: ubuntu-latest, suite: e2e_vendor_gem_build, ruby: '3.4', bundler: '4.0.21'} - # not #[ignore]-gated -> --include-ignored is mandatory - - {os: ubuntu-latest, suite: setup_matrix_gem, ruby: '3.3', bundler: '2.7.2', test_filter: --include-ignored} # The live-API smoke suites (e2e_npm, e2e_pypi, e2e_gem, # e2e_scan) are intentionally NOT in the PR matrix — their # `#[ignore]`-gated tests hit the real public proxy at @@ -1612,78 +1585,6 @@ jobs: set -euo pipefail cargo test -p socket-patch-cli --test e2e_vendor_cargo_build -- old_toolchain --nocapture - # ---------------------------------------------------------------------- - # Experimental `setup`-flow matrix (NON-BLOCKING). - # - # For each ecosystem/package manager, drives the full intended flow — - # prepare deps + a committed patch set, run `socket-patch setup`, run - # the native install, check whether the patch was applied — plus the - # negative controls (no setup, empty/wrong/alt patch sets). See - # tests/setup_matrix/ and scripts/setup-matrix.sh. - # - # This is EXPERIMENTAL and intentionally not required to pass yet: - # `setup` configures install hooks for npm, PyPI, Bundler and Composer - # only, so the other ecosystems' `baseline_with_setup` cases are - # EXPECTED to fail (a baseline of what `setup` must eventually support). `continue-on-error: true` - # means this job never blocks a PR — it must ALSO be left OUT of the - # repo's required status checks (configured in the branch-protection - # UI, not in this file). The orchestrator exits non-zero only on a - # *regression* vs the recorded baseline; the full per-case result set - # is uploaded as a JSON artifact for inspection. - # ---------------------------------------------------------------------- - setup-matrix: - runs-on: ubuntu-latest - timeout-minutes: 45 - continue-on-error: true - permissions: - contents: read - strategy: - fail-fast: false - matrix: - ecosystem: [npm, pypi, cargo, gem, golang, maven, composer, nuget, deno] - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Docker Buildx - # `driver: docker` — the per-ecosystem image's `FROM - # socket-patch-test-base:latest` only resolves when buildx talks - # directly to the host docker daemon (see e2e-docker above). - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - with: - driver: docker - - - name: Install Rust - run: rustup show - - - name: Build base image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: tests/docker/Dockerfile.base - tags: socket-patch-test-base:latest - load: true - - - name: Build ${{ matrix.ecosystem }} image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: tests/docker/Dockerfile.${{ matrix.ecosystem }} - tags: socket-patch-test-${{ matrix.ecosystem }}:latest - load: true - - - name: Run ${{ matrix.ecosystem }} setup-matrix - run: scripts/setup-matrix.sh run --ecosystem ${{ matrix.ecosystem }} --out "report-${{ matrix.ecosystem }}.json" - - - name: Upload ${{ matrix.ecosystem }} setup-matrix report - if: always() - uses: ./.github/actions/upload-artifact - with: - name: setup-matrix-${{ matrix.ecosystem }} - path: report-${{ matrix.ecosystem }}.json - # ---------------------------------------------------------------------- # Hosted-mode production e2e — REQUIRED status check, with a kill switch. # diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 959305c0..1c97969f 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,8 +1,9 @@ name: Publish PyPI run-name: "Publish PyPI ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" -# Publishes socket-patch (platform wheels) + socket-patch-hook (pure-python -# .pth carrier) to PyPI for an existing v release. Dispatched two +# Publishes socket-patch (platform wheels) to PyPI for an existing +# v release. (The socket-patch-hook .pth wheel is no longer +# published: `setup` was removed in v5.) Dispatched two # ways, both as a plain workflow_dispatch run: # - by release.yml (scripts/dispatch-publish.sh), as one leg of the # single-dispatch release fan-out — distinct-id carries the release @@ -137,19 +138,10 @@ jobs: - name: Copy README for PyPI package run: cp README.md pypi/socket-patch/README.md - - name: Build wheels (platform socket-patch + pure-python socket-patch-hook) + - name: Build wheels (platform socket-patch) env: VERSION: ${{ inputs.version }} - run: | - # Builds the platform-tagged socket-patch wheels AND the pure-python - # socket-patch-hook wheel (the .pth carrier behind `socket-patch[hook]`). - python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist - # socket-patch and socket-patch-hook are two distinct PyPI projects. - # Publish each from its own dir so trusted publishing mints an OIDC - # token scoped to the right project (one upload spanning both projects - # can be rejected). Each needs its own trusted publisher on PyPI. - mkdir -p dist-hook - mv dist/socket_patch_hook-*.whl dist-hook/ + run: python scripts/build-pypi-wheels.py --version "$VERSION" --artifacts artifacts --dist dist - name: Publish socket-patch to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 @@ -157,9 +149,3 @@ jobs: packages-dir: dist/ # Idempotent for re-runs: already-uploaded files skip. skip-existing: true - - - name: Publish socket-patch-hook to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist-hook/ - skip-existing: true diff --git a/.github/workflows/publish-rubygems.yml b/.github/workflows/publish-rubygems.yml index 01238d0f..c65f850a 100644 --- a/.github/workflows/publish-rubygems.yml +++ b/.github/workflows/publish-rubygems.yml @@ -1,12 +1,11 @@ name: Publish RubyGems run-name: "Publish RubyGems ${{ inputs.version }}${{ inputs.distinct-id != '' && format(' [{0}]', inputs.distinct-id) || '' }}" -# Publishes BOTH gems via one OIDC trusted-publishing exchange: -# - gem/socket-patch — the CLI launcher gem (downloads the -# prebuilt binary from the GitHub release at its own version, so this -# workflow only needs the GitHub release + SHA256SUMS to exist). -# - gem/socket-patch-bundler — Phase 2 scaffolding, non-blocking (see the -# step comment below). +# Publishes the CLI launcher gem (gem/socket-patch) via OIDC trusted +# publishing. The gem downloads the prebuilt binary from the GitHub release +# at its own version, so this workflow only needs the GitHub release + +# SHA256SUMS to exist. (The socket-patch-bundler plugin gem is no longer +# published: `setup` was removed in v5.) # # Dispatched two ways, both as a plain workflow_dispatch run: # - by release.yml (scripts/dispatch-publish.sh), as one leg of the @@ -26,8 +25,7 @@ run-name: "Publish RubyGems ${{ inputs.version }}${{ inputs.distinct-id != '' && # `rubygems`. Because this workflow only ever runs as its own top-level # workflow_dispatch run (never as a called reusable workflow), the OIDC # token's workflow_ref and job_workflow_ref claims both name this file — one -# publisher registration per gem covers every path, and one exchange -# satisfies both gems' publishers. +# publisher registration covers every path. on: workflow_dispatch: @@ -107,24 +105,7 @@ jobs: exit 1 fi - - name: Lint + version-check the bundler-plugin gem - working-directory: gem/socket-patch-bundler - env: - EXPECTED_VERSION: ${{ inputs.version }} - run: | - ruby -c plugins.rb - # The gemspec version is baked at the tag by scripts/version-sync.sh. - gemver="$(ruby -e 'print Gem::Specification.load("socket-patch-bundler.gemspec").version')" - if [ "$gemver" != "$EXPECTED_VERSION" ]; then - echo "::error::gemspec version $gemver != release $EXPECTED_VERSION (run scripts/version-sync.sh before tagging)" - exit 1 - fi - - name: Configure RubyGems credentials (OIDC trusted publishing) - # One OIDC exchange covers both gems: a trusted publisher keyed on - # this repo + workflow (+ the `rubygems` environment) can be - # registered on multiple gems on rubygems.org, and the exchanged - # token pushes any gem whose publisher matches. uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 - name: Publish socket-patch to RubyGems @@ -141,25 +122,3 @@ jobs: exit 0 fi gem push "socket-patch-${VERSION}.gem" - - # 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 through an in-tree - # `plugin ..., path:` directive. 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 - # continue-on-error. - - name: Publish socket-patch-bundler to RubyGems - continue-on-error: true - working-directory: gem/socket-patch-bundler - env: - VERSION: ${{ inputs.version }} - run: | - gem build socket-patch-bundler.gemspec - # Same precise-list-element match as the launcher gem above. - if gem list --remote --exact --all socket-patch-bundler 2>/dev/null | grep -qE "[ (]${VERSION}[,)]"; then - echo "socket-patch-bundler ${VERSION} already on RubyGems; skipping." - exit 0 - fi - gem push "socket-patch-bundler-${VERSION}.gem" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3336c096..eaf6eda9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,8 @@ name: Release # - npm: OIDC via `npm stage publish`; staged versions require # manual 2FA approval (see the npm run's step summary). # - PyPI: OIDC trusted publishing; environment `pypi`. -# - RubyGems: OIDC trusted publishing; environment `rubygems`. One -# repo+workflow publisher per gem (`socket-patch` and -# `socket-patch-bundler`), both satisfied by one exchange. +# - RubyGems: OIDC trusted publishing; environment `rubygems` +# (the `socket-patch` launcher gem). # Every registry's trusted publisher is keyed on the repo + the publish # workflow's own filename (see each publish-*.yml header), NOT release.yml. diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml index 6fd588bd..cf809068 100644 --- a/.github/workflows/vlt-compatibility.yml +++ b/.github/workflows/vlt-compatibility.yml @@ -35,7 +35,6 @@ on: - 'crates/socket-patch-cli/src/commands/apply.rs' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/remove.rs' - - 'crates/socket-patch-cli/src/commands/setup.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' - 'crates/socket-patch-cli/src/commands/get.rs' @@ -72,7 +71,6 @@ on: - 'crates/socket-patch-cli/src/commands/apply.rs' - 'crates/socket-patch-cli/src/commands/rollback.rs' - 'crates/socket-patch-cli/src/commands/remove.rs' - - 'crates/socket-patch-cli/src/commands/setup.rs' - 'crates/socket-patch-cli/src/commands/vendor.rs' - 'crates/socket-patch-cli/src/commands/repair_vendor.rs' - 'crates/socket-patch-cli/src/commands/get.rs' diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a44ba3..6e906da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,8 @@ into the new version's section — see docs/releasing.md. > the default, and scan never prompts); `socket-patch vex` emits OpenVEX for > vulnerability scanners; `socket-patch vendor` ejects the patches into > `.socket/vendor/` for offline installs; `socket-patch list` shows them. -> `get`, `apply`, `setup`, `rollback`, `remove` and `repair` (the agent-mode -> commands) keep working and are listed after these. +> `get`, `apply`, `rollback`, `remove` and `repair` (the agent-mode +> commands) keep working and are listed after these; `setup` is removed. > **Semver note:** this entry changes `rollback`'s default behavior, narrows > the meaning of its existing `vendored: []` JSON key, makes vendored mode @@ -35,10 +35,42 @@ into the new version's section — see docs/releasing.md. > package has several, makes `vex` > refuse to attest stale ledger records and corrupt vendor ledgers, and > retries a throttled patch API (new error text, added waiting, a throttled -> package failing its legacy-proxy batch) — all +> package failing its legacy-proxy batch), and removes the `setup` +> subcommand — all > MAJOR per CLI_CONTRACT.md's semver policy — so it ships as the next major > release (v5.0). +### Removed (BREAKING) + +- **`setup` is removed, with every install hook it wired.** `socket-patch + setup` (and `--check` / `--remove` / `--exclude`, `SOCKET_SETUP_EXCLUDE`) + is now an unknown subcommand: a clap usage error, exit 2. The hooks it + wrote were npm `postinstall` / `dependencies` scripts, the + `socket-patch[hook]` Python dependency (a `.pth` startup hook), a Bundler + plugin under `.socket/bundler-plugin/` plus a managed `plugin + "socket-patch"` Gemfile block, and Composer `post-install-cmd` / + `post-update-cmd` entries. Hooks already committed keep working, since + they only call `socket-patch apply`, which stays; delete them by hand to + stop them. Agent mode is now `socket-patch scan --mode agent` once + (commit `.socket/`), then `socket-patch apply` in CI after every install. + Hosted and vendored mode never needed a hook. +- **The `socket-patch-hook` PyPI wheel and the `socket-patch-bundler` gem + are no longer built or published**, and the `socket-patch[hook]` extra is + gone from the `socket-patch` wheel (pip warns about the unknown extra and + installs the CLI). Their sources stay in the tree, frozen, for reference. +- **`vex` no longer drops agent-mode patches whose ecosystem has no + install hook** ("Property 7"). A manifest patch that verifies as applied + (or any manifest patch under `--no-verify`) is now attested whatever the + ecosystem. The `ecosystem_not_setup` `skipped` code, its stderr note and + its `no_applicable_patches` message are retired. A manifest's `setup` + object (`manual`, `exclude`) still parses and is kept on rewrite, but + nothing reads it. +- **The `patch_setup` telemetry event** is gone with the command. +- **Core crate:** the `setup` and `package_json` modules and the + `gem_setup` / `composer_setup` / `pth_hook` aliases are removed from + `socket-patch-core`, along with the setup-only `npm_family` table column + (`FileRow::detects_pnpm`) and `VLT_SETUP_MARKERS`. + ### Changed (BREAKING) - **Vendored runs refuse lock-text failures before downloading them.** @@ -297,8 +329,7 @@ into the new version's section — see docs/releasing.md. vanished-file retries into a spurious `lock_io`. Agent-mode `get` and `scan --apply`/`--sync` hold one lock window across download → manifest write → nested apply (the nested apply no longer re-acquires and - now inherits `--lock-timeout`/`--verbose`); `setup` takes the lock while - persisting `--exclude`; `scan --prune` acquires once for its vendored + now inherits `--lock-timeout`/`--verbose`); `scan --prune` acquires once for its vendored reconcile and manifest prune, and the GC legs of `scan --prune` and `vendor` honor `--lock-timeout` and report a lock I/O error instead of silently skipping on it. @@ -505,22 +536,6 @@ into the new version's section — see docs/releasing.md. verified without its vendor ledger checks a devDependencies-stripped `package.json` against the patched blob in `.socket/blobs`, and is omitted as `vendor_manifest_unverifiable` when that blob is absent. - `setup.manual` accepts `vlt`. -- **`setup` wires vlt projects.** A `vlt-lock.json`, `vlt.json`, - `node_modules/.vlt-lock.json` or `node_modules/.vlt/` directory in the - project root makes `setup` treat it as vlt, ahead of any pnpm marker. The - hook is npm's `npx @socketsecurity/socket-patch apply --silent --ecosystems - npm`, and a vlt workspace (vlt.json `workspaces`, or vlt <= 0.0.0-12's - `vlt-workspaces.json`) is wired at the root only, because vlt runs the - root hook once per install. The `setup --json` `packageManager` and the - `patch_setup` telemetry `manager` report `vlt`. vlt before 1.0.0-rc.13 - never runs a root `postinstall`: `setup` still wires the project and - warns `vlt_root_scripts_not_run` — definitely when the `vlt` on `PATH` - reports such a version, and as a "may" when `vlt-lock.json` has - `lockfileVersion` 0 or none and no usable `vlt` is found, or the one - found would not write that lock (a v0 lock beside vlt 1.0.0-rc.15 or - later). `setup --remove` also clears the hooks earlier releases wrote - into vlt workspace members. - **vlt support is proven against real vlt releases.** Every supported vlt release (0.0.0-1 … 1.2.0, see `docs/testing/vlt-compatibility.md` for the excluded ones) ran the five real-vlt capstones locally; CI now runs 35 of @@ -692,7 +707,7 @@ into the new version's section — see docs/releasing.md. command now also writes `record` into `.socket/vendor/state.json` (never `detached` — the manifest record stays authoritative while the manifest covers the package), so a project it wired whose manifest is gone still - verifies, lists and attests offline: `vex`, `list` and `setup --check` read + verifies, lists and attests offline: `vex` and `list` read the embedded copy whenever no manifest entry covers the package, and `repair` recovers the record without the API when there is no manifest at all. Entries written by older releases keep working. @@ -1114,13 +1129,6 @@ into the new version's section — see docs/releasing.md. `vendor_yarn_berry_mixed_line_endings`). Both takeovers now run the new mode's berry gates first — wet and `--dry-run` alike — and a refused purl keeps the old mode's wiring byte-identical. -- **`setup` keeps a CRLF `package.json` CRLF.** `setup` and `setup --remove` - re-serialized `package.json` with bare LF and dropped a leading BOM, so on - a Windows yarn berry project (yarn pretty-prints the manifest with CRLF) a - two-key script edit became a whole-file diff that yarn then kept, and - `setup --remove` could not land byte-identical on the pre-setup file. - `package.json` is now written in its own layout (BOM, indent, line ending, - trailing-newline shape), the same helper the vendored backends use. - **Two vendored versions of one cargo crate are documented — and now warned about — as needing cargo 1.45.** The docs said older cargo (1.41) only needed a populated crates.io index. It needs more than that: cargo @@ -1187,7 +1195,7 @@ into the new version's section — see docs/releasing.md. Progress, prompts, color and truncation now share one implementation. - **Progress lines:** a status line clears itself on finish. It is never drawn off a TTY, under `TERM=dumb`, in debug mode, or under - `--json`/`--silent`. `fetch`, `vendor`, `setup`, lock waits and + `--json`/`--silent`. `fetch`, `vendor`, lock waits and `--update` checks now show progress instead of going quiet. - **Prompts:** Ctrl-D at a `[Y/n]` prompt now declines instead of accepting. Keys pressed while a command is working no longer answer @@ -1213,10 +1221,7 @@ into the new version's section — see docs/releasing.md. stores are removed, and `.socket/` itself goes with the lock when nothing is left — so a fully unwound hosted or vendored project has no `.socket/` at all. Deliberately kept: the zero-patch `.socket/manifest.json` - (`{"patches": {}}` + its `setup` block — `list`/`apply`/`vex` exit codes - depend on it) and the `setup`-owned `.socket/.gitignore`, - `gem-plugin-stamp` and `bundler-plugin/` (rollback never undoes setup). - `setup --remove` now also removes an emptied `.socket/`. + (`{"patches": {}}` — `list`/`apply`/`vex` exit codes depend on it). - **`scan --prune` says what it skipped and what it could not finish.** The `gc` JSON sub-object gains `failedVendoredEntries` plus the additive `skipped: {code, message}` (`lock_held` | `lock_io`) and @@ -1260,19 +1265,10 @@ into the new version's section — see docs/releasing.md. `(not installed)` line prints only when something was not installed. `rollback` prints `No patches found in manifest` only for an unscoped run with no work in any leg. -- **`setup --exclude` persists after the prompt, under the lock.** The - exclusion list is written after discovery and confirmation (also on the - already-configured path when the flag is explicit) as a read-modify-write - under `apply.lock`; a held or unopenable lock, or a manifest that cannot - be read or written, is reported as `not persisting --exclude: …` instead - of being swallowed. `setup --check` reads the vendor ledger even without - a manifest and, on a corrupt one, warns `unreadable vendor state` and - reports a `vendor_ledger` error entry (verdict `error`, exit 1) — never - `configured`; `vex` refuses the same unreadable ledger outright - (`vendor_ledger_corrupt`, see Changed); `list` - degrades a corrupt vendor ledger to a `Warning: unreadable vendor ledger …` - line (muted by `--silent`) rather than an error; `patch_setup` telemetry - fires only for a successful, non-dry-run setup. +- **A corrupt vendor ledger is reported, never swallowed.** `vex` refuses + it outright (`vendor_ledger_corrupt`, see Changed); `list` degrades it to + a `Warning: unreadable vendor ledger …` line (muted by `--silent`) rather + than an error. - **`repair`/`vendor` state hygiene.** `repair` resolves installed copies through qualified ledger keys (gem `?platform=`, pypi `?artifact_id=`, maven `?classifier=` no longer read as "not installed"), puts a crashed @@ -1313,8 +1309,8 @@ into the new version's section — see docs/releasing.md. - **`apply --silent` on an all-unmatched manifest prints its error line** — errors are never muted by `--silent`; and the no-manifest early exits of `apply` and `vendor` name the missing `.socket/manifest.json` instead of - "No .socket folder found" (the folder may legitimately hold setup files or - vendored state). + "No .socket folder found" (the folder may legitimately hold vendored + state). - **Hosted redirect hygiene.** Missing project files no longer skip silently: `redirect_composer_no_lockfile`, `redirect_gem_no_gemfile` (neither manifest nor lock present) and `redirect_maven_no_pom` (no `pom.xml`, no Gradle @@ -1342,7 +1338,7 @@ into the new version's section — see docs/releasing.md. symlinked targets (`pypi_{poetry,pipenv,requirements}_symlink_unsupported`) and every pypi flavor refuses a project file that changed between plan and write (`pypi_{poetry,pdm,pipenv,uv}_changed`) instead of clobbering it; - `pyproject.toml` edits made by `setup` preserve CRLF line endings; an + an unreadable (EACCES / squatting directory or FIFO) redirect ledger is reported as unreadable and left in place instead of being quarantined as "malformed"; a blob-cleanup pass keeps sweeping after one unremovable file diff --git a/README.md b/README.md index af7fc5af..22237395 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ entries and drops the records. That's the whole loop: **scan → commit → reinstall → vex**, with `scan --mode vendored` when installs must be offline. The older *agent* mode, which patches installed files in -place and re-applies them from an install hook, is still supported; the next section +place and needs `socket-patch apply` after every install, is still supported; the next section compares the three. ## How Socket Patch works @@ -270,7 +270,7 @@ Hosted and vendored mode never write `manifest.json`. > removes it. Nothing in the table is written until there is something to record: a > report-only `scan`, a `--dry-run`, or a run that changes nothing leaves no `.socket/` at > all, and a full [`rollback`](#rollback) removes everything it created (only the -> zero-patch `manifest.json` and any [`setup`](#setup) files stay). +> zero-patch `manifest.json` stays). ### Three patch modes @@ -282,7 +282,7 @@ run; a bare `scan` is hosted. |------|----------------------|--------------------------|-----------| | **hosted** (default) — `scan` | Nowhere in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits and patch records are ledgered in `.socket/vendor/redirect-state.json` | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | | **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/`, with the lockfile rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | -| **agent** (older) — `scan --mode agent`, [`get`](#get), [`apply`](#apply) | `.socket/manifest.json` + blobs, committed; the CLI patches installed files in place | The `socket-patch` CLI must run after every install (an install hook via [`setup`](#setup), or an `apply` step in CI) | No lockfile edits and a small repo footprint, but the only mode that needs CI / install-hook changes | +| **agent** (older) — `scan --mode agent`, [`get`](#get), [`apply`](#apply) | `.socket/manifest.json` + blobs, committed; the CLI patches installed files in place | The `socket-patch` CLI must run after every install (an `apply` step in CI; see [Agent mode in CI](#agent-mode-in-ci)) | No lockfile edits and a small repo footprint, but the only mode that needs CI changes | Every mode pins the patched bytes: vendored and hosted modes lean on your package manager's own lockfile integrity checks (sha512 / sha256 / contentHash / CHECKSUMS) where @@ -459,8 +459,6 @@ need the network and refuse to run with `--offline`. | [`scan --prune`](#scan) | Agent mode: **reconciles, doesn't reverse** — drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files | | [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | -And `setup --remove` reverts the install hooks that `setup` added. - > If you revert a hosted edit by hand instead (e.g. `git checkout -- `), also > delete `.socket/vendor/redirect-state.json` — its recorded originals are then stale. A > leftover ledger does not make [`vex`](#vex) attest the removed redirects: a record @@ -477,7 +475,6 @@ And `setup --remove` reverts the install hooks that `setup` added. | **Agent mode (older commands)** | | | [`get`](#get) | Fetch and apply one patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | | [`apply`](#apply) | Apply the patches in `.socket/manifest.json` in place | -| [`setup`](#setup) | Wire install hooks (npm, Python, Bundler, Composer) that re-apply patches after install | | [`rollback`](#rollback) | Undo patches in every mode: restore original files and unwind hosted or vendored lockfile wiring | | [`remove`](#remove) | Remove one patch by PURL or UUID (rolls back first) | | [`repair`](#repair) | Download missing patch artifacts, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | @@ -517,8 +514,8 @@ settings, described in [Configuration sources](#configuration-sources) below. | `-v, --verbose` | `SOCKET_VERBOSE` | Show extra detail in human-readable output. | | `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | -| `-y, --yes` | `SOCKET_YES` | Skip confirmation prompts (`get`, `rollback`, `remove`, `setup`, `--update`). `scan` never prompts, so it ignores this flag. | -| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for the commands that take the lock — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted). The lock file exists only while a command runs. | +| `-y, --yes` | `SOCKET_YES` | Skip confirmation prompts (`get`, `rollback`, `remove`, `--update`). `scan` never prompts, so it ignores this flag. | +| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for the commands that take the lock — `apply`, `rollback`, `repair`, `remove`, `vendor`, and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted). The lock file exists only while a command runs. | | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | | `--no-npm-allow-remote-config` | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | Hosted mode: don't write `allow-remote=all` to the project `.npmrc` (see [npm compatibility](#npm-hosted-mode-and-npm-12)). | @@ -766,7 +763,7 @@ it: lockfile is reverted and dropped); newer patches show up in `updates[]` as the signal to re-run `scan --mode vendored`. - [`vex`](#vex) attests vendored patches by verifying the **committed artifact** (marked - `(vendored)` in the impact statement) — no `setup` install hook needed. + `(vendored)` in the impact statement) — no install step needed. - Re-running either form is idempotent. Patches dropped from `.socket/manifest.json` are auto-reverted on the next `vendor` run; on a project vendored by `scan --mode vendored`, use [`repair`](#repair) to verify or rebuild the committed @@ -954,129 +951,47 @@ socket-patch apply --vex socket.vex.json > committed vendored artifact is the patch, so there is nothing for `apply` to do — even > when the installed tree (e.g. `node_modules/`) is absent. -### `setup` - -Agent mode only. Configure your project so in-place patches are **re-applied -automatically after install** — no manual `socket-patch apply` step in CI. (Hosted and -vendored projects need no hook: the lockfile already names the patched packages.) `setup` is a one-time operation: run it, commit -the change together with your `.socket/` patches, and every later install handles the -rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the -result. - -What gets wired, per ecosystem: - -- **npm / yarn / pnpm / bun / vlt** — writes `postinstall` and `dependencies` scripts into - `package.json` so any install — including `npm install ` — re-applies patches - (pnpm and vlt: root package only). vlt uses the same `npx` hook, runs it on every - install that changes the tree (never on a no-op install), and aborts the install when - it fails; vlt before 1.0.0-rc.13 never runs a root `postinstall`, which `setup` warns - about (`vlt_root_scripts_not_run`). -- **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install - hook, so `setup` instead adds a **`socket-patch[hook]`** dependency to your manifest - (`pyproject.toml` / `requirements.txt`; for classic Poetry, the equivalent - `socket-patch = { extras = ["hook"] }`). Installing it lays down - a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your - committed `.socket/` patches the next time the interpreter runs. It is - package-manager-agnostic (it rides the interpreter, not any one installer) and - **fail-open** — a hook error can never break interpreter startup. Details below. -- **RubyGems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` - and generates an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies - patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` - CLI on `PATH`, and **bundler >= 2.2**: bundler 1.x cannot load a `plugin ... path:` - directive — it resolves it as an ordinary gem and every later `bundle install` fails — - so `setup` refuses to wire a project whose lock or `bundle --version` reports an older - bundler, and `setup --check` red-flags a wired project that lands in that state.) -- **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s - `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every - `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) -- **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't - possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: - **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting +### Agent mode in CI + +Agent mode patches the installed files in place, so every fresh dependency install +reverts the patches until `socket-patch apply` runs again. (Hosted and vendored projects +need no such step: the lockfile already names the patched packages.) Commit +`.socket/manifest.json` and its blobs, then run `apply` in CI after every install: + +```bash +# once, locally: record the patches (commit .socket/) +socket-patch scan --mode agent + +# in CI, after `npm ci` / `pip install` / `bundle install` / ... +socket-patch apply +``` + +> **v5.0: `setup` was removed.** It used to wire install hooks (npm `postinstall` / +> `dependencies` scripts, the `socket-patch[hook]` Python `.pth` wheel, a Bundler plugin, +> Composer script events) that ran `apply` for you. Hooks an earlier release committed +> keep working — they call `socket-patch apply`, which still exists — until you delete +> them by hand: the `package.json` scripts, the `socket-patch[hook]` dependency (the +> `socket-patch-hook` wheel is no longer published; `pip uninstall socket-patch-hook`), +> the managed `plugin "socket-patch"` Gemfile block and `.socket/bundler-plugin/`, and +> the `composer.json` script entries. + +Per-ecosystem notes for in-place patching: + +- **Cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting `.cargo-checksum.json` so `cargo build` accepts it) — note that a non-vendored crate patches the **shared** `$CARGO_HOME/registry` cache, which affects every project on the machine and is silently reset by `cargo clean` or a cache prune; vendor the - dependency (`--mode vendored`) for a project-local, committable patch. **go** writes a - project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` - directive (the module cache is `go.sum`-verified, so in-place patching can't build); - commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. To have - [`vex`](#vex) still attest these hand-applied patches, add a `setup.manual` array to - `.socket/manifest.json` by hand (there is no CLI flag for it yet): - `"setup": { "manual": ["cargo", "golang"] }`. -- **Maven / NuGet / Deno** — also apply-only: no native install hook exists to wire, so - `setup` reports `no_files`; patch them on demand with `socket-patch apply`, and declare - them in `setup.manual` (the same hand-edit as the Cargo & Go note above, e.g. - `"setup": { "manual": ["deno"] }`) so [`vex`](#vex) still attests the hand-applied - patches — this matters most for Deno, which has no vendored or hosted alternative. - For Maven - and NuGet, note that in-place patching leaves the caches' own checksum sidecars stale + dependency (`--mode vendored`) for a project-local, committable patch. +- **Go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` + `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't + build); commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. +- **Maven / NuGet**: in-place patching leaves the caches' own checksum sidecars stale (NuGet's fixup deletes `.nupkg.metadata` and raises an advisory for the signed-package `.nupkg.sha512` marker; Maven's `.jar.sha1`/`.jar.md5` are left as-is) — the copy-out modes, `scan --mode vendored` and `scan --mode hosted`, never touch the caches and avoid the issue entirely. See [ecosystems.md](docs/ecosystems.md#maven--nuget-caveats). - -**Usage:** -```bash -socket-patch setup # configure (interactive) -socket-patch setup --check # verify configured; non-zero exit if not (CI gate) -socket-patch setup --remove # revert what setup added -``` - -**Command-specific options** (plus all [Global options](#global-options) — `--dry-run`, -`--yes`, `--json`, `--cwd` are the most relevant): -| Flag | Env var | Description | -|------|---------|-------------| -| `--check` | — | Read-only verification that every manifest is configured **and** every installed patch is still applied on disk (each file matches its recorded `afterHash`); exits non-zero if any manifest still needs setup or a patch has drifted. Never writes (safe in CI). Conflicts with `--remove`. | -| `--remove` | — | Revert every install hook `setup` added (npm `package.json` scripts, the Python `socket-patch[hook]` dependency, the gem Bundler plugin wiring — including bundler's machine-local `.bundle/plugin` registration, so later `bundle install`s don't warn about the unwired plugin — and the Composer `post-install-cmd`/`post-update-cmd` script entries). If the registration can't be cleared automatically (unexpected index format), the error names the fallback: `bundler plugin uninstall socket-patch`. | -| `--exclude ` | `SOCKET_SETUP_EXCLUDE` | Workspace-member path(s) to exclude from setup (comma-separated, relative to the repo root). The exclusion is persisted in `.socket/manifest.json`, so `setup --check` and a fresh clone honor it without re-passing the flag. | - -#### Disabling / opting out (Python hook) - -The Python hook is designed to be easy to skip or remove: - -- **Per interpreter / CI step:** set `SOCKET_PATCH_HOOK=off` (or `SOCKET_NO_HOOK=1`). - This is checked *before any hook code runs*, so it fully bypasses the hook for that - process. -- **Remove from a project:** `socket-patch setup --remove`, then - `pip uninstall socket-patch-hook`. -- **Never opted in:** if you don't run `setup`, there is no hook — it is opt-in by - design. - -#### What the Python hook does, and its safety model - -On interpreter startup, *only when the set of installed packages changed*, the hook runs -`socket-patch apply --offline --ecosystems pypi` for the project that owns the current -virtualenv, re-applying only the patches committed in that project's `.socket/`. -Specifically: - -- It is **anchored to the virtualenv** it is installed in (not the working directory), so - a `python` started from an unrelated directory cannot pull in a foreign - `.socket/manifest.json`. -- It **verifies each file's hash before patching** and **never writes outside the - installed package directory** (path-escaping manifest keys are refused). -- It **prefers the binary shipped in the installed `socket-patch` package** over `PATH`, - so a binary planted earlier on `PATH` cannot shadow it; `PATH` is consulted only as a - fallback when that package isn't installed. -- It runs **offline** (no network at startup) and is **fail-open** (any error is - swallowed; it can never abort the interpreter). - -**Examples:** -```bash -# Interactive setup (all detected ecosystems, auto-detected) -socket-patch setup - -# Non-interactive -socket-patch setup -y - -# Preview changes -socket-patch setup --dry-run - -# Verify configuration in CI (exits non-zero if not set up or a patch has drifted) -socket-patch setup --check - -# JSON output for scripting -socket-patch setup --json -y -``` +- **Deno** has no hosted or vendored mode, so agent mode is the only way to patch it. ### `rollback` @@ -1225,8 +1140,7 @@ place — without bumping the package version. covers patches that are actually applied: agent patches against the installed tree, vendored patches against the **committed artifact** (marker `(vendored)`), and hosted patches against the installed copy the build consumes — or, before any install, against - the lockfile's integrity pin (marker `(redirected)`). Vendored and hosted patches need - no `setup` install hook to be attested. Whatever `--no-verify` says, a ledger record the + the lockfile's integrity pin (marker `(redirected)`). Whatever `--no-verify` says, a ledger record the lockfile no longer wires is never attested. 3. Auto-detects the top-level **product** identifier (override with `--product`), probing in order: @@ -1251,7 +1165,7 @@ Each statement's impact string records *how* the patch is persisted — one mark | Impact statement | Mode | What the evidence is | What a consumer should do | |---|---|---|---| -| `Patched via Socket patch ` | agent | The installed tree: every patched file's hash was verified against the manifest's `afterHash` | Trust the statement as long as the agent install hook (or a CI `apply`) keeps re-applying; ecosystems without a hook must be declared in `setup.manual` | +| `Patched via Socket patch ` | agent | The installed tree: every patched file's hash was verified against the manifest's `afterHash` | Trust the statement as long as a CI `apply` keeps re-applying after every install | | `Patched via Socket patch (vendored)` | vendored | The **committed** `.socket/vendor/` artifact was hash-verified — no install hook needed; the lockfile wiring is the persistence mechanism | Trust it on any checkout; the committed bytes are the patch | | `Patched via Socket patch (redirected)` | hosted | The lockfile's integrity pin points at the Socket-hosted patched package. A post-install `socket-patch vex` hash-verifies the installed copy; before any install it attests from the pin. When emitted in-run by a hosted `scan --vex`, the statement is attested **without hash verification** (the bytes are fetched at install time — the JSON `vex` summary carries `verified: false`) | Ensure installs still resolve from `patch.socket.dev` (the lockfile edit is intact), and run `socket-patch vex` **after installing** to have the redirected patches hash-verified against the installed tree | @@ -1337,7 +1251,7 @@ Behavior worth knowing: | composer | `composer.lock` | `installed.json` and `COMPOSER=`-renamed locks are not read | | maven | `pom.xml` (+ `.mvn/` checksums) | Root pom only (no parents / submodules, no Gradle); legacy same-GAV hosted repositories cannot be attributed | | nuget | `nuget.config`, `packages.lock.json` | Hosted needs a `packages.lock.json` entry for the id (with no lock at all, an exclusive exact-id mapping still keeps the redirect ledger's record live); root config only | -| deno | none | No hosted or vendored mode exists; Deno patches attest only through the manifest (agent mode + `setup.manual`) | +| deno | none | No hosted or vendored mode exists; Deno patches attest only through the manifest (agent mode) | The full recognition rules are in [CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md) ("Manifest-less VEX"). @@ -1415,7 +1329,7 @@ socket-patch apply --json | jq '.status' ``` `scan` never prompts, so CI needs no `--yes` for it. The commands that do confirm -(`get`, `rollback`, `remove`, `setup`) auto-proceed when stdin is not a TTY. Progress +(`get`, `rollback`, `remove`) auto-proceed when stdin is not a TTY. Progress indicators and ANSI colors are automatically suppressed when output is piped. The exact JSON shapes, exit codes, and stability guarantees are specified in @@ -1456,11 +1370,9 @@ mode never write it): Patched file contents are in `.socket/blobs/` (named by git SHA256 hash). -The manifest may also carry an optional top-level `"setup"` key persisting setup state — -`"setup": { "manual": ["cargo"], "exclude": ["packages/legacy"] }` — where `manual` -lists ecosystems you patch by hand so [`vex`](#vex) still attests them (see -[`setup`](#setup)), and `exclude` lists workspace members excluded from setup (written -by `setup --exclude`). +A manifest written by an earlier release may also carry a top-level `"setup"` key +(`manual`, `exclude`) from the removed `setup` command; v5 keeps it on rewrite but +ignores it. ## Further reading diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 55a3983a..8041eed7 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -14,16 +14,17 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | | `get` | `download` | Agent mode by default (`--mode` selects hosted/vendored): fetch + apply a patch; requires positional `identifier` | | `apply` | — | Agent mode: apply patches from the local manifest | -| `setup` | — | Agent mode: wire automatic-patching install hooks (npm/pypi/gem/composer) | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `remove` | — | Agent mode: remove a patch from manifest (rolls back first); requires positional `identifier` | | `repair` | `gc` | Agent mode: download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex` → `vendor`, with `list` to inspect), then the agent-mode (in-place patching) commands. +**Removed in v5.0:** the `setup` subcommand (see [Agent mode in CI](#agent-mode-in-ci-v50-setup-removed)). + **Removed in v4.0:** the `unlock` subcommand (a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command; `repair` briefly owned lock-file cleanup in v4.x, and since v5.0 every lock-taking command removes its own lock file on exit). -**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. A leftover file from a crashed (SIGKILLed) run is reclaimed in place and removed by the next lock-taking command. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, `setup` while it persists `--exclude` (v5.0), agent-mode `get` and `scan --apply`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it around its first wet write (the takeover pre-reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. +**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. A leftover file from a crashed (SIGKILLed) run is reclaimed in place and removed by the next lock-taking command. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, agent-mode `get` and `scan --apply`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it around its first wet write (the takeover pre-reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). @@ -57,7 +58,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--silent` | `-s` | `SOCKET_SILENT` | `false` | bool | Errors only | | `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations (a dry run may still take the transient `apply.lock`, removed again on exit — see "Lock lifecycle"; hosted and vendored previews never leave a `.socket/`) | | `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts (`scan` never prompts) | -| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | +| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | | `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | @@ -93,7 +94,6 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--one-off`; `--preserve-state` (v5.0) | `SOCKET_ONE_OFF`, `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | | `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | -| `setup` | `--check`, `--remove` (mutually exclusive); `--exclude` (CSV member paths); honors global `--ecosystems` | `SOCKET_SETUP_EXCLUDE`, `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. `--exclude` skips + persists workspace members (property 9). See [Setup command contract](#setup-command-contract) | **pnpm hosted-mode contract**: `scan --mode hosted` handles block and flow resolutions in legacy `shrinkwrap.yaml` and lockfileVersion 5.x, 6.0, and 9.0. The [pinned compatibility matrix](../../docs/testing/pnpm-compatibility.md) samples pnpm majors 1–12. Early shrinkwrapVersion 3 without a positive minor version is refused with `redirect_pnpm_legacy_lockfile_unsupported`: pnpm 1.0.0 discards hosted URLs even on frozen installs. Upgrade to a tested release (1.43.1 or newer) and regenerate the lock, or use agent mode. @@ -113,7 +113,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--redirect`, `--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. An explicit `--mode hosted` (or `--redirect`) with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect). -**scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get`, `rollback`, `remove`, `setup` and `--update` keep their prompts. +**scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`, and the same liveness rule `vex` applies to a redirect-ledger record (see "Manifest-less VEX (lockfile discovery)"). Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. @@ -144,7 +144,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). -**Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. +**Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. `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. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. 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). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). @@ -156,7 +156,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **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: -* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex`, `list` and `setup --check` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. +* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex` and `list` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. * `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here; v5.0: a byte-identical re-save is skipped, which still satisfies the rule); `records` maps PURL → the full manifest `PatchRecord`, one of `vex`'s record sources for redirected patches with no manifest entry (a record attests only while a lockfile still wires its hosted patch — see "Manifest-less VEX" below). The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: @@ -186,7 +186,7 @@ Contract details: * **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, nothing to attest in the manifest / ledgers / lockfiles, all patches omitted, a corrupt ledger, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). * **Built from the post-run state** — the manifest, both `.socket/vendor` ledgers and the project's lockfile references (see "Manifest-less VEX" below) — and verified against on-disk state (unless `--vex-no-verify`; the wiring gates apply either way). Generated for real applies and read-only `scan` alike; `--dry-run` skips generation on every host command (nothing was changed, and a preview must not write an attestation — `scan --json` marks it `vex: {skipped: true, reason: "dry_run"}`). * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. -* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold setup files or vendored state) and `vendor`'s (`No manifest found, nothing to vendor.`) still generate the document from the lockfiles and `.socket/vendor` ledgers (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. +* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.`) still generate the document from the lockfiles and `.socket/vendor` ledgers (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. * **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` or `redirect-state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` / `redirect_ledger_corrupt` (see the error-code table). @@ -242,7 +242,7 @@ Recognition rules that hold for every ecosystem: **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the redirect ledger's `records`, then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. -**Verification basis.** `(vendored)` and `(redirected)` patches bypass the Property 7 ecosystem filter, because their wiring is the persistence. With no manifest there is no `setup.manual`, and none is needed. +**Verification basis.** | Wiring | Evidence (verify mode) | Marker | |---|---|---| @@ -272,136 +272,37 @@ Human mode also prints `Note:` lines: superseded records, fetch failures, `--off **Output.** A manifest-less run honors every `vex` output convention: `--output -` (or `-O -`) prints the document to stdout; `--dry-run` still discovers, fetches records and verifies, but writes nothing and leaves a previous document at the path alone (`[dry-run] Would write …`, `dryRun: true`); an embedded `--vex` under `--dry-run` skips generation with the shared `Skipping VEX generation (--dry-run: nothing was …).` line. -## Setup command contract - -`setup` wires a repository for **automatic patching**: after the ecosystem's own install/build step -runs, locally-installed dependencies are re-patched to match the Socket manifest (`.socket/manifest.json`) -with no further human action. It does this by installing an ecosystem-native hook (see the support -matrix below). `setup --check` verifies that state; `setup --remove` reverts it. - -The properties below are the public contract. Each is backed by a test under -`crates/socket-patch-cli/tests/setup_*.rs` (`setup_contract_gaps.rs` holds the guards for the -properties that shipped after the contract was written; a failure there is a regression). Changing -any property below is governed by the [semver policy](#semver-policy). - -1. **Idempotent.** Re-running `setup` on an already-configured repo changes nothing: status - `already_configured`, `updated: 0`, every manifest byte-identical. *(Implemented.)* - -2. **Ecosystem-scoped.** `setup`, `setup --check`, and `setup --remove` honor the global - `--ecosystems` filter and act on only the named ecosystems; with no filter they act on every - detected ecosystem. *(Implemented — `eco_in_scope` gates the npm, Python, Bundler and Composer - legs on `--ecosystems`.)* - -3. **Consistency after install.** Once an ecosystem is set up, its locally-installed dependencies are - re-patched to match the manifest after **any** of: a dependency added, updated, or removed; **or** a - new patch added to the manifest. The re-patch is carried by the ecosystem's install hook (npm - `postinstall`/`dependencies`, the Python `.pth` startup hook, the gem Bundler plugin, the Composer - `post-install-cmd`/`post-update-cmd` scripts) which runs - `socket-patch apply` after the ecosystem's installer finishes, so patch state always reconverges with - the manifest. *(Implemented for npm/pypi/gem/composer via the support matrix. Cargo and Go have no `setup` - hook — see "Cargo and Go: apply-only, no setup" below.)* - -4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the - in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and** - on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches - `afterHash`). *(Implemented — `run_check` appends a `patch` entry per installed-but-drifted PURL via - `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift. - v5.0: vendored patches are consulted from the vendor ledger's embedded `record`s and verified - against the committed artifact — a manifest-less vendored project is checked the same way.)* - -5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`, - `pyproject.toml`/`requirements.txt`, `composer.json` (the `post-install-cmd`/`post-update-cmd` - hooks), the `Gemfile` + the generated `.socket/bundler-plugin/{plugins.rb,socket-patch.gemspec}` - and `.socket/.gitignore` (one line ignoring the machine-local stamp), and `.socket/manifest.json` - only when `--exclude` persists an exclusion (property 9). Every artifact is git-committable. - `setup --check` writes nothing, and an already-configured `setup` writes nothing unless - `--exclude` is passed explicitly. The `--exclude` persistence (v5.0) runs AFTER discovery and - the confirm prompt, as a read-modify-write under `<.socket>/apply.lock` (`setup` joins the - `--lock-timeout` contenders): a held or unopenable lock, or a manifest that cannot be read or - written, is reported as a `not persisting --exclude: — ` warning — never exit 1 — - and a byte-identical exclude list neither locks nor rewrites. `--check` (property 4) reads the - vendor ledger even without a manifest; a ledger it cannot read or parse is surfaced as a - `Warning: Unreadable vendor state (…)` line (muted by `--silent`) plus a `vendor_ledger` `files[]` - entry with `status: error` — verdict `error`, exit 1 — never as a `configured` verdict. It never writes outside - `--cwd` — no `$HOME`, no global `site-packages` (the Python `.pth` wheel is installed later by the - user's package manager, not by `setup`; the gem patch stamp is written by the plugin at - `bundle install` time, not by `setup`, at `.socket/gem-plugin-stamp` — machine-local, hence the - `.gitignore` line; the legacy stamp under `Bundler.bundle_path` is deleted by the plugin). These - files are **setup-owned residue**: `rollback`/`remove` never undo `setup`, so `.socket/.gitignore`, - `.socket/bundler-plugin/` and `gem-plugin-stamp` survive a full reversal (see the residue rule - under the rollback contract). *(Implemented — `crates/socket-patch-core/src/setup/gem/mod.rs`.)* - -6. **Clone-portable.** Because all setup state is committed files, a fresh checkout on another host — - CI, a deploy, a teammate's machine — inherits the setup state unchanged; `setup --check` passes on - the clone with no re-run required. *(Implemented; a consequence of properties 5 + 1.)* - -7. **Reflected in VEX.** A patch contributes a `not_affected` statement to the repo's OpenVEX document - only for ecosystems that are **actually set up** — or explicitly declared **manual** (below) — or - **vendored** (a `socket-patch vendor`ed package needs no install hook by construction: the package - manager itself installs the patched artifact, so its purls bypass this filter) — or **hosted** (a - live lockfile redirect is likewise its own persistence; manifest-less lockfile references are always - vendored or hosted, so they never need `setup.manual`). Patches for an - ecosystem that is neither set up, declared manual, vendored, nor hosted produce no VEX statement. *(Implemented — - `generate_vex` filters `applied` to ecosystems returned by `commands/setup::configured_ecosystems` - (on-disk hook presence) ∪ the manifest's `setup.manual`, in addition to the existing `--ecosystems` - filter and on-disk verification. Applies in both verify and `--no-verify` modes.)* - - **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) declare an - ecosystem as `manual` so VEX still attests its patches even though the auto-install hook is - intentionally not wired. This is the normal path for **cargo** and **golang** (apply-only, no - `setup` hook). Home: the `setup.manual` array (a list of ecosystem `cli_name`s — `pypi`, `cargo`, - `golang`, …) in `.socket/manifest.json`. *(Implemented for the read/attest path; a `setup` flag to - populate it is a future nicety — today it's hand-authored in the manifest.)* - -8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores - the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies - preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration - again. For gem projects it also removes the plugin dir, the stamp and its `.gitignore` line, and - (v5.0) prunes an emptied `.socket/` (non-recursive `remove_dir` — a `.socket/` still holding a - manifest, blobs, vendored state or a user-authored `.gitignore` is kept), so a project that never - ran `apply` is back to its pre-setup tree. *(Implemented for the manifest edits — npm - `package.json` and Python deps round-trip byte-for-byte. `package.json` is re-serialized in its - own layout — BOM, indent, line ending and trailing-newline shape (v5.0) — so a Windows manifest - (yarn berry pretty-prints it with CRLF) keeps CRLF through `setup` and `setup --remove`.)* - -9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm / - yarn / pnpm / bun workspace members are all discovered and configured (pnpm is root-package-only by - design, because workspace-member `postinstall` scripts fail under pnpm's strict module isolation). - Selected paths may be **excluded**, and the exclusion is **persisted in `.socket/manifest.json`** so - `check`, `apply`, and any clone all honor it. *(Implemented — nested-workspace discovery plus the - `--exclude` flag, persisted as the `setup.exclude` array in `.socket/manifest.json` and honored by - discovery + `check` (a fresh clone inherits it without re-passing the flag). Excludes apply to npm - workspace members; the repo root is never excludable.)* - - **Nested workspaces (implemented).** A workspace member that is itself a workspace root is recursed - into and has its own members configured. `collect_workspace_members` - (`socket-patch-core/src/package_json/find.rs`) re-reads each discovered member's own - `workspaces` field (bounded depth). Guarded by the nested-workspace pins in - `tests/setup_invariants.rs`. - -### Per-ecosystem setup support - -`setup` installs an automatic-repatch hook for the four ecosystems with a usable post-install / -startup hook (npm, pypi, gem, composer — every ecosystem is built in unconditionally; there are no -ecosystem feature gates). The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but -there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are exactly -the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied patches -still show up in VEX). - -| Ecosystem | Hook `setup` installs | Repatch trigger | Notes | -|---|---|---|---| -| npm / yarn / pnpm / bun / vlt | `scripts.postinstall` + `scripts.dependencies` | `npm/pnpm install` (+ `install `); vlt: every install that changes the graph (`vlt install`, `install `, `vlt ci`), never a no-op install | pnpm and vlt: root package only. vlt gets npm's `npx` hook (never `vlx`) and is detected by `vlt-lock.json`, `vlt.json`, `node_modules/.vlt-lock.json` or a `node_modules/.vlt/` directory in `--cwd` (before the pnpm markers; an ancestor `vlt.json` is ignored). vlt < 1.0.0-rc.13 never runs a root `postinstall`: `setup` still wires it and warns `vlt_root_scripts_not_run`. A failing hook aborts and rolls back the whole `vlt install`, so `apply --silent` exits 0 when there is nothing to do (no manifest); a manifest whose only patch targets a package that is not installed exits 1 and aborts the install, as it fails `npm install` | -| pypi | `socket-patch[hook]` dependency → `.pth` startup hook | Python interpreter startup after installed-set change | manifest = `pyproject.toml` (uv/poetry/pdm/hatch) or `requirements.txt` (pip) | -| gem | managed `plugin "socket-patch"` block in the `Gemfile` → committed in-tree Bundler plugin under `.socket/bundler-plugin/` | every `bundle install` (cached + fresh: load-time digest gate + `after-install-all` hook) | the plugin is `path:`-sourced (a `git:` dir source is uncloneable — the generated dir is not a git repo — and fails `bundle install`); the dir must be committed so clones/CI have it; CLI must be on `PATH`. Phase 2 (follow-up) switches to a published `socket-patch-bundler` gem | -| composer | `socket-patch apply` appended to `composer.json`'s `post-install-cmd` + `post-update-cmd` script events | every `composer install` / `composer update` | CLI must be on `PATH` | -| cargo · golang | **none** (apply-only) | — | see "Cargo and Go: apply-only, no setup" below; candidates for the **manual** declaration | -| nuget · maven · deno | **none** (apply-only) | — | `setup` reports `no_files`; candidates for the **manual** declaration | +## Agent mode in CI (v5.0: `setup` removed) + +**Removed in v5.0 (MAJOR):** the `setup` subcommand and every install hook it wired (npm +`postinstall`/`dependencies` scripts, the `socket-patch[hook]` Python `.pth` wheel, the Bundler +plugin under `.socket/bundler-plugin/`, the Composer script hook). `socket-patch setup` is now an +unknown subcommand (clap usage error, exit `2`). Hooks a previous release committed keep calling +`socket-patch apply`, which still exists, so they keep working until you delete them; remove them by +hand (the `postinstall`/`dependencies` entries, the `socket-patch[hook]` dependency, the managed +`plugin "socket-patch"` Gemfile block + `.socket/bundler-plugin/`, the composer +`post-install-cmd`/`post-update-cmd` entries). The `socket-patch-hook` wheel and the +`socket-patch-bundler` gem are no longer published. + +Prefer hosted or vendored mode: their lockfile (and `.socket/vendor/`) edits are the persistence, so +no install step exists. Agent mode (`scan --mode agent`, `get`, `apply`) patches the installed tree +in place, which the next package-manager install reverts; wire it into CI yourself: + +```sh +socket-patch scan --mode agent # once, locally: record patches in .socket/manifest.json (commit it) +# in CI, after every dependency install: +socket-patch apply # re-apply the committed manifest +``` + +`vex` attests an agent-mode patch whenever verification finds it applied (v5.0: the old "Property 7" +filter, which dropped patches for ecosystems with no configured install hook unless declared in +`setup.manual`, is gone together with `setup`; the `ecosystem_not_setup` omission code is retired). A +manifest's legacy `setup` object (`manual`, `exclude`) still parses and round-trips but is ignored. -#### Cargo and Go: apply-only, no setup +### Cargo and Go in agent mode -Cargo and Go have **no `setup` hook** — a one-click, auto-repatch-on-build setup isn't possible for -them, so `setup` skips both (it makes no manifest edits for either as a *setup* action; the `go.mod` -`replace` that local-mode `apply` writes is an *apply*-time redirect, not setup state). Patch them -with `socket-patch apply` directly (manually or from a per-project install script), and declare them -in `setup.manual` for VEX attestation. +Hosted and vendored mode need no per-install step for any ecosystem. In agent mode, cargo and Go +are patched by `socket-patch apply` like every other ecosystem: - **cargo** — `apply` patches the crate **in place** wherever the crawler finds it: the project `vendor/` directory or the shared registry cache (`$CARGO_HOME/registry/src/...`). The @@ -412,26 +313,25 @@ in `setup.manual` for VEX attestation. - **golang** — `apply` writes a project-local **patched copy** under `.socket/go-patches/@/` and a `go.mod` `replace` directive pointing at it; `go build` links the copy (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` + your - `.socket/` patches so a clone builds the patched bytes with no further setup. `socket-patch apply + `.socket/` patches so a clone builds the patched bytes with no further step. `socket-patch apply --check` is a read-only audit of the committed redirect. ### Monorepo / multi-project discovery model -How `setup` (and the underlying `scan`/`apply` crawlers) find subprojects differs by ecosystem, and +How the `scan`/`apply` crawlers find subprojects differs by ecosystem, and the model is **not uniform** today: - **Workspace-aware (walk members):** npm / yarn / pnpm / bun / vlt (`workspaces` / `pnpm-workspace.yaml` / vlt.json `workspaces` — a glob, a list, or named groups of either — or vlt <= 0.0.0-12's `vlt-workspaces.json`; vlt's declaration wins over the others and vlt never reads package.json - `workspaces`). One repo-root invocation discovers and configures every member (pnpm and vlt: the - root package only — vlt runs the root hook once per install, even one started from a member; `setup --remove` also clears a vlt member that still carries a hook, as releases before vlt workspace support wired every member). A member that is itself a workspace root is recursed into - (bounded depth; see property 9). + `workspaces`). One repo-root invocation discovers every member. A member that is itself a + workspace root is recursed into (bounded depth). - **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project rooted at `--cwd` (pypi looks at `$VIRTUAL_ENV`, `/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not** descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories (`backend/Gemfile.lock` + `frontend/Gemfile.lock`, multiple `.venv`, multiple `go.mod` / `composer.json`) is handled by invoking the tool **once per subproject** (`--cwd` each), as a - per-directory install hook would. + per-directory CI step would. *Gem install roots (a refinement of "cwd-only", not an exception to the one-project model):* the crawler probes the project's Bundler install roots in **bundler's own precedence order** — the app @@ -452,7 +352,7 @@ the model is **not uniform** today: **coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy, mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`, - `setup`, `vex`) use the highest-precedence copy. + `vex`) use the highest-precedence copy. *Copy classes (additive to the multi-copy vocabulary):* a copy under a **bundle-path store** (config/env/default root) is PRIMARY — a variant mismatch or write failure there fails the run, @@ -482,75 +382,6 @@ is patched identically to a direct one. Both halves are pinned in back breadth-first into nested `node_modules` for still-unresolved PURLs; a root-level install always wins, pinned by `find_by_purls_prefers_root_copy_over_nested_duplicate`). -### JSON output shapes (`setup`, `setup --check`, `setup --remove`) - -`setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0; -consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of -`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`, `patch` (`--check` property 4: a manifest -or ledger patch not applied on disk, `needs_configuration`), `vendor_ledger` (`--check`: a -`.socket/vendor/state.json` that cannot be read or parsed, `error`), `gem_plugin_registration` (the last is -`setup --remove`-only: clearing bundler's machine-local `.bundle/plugin` registration of the wired -plugin — emitted only when a registration existed; `status: error` carries the -`bundler plugin uninstall socket-patch` remedy when it could not be cleared safely). - -**`setup`:** - -```jsonc -{ - "status": "success" | "already_configured" | "dry_run" | "partial_failure" | "error" | "no_files", - "updated": 0, - "alreadyConfigured": 0, - "errors": 0, - "packageManager": "npm" | "pnpm" | "vlt", // always emitted; defaults to "npm", only meaningful when npm files were found ("vlt" is additive) - "pythonPackageManager":"pip" | "uv" | "poetry" | "pdm" | "hatch", // present only when Python detected - "dryRun": true, // only on status=dry_run - "wouldUpdate": 0, // only on status=dry_run - "warnings": [ "..." ], // only when non-empty (e.g. lockfile refresh; "vlt_root_scripts_not_run: ") - "files": [ - { "kind": "package_json", "path": "...", "status": "updated" | "already_configured" | "error", - "error": null | "..." } - ] -} -``` - -**`setup --check`** (read-only; never writes — exit `0` only when all in-scope manifests are configured -and none errored): - -```jsonc -{ - "status": "configured" | "needs_configuration" | "error" | "no_files", - "configured": 0, - "needsConfiguration": 0, - "errors": 0, - "files": [ - { "kind": "...", "path": "...", "status": "configured" | "needs_configuration" | "error", - "error": null | "..." } - ] -} -``` - -**`setup --remove`:** - -```jsonc -{ - "status": "success" | "not_configured" | "dry_run" | "partial_failure" | "error" | "no_files", - "removed": 0, - "notConfigured": 0, - "errors": 0, - "dryRun": true, // only on status=dry_run - "wouldRemove": 0, // only on status=dry_run - "warnings": [ "..." ], // only when non-empty - "files": [ - { "kind": "...", "path": "...", "status": "removed" | "not_configured" | "error", - "error": null | "..." } - ] -} -``` - -**Exit codes** (all three): `0` when nothing errored and the operation was satisfiable (including -`no_files` and `not_configured`); `1` on any per-file error, partial failure, or — for `--check` — any -manifest that needs configuration. `setup --check --remove` is a clap usage error (exit `2`). - ## Vendor command contract `vendor` is `apply`'s committable sibling: instead of patching installed packages in place @@ -783,8 +614,8 @@ worse, lets a warm cache silently serve unpatched bytes): same committed-file trust class as the manifest; artifact verification still re-hashes against its afterHashes and the uuid-in-path cross-checks); standalone `vendor` fed by an agent-mode manifest embeds `record` too, as a fallback copy, but never `detached` — the manifest record stays - authoritative while the manifest covers the entry (ledger key or base purl); `vex`, `list` and - `setup --check` read the fallback copy only when it does not, `repair` only with no manifest at all. + authoritative while the manifest covers the entry (ledger key or base purl); `vex` and `list` + read the fallback copy only when it does not, `repair` only with no manifest at all. * **Re-vendor carries originals forward**: re-vendoring under a newer patch uuid rewrites the previous run's own wiring (`original: None` from the backend — it must never record a dangling `.socket/vendor/` pointer as pre-vendor state); the engine merges the TRUE pre-vendor originals @@ -948,7 +779,7 @@ A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatc Restore the system but keep the local patch state for a later re-apply: manifest entries kept, vendored artifacts + ledger entries kept byte-identical (only the lockfile wiring is reverted; the already-reverted wiring records replay as silent no-ops on a later revert, and a re-vendor re-wires from the live lock), and all blob/archive GC skipped. **Hosted redirects have no preservable local state**: their ledger records describe live wiring only, so a preserve run still unwinds them and drops the records either way — surfaced as the `hosted_state_not_preservable` warning (re-run `scan --mode hosted` to re-wire). Caveat (documented): preserved vendored entries may be reclaimed by an explicit later `scan --prune` (user-invoked GC); `vendor` re-runs re-wire them. -**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties a ledger deletes the file — `redirect-state.json` and/or `vendor/state.json` — and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. +**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties a ledger deletes the file — `redirect-state.json` and/or `vendor/state.json` — and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ any legacy `setup` block — never deleted, see the exit-code section), a `.socket/.gitignore`, `gem-plugin-stamp` or `bundler-plugin/` left by a pre-v5 `setup`, and `.corrupt` quarantine files. ### Hosted unwind coverage @@ -1058,7 +889,7 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_VERBOSE` | `--verbose` / `-v` | `false` | — | | `SOCKET_SILENT` | `--silent` / `-s` | `false` | — | | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | -| `SOCKET_YES` | `--yes` / `-y` | `false` | Skips the prompts of `get`, `rollback`, `remove`, `setup` and `--update`; `scan` never prompts, so it has no effect there. | +| `SOCKET_YES` | `--yes` / `-y` | `false` | Skips the prompts of `get`, `rollback`, `remove` and `--update`; `scan` never prompts, so it has no effect there. | | `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock` on the lock-taking subcommands (incl. hosted/vendored `scan`/`get`); unset/`0` = single non-blocking try. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | @@ -1076,7 +907,6 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_PRESERVE_STATE` | `rollback --preserve-state` / `remove --preserve-state` | `false` | (v5.0) Shared by `rollback`/`remove` (boolish, empty-tolerant parse like the other bool flags): restore the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — and skip all GC. On `remove`, combining it with `--skip-rollback` is a usage error (exit 2) **whether either side is flag- or env-sourced** (`SOCKET_PRESERVE_STATE=true remove --skip-rollback` exits 2 too). | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | | `SOCKET_VENDOR_REVERT` | `vendor --revert` | `false` | Local to `vendor`. | -| `SOCKET_SETUP_EXCLUDE` | `setup --exclude` | (none) | Local to `setup`; comma-separated workspace-member paths, persisted to `setup.exclude`. | | `SOCKET_VEX` | `apply --vex` / `scan --vex` / `vendor --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on the host commands they bind to `--vex-product` etc. | | `SOCKET_VEX_OUTPUT` | `vex --output` / `-O` | (none) | Local to the standalone `vex`: document output path (required with `--json`). | @@ -1161,7 +991,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified ```jsonc { - "command": "scan" | "apply" | "vex" | "vendor" | "setup" | "rollback" | "get" | "list" | "remove" | "repair", + "command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair", "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound", "dryRun": false, "events": [ , ... ], @@ -1260,7 +1090,6 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | | `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | -| `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). | | `stale_install` | `skipped` | vex (in-run `scan --mode hosted --vex`): a hosted stale-install probe found positively unpatched installed bytes, so the purl is omitted even under `--vex-no-verify` (see the gem / Python stale-install guards). | | `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | | `record_mismatch` | `skipped` | vex (manifest-less): the record found for a wired patch names another package or another patch uuid than the wiring. | @@ -1342,7 +1171,6 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_vlt_no_lockfile` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | | `redirect_vlt_artifact_unverifiable` | `redirect.warnings[]` (warning), `redirect.skipped[].reason` | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | | `redirect_vlt_reinstall_required` | `redirect.warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | -| `vlt_root_scripts_not_run` | setup `warnings[]` entry `vlt_root_scripts_not_run: ` (advisory; human: `Warning (vlt_root_scripts_not_run): ` on stderr, muted by `--silent`) | setup (vlt project, npm in scope, every non-`no_files` run incl. `--dry-run` and already-configured): vlt before 1.0.0-rc.13 never runs a root `postinstall`, so the wired hook would not fire. Definite when the `vlt` on `PATH` (absolute entries only; spawned with `VLT_TELEMETRY=0`, 5 s budget) reports a semver below 1.0.0-rc.13 (the detail ends in "(`vlt --version` reports )"); an unparseable `--version` never warns. Otherwise it is a "may" when `vlt-lock.json` has `lockfileVersion` `0` or none, a lock the reported `vlt` would not write: always without a usable `vlt` (absent, non-zero exit, timeout); with one at or above 1.0.0-rc.13, for a lock with no `lockfileVersion`, and for a `0` lock when it reports 1.0.0-rc.15 or later (which refuses a v0 lock, so another vlt installs the project). Remedy: upgrade vlt or run `socket-patch apply` after `vlt ci`. The hook is still written. | | `vendor_prebuilt_stub_invalid` | `failed` / `skipped` (warning) | vendor (gem, `--vendor-source`): the served stub gemspec fails the rubygems `summary`/`authors` bar, so bundler would refuse the vendored path source at install time. `service`: refusal naming the missing attributes; `auto`: loud warning + local-build fallback — or, when the gem is also not installed locally (no stub to derive), a refusal naming the served defect and the install-the-gem remedy. | | `gem_spec_invalid` | `failed` | vendor (gem): the LOCAL `specifications/` stub gemspec fails the same rubygems `summary`/`authors` bar (a corrupted or hand-edited gem home); the refusal names the file — reinstall the gem (`gem pristine ` / fresh `bundle install`). | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | @@ -1387,7 +1215,6 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. - ⏳ `get` — still emits per-patch action arrays. - ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1-on-unmatched). v5.0 keeps that legacy shape and adds the ALWAYS-PRESENT keys `warnings[]` (`{code, detail}` objects, now populated), `vendored` (meaning narrowed — MAJOR), `vendoredReverted`, `vendoredPreserved`, `vendoredKept` (`{purl, reason}`), `hosted` (`{reverted, failed: [{purl, error}], unsupported, editedFiles}`), `manifest` (`{removedEntries, preserved}`), `gc` (`{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`), and `paths` — full key semantics and exit rules in the [Rollback command contract](#rollback-command-contract-v50). -- ⏳ `setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract). One command is **intentionally not** plain-envelope and will stay that way (not migration debt): @@ -1596,7 +1423,7 @@ socket-patch apply --json | jq ' Exit `0` when `status` is `success`, `noManifest`, or `notFound`-with-zero-failed. Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) or `error`. -`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. A fully rolled-back agent project therefore keeps `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block): the v5.0 residue rule never deletes a zero-patch manifest, precisely so these hook exits (and `list`'s 0-vs-1 below) never flip. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for CI steps that run `apply` after every install. A fully rolled-back agent project therefore keeps `.socket/manifest.json` at `{"patches": {}}`: the v5.0 residue rule never deletes a zero-patch manifest, precisely so these CI exits (and `list`'s 0-vs-1 below) never flip. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. ## Exit codes @@ -1604,7 +1431,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | `list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. @@ -1613,7 +1440,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | Code | Meaning | |---|---| | `0` | A non-empty OpenVEX document was produced | -| `1` | Nothing attested: `no_applicable_patches` (every candidate was omitted — by verification, a wiring gate, a missing record, or Property 7; the omissions ride `skipped` events) or `no_patches` (an empty manifest file and nothing wired anywhere) | +| `1` | Nothing attested: `no_applicable_patches` (every candidate was omitted — by verification, a wiring gate, or a missing record; the omissions ride `skipped` events) or `no_patches` (an empty manifest file and nothing wired anywhere) | | `2` | Hard error: `manifest_not_found` (no manifest AND no ledger record / lockfile reference anywhere), `manifest_unreadable`, `redirect_ledger_corrupt`, `vendor_ledger_corrupt`, `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | A missing manifest alone is not an error: a hosted or vendored checkout attests from its lockfiles (see "Manifest-less VEX"). Embedded `--vex` maps every failure to the host command's exit `1`. @@ -1669,8 +1496,7 @@ This syncs the workspace package version into: - `npm/socket-patch/package.json` (and its `optionalDependencies`) - every per-platform `npm/socket-patch-*/package.json` -- `pypi/socket-patch/pyproject.toml` and `pypi/socket-patch-hook/pyproject.toml` -- `gem/socket-patch-bundler/socket-patch-bundler.gemspec` (the Bundler plugin gem) +- `pypi/socket-patch/pyproject.toml` - `gem/socket-patch/socket-patch.gemspec` + its launcher `VERSION` (the RubyGems CLI launcher) All ecosystem publishing fans out from the single @@ -1689,6 +1515,6 @@ Every item in this document is locked in by at least one of: - **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. - **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, the JSON serializers, and the terminal UI in `src/ui/` (`StatusLine` redraw/clear/`println` byte streams, `confirm_with` answers and non-interactive notes, `select_one`'s JSON/empty guards, `plural`, `truncate`, the `color_enabled` truth table, `paint`/`severity`, and `pad`/`strip_ansi` alignment). -- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. +- **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 4858d339..bd7262d0 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -52,16 +52,6 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ # `tests/docker_e2e_*.rs`. Tests in this suite require either a running # Docker daemon OR `SOCKET_PATCH_TEST_HOST=1` (host-toolchain mode). docker-e2e = [] -# Enables the experimental `setup` end-to-end test matrix under -# `tests/setup_matrix_*.rs`, which drives the `socket-patch setup` → -# native-install → patch-applied flow across every ecosystem/package -# manager via `tests/setup_matrix/run-case.sh`. Same runtime requirement -# as docker-e2e (Docker daemon OR `SOCKET_PATCH_TEST_HOST=1`). These -# tests are ASPIRATIONAL: they assert the ideal (install applies the -# patch) and are EXPECTED to fail for ecosystems whose install hooks -# `setup` does not yet configure. Kept off `--all-features`-required CI; -# the dedicated `setup-matrix` CI job runs them non-blocking. -setup-e2e = [] [dev-dependencies] # vendor_crash_safety_e2e / vendor_group_commit_e2e crash the binary through diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 29a09df7..d7b0abca 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -303,7 +303,7 @@ pub struct GlobalArgs { /// backoff until the lock frees or the budget elapses. Only meaningful /// for the commands that take the lock (`apply`, `rollback`, `repair`, /// `remove`, `vendor`, `get` and `scan` when they record, apply, - /// vendor or redirect patches, and `setup --exclude`'s manifest write); + /// vendor or redirect patches); /// other commands accept it silently. Every holder removes the lock file on exit, so a leftover /// from a crashed run never contends. #[arg(help_heading = GLOBAL_OPTIONS, long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] @@ -483,7 +483,7 @@ impl GlobalArgs { /// The `(api_token, org_slug)` telemetry is attributed with, resolved /// through the API client's own credential chain (flag → the /// `SOCKET_NO_API_TOKEN` veto → env → `socket login` config) WITHOUT - /// building a client. For the purely local commands (`list`, `setup`, + /// building a client. For the purely local commands (`list`, /// `vex`): a client would add the org-slug auto-resolve round-trip and /// the "No SOCKET_API_TOKEN set" advisory to a command that needs /// neither, while anything less than the full chain reported a @@ -619,7 +619,6 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_SKIP_ROLLBACK", "SOCKET_PRESERVE_STATE", "SOCKET_DOWNLOAD_ONLY", - "SOCKET_SETUP_EXCLUDE", "SOCKET_VENDOR_REVERT", "SOCKET_BATCH_SIZE", "SOCKET_SCAN_PACKAGES", @@ -1484,15 +1483,15 @@ mod tests { } /// The mirror only works if every subcommand's `run` actually calls - /// `apply_env_toggles`. `list` and `setup` fire telemetry - /// (`track_patch_listed` / `track_patch_setup`) whose kill-switch reads + /// `apply_env_toggles`. `list` fires telemetry + /// (`track_patch_listed`) whose kill-switch reads /// `SOCKET_TELEMETRY_DISABLED` / `SOCKET_OFFLINE` from the env only — a /// run entry point that skips the mirror silently ignores /// `--no-telemetry` and lets `--offline` (strict airgap: never contact /// the network) still fire the telemetry HTTP request. #[test] #[serial_test::serial] - fn list_and_setup_run_mirror_global_toggles_for_airgap() { + fn list_run_mirrors_global_toggles_for_airgap() { with_clean_socket_env(|| { with_clean_telemetry_env(|| { let rt = tokio::runtime::Builder::new_current_thread() @@ -1522,28 +1521,6 @@ mod tests { env — its telemetry kill-switch reads only SOCKET_OFFLINE / \ SOCKET_TELEMETRY_DISABLED", ); - - // Reset the mirrored vars so setup can't pass on list's leftovers. - std::env::remove_var("SOCKET_OFFLINE"); - std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); - - let tmp = tempfile::tempdir().unwrap(); - rt.block_on(crate::commands::setup::run( - crate::commands::setup::SetupArgs { - check: false, - remove: false, - exclude: Vec::new(), - common: GlobalArgs { - // `setup` must not write anything from a unit test. - dry_run: true, - ..toggles_on(tmp.path()) - }, - }, - )); - assert!( - socket_patch_core::telemetry::is_telemetry_disabled(), - "`setup --offline --no-telemetry` must mirror the toggles into the env", - ); }); }); } @@ -1607,7 +1584,7 @@ mod tests { ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "vex"]), ("SOCKET_VEX_COMPACT", &["socket-patch", "vex"]), // The embedded `--vex-*` twins share the same env vars and must - // not abort host commands (e.g. apply from a postinstall hook). + // not abort host commands (e.g. apply from a CI step). ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "apply"]), ("SOCKET_VEX_COMPACT", &["socket-patch", "scan"]), ]; @@ -1645,7 +1622,6 @@ mod tests { ("SOCKET_BATCH_SIZE", &["socket-patch", "scan"]), ("SOCKET_SCAN_PACKAGES", &["socket-patch", "scan"]), ("SOCKET_PATCH_VERSION", &["socket-patch", "self-update"]), - ("SOCKET_SETUP_EXCLUDE", &["socket-patch", "setup"]), ("SOCKET_VEX", &["socket-patch", "apply"]), ("SOCKET_VEX_OUTPUT", &["socket-patch", "vex"]), ("SOCKET_VEX_PRODUCT", &["socket-patch", "vex"]), diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index b1b932eb..bbf06034 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -765,7 +765,8 @@ pub async fn run(args: ApplyArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); // No manifest → nothing to apply: a clean exit-0 no-op (load-bearing - // for the install hooks, which run `apply --silent` on every install). + // for CI steps and legacy install hooks that run `apply --silent` on + // every install). // Nothing below this gate is touched — no API client (its config read, // stderr advisory and org-slug round-trip), no lock, no `.socket/`. if tokio::fs::metadata(&manifest_path).await.is_err() { diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index ddda23e2..c38fcb74 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -10,7 +10,6 @@ pub mod repair; pub(crate) mod repair_vendor; pub mod rollback; pub mod scan; -pub mod setup; pub mod update; pub mod vendor; pub mod vex; @@ -112,8 +111,8 @@ pub(crate) async fn load_vendor_state_lenient( } /// Whether a vendor-ledger entry's embedded `record` stands on its own — -/// the rule every reader of embedded records shares (`vex`'s record plan, -/// `list`, and `setup --check` through [`fold_vendor_records`]), so one +/// the rule every reader of embedded records shares (`vex`'s record plan +/// and `list`), so one /// tree never lists "no patches" while its VEX document attests one. /// /// A `detached` entry (every `scan`/`get --mode vendored` entry) has no @@ -137,124 +136,3 @@ pub(crate) fn vendor_record_is_unowned( !m.patches.contains_key(key) && !m.patches.contains_key(&entry.base_purl) }) } - -/// Fold the vendor ledger's embedded records into a manifest view. -/// Vendored mode is manifest-free (every `scan`/`get --mode vendored` entry -/// carries `detached: true` plus its embedded patch `record`), so the -/// ledger is the only copy of those records: verification (`setup --check`, -/// property 4) must see them exactly like manifest entries (`vex` gathers -/// them through its own gated plan, `commands::vex_sources`). A standalone -/// `vendor` entry's fallback copy folds in under the same -/// [`vendor_record_is_unowned`] rule `vex` and `list` apply — only when the -/// manifest does not cover the entry. Keyed by the ledger key; an existing -/// manifest entry wins a collision (that purl is manifest-owned and -/// verifies against the manifest's record). Ownership is judged against -/// the manifest as given, never against records folded earlier in the same -/// pass (`HashMap` order must not decide which entries fold). -pub(crate) fn fold_vendor_records( - manifest: &mut socket_patch_core::manifest::schema::PatchManifest, - entries: &std::collections::HashMap, -) { - let folded: Vec<(String, socket_patch_core::manifest::schema::PatchRecord)> = entries - .iter() - .filter(|(key, entry)| { - vendor_record_is_unowned(key, entry, Some(&*manifest)) - && !manifest.patches.contains_key(key.as_str()) - }) - .filter_map(|(key, entry)| Some((key.clone(), entry.record.clone()?))) - .collect(); - manifest.patches.extend(folded); -} - -#[cfg(test)] -mod vendor_record_fold_tests { - use std::collections::HashMap; - - use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; - use socket_patch_core::vendor::VendorEntry; - - use super::fold_vendor_records; - - fn record(uuid: &str) -> PatchRecord { - serde_json::from_value(serde_json::json!({ - "uuid": uuid, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { "package/index.js": { "beforeHash": "b", "afterHash": "a" } }, - "vulnerabilities": {}, - "description": "fixture", - "license": "MIT", - "tier": "free", - })) - .expect("record fixture deserializes") - } - - fn entry(base_purl: &str, uuid: &str, detached: bool, embedded: bool) -> VendorEntry { - serde_json::from_value(serde_json::json!({ - "ecosystem": "npm", - "basePurl": base_purl, - "uuid": uuid, - "artifact": { "path": format!(".socket/vendor/npm/{uuid}/pkg.tgz") }, - "wiring": [], - "detached": detached, - "record": embedded.then(|| record(uuid)), - })) - .expect("vendor entry fixture deserializes") - } - - /// `setup --check`'s fold follows the rule `vex` attests by: detached - /// records always fold (a manifest entry wins its own key), a standalone - /// `vendor` entry's fallback copy folds only when the manifest covers - /// neither its key nor its base purl, and a record-less legacy entry - /// never folds. Ownership is judged against the manifest as given. - #[test] - fn standalone_vendor_fallback_folds_only_when_uncovered() { - let mut entries = HashMap::new(); - entries.insert( - "pkg:npm/owned@1.0.0".to_string(), - entry("pkg:npm/owned@1.0.0", "u-stale", false, true), - ); - entries.insert( - "pkg:npm/owned@1.0.0?variant=x".to_string(), - entry("pkg:npm/owned@1.0.0", "u-variant", false, true), - ); - entries.insert( - "pkg:npm/dropped@1.0.0".to_string(), - entry("pkg:npm/dropped@1.0.0", "u-dropped", false, true), - ); - entries.insert( - "pkg:npm/detached@1.0.0".to_string(), - entry("pkg:npm/detached@1.0.0", "u-detached", true, true), - ); - entries.insert( - "pkg:npm/legacy@1.0.0".to_string(), - entry("pkg:npm/legacy@1.0.0", "u-legacy", false, false), - ); - - let mut manifest = PatchManifest::default(); - manifest - .patches - .insert("pkg:npm/owned@1.0.0".to_string(), record("u-manifest")); - fold_vendor_records(&mut manifest, &entries); - let mut folded: Vec<(&str, &str)> = manifest - .patches - .iter() - .map(|(k, r)| (k.as_str(), r.uuid.as_str())) - .collect(); - folded.sort(); - assert_eq!( - folded, - vec![ - ("pkg:npm/detached@1.0.0", "u-detached"), - ("pkg:npm/dropped@1.0.0", "u-dropped"), - ("pkg:npm/owned@1.0.0", "u-manifest"), - ], - "a newer manifest uuid keeps winning; covered and record-less entries stay out" - ); - - // No manifest records at all: every embedded copy folds. - let mut empty = PatchManifest::default(); - fold_vendor_records(&mut empty, &entries); - assert_eq!(empty.patches.len(), 4, "{:?}", empty.patches.keys()); - assert_eq!(empty.patches["pkg:npm/owned@1.0.0"].uuid, "u-stale"); - } -} diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 9ce95401..ec46e247 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -841,7 +841,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // the blob sweep below can still preview against the post-removal // reference set. `--preserve-state` deliberately touches neither the // manifest nor the blobs. An emptied manifest stays on disk as - // `{"patches": {}}` — it carries the setup block and the + // `{"patches": {}}` — it carries any legacy setup block and the // empty-vs-missing exit codes of `list`/`apply`/`repair`. let mut updated_manifest = manifest.clone(); let removed = if args.preserve_state { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 14806e79..d8ff421a 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1760,7 +1760,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // The manifest is rewritten only when an entry actually leaves // it; an emptied manifest stays on disk as `{"patches": {}}` - // (it carries the setup block and `list`/`apply`/`repair`'s + // (it carries any legacy setup block and `list`/`apply`/`repair`'s // empty-vs-missing exit codes) — never deleted. let mut removed: Vec = Vec::new(); let mut updated_manifest = manifest.clone(); diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs deleted file mode 100644 index 63106bdc..00000000 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ /dev/null @@ -1,3018 +0,0 @@ -use clap::Args; -use socket_patch_core::crawlers::python_crawler::is_python_project; -use socket_patch_core::crawlers::Ecosystem; -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}; -use socket_patch_core::package_json::find::{ - detect_package_manager, find_package_json_files, legacy_vlt_lock, LegacyVltLock, - PackageJsonFindResult, PackageJsonLocation, WorkspaceType, -}; -use socket_patch_core::package_json::update::{ - remove_package_json, update_package_json, RemoveResult, RemoveStatus, UpdateResult, - UpdateStatus, -}; -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::setup::pypi::edit::{ - add_hook_dependency, pyproject_contains_hook, remove_hook_dependency, ManifestKind, - PthEditResult, PthStatus, -}; -use socket_patch_core::telemetry::track_patch_setup; -use socket_patch_core::utils::process::{command_for, resolve_tool}; -use socket_patch_core::vex::applied_patches_with_vendor; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::Duration; - -use crate::args::{apply_env_toggles, GlobalArgs}; -use crate::ecosystem_dispatch::find_manifest_package_paths; -use crate::ui::plural; - -/// Stringify the detected npm-family manager for telemetry. -fn manager_name(pm: PackageManager) -> &'static str { - match pm { - PackageManager::Npm => "npm", - PackageManager::Pnpm => "pnpm", - PackageManager::Vlt => "vlt", - } -} - -const VLT_ROOT_SCRIPTS_NOT_RUN: &str = "vlt_root_scripts_not_run"; -const VLT_ROOT_SCRIPTS_REMEDY: &str = "vlt before 1.0.0-rc.13 does not run the root postinstall \ - hook; upgrade vlt or run `socket-patch apply` after `vlt ci`"; -/// The first vlt release that runs a root `postinstall` without an -/// `install` script. -const VLT_ROOT_SCRIPTS_FLOOR: &str = "1.0.0-rc.13"; -/// The first vlt release that writes `lockfileVersion` 1 and refuses a v0 -/// lock. -const VLT_LOCK_V1_FLOOR: &str = "1.0.0-rc.15"; -const VLT_VERSION_TIMEOUT: Duration = Duration::from_secs(5); - -/// What `vlt --version` said, when a `vlt` resolved on PATH and exited 0 -/// within [`VLT_VERSION_TIMEOUT`]. -#[derive(Debug, Clone, PartialEq)] -enum VltVersionProbe { - Reported(String), - Unavailable, -} - -async fn probe_vlt_version(cwd: &Path) -> VltVersionProbe { - let Some(program) = resolve_tool("vlt") else { - return VltVersionProbe::Unavailable; - }; - let mut cmd = tokio::process::Command::from(command_for(&program)); - cmd.arg("--version") - .env("VLT_TELEMETRY", "0") - .current_dir(cwd) - .stdin(Stdio::null()) - .kill_on_drop(true); - match tokio::time::timeout(VLT_VERSION_TIMEOUT, cmd.output()).await { - Ok(Ok(out)) if out.status.success() => { - VltVersionProbe::Reported(String::from_utf8_lossy(&out.stdout).trim().to_string()) - } - _ => VltVersionProbe::Unavailable, - } -} - -/// The `vlt_root_scripts_not_run` detail, if any. A reported version -/// below the floor is definite and an unparseable one never warns. Else a -/// lock only an old vlt writes makes the warning a "may", unless the -/// reported vlt writes that lock itself (rc.13 and rc.14 write v0). -fn vlt_root_scripts_advisory( - probe: &VltVersionProbe, - lock: Option, -) -> Option { - if let VltVersionProbe::Reported(raw) = probe { - let floor = semver::Version::parse(VLT_ROOT_SCRIPTS_FLOOR).ok()?; - let lock_v1_floor = semver::Version::parse(VLT_LOCK_V1_FLOOR).ok()?; - let version = semver::Version::parse(raw).ok()?; - if version < floor { - return Some(format!( - "{VLT_ROOT_SCRIPTS_REMEDY} (`vlt --version` reports {raw})" - )); - } - if lock == Some(LegacyVltLock::VersionZero) && version < lock_v1_floor { - return None; - } - } - lock.map(|lock| { - let version = match lock { - LegacyVltLock::NoVersion => "has no lockfileVersion", - LegacyVltLock::VersionZero => "has lockfileVersion 0", - }; - format!( - "{VLT_ROOT_SCRIPTS_REMEDY} (vlt-lock.json {version}, so this project may be \ - installed by such a vlt)" - ) - }) -} - -/// The run's `(code, detail)` advisories for a vlt project `setup` wires. -async fn vlt_setup_advisories(cwd: &Path) -> Vec<(&'static str, String)> { - let probe = probe_vlt_version(cwd).await; - let lock = legacy_vlt_lock(cwd).await; - vlt_root_scripts_advisory(&probe, lock) - .map(|detail| (VLT_ROOT_SCRIPTS_NOT_RUN, detail)) - .into_iter() - .collect() -} - -/// `Warning (): ` on stderr in human mode; `--json` carries -/// them in the envelope's `warnings` and `--silent` mutes them. -fn print_advisories(common: &GlobalArgs, advisories: &[(&str, String)]) { - if common.json || common.silent { - return; - } - for (code, detail) in advisories { - eprintln!("Warning ({code}): {detail}"); - } -} - -/// Compose the `+`-joined telemetry manager tag across the ecosystems in scope -/// (e.g. `npm+pypi+gem`), or `none`. -fn telemetry_manager_str( - npm: bool, - py: bool, - gem: bool, - composer: bool, - npm_pm: PackageManager, -) -> String { - let mut parts: Vec<&str> = Vec::new(); - if npm { - parts.push(manager_name(npm_pm)); - } - if py { - parts.push("pypi"); - } - if gem { - parts.push("gem"); - } - if composer { - parts.push("composer"); - } - if parts.is_empty() { - "none".to_string() - } else { - parts.join("+") - } -} - -#[derive(Args)] -pub struct SetupArgs { - /// Verify the project is configured for socket-patch without changing - /// anything. Exits non-zero if any manifest still needs setup. - #[arg( - long = "check", - conflicts_with = "remove", - default_value_t = false, - value_parser = crate::args::parse_bool_flag, - )] - pub check: bool, - - /// Revert the install hooks that `setup` added: npm `package.json` scripts, - /// the Python `socket-patch[hook]` dependency, the gem Bundler plugin - /// wiring, and the Composer `composer.json` script. - #[arg( - long = "remove", - default_value_t = false, - value_parser = crate::args::parse_bool_flag, - )] - pub remove: bool, - - /// Workspace-member path(s) to exclude from setup (comma-separated, relative - /// to the repo root). The exclusion is persisted in `.socket/manifest.json` - /// so `setup --check` and a fresh clone honor it without re-passing the flag. - // CLI_CONTRACT property 9. - #[arg(long = "exclude", env = "SOCKET_SETUP_EXCLUDE", value_delimiter = ',')] - pub exclude: Vec, - - #[command(flatten)] - pub common: GlobalArgs, -} - -pub async fn run(args: SetupArgs) -> i32 { - apply_env_toggles(&args.common); - if args.check { - run_check(&args).await - } else if args.remove { - run_remove(&args).await - } else { - run_setup(&args).await - } -} - -/// Discover the package.json files `setup`/`check`/`remove` should act on, -/// applying the pnpm "root-only" filtering. Returns an empty vec when none are -/// found (callers also consider Python before reporting `no_files`). -async fn discover(args: &SetupArgs, excludes: &[String]) -> Vec { - discover_members(args, excludes, false).await -} - -/// [`discover`] for `--remove`, which also visits the vlt workspace members -/// that still carry a hook: releases before vlt workspace support wired -/// every member. -async fn discover_for_remove(args: &SetupArgs, excludes: &[String]) -> Vec { - discover_members(args, excludes, true).await -} - -async fn discover_members( - args: &SetupArgs, - excludes: &[String], - keep_hooked_vlt_members: bool, -) -> Vec { - let Some(found) = find_members(args).await else { - return Vec::new(); - }; - warn_unmatched_excludes( - &args.common, - &unmatched_excludes(&found, &args.common.cwd, excludes), - ); - let keep = if keep_hooked_vlt_members { - hooked_vlt_members(&found).await - } else { - Vec::new() - }; - select_members(found, &args.common.cwd, excludes, &keep) -} - -/// The vlt workspace members whose package.json carries either hook. -async fn hooked_vlt_members(found: &PackageJsonFindResult) -> Vec { - if !matches!(found.workspace_type, WorkspaceType::Vlt) { - return Vec::new(); - } - let mut hooked = Vec::new(); - for loc in found.files.iter().filter(|loc| !loc.is_root) { - if let Ok(content) = socket_patch_core::utils::fs::read_regular_to_string(&loc.path).await - { - let status = is_setup_configured_str(&content); - if status.postinstall_configured || status.dependencies_configured { - hooked.push(loc.path.clone()); - } - } - } - hooked -} - -/// Walk for package.json files; `None` when npm is out of `--ecosystems` scope. -async fn find_members(args: &SetupArgs) -> Option { - if !eco_in_scope(&args.common, Ecosystem::Npm) { - return None; - } - Some(find_package_json_files(&args.common.cwd).await) -} - -/// The exclude values (normalized) that cover no discovered member. Such a -/// value is almost always a typo. Checked against every member, before the -/// pnpm root-only filter. -fn unmatched_excludes( - found: &PackageJsonFindResult, - cwd: &Path, - excludes: &[String], -) -> Vec { - let mut out: Vec = Vec::new(); - for e in excludes { - let n = normalize_rel_path(e); - if n.is_empty() || out.contains(&n) { - continue; - } - let matched = found.files.iter().any(|loc| { - !loc.is_root && is_member_excluded(&loc.path, cwd, std::slice::from_ref(&n)) - }); - if !matched { - out.push(n); - } - } - out -} - -/// Say so (human mode only) rather than silently doing nothing. -fn warn_unmatched_excludes(common: &GlobalArgs, unmatched: &[String]) { - if common.json || common.silent { - return; - } - for e in unmatched { - eprintln!("Warning: {}", format_unmatched_exclude(e)); - } -} - -/// Apply the pnpm/vlt root-only rule (sparing the members in `keep`) and -/// drop excluded members. -fn select_members( - found: PackageJsonFindResult, - cwd: &Path, - excludes: &[String], - keep: &[PathBuf], -) -> Vec { - // For pnpm monorepos, only update root package.json. pnpm runs root - // postinstall on `pnpm install`, so workspace-level postinstall scripts are - // unnecessary and would fail under pnpm's strict module isolation. vlt - // has one root store and runs the root hook once per install, even one - // started from a member directory. - let files: Vec = match found.workspace_type { - WorkspaceType::Pnpm | WorkspaceType::Vlt => found - .files - .into_iter() - .filter(|loc| loc.is_root || keep.contains(&loc.path)) - .collect(), - _ => found.files, - }; - - // Property 9: drop excluded workspace members (the root is never excludable). - files - .into_iter() - .filter(|loc| loc.is_root || !is_member_excluded(&loc.path, cwd, excludes)) - .collect() -} - -/// Emit the shared `no_files` result and exit code. `counts` carries the -/// per-command zero-valued summary fields (`setup` → updated/already/errors, -/// `check` → configured/needs/errors, `remove` → removed/notConfigured/errors) -/// so the `no_files` envelope keeps the documented shape (CLI_CONTRACT "Setup -/// command contract") instead of dropping them. -fn report_no_files(args: &SetupArgs, counts: &[(&str, i64)]) -> i32 { - if args.common.json { - // `serde_json::Map` preserves insertion order (the crate enables - // `preserve_order`), so status → counts → files comes out in that order. - let mut map = serde_json::Map::new(); - map.insert("status".to_string(), serde_json::json!("no_files")); - for (key, value) in counts { - map.insert((*key).to_string(), serde_json::json!(value)); - } - map.insert("files".to_string(), serde_json::json!([])); - println!( - "{}", - serde_json::to_string_pretty(&serde_json::Value::Object(map)) - .expect("serializing an in-memory JSON value cannot fail") - ); - } else if !args.common.silent { - println!("{}", no_files_message(&args.common)); - } - 0 -} - -/// The setup-capable ecosystems (their `--ecosystems` tokens) and the name -/// of the project each one looks for, in discovery order. -const SETUP_ECOSYSTEMS: &[(Ecosystem, &str)] = &[ - (Ecosystem::Npm, "package.json"), - (Ecosystem::Pypi, "Python"), - (Ecosystem::Gem, "Bundler"), - (Ecosystem::Composer, "Composer"), -]; - -/// The human `no_files` line for this run's `--ecosystems` scope. -fn no_files_message(common: &GlobalArgs) -> String { - let in_scope: Vec<&str> = SETUP_ECOSYSTEMS - .iter() - .filter(|(eco, _)| eco_in_scope(common, *eco)) - .map(|(_, label)| *label) - .collect(); - format_no_files(&in_scope, common.ecosystems.as_deref().unwrap_or(&[])) -} - -/// `No package.json, Python, Bundler, or Composer project found`, narrowed -/// to the in-scope ecosystems. When `--ecosystems` names none that `setup` -/// can wire, "no project found" would be false (the project may well -/// exist), so say that setup has no hook for them instead. -fn format_no_files(in_scope: &[&str], requested: &[String]) -> String { - if in_scope.is_empty() { - return format!( - "Setup has no install hook for: {} (supported: npm, pypi, gem, composer)", - requested.join(", ") - ); - } - format!("No {} project found", join_or(in_scope)) -} - -/// `a`, `a or b`, `a, b, or c`. -fn join_or(items: &[&str]) -> String { - match items { - [] => String::new(), - [one] => (*one).to_string(), - [a, b] => format!("{a} or {b}"), - [init @ .., last] => format!("{}, or {last}", init.join(", ")), - } -} - -/// The warning for an `--exclude` value that matches no workspace member. -fn format_unmatched_exclude(value: &str) -> String { - format!("--exclude {:?} matched no workspace member", value.trim()) -} - -fn pathdiff(path: &str, base: &Path) -> String { - let p = Path::new(path); - p.strip_prefix(base) - .map(|r| r.display().to_string()) - .unwrap_or_else(|_| path.to_string()) -} - -/// Whether an ecosystem is in scope for this run, honoring the global -/// `--ecosystems` filter (`CLI_CONTRACT.md` → "Setup command contract", -/// property 2). With no filter (or an empty one) every ecosystem is in scope. -/// The exact `cli_name` match is the only one that can ever fire — clap's -/// value parser admits no alias or case variant — and it is the same rule -/// `partition_purls` applies, so setup's scope never diverges from apply's. -fn eco_in_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { - common.ecosystem_selected(eco) -} - -/// 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.trim().replace('\\', "/"); - let p = p.strip_prefix("./").unwrap_or(&p); - p.trim_end_matches('/').to_string() -} - -/// Whether a discovered member manifest (`package.json` / `Cargo.toml`) lies in -/// an excluded workspace-member directory (relative to `cwd`). The repo root -/// (relative path `""`) is never excludable — `--exclude` targets members. -/// (CLI_CONTRACT property 9.) -fn is_member_excluded(manifest_path: &Path, cwd: &Path, excludes: &[String]) -> bool { - if excludes.is_empty() { - return false; - } - let dir = match manifest_path.parent() { - Some(d) => d, - None => return false, - }; - let rel = match dir.strip_prefix(cwd) { - Ok(r) => normalize_rel_path(&r.to_string_lossy()), - Err(_) => return false, // outside cwd → not an excludable member - }; - if rel.is_empty() { - return false; - } - 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}/"))) - }) -} - -/// This run's ONE read of `.socket/manifest.json`, shared by the exclude -/// resolution, the `--exclude` persistence's already-persisted check and -/// `--check`'s patch-consistency pass. -async fn read_setup_manifest(common: &GlobalArgs) -> io::Result> { - read_manifest(&common.resolved_manifest_path()).await -} - -/// The manifest as the read-only consumers see it: absent OR unreadable -/// contribute nothing (the persistence step is what reports an unreadable -/// manifest). -fn manifest_view(existing: &io::Result>) -> Option<&PatchManifest> { - existing.as_ref().ok().and_then(Option::as_ref) -} - -/// The exclude set in effect for this run: the persisted `setup.exclude` list -/// from the manifest (empty if no manifest / no setup state) union the -/// `--exclude` flag values (all normalized). This is what a clone inherits — -/// a clone with no flag still reads the persisted set. -fn effective_excludes(manifest: Option<&PatchManifest>, flag: &[String]) -> Vec { - let mut set: Vec = manifest - .and_then(|m| m.setup.as_ref()) - .map(|s| s.exclude.iter().map(|e| normalize_rel_path(e)).collect()) - .unwrap_or_default(); - for e in flag { - let n = normalize_rel_path(e); - if !n.is_empty() && !set.contains(&n) { - set.push(n); - } - } - set -} - -/// Persist the effective exclude set into `.socket/manifest.json` (creating a -/// minimal manifest if none exists) so `--check` and a fresh clone honor it -/// without re-passing `--exclude`. No-op when the set is empty or `existing` -/// (this run's read) already carries it exactly — no lock, no rewrite, the -/// manifest stays byte-stable. Called only past the run's mutation gate -/// (discovery found work, the preview was confirmed or nothing needed -/// confirming) and never under `--dry-run`, so a no-project directory or an -/// aborted prompt leaves no `.socket/` behind. -/// -/// The write is a read-modify-write of the file `apply`/`get`/`remove`/ -/// `rollback` rewrite under `apply.lock`, so it takes the same lock and -/// re-reads under it; a missing `.socket/` is created by the acquire and -/// pruned again by the guard's drop if nothing gets written. -/// -/// Returns a warning string when persistence was SKIPPED (fail-closed: the -/// lock is held elsewhere, the manifest cannot be read, or the write -/// failed) — 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, - existing: &io::Result>, - excludes: &[String], -) -> Option { - if excludes.is_empty() { - return None; - } - let mut merged: Vec = excludes.to_vec(); - merged.sort(); - merged.dedup(); - let persisted_exactly = |manifest: &Option| { - manifest - .as_ref() - .and_then(|m| m.setup.as_ref()) - .map(|s| &s.exclude) - == Some(&merged) - }; - if matches!(existing, Ok(manifest) if persisted_exactly(manifest)) { - return None; // already persisted exactly — don't lock, don't rewrite - } - - let path = common.resolved_manifest_path(); - let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - let _lock = match crate::commands::lock_cli::acquire_with_status(&common.socket_dir(), timeout) - { - Ok(guard) => guard, - Err(err) => { - let (code, message) = crate::commands::lock_cli::lock_failure(&err, timeout); - let hint = if code == "lock_held" { - "re-run `setup` (or pass --lock-timeout ) to persist it" - } else { - "the exclude list will need re-passing" - }; - return Some(format!("not persisting --exclude: {message} — {hint}")); - } - }; - // 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) => { - return Some(format!( - "not persisting --exclude: cannot read {}: {e} — the exclude list will \ - need re-passing until the manifest is repaired", - path.display() - )); - } - }; - if persisted_exactly(&existing) { - return None; // a concurrent run persisted it meanwhile - } - // Preserve any existing `manual` declarations (property 7) when rewriting. - let manual = existing - .as_ref() - .and_then(|m| m.setup.as_ref()) - .map(|s| s.manual.clone()) - .unwrap_or_default(); - let mut manifest = existing.unwrap_or_else(PatchManifest::new); - manifest.setup = Some(SetupConfig { - exclude: merged, - manual, - }); - // The acquire created the manifest's directory; a failed write is the - // same fail-closed skip as an unreadable manifest, never a silent - // "persisted". - if let Err(e) = write_manifest(&path, &manifest).await { - return Some(format!( - "not persisting --exclude: cannot write {}: {e} — the exclude list will \ - need re-passing", - path.display() - )); - } - None -} - -/// Which ecosystems are **actually set up** at `cwd` — i.e. their auto-repatch -/// hook is present on disk (the same presence checks `setup --check` runs). VEX -/// uses this (∪ the manifest's `manual` declarations) to attest patches only for -/// set-up-or-manual ecosystems (CLI_CONTRACT property 7). Read-only; ignores the -/// `--ecosystems` filter (it reports real on-disk state). -pub(crate) async fn configured_ecosystems( - common: &GlobalArgs, -) -> std::collections::HashSet { - let mut set = std::collections::HashSet::new(); - - // npm: any discovered package.json whose hook scripts are present. - let npm = find_package_json_files(&common.cwd).await; - for loc in &npm.files { - if let Ok(content) = tokio::fs::read_to_string(&loc.path).await { - if !is_setup_configured_str(&content).needs_update { - set.insert(Ecosystem::Npm); - break; - } - } - } - - // pypi: a chosen python manifest carries the `socket-patch[hook]` dep. - // Detect on-disk state DIRECTLY — not via `plan_python`, which applies the - // `--ecosystems` filter; this probe must report real state regardless of it - // (e.g. `vex --ecosystems cargo` must still see a set-up python project). - if is_python_project(&common.cwd).await { - let pm = detect_python_pm(&common.cwd).await; - for (path, kind) in choose_python_manifests(&common.cwd, pm).await { - if let Ok(content) = tokio::fs::read_to_string(&path).await { - if manifest_contains_hook(kind, &content) { - set.insert(Ecosystem::Pypi); - break; - } - } - } - } - - // gem: the managed plugin directive is present in the Gemfile. - 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::is_plugin_directive_present(&content) { - set.insert(Ecosystem::Gem); - } - } - } - - 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::is_hook_present(&content) { - set.insert(Ecosystem::Composer); - } - } - } - - set -} - -// ───────────────────────────────────────────────────────────────────────── -// Python (.pth hook) helpers -// ───────────────────────────────────────────────────────────────────────── - -/// Is the hook dependency present in a Python manifest's content? Picks the -/// right detector for the manifest kind: `pyproject.toml` needs the *structural* -/// probe ([`pyproject_contains_hook`]) because the classic-Poetry form -/// (`socket-patch = { extras = ["hook"] }`) has no literal `socket-patch[hook]` -/// substring, so the textual probe would mis-report a configured project; -/// `requirements.txt` uses the textual line probe. -fn manifest_contains_hook(kind: ManifestKind, content: &str) -> bool { - match kind { - ManifestKind::Pyproject => pyproject_contains_hook(content), - ManifestKind::Requirements => deps_contain_hook(content), - } -} - -/// A Python manifest `setup` will edit, plus the resolved package manager. -struct PythonPlan { - pm: PythonPackageManager, - manifests: Vec<(PathBuf, ManifestKind)>, -} - -/// Decide which Python manifest(s) to edit for the detected package manager. -/// -/// pyproject-based managers (uv/poetry/pdm/hatch) edit `pyproject.toml`; pip -/// prefers an existing `requirements.txt`, then a PEP 621 `pyproject.toml`, and -/// otherwise creates `requirements.txt`. -async fn choose_python_manifests( - cwd: &Path, - pm: PythonPackageManager, -) -> Vec<(PathBuf, ManifestKind)> { - let pyproject = cwd.join("pyproject.toml"); - let requirements = cwd.join("requirements.txt"); - let pyproject_exists = tokio::fs::metadata(&pyproject).await.is_ok(); - let requirements_exists = tokio::fs::metadata(&requirements).await.is_ok(); - - match pm { - PythonPackageManager::Uv - | PythonPackageManager::Poetry - | PythonPackageManager::Pdm - | PythonPackageManager::Hatch => { - if pyproject_exists { - vec![(pyproject, ManifestKind::Pyproject)] - } else { - vec![] - } - } - PythonPackageManager::Pip => { - if requirements_exists { - vec![(requirements, ManifestKind::Requirements)] - } else if pyproject_exists { - vec![(pyproject, ManifestKind::Pyproject)] - } else { - // Nothing to edit yet: create requirements.txt so a CI - // `pip install -r requirements.txt` installs the hook. - vec![(requirements, ManifestKind::Requirements)] - } - } - } -} - -async fn plan_python(common: &GlobalArgs) -> Option { - if !eco_in_scope(common, Ecosystem::Pypi) { - return None; - } - if !is_python_project(&common.cwd).await { - return None; - } - let pm = detect_python_pm(&common.cwd).await; - let manifests = choose_python_manifests(&common.cwd, pm).await; - if manifests.is_empty() { - return None; - } - Some(PythonPlan { pm, manifests }) -} - -/// Run the hook-dependency edits for a plan (add or remove) at the given -/// dry-run setting. Returns per-manifest results. -async fn edit_python_manifests( - plan: &PythonPlan, - remove: bool, - dry_run: bool, -) -> Vec { - let mut out = Vec::new(); - for (path, kind) in &plan.manifests { - let res = if remove { - remove_hook_dependency(path, *kind, dry_run).await - } else { - add_hook_dependency(path, *kind, dry_run).await - }; - out.push(res); - } - out -} - -/// After a real (non-dry-run) edit that changed a manifest, refresh the -/// lockfile. Returns any warnings to surface. (There is no separate marker / -/// audit file: the committed dependency line is the source of truth.) -async fn finalize_python(plan: &PythonPlan, edits: &[PthEditResult], cwd: &Path) -> Vec { - let mut warnings = Vec::new(); - let any_changed = edits.iter().any(|e| e.status == PthStatus::Updated); - if !any_changed { - return warnings; - } - // Lockfile refresh (broad auto-edit): only when the manager uses a lockfile - // that exists. Best-effort — never fatal. The spellings are tried in - // order: pin-preserving first (`poetry lock --no-update`, - // `pdm lock --update-reuse`), bare `lock` as the fallback for versions - // that dropped the flag (Poetry 2.x, where bare `lock` is already - // pin-preserving). A successful fallback is not a failure — only warn - // when every spelling failed. - if let Some((program, spellings)) = plan.pm.lock_commands() { - let lockfile = match plan.pm { - PythonPackageManager::Uv => Some("uv.lock"), - PythonPackageManager::Poetry => Some("poetry.lock"), - PythonPackageManager::Pdm => Some("pdm.lock"), - _ => None, - }; - let lock_present = match lockfile { - Some(name) => tokio::fs::metadata(cwd.join(name)).await.is_ok(), - None => false, - }; - if lock_present { - let mut failure: Option = None; - for args in spellings { - match tokio::process::Command::new(program) - .args(*args) - .current_dir(cwd) - .output() - .await - { - Ok(o) if o.status.success() => { - failure = None; - break; - } - Ok(o) => { - failure = Some(format!( - "`{program} {}` failed ({}); update the lockfile manually", - args.join(" "), - o.status - )); - } - Err(e) => { - // The program itself didn't spawn (not installed / - // not on PATH): retrying another spelling of the - // same program is pointless. - failure = Some(format!( - "could not run `{program} {}`: {e}; update the lockfile manually", - args.join(" ") - )); - break; - } - } - } - if let Some(w) = failure { - warnings.push(w); - } - } - } - warnings -} - -// ───────────────────────────────────────────────────────────────────────── -// Shared per-ecosystem setup outcome -// ───────────────────────────────────────────────────────────────────────── - -/// Summary of one ecosystem branch's contribution to a -/// setup/remove run. Each `build_*_outcome` returns one of these and the shared -/// reporting code merges + renders them without naming ecosystem-specific types. -#[derive(Default)] -struct SetupOutcome { - /// A project for this ecosystem was discovered (gates the `no_files` decision). - present: bool, - /// Items changed (hook added/removed). - changed: usize, - already: usize, - errors: usize, - /// Envelope `files[]` entries (kind = `package_json` / `pth` / `gemfile` / …). - json_files: Vec, - /// Human-readable preview lines (already formatted). - preview: Vec, -} - -// ───────────────────────────────────────────────────────────────────────── -// Gem (Bundler plugin) helpers -// ───────────────────────────────────────────────────────────────────────── - -/// The Bundler project this run acts on, discovered ONCE per run (honoring -/// `--ecosystems`); `None` when gem is out of scope or no Gemfile is found. -async fn discover_gem_project(common: &GlobalArgs) -> Option { - if !eco_in_scope(common, Ecosystem::Gem) { - return None; - } - gem::discover_bundler_project(&common.cwd).await -} - -/// The gem project a setup run wires, paired with the run's ONE bundler -/// probe (a `Gemfile.lock` read, or a `bundle --version` spawn bounded by -/// its timeout): the preview and the real edit share both, so the probe -/// runs at most once per run. -async fn discover_gem_target( - common: &GlobalArgs, -) -> Option<(gem::BundlerProject, gem::BundlerProbe)> { - let project = discover_gem_project(common).await?; - let probe = gem::probe_bundler(&project).await; - Some((project, probe)) -} - -/// What the gem branch does to a project. -enum GemEdit<'a> { - /// Wire the plugin; `add_plugin_directive_with` refuses below the - /// bundler floor, judged from the run's probe. - Add(&'a gem::BundlerProbe), - /// Unwire. Deliberately ungated and never probes: it is the recovery - /// path for an already-wired bundler-1.x project. - Remove, -} - -/// Build the gem branch's contribution to a setup/remove run: add (or remove) -/// the managed `plugin "socket-patch"` block in the Gemfile + the generated -/// `.socket/bundler-plugin/` plugin files. `target` is the discovered project -/// with the edit to make ([`discover_gem_target`] pairs the add path with the -/// run's one probe); `None` when the project has no Gemfile. -async fn build_gem_outcome( - common: &GlobalArgs, - target: Option<(&gem::BundlerProject, GemEdit<'_>)>, - dry_run: bool, -) -> SetupOutcome { - let Some((project, edit)) = target else { - return SetupOutcome::default(); - }; - - let mut out = SetupOutcome { - present: true, - ..Default::default() - }; - - let remove = matches!(edit, GemEdit::Remove); - let results = match edit { - GemEdit::Add(probe) => gem::add_plugin_directive_with(project, probe, dry_run).await, - GemEdit::Remove => gem::remove_plugin_directive(project, dry_run).await, - }; - - let mut added_paths: Vec = Vec::new(); - for r in &results { - match r.status { - GemSetupStatus::Updated => { - out.changed += 1; - added_paths.push(r.path.clone()); - } - GemSetupStatus::AlreadyConfigured => out.already += 1, - GemSetupStatus::Error => out.errors += 1, - } - out.json_files.push(serde_json::json!({ - "kind": r.kind, - "path": r.path, - "status": gem_status_str(&r.status, remove), - "error": r.error, - })); - } - - if !added_paths.is_empty() { - let header = if remove { - "Gem: remove the socket-patch Bundler plugin wiring from:" - } else { - "Gem: add the socket-patch Bundler plugin wiring to:" - }; - out.preview.push(header.to_string()); - let marker = if remove { "-" } else { "+" }; - for p in &added_paths { - out.preview - .push(format!(" {marker} {}", pathdiff(p, &common.cwd))); - } - } - - out -} - -fn gem_status_str(s: &GemSetupStatus, for_remove: bool) -> &'static str { - match (s, for_remove) { - (GemSetupStatus::Updated, false) => "updated", - (GemSetupStatus::Updated, true) => "removed", - (GemSetupStatus::AlreadyConfigured, false) => "already_configured", - (GemSetupStatus::AlreadyConfigured, true) => "not_configured", - (GemSetupStatus::Error, _) => "error", - } -} - -// ───────────────────────────────────────────────────────────────────────── -// Composer (composer.json scripts post-install/post-update hook) helpers -// ───────────────────────────────────────────────────────────────────────── - -/// The `composer.json` this run acts on, discovered ONCE per run (honoring -/// `--ecosystems`). -async fn discover_composer_json(common: &GlobalArgs) -> Option { - if !eco_in_scope(common, Ecosystem::Composer) { - return None; - } - composer::discover_composer_project(&common.cwd).await -} - -/// Build the composer branch's contribution to a setup/remove run: add (or -/// remove) the `socket-patch apply` command in `composer.json`'s -/// `post-install-cmd` / `post-update-cmd` script events. `composer_json` -/// comes from [`discover_composer_json`], shared by the preview and the -/// real edit. -async fn build_composer_outcome( - common: &GlobalArgs, - composer_json: Option<&Path>, - remove: bool, - dry_run: bool, -) -> SetupOutcome { - let Some(composer_json) = composer_json else { - return SetupOutcome::default(); - }; - - let mut out = SetupOutcome { - present: true, - ..Default::default() - }; - - let r = if remove { - composer::remove_hook(composer_json, dry_run).await - } else { - composer::add_hook(composer_json, dry_run).await - }; - - let mut added_paths: Vec = Vec::new(); - match r.status { - ComposerSetupStatus::Updated => { - out.changed += 1; - added_paths.push(r.path.clone()); - } - ComposerSetupStatus::AlreadyConfigured => out.already += 1, - ComposerSetupStatus::Error => out.errors += 1, - } - out.json_files.push(serde_json::json!({ - "kind": r.kind, - "path": r.path, - "status": composer_status_str(&r.status, remove), - "error": r.error, - })); - - if !added_paths.is_empty() { - let header = if remove { - "Composer: remove the socket-patch re-apply hook from:" - } else { - "Composer: add the socket-patch re-apply hook to:" - }; - out.preview.push(header.to_string()); - let marker = if remove { "-" } else { "+" }; - for p in &added_paths { - out.preview - .push(format!(" {marker} {}", pathdiff(p, &common.cwd))); - } - } - - out -} - -fn composer_status_str(s: &ComposerSetupStatus, for_remove: bool) -> &'static str { - match (s, for_remove) { - (ComposerSetupStatus::Updated, false) => "updated", - (ComposerSetupStatus::Updated, true) => "removed", - (ComposerSetupStatus::AlreadyConfigured, false) => "already_configured", - (ComposerSetupStatus::AlreadyConfigured, true) => "not_configured", - (ComposerSetupStatus::Error, _) => "error", - } -} - -/// Append composer check entry (the `composer.json` hook presence) to the shared -/// `run_check` entries list. Returns whether a composer project was found. -/// Checks the SETUP wiring only — patch consistency is the shared -/// `append_patch_consistency_entries` pass. -async fn append_composer_check_entries( - common: &GlobalArgs, - entries: &mut Vec<(&'static str, String, CheckState, Option)>, -) -> bool { - if !eco_in_scope(common, Ecosystem::Composer) { - return false; - } - 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::is_hook_present(&content) { - (CheckState::Configured, None) - } else { - (CheckState::NeedsConfiguration, None) - } - } - Err(e) => (CheckState::Error, Some(e.to_string())), - }; - entries.push(("composer", composer_json.display().to_string(), state, err)); - true -} - -/// Materialise gem patches right after wiring the plugin (the "automatic" step) -/// so the first `bundle install` finds them already applied. Best-effort and -/// offline; a non-zero exit becomes a warning — the plugin heals on the next -/// `bundle install`. -async fn finalize_gem(common: &GlobalArgs) -> Vec { - let exe = match std::env::current_exe() { - Ok(e) => e, - Err(e) => { - return vec![format!( - "could not locate socket-patch to materialize gem patches ({e}); \ - run `socket-patch apply --ecosystems gem`" - )] - } - }; - 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, - "--manifest-path", - &manifest, - "--silent", - ]) - .output() - .await - { - Ok(o) if o.status.success() => Vec::new(), - Ok(o) => vec![format!( - "materializing gem patches exited with {}; the Bundler plugin will heal on next `bundle install`", - o.status - .code() - .map(|c| c.to_string()) - .unwrap_or_else(|| "signal".into()) - )], - Err(e) => vec![format!( - "could not run apply to materialize gem patches ({e}); the Bundler plugin will heal on next `bundle install`" - )], - } -} - -/// Append gem check entries (the Gemfile `plugin` directive + the generated -/// plugin dir) to the shared `run_check` entries list. Returns whether a -/// Bundler project was found. Checks the SETUP wiring only — patch consistency -/// is the shared `append_patch_consistency_entries` pass. -async fn append_gem_check_entries( - common: &GlobalArgs, - entries: &mut Vec<(&'static str, String, CheckState, Option)>, -) -> bool { - if !eco_in_scope(common, Ecosystem::Gem) { - return false; - } - let project = match gem::discover_bundler_project(&common.cwd).await { - Some(p) => p, - None => return false, - }; - // The bundler version floor (core `setup::gem::version`): bundler 1.x - // cannot load the `plugin ... path:` directive, so a WIRED project fails - // every `bundle install` (exit 7, an error that never names socket-patch) - // — the campaign-confirmed state where `--check` kept saying "configured" - // while the CI gate it exists to be went green over broken installs. Both - // wired and unwired unsupported projects are red-flagged as errors: setup - // itself refuses to wire below the floor, so "needs_configuration" (run - // `setup` to fix) would point at a command that cannot help. - let probe = gem::probe_bundler(&project).await; - let (state, err) = match tokio::fs::read_to_string(&project.gemfile).await { - Ok(content) => { - let wired = gem::is_plugin_directive_present(&content); - match (&probe, wired) { - (gem::BundlerProbe::Unsupported { version, source }, true) => ( - CheckState::Error, - Some(format!( - "the wired socket-patch plugin cannot load under bundler \ - {version} (from {source}; needs >= {}.{}): every `bundle \ - install` fails resolving 'socket-patch' as an ordinary gem \ - (exit 7) before the plugin registers. Run `socket-patch \ - setup --remove` to unwire, or upgrade bundler", - gem::MIN_BUNDLER.0, - gem::MIN_BUNDLER.1 - )), - ), - (gem::BundlerProbe::Unsupported { version, source }, false) => ( - CheckState::Error, - Some(gem::unsupported_bundler_message(version, source)), - ), - (_, true) => (CheckState::Configured, None), - (_, false) => (CheckState::NeedsConfiguration, None), - } - } - Err(e) => (CheckState::Error, Some(e.to_string())), - }; - entries.push(("gemfile", project.gemfile.display().to_string(), state, err)); - let dir_state = if gem::plugin_files_present(&project.root).await { - CheckState::Configured - } else { - CheckState::NeedsConfiguration - }; - entries.push(( - "gem_plugin", - gem::plugin_dir(&project.root).display().to_string(), - dir_state, - None, - )); - true -} - -/// Append a `needs_configuration` entry for every in-scope manifest patch that -/// is installed but NOT correctly applied on disk (a file's hash != its -/// `afterHash`). This is the `apply --check` invariant that property 4 requires -/// `setup --check` to prove *in addition to* hook presence: a repo with hooks -/// wired but patches drifted/un-applied is not in a correctly-patched state. -/// -/// Reuses the same machinery `vex` uses — the qualified-aware rollback resolver -/// (so release-variant PURLs resolve) honoring `--ecosystems`, the committed -/// vendor ledger ([`crate::commands::vex::vendor_context_from`]: a vendored -/// patch is judged by its `.socket/vendor/` artifact — the bytes the next -/// install consumes — never the expectedly-unpatched installed tree), then -/// [`applied_patches_with_vendor`]. An *uninstalled* package (`package_not_found`, also the -/// bucket for out-of-scope PURLs absent from the map) cannot be patched yet, and -/// a degenerate zero-file record (`no_files`) has nothing to hash — neither is -/// drift, so both are skipped. A missing/empty/unreadable manifest contributes -/// nothing of its own; the vendor ledger's embedded records (vendored mode is -/// manifest-free; standalone `vendor`'s fallback copies where the manifest no -/// longer covers them) are folded in by the rule `vex` applies, so a -/// vendored-only project is judged too. Read-only: it crawls but never writes. -async fn append_patch_consistency_entries( - common: &GlobalArgs, - manifest: Option, - entries: &mut Vec<(&'static str, String, CheckState, Option)>, -) { - // ONE ledger read serves both the record fold and the verifier's - // VendorContext below. Without the fold a project whose committed - // `.socket/vendor/**` artifact is missing or corrupt reported - // `configured` — the exact hooks-present-but-state-drifted case - // property 4 exists to catch. A ledger that cannot be read or parsed is - // that case too (contract: "never as a `configured` verdict"): it is - // surfaced BEFORE the emptiness return below — on a manifest-free - // vendored project the fold is the only source of purls, so returning - // early would report `configured` with no signal at all — as the shared - // `unreadable vendor state` warning plus a `vendor_ledger` error entry - // (verdict `error`, exit 1), and the verifier proceeds over an empty - // ledger (already reported, so not warned twice). - let mut manifest = manifest.unwrap_or_default(); - let ledger = match socket_patch_core::vendor::load_state(&common.cwd).await { - Ok(state) => { - crate::commands::fold_vendor_records(&mut manifest, &state.entries); - Ok(state) - } - Err(e) => { - crate::commands::vex::warn_unreadable_vendor_state(common, &e); - entries.push(( - "vendor_ledger", - common - .cwd - .join(socket_patch_core::vendor::VENDOR_STATE_REL) - .display() - .to_string(), - CheckState::Error, - Some(format!("unreadable vendor state ({e})")), - )); - Ok(socket_patch_core::vendor::VendorState::new()) - } - }; - if manifest.patches.is_empty() { - return; - } - - let purls: Vec = manifest.patches.keys().cloned().collect(); - // `--json` reserves stdout for the check report: silence the dispatch's - // human chrome ("Using at: ...") like apply/rollback do. - let package_paths = - find_manifest_package_paths(&purls, common, common.silent || common.json).await; - - // The ledger passed here is always readable (an unreadable one was - // reported above and replaced by an empty one), so there is no - // degrade warning left to surface. - let (vendor, _) = crate::commands::vex::vendor_context_from(common, &manifest, ledger).await; - let outcome = applied_patches_with_vendor(&manifest, &package_paths, vendor.as_ref()).await; - for failed in &outcome.failed { - match failed.reason.as_str() { - // Not installed (or out of scope) / nothing to hash → not drift. - "package_not_found" | "no_files" => continue, - // Installed but the on-disk file is not at its afterHash → drift. - _ => entries.push(( - "patch", - failed.purl.clone(), - CheckState::NeedsConfiguration, - Some(format!("patch not applied on disk ({})", failed.reason)), - )), - } - } -} - -/// Combine two ecosystem outcomes into one for the shared preview/envelope -/// printers, which take a single [`SetupOutcome`]. -fn merge_outcomes(mut a: SetupOutcome, b: SetupOutcome) -> SetupOutcome { - a.present |= b.present; - a.changed += b.changed; - a.already += b.already; - a.errors += b.errors; - a.json_files.extend(b.json_files); - a.preview.extend(b.preview); - a -} - -// ───────────────────────────────────────────────────────────────────────── -// check -// ───────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Copy, PartialEq, Debug)] -enum CheckState { - Configured, - NeedsConfiguration, - Error, -} - -/// One `setup --check` status line. A drifted patch (`kind == "patch"`) -/// shows why it is not applied instead of "needs setup", which re-running -/// `setup` would not fix. -fn format_check_line(kind: &str, rel: &str, state: CheckState, err: Option<&str>) -> String { - match (state, err) { - (CheckState::Configured, _) => format!(" ✓ {rel} (configured)"), - (CheckState::NeedsConfiguration, _) if kind == "patch" => { - format!(" ✗ {rel}: {}", err.unwrap_or("patch not applied on disk")) - } - (CheckState::NeedsConfiguration, Some(e)) => format!(" ✗ {rel} (needs setup: {e})"), - (CheckState::NeedsConfiguration, None) => format!(" ✗ {rel} (needs setup)"), - (CheckState::Error, e) => format!(" ! {rel}: {}", e.unwrap_or("unknown error")), - } -} - -/// The `setup --check` verdict: what is wrong, then the command that fixes -/// each kind of problem (`setup` for missing hooks, `apply` for drifted -/// patches; invalid files need a hand edit). -fn format_check_footer(hooks: usize, drifted: usize, errors: usize) -> String { - if hooks + drifted + errors == 0 { - return "All manifests are configured with socket-patch.".to_string(); - } - let mut problems = Vec::new(); - let mut advice = Vec::new(); - if hooks > 0 { - problems.push(format!( - "{} configuration", - plural(hooks, "manifest needs", "manifests need") - )); - advice.push("Run `socket-patch setup` to add the missing install hooks."); - } - if drifted > 0 { - problems.push(format!( - "{} not applied on disk", - plural(drifted, "patch is", "patches are") - )); - advice.push("Run `socket-patch apply` to re-apply the patches."); - } - if errors > 0 { - problems.push(plural(errors, "error", "errors")); - advice.push("Fix the errors above, then re-run `socket-patch setup --check`."); - } - format!("{}. {}", problems.join(", "), advice.join(" ")) -} - -/// The status line while `setup --check` / `--remove` discover manifests. -const SEARCHING: &str = "Searching for package.json / Python / Bundler / Composer manifests..."; - -/// Read-only verification that every discovered install hook (npm -/// package.json, the Python dependency manifest, the Bundler plugin, the -/// Composer script) is configured for socket-patch, plus the shared -/// patch-consistency pass. Never writes (so `--dry-run` is a harmless no-op -/// here). Exits 0 only when all are configured and none failed to parse. -async fn run_check(args: &SetupArgs) -> i32 { - // `--silent` is "errors only" (CLI_CONTRACT.md): suppress the entire - // human-readable report, mirroring `list`/`repair`/`get`/`remove`/`scan`. - // The exit code still distinguishes the configuration states. - let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); - status.set(SEARCHING); - - // Excluded members (persisted in the manifest + any passed via `--exclude`) - // are skipped by discovery. Read-only: `--check` never persists. - let existing = read_setup_manifest(&args.common).await; - let excludes = effective_excludes(manifest_view(&existing), &args.exclude); - let npm_files = discover(args, &excludes).await; - let py_plan = plan_python(&args.common).await; - - // (kind, path, state, error) - let mut entries: Vec<(&'static str, String, CheckState, Option)> = Vec::new(); - - for loc in &npm_files { - let (state, err) = match tokio::fs::read_to_string(&loc.path).await { - Ok(content) => { - // npm and Node strip a leading UTF-8 BOM when reading - // package.json (and `setup` itself tolerates one via - // `is_setup_configured_str`); parse the same bytes they would, - // or a BOM'd configured file fails `--check` as "Invalid - // package.json" while `setup` calls it already_configured. - let json = content.strip_prefix('\u{feff}').unwrap_or(&content); - if let Err(e) = serde_json::from_str::(json) { - // Keep the parser's detail (line/column): "Invalid - // package.json" alone leaves the user hunting. - ( - CheckState::Error, - Some(format!("Invalid package.json: {e}")), - ) - } else if is_setup_configured_str(&content).needs_update { - (CheckState::NeedsConfiguration, None) - } else { - (CheckState::Configured, None) - } - } - Err(e) => (CheckState::Error, Some(e.to_string())), - }; - entries.push(("package_json", loc.path.display().to_string(), state, err)); - } - - if let Some(plan) = &py_plan { - for (path, kind) in &plan.manifests { - let (state, err) = match tokio::fs::read_to_string(path).await { - Ok(content) => { - if manifest_contains_hook(*kind, &content) { - (CheckState::Configured, None) - } else { - (CheckState::NeedsConfiguration, None) - } - } - // A not-yet-created requirements.txt simply needs setup; a - // missing pyproject we'd have to edit is an error. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => match kind { - ManifestKind::Requirements => (CheckState::NeedsConfiguration, None), - ManifestKind::Pyproject => (CheckState::Error, Some(e.to_string())), - }, - Err(e) => (CheckState::Error, Some(e.to_string())), - }; - entries.push(("pth", path.display().to_string(), state, err)); - } - } - - append_gem_check_entries(&args.common, &mut entries).await; - append_composer_check_entries(&args.common, &mut entries).await; - - // Property 4: prove a correctly-patched state, not just hook presence — - // every in-scope manifest patch must be applied on disk (`apply --check` - // invariant). Drifted/un-applied patches add `needs_configuration` entries. - append_patch_consistency_entries(&args.common, existing.ok().flatten(), &mut entries).await; - status.finish(); - - if entries.is_empty() { - return report_no_files( - args, - &[("configured", 0), ("needsConfiguration", 0), ("errors", 0)], - ); - } - - let configured = entries - .iter() - .filter(|(_, _, s, _)| *s == CheckState::Configured) - .count(); - let needs = entries - .iter() - .filter(|(_, _, s, _)| *s == CheckState::NeedsConfiguration) - .count(); - // Drifted patches need `apply`, not `setup`: counted apart so the - // footer can say which command fixes what. - let drifted = entries - .iter() - .filter(|(k, _, s, _)| *k == "patch" && *s == CheckState::NeedsConfiguration) - .count(); - let errs = entries - .iter() - .filter(|(_, _, s, _)| *s == CheckState::Error) - .count(); - - let all_ok = needs == 0 && errs == 0; - let status = if errs > 0 { - "error" - } else if all_ok { - "configured" - } else { - "needs_configuration" - }; - - if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": status, - "configured": configured, - "needsConfiguration": needs, - "errors": errs, - "files": entries.iter().map(|(kind, path, state, err)| { - serde_json::json!({ - "kind": kind, - "path": path, - "status": match state { - CheckState::Configured => "configured", - CheckState::NeedsConfiguration => "needs_configuration", - CheckState::Error => "error", - }, - "error": err, - }) - }).collect::>(), - })) - .expect("serializing an in-memory JSON value cannot fail") - ); - } else if !args.common.silent { - println!("\nConfiguration status:\n"); - for (kind, path, state, err) in &entries { - let rel = pathdiff(path, &args.common.cwd); - println!("{}", format_check_line(kind, &rel, *state, err.as_deref())); - } - println!(); - println!("{}", format_check_footer(needs - drifted, drifted, errs)); - if errs > 0 { - eprintln!("{}", format_items_failed(errs)); - } - } else { - // `--silent` is "errors only": the status report is muted, but - // read/parse failures must still reach stderr. A plain - // needs-configuration state is not an error — the exit code alone - // carries it. - for (_, path, state, err) in &entries { - if *state == CheckState::Error { - eprintln!( - "Error: {}: {}", - pathdiff(path, &args.common.cwd), - err.as_deref().unwrap_or("unknown error") - ); - } - } - } - - if all_ok { - 0 - } else { - 1 - } -} - -// ───────────────────────────────────────────────────────────────────────── -// remove -// ───────────────────────────────────────────────────────────────────────── - -/// One script's before/after pair in the remove preview, the two values -/// aligned in one column: -/// -/// ```text -/// postinstall: "socket-patch apply && echo hi" -/// -> postinstall: "echo hi" -/// ``` -fn format_script_change(key: &str, old: &str, new: &str) -> String { - let label = format!("{key}:"); - let width = label.len() + 3; // the width of "-> :" - format!(" {label: {label} {new}\n") -} - -/// Render a removed script value: `None` means the key is being deleted. -fn render_removed(new: &Option) -> String { - match new { - Some(s) if !s.is_empty() => format!("\"{s}\""), - _ => "(removed)".to_string(), - } -} - -/// Revert the install hooks `setup` added (npm package.json scripts, the -/// Python `socket-patch[hook]` dependency, the gem Bundler plugin wiring and -/// the Composer script). Honors `--dry-run`, `--yes`, `--json`. -async fn run_remove(args: &SetupArgs) -> i32 { - let common = &args.common; - // `--silent` is "errors only" (CLI_CONTRACT.md): mute the human-readable - // chatter just like `--json` does; the mutation and exit code are - // unaffected, and prompting follows the shared `confirm()` semantics. - let quiet = common.json || common.silent; - let mut status = crate::ui::StatusLine::stderr(common.json, common.silent); - status.set(SEARCHING); - - // Honor the persisted/`--exclude` member set so we never touch a member that - // was deliberately excluded from setup. Remove does not change the set. - let existing = read_setup_manifest(common).await; - let excludes = effective_excludes(manifest_view(&existing), &args.exclude); - let npm_files = discover_for_remove(args, &excludes).await; - let py_plan = plan_python(common).await; - // Gem + Composer projects are discovered ONCE; the preview and the real - // removal below share them. - let gem_project = discover_gem_project(common).await; - let composer_json = discover_composer_json(common).await; - let gem_preview = build_gem_outcome( - common, - gem_project.as_ref().map(|p| (p, GemEdit::Remove)), - true, - ) - .await; - let composer_preview = - build_composer_outcome(common, composer_json.as_deref(), true, true).await; - status.finish(); - if npm_files.is_empty() - && py_plan.is_none() - && !gem_preview.present - && !composer_preview.present - { - return report_no_files(args, &[("removed", 0), ("notConfigured", 0), ("errors", 0)]); - } - let gem_present = gem_preview.present; - let extra_preview = merge_outcomes(gem_preview, composer_preview); - - // Preview (dry_run=true never writes). - let mut npm_preview = Vec::new(); - for loc in &npm_files { - npm_preview.push(remove_package_json(&loc.path, true).await); - } - let py_preview = match &py_plan { - Some(p) => edit_python_manifests(p, true, true).await, - None => Vec::new(), - }; - - if !quiet { - print!( - "{}", - format_remove_preview(&npm_preview, &py_preview, &extra_preview, &common.cwd) - ); - } - - let n_remove = npm_preview - .iter() - .filter(|r| r.status == RemoveStatus::Removed) - .count() - + py_preview - .iter() - .filter(|r| r.status == PthStatus::Updated) - .count() - + extra_preview.changed; - let preview_errs = npm_preview - .iter() - .filter(|r| r.status == RemoveStatus::Error) - .count() - + py_preview - .iter() - .filter(|r| r.status == PthStatus::Error) - .count() - + extra_preview.errors; - - // Nothing to remove: clean (exit 0) or some file errored (exit 1). - if n_remove == 0 { - if common.json { - print_remove_envelope( - if preview_errs > 0 { - "error" - } else { - "not_configured" - }, - &npm_preview, - &py_preview, - &extra_preview, - &[], - ); - } else if !common.silent { - if preview_errs > 0 { - println!( - "\nNothing removed; {} (see errors above).", - plural( - preview_errs, - "item could not be processed", - "items could not be processed" - ) - ); - } else { - println!("No socket-patch install hooks found to remove."); - } - } - eprint_errors( - common, - &remove_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), - ); - return if preview_errs > 0 { 1 } else { 0 }; - } - - // Dry-run: preview already shown; report and exit without writing. - if common.dry_run { - if common.json { - print_remove_envelope("dry_run", &npm_preview, &py_preview, &extra_preview, &[]); - } else if !common.silent { - println!("\nSummary (dry run):"); - println!( - " {}", - plural( - n_remove, - "item would have socket-patch removed", - "items would have socket-patch removed" - ) - ); - } - eprint_errors( - common, - &remove_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), - ); - return if preview_errs > 0 { 1 } else { 0 }; - } - - // Confirm before mutating. - // Default-no on a terminal; proceeds when stdin is not interactive. - // Keep the prompt (or its non-interactive note) off the last preview - // line. With --yes nothing is printed there, and the progress line below - // already opens with its own blank line. - if !quiet && !common.yes { - eprintln!(); - } - if !crate::ui::confirm_or_proceed("Remove these install hooks?", common) { - if !common.silent { - eprintln!("{REMOVE_CANCELLED}"); - } - return 0; - } - - status.set("Removing install hooks..."); - let mut npm_results = Vec::new(); - for loc in &npm_files { - npm_results.push(remove_package_json(&loc.path, false).await); - } - let mut py_results = Vec::new(); - let mut warnings = Vec::new(); - if let Some(plan) = &py_plan { - py_results = edit_python_manifests(plan, true, false).await; - warnings = finalize_python(plan, &py_results, &common.cwd).await; - } - // Real gem + composer removal (gem Gemfile `plugin` block + generated plugin - // dir; composer.json script-event command). - let extra_results = merge_outcomes( - build_gem_outcome( - common, - gem_project.as_ref().map(|p| (p, GemEdit::Remove)), - false, - ) - .await, - build_composer_outcome(common, composer_json.as_deref(), true, false).await, - ); - status.finish(); - - let errs = npm_results - .iter() - .filter(|r| r.status == RemoveStatus::Error) - .count() - + py_results - .iter() - .filter(|r| r.status == PthStatus::Error) - .count() - + extra_results.errors; - - if common.json { - print_remove_envelope( - if errs > 0 { - "partial_failure" - } else { - "success" - }, - &npm_results, - &py_results, - &extra_results, - &warnings, - ); - } else if !common.silent { - let removed = npm_results - .iter() - .filter(|r| r.status == RemoveStatus::Removed) - .count() - + py_results - .iter() - .filter(|r| r.status == PthStatus::Updated) - .count() - + extra_results.changed; - println!("\nSummary:"); - println!( - " {}", - plural( - removed, - "item had socket-patch removed", - "items had socket-patch removed" - ) - ); - if errs > 0 { - println!(" {}", plural(errs, "error", "errors")); - } - if py_plan.is_some() { - println!("\nAlso run `pip uninstall socket-patch-hook` to remove the installed .pth."); - } - if gem_present { - println!( - "\nNote: the Bundler plugin wiring was removed; already-patched gems on disk are \ - reverted by a fresh `bundle install` (or `socket-patch rollback`)." - ); - } - } - - print_warnings(common, &warnings); - eprint_errors( - common, - &remove_error_messages(&npm_results, &py_results, &extra_results, &common.cwd), - ); - - if errs > 0 { - 1 - } else { - 0 - } -} - -/// Error messages from a gem/composer [`SetupOutcome`]'s rendered `files[]` -/// entries — the only place per-edit errors for those ecosystems are retained. -/// The setup/remove previews use this so their human-mode "Errors:" sections -/// actually list gem/composer failures, honoring the "(see errors above)" line -/// both flows print when `preview_errors > 0`. Each message is prefixed with -/// the file's path (relative to `cwd`). -fn outcome_error_messages(o: &SetupOutcome, cwd: &Path) -> Vec { - o.json_files - .iter() - .filter(|f| f.get("status").and_then(|s| s.as_str()) == Some("error")) - .filter_map(|f| { - let err = f.get("error").and_then(|e| e.as_str())?; - let path = f.get("path").and_then(|p| p.as_str()).unwrap_or(""); - Some(format_item_error(path, err, cwd)) - }) - .collect() -} - -/// `packages/a/package.json: Invalid package.json: ...` — in a workspace -/// there can be dozens of manifests, so an error must say which one. -fn format_item_error(path: &str, err: &str, cwd: &Path) -> String { - if path.is_empty() { - err.to_string() - } else { - format!("{}: {err}", pathdiff(path, cwd)) - } -} - -/// Print run warnings to stderr (`Warning: ...`) in human mode; `--json` -/// carries them in the envelope and `--silent` mutes them. -fn print_warnings(common: &GlobalArgs, warnings: &[String]) { - if common.json || common.silent { - return; - } - for w in warnings { - eprintln!("{}", format_warning(w)); - } -} - -/// `Warning: ` — the warning texts start lowercase because they -/// double as JSON `warnings[]` strings; the human line capitalizes them. -fn format_warning(w: &str) -> String { - let mut chars = w.chars(); - match chars.next() { - Some(first) => format!("Warning: {}{}", first.to_uppercase(), chars.as_str()), - None => "Warning:".to_string(), - } -} - -/// `--silent` is "errors only" (CLI_CONTRACT.md): the previews, summaries, -/// and status report that normally carry per-item failures are muted, so -/// before an error exit the failures themselves must still reach stderr — -/// mirroring `remove`/`scan`, whose error paths keep their stderr output. -/// JSON mode is exempt: its envelope already carries the errors. -/// -/// Without `--silent` the per-item errors are in the stdout report -/// already, so stderr gets one closing [`format_items_failed`] line: the -/// run exits 1, and the error stream says so. -fn eprint_errors(common: &GlobalArgs, errs: &[String]) { - if common.json || errs.is_empty() { - return; - } - if common.silent { - for e in errs { - eprintln!("Error: {e}"); - } - } else { - eprintln!("{}", format_items_failed(errs.len())); - } -} - -/// The closing stderr error of a human run whose report lists per-item -/// errors. -fn format_items_failed(n: usize) -> String { - format!( - "Error: {}.", - plural( - n, - "item could not be processed", - "items could not be processed" - ) - ) -} - -/// Per-item error messages across the three remove result families (npm + -/// Python + gem/composer) — the preview "Errors:" section and the -/// silent-mode stderr reporting share this. -fn remove_error_messages( - npm: &[RemoveResult], - py: &[PthEditResult], - extra: &SetupOutcome, - cwd: &Path, -) -> Vec { - let mut errs: Vec = npm - .iter() - .filter(|r| r.status == RemoveStatus::Error) - .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))) - .chain( - py.iter() - .filter(|r| r.status == PthStatus::Error) - .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))), - ) - .collect(); - errs.extend(outcome_error_messages(extra, cwd)); - errs -} - -/// Per-item error messages across the three setup result families (npm + -/// Python + gem/composer) — the preview "Errors:" section and the -/// silent-mode stderr reporting share this. -fn setup_error_messages( - npm: &[UpdateResult], - py: &[PthEditResult], - extra: &SetupOutcome, - cwd: &Path, -) -> Vec { - let mut errs: Vec = npm - .iter() - .filter(|r| r.status == UpdateStatus::Error) - .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))) - .chain( - py.iter() - .filter(|r| r.status == PthStatus::Error) - .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))), - ) - .collect(); - errs.extend(outcome_error_messages(extra, cwd)); - errs -} - -/// The `setup --remove` preview. Every section starts with a blank line (so -/// the block never ends in a stray one before the summary or prompt). -fn format_remove_preview( - npm: &[RemoveResult], - py: &[PthEditResult], - extra: &SetupOutcome, - cwd: &Path, -) -> String { - let mut out = String::new(); - let to_remove: Vec<_> = npm - .iter() - .filter(|r| r.status == RemoveStatus::Removed) - .collect(); - if !to_remove.is_empty() { - out.push_str("\nWill remove socket-patch from:\n"); - for r in &to_remove { - out.push_str(&format!(" - {}\n", pathdiff(&r.path, cwd))); - // Only the scripts the file actually has: a missing hook has - // nothing to remove. - for (key, old, new) in [ - ("postinstall", &r.old_script, &r.new_script), - ( - "dependencies", - &r.old_dependencies_script, - &r.new_dependencies_script, - ), - ] { - if !old.is_empty() { - out.push_str(&format_script_change(key, old, &render_removed(new))); - } - } - } - } - let py_remove: Vec<_> = py - .iter() - .filter(|r| r.status == PthStatus::Updated) - .collect(); - if !py_remove.is_empty() { - out.push_str("\nWill remove the socket-patch[hook] dependency from:\n"); - for r in &py_remove { - out.push_str(&format!(" - {}\n", pathdiff(&r.path, cwd))); - } - } - push_extra_preview(&mut out, extra); - // Surface failures so the "(see errors above)" line `run_remove` prints when - // nothing could be removed actually points at something. - push_errors(&mut out, &remove_error_messages(npm, py, extra, cwd)); - // No header over an empty preview: `run_remove` then says there is - // nothing to remove. - if out.is_empty() { - return out; - } - format!("\nProposed changes:\n{out}") -} - -/// The gem/composer preview lines, as their own blank-line-led section. -fn push_extra_preview(out: &mut String, extra: &SetupOutcome) { - if !extra.preview.is_empty() { - out.push('\n'); - for line in &extra.preview { - out.push_str(line); - out.push('\n'); - } - } -} - -/// The preview's "Errors:" section (nothing when there are none). -fn push_errors(out: &mut String, errs: &[String]) { - if !errs.is_empty() { - out.push_str("\nErrors:\n"); - for e in errs { - out.push_str(&format!(" ! {e}\n")); - } - } -} - -fn print_remove_envelope( - status: &str, - npm: &[RemoveResult], - py: &[PthEditResult], - extra: &SetupOutcome, - warnings: &[String], -) { - let removed = npm - .iter() - .filter(|r| r.status == RemoveStatus::Removed) - .count() - + py.iter().filter(|r| r.status == PthStatus::Updated).count() - + extra.changed; - let not_cfg = npm - .iter() - .filter(|r| r.status == RemoveStatus::NotConfigured) - .count() - + py.iter() - .filter(|r| r.status == PthStatus::AlreadyConfigured) - .count() - + extra.already; - let errors = npm - .iter() - .filter(|r| r.status == RemoveStatus::Error) - .count() - + py.iter().filter(|r| r.status == PthStatus::Error).count() - + extra.errors; - - let mut files: Vec = npm - .iter() - .map(|r| { - serde_json::json!({ - "kind": "package_json", - "path": r.path, - "status": match r.status { - RemoveStatus::Removed => "removed", - RemoveStatus::NotConfigured => "not_configured", - RemoveStatus::Error => "error", - }, - "error": r.error, - }) - }) - .collect(); - files.extend(py.iter().map(|r| { - serde_json::json!({ - "kind": "pth", - "path": r.path, - "status": match r.status { - PthStatus::Updated => "removed", - PthStatus::AlreadyConfigured => "not_configured", - PthStatus::Error => "error", - }, - "error": r.error, - }) - })); - // extra.json_files already use the remove vocabulary - // (removed/not_configured/error), built by the gem/composer outcomes. - files.extend(extra.json_files.iter().cloned()); - - let mut obj = serde_json::json!({ - "status": status, - "removed": removed, - "notConfigured": not_cfg, - "errors": errors, - "files": files, - }); - if status == "dry_run" { - obj["dryRun"] = serde_json::json!(true); - obj["wouldRemove"] = serde_json::json!(removed); - } - if !warnings.is_empty() { - obj["warnings"] = serde_json::json!(warnings); - } - println!( - "{}", - serde_json::to_string_pretty(&obj) - .expect("serializing an in-memory JSON value cannot fail") - ); -} - -// ───────────────────────────────────────────────────────────────────────── -// setup (npm package.json + Python .pth hook, combined) -// ───────────────────────────────────────────────────────────────────────── - -/// Declining the `setup` / `setup --remove` prompt (the " -/// cancelled." wording every other command uses). -const SETUP_CANCELLED: &str = "Setup cancelled."; -const REMOVE_CANCELLED: &str = "Hook removal cancelled."; - -/// The status line while `setup` discovers what to configure. -const CONFIGURING: &str = "Configuring socket-patch install hooks..."; - -async fn run_setup(args: &SetupArgs) -> i32 { - let common = &args.common; - // `--silent` is "errors only" (CLI_CONTRACT.md): mute the human-readable - // chatter just like `--json` does; the mutation and exit code are - // unaffected, and prompting follows the shared `confirm()` semantics. - let quiet = common.json || common.silent; - let mut status = crate::ui::StatusLine::stderr(common.json, common.silent); - status.set(CONFIGURING); - - // Resolve the effective exclude set (persisted + `--exclude`); excluded - // members are skipped by discovery. Persisting it waits for the mutation - // gate below (past discovery and the confirm prompt) so a no-project - // directory or an aborted run leaves no `.socket/` behind. - let existing = read_setup_manifest(common).await; - let excludes = effective_excludes(manifest_view(&existing), &args.exclude); - let found = find_members(args).await; - let unmatched = found - .as_ref() - .map(|f| unmatched_excludes(f, &common.cwd, &excludes)) - .unwrap_or_default(); - if !unmatched.is_empty() { - // A permanent line: take the status down, then put it back. - status.finish(); - warn_unmatched_excludes(common, &unmatched); - status.set(CONFIGURING); - } - // A new `--exclude` value that matches no member is warned about above - // and not persisted, so a typo does not ride into every later run and - // clone. Values already persisted stay (they warn on every run instead - // of being silently dropped from the user's manifest). - let persisted = effective_excludes(manifest_view(&existing), &[]); - let to_persist: Vec = excludes - .iter() - .filter(|e| !unmatched.contains(e) || persisted.contains(e)) - .cloned() - .collect(); - let npm_files = found - .map(|f| select_members(f, &common.cwd, &excludes, &[])) - .unwrap_or_default(); - let py_plan = plan_python(common).await; - // Gem + Composer projects are discovered ONCE and bundler probed ONCE: - // the preview and the real edit below share both. - let gem = discover_gem_target(common).await; - let gem_add = || { - gem.as_ref() - .map(|(project, probe)| (project, GemEdit::Add(probe))) - }; - let composer_json = discover_composer_json(common).await; - // Gem + Composer previews (dry-run); `.present` also tells us each project exists. - let gem_preview = build_gem_outcome(common, gem_add(), true).await; - let composer_preview = - build_composer_outcome(common, composer_json.as_deref(), false, true).await; - status.finish(); - - if npm_files.is_empty() - && py_plan.is_none() - && !gem_preview.present - && !composer_preview.present - { - return report_no_files( - args, - &[("updated", 0), ("alreadyConfigured", 0), ("errors", 0)], - ); - } - - let gem_present = gem_preview.present; - let composer_present = composer_preview.present; - let extra_preview = merge_outcomes(gem_preview, composer_preview); - - let npm_pm = detect_package_manager(&common.cwd).await; - let advisories = if npm_pm == PackageManager::Vlt && !npm_files.is_empty() { - vlt_setup_advisories(&common.cwd).await - } else { - Vec::new() - }; - - // `patch_setup` telemetry ("a successful setup") fires only on the two - // exit-0, non-dry-run paths below — never for a dry run, an aborted - // prompt, a no-project directory or an errored run. - let track_setup = || { - track_setup_success( - common, - !npm_files.is_empty(), - py_plan.is_some(), - gem_present, - composer_present, - npm_pm, - ) - }; - - // Preview (always dry-run first). - let mut npm_preview = Vec::new(); - for loc in &npm_files { - npm_preview.push(update_package_json(&loc.path, true, npm_pm).await); - } - let py_preview = match &py_plan { - Some(plan) => edit_python_manifests(plan, false, true).await, - None => Vec::new(), - }; - - let n_changes = npm_preview - .iter() - .filter(|r| r.status == UpdateStatus::Updated) - .count() - + py_preview - .iter() - .filter(|r| r.status == PthStatus::Updated) - .count() - + extra_preview.changed; - if !quiet { - print!( - "{}", - format_setup_preview( - &npm_preview, - &py_preview, - &extra_preview, - &common.cwd, - n_changes - ) - ); - } - print_advisories(common, &advisories); - - let preview_errors = npm_preview - .iter() - .filter(|r| r.status == UpdateStatus::Error) - .count() - + py_preview - .iter() - .filter(|r| r.status == PthStatus::Error) - .count() - + extra_preview.errors; - - if n_changes == 0 { - // No hook needs editing, so there is no preview to confirm — but an - // EXPLICIT new `--exclude` is the user's stated intent and is still - // persisted (never under --dry-run, which returns below with the - // preview). A skipped (fail-closed) persistence rides the warnings - // channel exactly like on the mutating path. - let warnings: Vec = if !common.dry_run && !args.exclude.is_empty() { - persist_setup_excludes(common, &existing, &to_persist) - .await - .into_iter() - .collect() - } else { - Vec::new() - }; - if common.json { - print_setup_envelope( - if preview_errors > 0 { - "error" - } else { - "already_configured" - }, - &npm_preview, - &py_preview, - &extra_preview, - npm_pm, - py_plan.as_ref(), - &warnings, - &advisories, - ); - } else if !common.silent { - if preview_errors > 0 { - println!( - "\nNo hooks were changed; {} (see errors above).", - plural( - preview_errors, - "item could not be processed", - "items could not be processed" - ) - ); - } else { - println!("All install hooks are already configured with socket-patch!"); - } - } - print_warnings(common, &warnings); - eprint_errors( - common, - &setup_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), - ); - if preview_errors > 0 { - return 1; - } - if !common.dry_run { - track_setup().await; - } - return 0; - } - - if common.dry_run { - if common.json { - print_setup_envelope( - "dry_run", - &npm_preview, - &py_preview, - &extra_preview, - npm_pm, - py_plan.as_ref(), - &[], - &advisories, - ); - } else if !common.silent { - println!("\nSummary (dry run):"); - println!( - " {}", - plural(n_changes, "item would be updated", "items would be updated") - ); - } - eprint_errors( - common, - &setup_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), - ); - return if preview_errors > 0 { 1 } else { 0 }; - } - - // Default-no on a terminal; proceeds when stdin is not interactive. - // Keep the prompt (or its non-interactive note) off the last preview - // line. With --yes nothing is printed there, and the progress line below - // already opens with its own blank line. - if !quiet && !common.yes { - eprintln!(); - } - if !crate::ui::confirm_or_proceed("Proceed with these changes?", common) { - if !common.silent { - eprintln!("{SETUP_CANCELLED}"); - } - return 0; - } - - // Past the mutation gate: persist the exclude set now (a dry run - // returned above; an aborted or no-project run never gets here). - let persist_warning = persist_setup_excludes(common, &existing, &to_persist).await; - - status.set("Applying changes..."); - - let mut npm_results = Vec::new(); - for loc in &npm_files { - npm_results.push(update_package_json(&loc.path, false, npm_pm).await); - } - let mut py_results = Vec::new(); - let mut warnings = Vec::new(); - if let Some(plan) = &py_plan { - 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( - build_gem_outcome(common, gem_add(), false).await, - build_composer_outcome(common, composer_json.as_deref(), false, false).await, - ); - - // Materialise gem patches now so the first `bundle install` finds them - // applied. Best-effort → warnings only. - if gem_present { - warnings.extend(finalize_gem(common).await); - } - status.finish(); - - let errors = npm_results - .iter() - .filter(|r| r.status == UpdateStatus::Error) - .count() - + py_results - .iter() - .filter(|r| r.status == PthStatus::Error) - .count() - + extra_results.errors; - if errors == 0 { - track_setup().await; - } - - if common.json { - print_setup_envelope( - if errors > 0 { - "partial_failure" - } else { - "success" - }, - &npm_results, - &py_results, - &extra_results, - npm_pm, - py_plan.as_ref(), - &warnings, - &advisories, - ); - } else if !common.silent { - let updated = npm_results - .iter() - .filter(|r| r.status == UpdateStatus::Updated) - .count() - + py_results - .iter() - .filter(|r| r.status == PthStatus::Updated) - .count() - + extra_results.changed; - println!("\nSummary:"); - println!(" {}", plural(updated, "item updated", "items updated")); - if errors > 0 { - println!(" {}", plural(errors, "error", "errors")); - } - if let Some(plan) = &py_plan { - println!( - "\nCommit the {} dependency change (and your .socket/ patches) so \ - the hook re-applies in CI after install.", - plan.pm.as_str() - ); - } - if gem_present { - println!( - "\nCommit the Gemfile (the `plugin` block), .socket/bundler-plugin/, and your \ - .socket/ patches so the Bundler plugin re-applies gem patches on every \ - `bundle install` (including cached/no-op installs in CI). The socket-patch CLI \ - must be on PATH wherever `bundle install` runs." - ); - } - } - - print_warnings(common, &warnings); - eprint_errors( - common, - &setup_error_messages(&npm_results, &py_results, &extra_results, &common.cwd), - ); - - if errors > 0 { - 1 - } else { - 0 - } -} - -/// Fire `patch_setup` — "a successful `setup`". Attributed 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 is consulted explicitly, -/// exactly as `list` does — otherwise a caller authenticated by `socket -/// login` alone reports anonymously to the public patch proxy (and, with an -/// on-prem `apiBaseUrl`, to a different host than the client would use). -async fn track_setup_success( - common: &GlobalArgs, - npm: bool, - py: bool, - gem: bool, - composer: bool, - npm_pm: PackageManager, -) { - let manager = telemetry_manager_str(npm, py, gem, composer, npm_pm); - let (token, org) = common.telemetry_credentials(); - track_patch_setup(&manager, token.as_deref(), org.as_deref()).await; -} - -/// The `setup` preview (same blank-line-led sections as -/// [`format_remove_preview`]). `n_changes == 0` leaves out the "already -/// configured" count: the caller then says everything is configured, and -/// the count would only repeat it. -fn format_setup_preview( - npm: &[UpdateResult], - py: &[PthEditResult], - extra: &SetupOutcome, - cwd: &Path, - n_changes: usize, -) -> String { - let mut out = String::new(); - let npm_changes: Vec<_> = npm - .iter() - .filter(|r| r.status == UpdateStatus::Updated) - .collect(); - if !npm_changes.is_empty() { - out.push_str("\npackage.json files to update:\n"); - for r in &npm_changes { - out.push_str(&format!(" + {}\n", pathdiff(&r.path, cwd))); - out.push_str(&format!(" -> postinstall: \"{}\"\n", r.new_script)); - // The run writes the `dependencies` hook too; show it when it - // changes, so the preview matches what is written. - if r.new_dependencies_script != r.old_dependencies_script { - out.push_str(&format!( - " -> dependencies: \"{}\"\n", - r.new_dependencies_script - )); - } - } - } - let py_changes: Vec<_> = py - .iter() - .filter(|r| r.status == PthStatus::Updated) - .collect(); - if !py_changes.is_empty() { - out.push_str("\nPython manifests to update (socket-patch[hook]):\n"); - for r in &py_changes { - out.push_str(&format!(" + {}\n", pathdiff(&r.path, cwd))); - } - } - push_extra_preview(&mut out, extra); - - let already = npm - .iter() - .filter(|r| r.status == UpdateStatus::AlreadyConfigured) - .count() - + py.iter() - .filter(|r| r.status == PthStatus::AlreadyConfigured) - .count() - + extra.already; - if already > 0 && n_changes > 0 { - out.push_str(&format!("\nAlready configured (will skip): {already}\n")); - } - - push_errors(&mut out, &setup_error_messages(npm, py, extra, cwd)); - out -} - -#[allow(clippy::too_many_arguments)] -fn print_setup_envelope( - status: &str, - npm: &[UpdateResult], - py: &[PthEditResult], - extra: &SetupOutcome, - npm_pm: PackageManager, - py_plan: Option<&PythonPlan>, - warnings: &[String], - advisories: &[(&str, String)], -) { - let updated = npm - .iter() - .filter(|r| r.status == UpdateStatus::Updated) - .count() - + py.iter().filter(|r| r.status == PthStatus::Updated).count() - + extra.changed; - let already = npm - .iter() - .filter(|r| r.status == UpdateStatus::AlreadyConfigured) - .count() - + py.iter() - .filter(|r| r.status == PthStatus::AlreadyConfigured) - .count() - + extra.already; - let errors = npm - .iter() - .filter(|r| r.status == UpdateStatus::Error) - .count() - + py.iter().filter(|r| r.status == PthStatus::Error).count() - + extra.errors; - - let mut files: Vec = npm - .iter() - .map(|r| { - serde_json::json!({ - "kind": "package_json", - "path": r.path, - "status": match r.status { - UpdateStatus::Updated => "updated", - UpdateStatus::AlreadyConfigured => "already_configured", - UpdateStatus::Error => "error", - }, - "error": r.error, - }) - }) - .collect(); - files.extend(py.iter().map(|r| { - serde_json::json!({ - "kind": "pth", - "path": r.path, - "status": match r.status { - PthStatus::Updated => "updated", - PthStatus::AlreadyConfigured => "already_configured", - PthStatus::Error => "error", - }, - "error": r.error, - }) - })); - files.extend(extra.json_files.iter().cloned()); - - let mut obj = serde_json::json!({ - "status": status, - "updated": updated, - "alreadyConfigured": already, - "errors": errors, - "packageManager": manager_name(npm_pm), - "files": files, - }); - if status == "dry_run" { - obj["dryRun"] = serde_json::json!(true); - obj["wouldUpdate"] = serde_json::json!(updated); - } - if let Some(plan) = py_plan { - obj["pythonPackageManager"] = serde_json::json!(plan.pm.as_str()); - } - let warnings: Vec = warnings - .iter() - .cloned() - .chain( - advisories - .iter() - .map(|(code, detail)| format!("{code}: {detail}")), - ) - .collect(); - if !warnings.is_empty() { - obj["warnings"] = serde_json::json!(warnings); - } - println!( - "{}", - serde_json::to_string_pretty(&obj) - .expect("serializing an in-memory JSON value cannot fail") - ); -} - -#[cfg(test)] -mod tests { - //! Exact-string tests for setup's human output builders. - use super::*; - - fn cwd() -> PathBuf { - PathBuf::from("/proj") - } - - fn update(path: &str, status: UpdateStatus, err: Option<&str>) -> UpdateResult { - UpdateResult { - path: path.to_string(), - status, - old_script: String::new(), - new_script: "npx @socketsecurity/socket-patch apply --silent".to_string(), - old_dependencies_script: "npx @socketsecurity/socket-patch apply --silent".to_string(), - new_dependencies_script: "npx @socketsecurity/socket-patch apply --silent".to_string(), - error: err.map(str::to_string), - } - } - - fn remove(path: &str, status: RemoveStatus) -> RemoveResult { - RemoveResult { - path: path.to_string(), - status, - old_script: "socket-patch apply && echo hi".to_string(), - new_script: Some("echo hi".to_string()), - old_dependencies_script: "socket-patch apply".to_string(), - new_dependencies_script: None, - error: None, - } - } - - #[test] - fn no_files_message_follows_scope() { - let all = ["package.json", "Python", "Bundler", "Composer"]; - assert_eq!( - format_no_files(&all, &[]), - "No package.json, Python, Bundler, or Composer project found" - ); - assert_eq!( - format_no_files(&["package.json"], &["npm".to_string()]), - "No package.json project found" - ); - assert_eq!( - format_no_files(&["Python", "Bundler"], &[]), - "No Python or Bundler project found" - ); - assert_eq!( - format_no_files(&[], &["cargo".to_string(), "maven".to_string()]), - "Setup has no install hook for: cargo, maven (supported: npm, pypi, gem, composer)" - ); - } - - #[test] - fn no_files_message_reads_the_ecosystems_filter() { - let mut common = GlobalArgs::default(); - assert_eq!( - no_files_message(&common), - "No package.json, Python, Bundler, or Composer project found" - ); - common.ecosystems = Some(vec!["cargo".to_string()]); - assert!(no_files_message(&common).starts_with("Setup has no install hook for: cargo")); - common.ecosystems = Some(vec!["cargo".to_string(), "pypi".to_string()]); - assert_eq!(no_files_message(&common), "No Python project found"); - } - - #[test] - fn warnings_are_capitalized_for_humans() { - assert_eq!( - format_warning("not persisting --exclude: x"), - "Warning: Not persisting --exclude: x" - ); - assert_eq!( - format_warning("`uv lock` failed"), - "Warning: `uv lock` failed" - ); - assert_eq!(format_warning("écrit"), "Warning: Écrit"); - assert_eq!(format_warning(""), "Warning:"); - } - - #[test] - fn unmatched_exclude_message_is_trimmed() { - assert_eq!( - format_unmatched_exclude(" nope"), - "--exclude \"nope\" matched no workspace member" - ); - assert_eq!( - format_unmatched_exclude("pkgs/ü"), - "--exclude \"pkgs/ü\" matched no workspace member" - ); - } - - #[test] - fn check_lines_render_each_state() { - use CheckState::*; - assert_eq!( - format_check_line("package_json", "package.json", Configured, None), - " ✓ package.json (configured)" - ); - assert_eq!( - format_check_line("package_json", "package.json", NeedsConfiguration, None), - " ✗ package.json (needs setup)" - ); - assert_eq!( - format_check_line( - "patch", - "pkg:npm/minimist@1.2.5", - NeedsConfiguration, - Some("patch not applied on disk (hash_mismatch)") - ), - " ✗ pkg:npm/minimist@1.2.5: patch not applied on disk (hash_mismatch)" - ); - assert_eq!( - format_check_line("gemfile", "Gemfile", NeedsConfiguration, Some("x")), - " ✗ Gemfile (needs setup: x)" - ); - assert_eq!( - format_check_line( - "package_json", - "a/package.json", - Error, - Some("Invalid package.json: EOF") - ), - " ! a/package.json: Invalid package.json: EOF" - ); - assert_eq!( - format_check_line("pth", "req.txt", Error, None), - " ! req.txt: unknown error" - ); - } - - #[test] - fn check_footer_names_the_fixing_command() { - assert_eq!( - format_check_footer(0, 0, 0), - "All manifests are configured with socket-patch." - ); - assert_eq!( - format_check_footer(1, 0, 0), - "1 manifest needs configuration. Run `socket-patch setup` to add the missing \ - install hooks." - ); - assert_eq!( - format_check_footer(0, 1, 0), - "1 patch is not applied on disk. Run `socket-patch apply` to re-apply the patches." - ); - assert_eq!( - format_check_footer(0, 0, 2), - "2 errors. Fix the errors above, then re-run `socket-patch setup --check`." - ); - assert_eq!( - format_check_footer(3, 2, 1), - "3 manifests need configuration, 2 patches are not applied on disk, 1 error. \ - Run `socket-patch setup` to add the missing install hooks. Run `socket-patch \ - apply` to re-apply the patches. Fix the errors above, then re-run `socket-patch \ - setup --check`." - ); - for f in [format_check_footer(1, 1, 1), format_check_footer(2, 2, 2)] { - assert!(!f.contains("(s)"), "{f}"); - } - } - - #[test] - fn item_errors_name_the_file() { - assert_eq!( - format_item_error( - "/proj/packages/a/package.json", - "Invalid package.json: x", - &cwd() - ), - "packages/a/package.json: Invalid package.json: x" - ); - assert_eq!(format_item_error("", "boom", &cwd()), "boom"); - assert_eq!( - format_item_error("/elsewhere/p.json", "boom", &cwd()), - "/elsewhere/p.json: boom" - ); - } - - #[test] - fn setup_preview_layout() { - let npm = vec![ - update("/proj/package.json", UpdateStatus::Updated, None), - update( - "/proj/packages/b/package.json", - UpdateStatus::AlreadyConfigured, - None, - ), - update( - "/proj/packages/bad/package.json", - UpdateStatus::Error, - Some("Invalid package.json: EOF"), - ), - ]; - let out = format_setup_preview(&npm, &[], &SetupOutcome::default(), &cwd(), 1); - assert_eq!( - out, - "\npackage.json files to update:\n + package.json\n -> postinstall: \"npx \ - @socketsecurity/socket-patch apply --silent\"\n\nAlready configured (will skip): \ - 1\n\nErrors:\n ! packages/bad/package.json: Invalid package.json: EOF\n" - ); - assert!(!out.contains("\n\n\n"), "{out:?}"); - } - - #[test] - fn setup_preview_shows_a_changed_dependencies_script() { - let mut r = update("/proj/package.json", UpdateStatus::Updated, None); - r.old_dependencies_script = String::new(); - r.new_dependencies_script = "socket-patch apply --silent".to_string(); - assert_eq!( - format_setup_preview(&[r], &[], &SetupOutcome::default(), &cwd(), 1), - "\npackage.json files to update:\n + package.json\n -> postinstall: \"npx \ - @socketsecurity/socket-patch apply --silent\"\n -> dependencies: \ - \"socket-patch apply --silent\"\n" - ); - } - - #[test] - fn setup_preview_skips_already_count_when_nothing_changes() { - let npm = vec![update( - "/proj/package.json", - UpdateStatus::AlreadyConfigured, - None, - )]; - assert_eq!( - format_setup_preview(&npm, &[], &SetupOutcome::default(), &cwd(), 0), - "" - ); - } - - #[test] - fn setup_preview_lists_gem_and_composer_lines() { - let extra = SetupOutcome { - preview: vec![ - "Gem: add the socket-patch Bundler plugin wiring to:".to_string(), - " + Gemfile".to_string(), - ], - changed: 1, - ..Default::default() - }; - assert_eq!( - format_setup_preview(&[], &[], &extra, &cwd(), 1), - "\nGem: add the socket-patch Bundler plugin wiring to:\n + Gemfile\n" - ); - } - - #[test] - fn cancel_lines_name_the_action() { - assert_eq!(SETUP_CANCELLED, "Setup cancelled."); - assert_eq!(REMOVE_CANCELLED, "Hook removal cancelled."); - assert_eq!( - SEARCHING, - "Searching for package.json / Python / Bundler / Composer manifests..." - ); - assert_eq!(CONFIGURING, "Configuring socket-patch install hooks..."); - } - - #[test] - fn remove_preview_is_empty_when_nothing_would_change() { - let npm = vec![remove("/proj/package.json", RemoveStatus::NotConfigured)]; - assert_eq!( - format_remove_preview(&npm, &[], &SetupOutcome::default(), &cwd()), - "" - ); - assert_eq!( - format_items_failed(1), - "Error: 1 item could not be processed." - ); - assert_eq!( - format_items_failed(2), - "Error: 2 items could not be processed." - ); - } - - #[test] - fn remove_preview_lists_only_the_scripts_the_file_has() { - let mut only_postinstall = remove("/proj/package.json", RemoveStatus::Removed); - only_postinstall.old_script = - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm".to_string(); - only_postinstall.new_script = None; - only_postinstall.old_dependencies_script = String::new(); - only_postinstall.new_dependencies_script = None; - let out = format_remove_preview(&[only_postinstall], &[], &SetupOutcome::default(), &cwd()); - assert_eq!( - out, - "\nProposed changes:\n\nWill remove socket-patch from:\n - package.json\n \ - postinstall: \"npx @socketsecurity/socket-patch apply --silent --ecosystems \ - npm\"\n -> postinstall: (removed)\n" - ); - assert_eq!( - format_script_change("dependencies", "socket-patch apply", "(removed)"), - " dependencies: \"socket-patch apply\"\n -> dependencies: (removed)\n" - ); - } - - #[test] - fn remove_preview_layout_has_no_double_blank_lines() { - let npm = vec![remove("/proj/package.json", RemoveStatus::Removed)]; - let py = vec![PthEditResult { - path: "/proj/requirements.txt".to_string(), - status: PthStatus::Updated, - error: None, - }]; - let extra = SetupOutcome { - preview: vec![ - "Gem: remove the socket-patch Bundler plugin wiring from:".to_string(), - " - Gemfile".to_string(), - ], - ..Default::default() - }; - let out = format_remove_preview(&npm, &py, &extra, &cwd()); - assert_eq!( - out, - "\nProposed changes:\n\nWill remove socket-patch from:\n - package.json\n \ - postinstall: \"socket-patch apply && echo hi\"\n -> postinstall: \"echo \ - hi\"\n dependencies: \"socket-patch apply\"\n -> dependencies: \ - (removed)\n\nWill remove the socket-patch[hook] dependency from:\n - \ - requirements.txt\n\nGem: remove the socket-patch Bundler plugin wiring from:\n \ - - Gemfile\n" - ); - assert!(!out.contains("\n\n\n"), "{out:?}"); - assert!( - out.ends_with("Gemfile\n"), - "no trailing blank line: {out:?}" - ); - } - - #[test] - fn vlt_is_its_own_telemetry_and_envelope_manager() { - assert_eq!(manager_name(PackageManager::Vlt), "vlt"); - assert_eq!( - telemetry_manager_str(true, true, false, false, PackageManager::Vlt), - "vlt+pypi" - ); - } - - #[test] - fn vlt_root_scripts_advisory_is_definite_only_below_rc13() { - let reported = |v: &str| VltVersionProbe::Reported(v.to_string()); - for old in [ - "0.0.0-1", - "0.0.0-0.1733957343934", - "0.0.0-32", - "1.0.0-rc.9", - "1.0.0-rc.12", - ] { - assert_eq!( - vlt_root_scripts_advisory(&reported(old), None).as_deref(), - Some( - format!( - "vlt before 1.0.0-rc.13 does not run the root postinstall hook; \ - upgrade vlt or run `socket-patch apply` after `vlt ci` (`vlt \ - --version` reports {old})" - ) - .as_str() - ), - "{old}" - ); - } - for new in ["1.0.0-rc.13", "1.0.0-rc.14", "1.0.0", "1.2.0"] { - assert_eq!( - vlt_root_scripts_advisory(&reported(new), None), - None, - "{new}" - ); - } - } - - #[test] - fn vlt_root_scripts_advisory_is_a_may_for_a_lock_the_reported_vlt_never_writes() { - let reported = |v: &str| VltVersionProbe::Reported(v.to_string()); - let may = |version: &str| { - format!( - "vlt before 1.0.0-rc.13 does not run the root postinstall hook; upgrade vlt \ - or run `socket-patch apply` after `vlt ci` (vlt-lock.json {version}, so this \ - project may be installed by such a vlt)" - ) - }; - for writes_v0 in ["1.0.0-rc.13", "1.0.0-rc.14"] { - assert_eq!( - vlt_root_scripts_advisory(&reported(writes_v0), Some(LegacyVltLock::VersionZero)), - None, - "{writes_v0}" - ); - } - for refuses_v0 in ["1.0.0-rc.15", "1.0.1", "1.2.0"] { - assert_eq!( - vlt_root_scripts_advisory(&reported(refuses_v0), Some(LegacyVltLock::VersionZero)), - Some(may("has lockfileVersion 0")), - "{refuses_v0}" - ); - } - for new in ["1.0.0-rc.13", "1.0.0-rc.14", "1.2.0"] { - assert_eq!( - vlt_root_scripts_advisory(&reported(new), Some(LegacyVltLock::NoVersion)), - Some(may("has no lockfileVersion")), - "{new}" - ); - } - } - - #[test] - fn vlt_root_scripts_advisory_never_fires_on_an_unparseable_version() { - for raw in [ - "", - "vlt 1.0.0-rc.12", - "v0.0.0-32", - "rc.12", - "1.0.0-rc.12\nextra", - ] { - assert_eq!( - vlt_root_scripts_advisory( - &VltVersionProbe::Reported(raw.to_string()), - Some(LegacyVltLock::NoVersion) - ), - None, - "{raw:?}" - ); - } - } - - #[test] - fn vlt_root_scripts_advisory_falls_back_to_a_may_from_the_lock() { - let probe = VltVersionProbe::Unavailable; - assert_eq!(vlt_root_scripts_advisory(&probe, None), None); - assert_eq!( - vlt_root_scripts_advisory(&probe, Some(LegacyVltLock::VersionZero)).as_deref(), - Some( - "vlt before 1.0.0-rc.13 does not run the root postinstall hook; upgrade vlt \ - or run `socket-patch apply` after `vlt ci` (vlt-lock.json has lockfileVersion \ - 0, so this project may be installed by such a vlt)" - ) - ); - assert!( - vlt_root_scripts_advisory(&probe, Some(LegacyVltLock::NoVersion)) - .unwrap() - .contains("(vlt-lock.json has no lockfileVersion, so this project may") - ); - } -} diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 4ece01fb..88f93bb4 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -20,11 +20,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; 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::telemetry::{track_vex_failed, track_vex_generated}; -use socket_patch_core::vendor::state::VendorState; use socket_patch_core::vex::{ build_document, detect_product, BuildOptions, Document, FailedPatch, VendorContext, VerifyOutcome, @@ -39,15 +37,6 @@ use crate::ecosystem_dispatch::{collapse_to_first, find_manifest_package_copies_ use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning}; use crate::ui::plural; -/// Routing tag for a patch omitted from VEX by the property-7 ecosystem -/// filter alone: the patch IS applied (byte-verified, or trusted under -/// `--no-verify`) and carries vulnerability metadata, but its ecosystem has -/// no install hook set up and is not declared `manual`. Distinct from the -/// verification tags (`hash_mismatch`, `package_not_found`, …) so a JSON -/// consumer can tell "not patched" from "patched but not persisted by any -/// hook". -const ECOSYSTEM_NOT_SETUP: &str = "ecosystem_not_setup"; - #[derive(Args)] pub struct VexArgs { #[command(flatten)] @@ -462,28 +451,6 @@ fn org_looks_like_path(org: Option<&str>) -> Option { }) } -/// Map a `setup.manual` entry to an `Ecosystem`, case-insensitively. -/// Accepts the canonical `cli_name`, the aliases `go`, `python`, `ruby`, -/// `rust`, `php`, `java`, `dotnet` and `jsr`, and the npm-family package -/// manager names (`yarn`/`pnpm`/`bun`/`vlt`). Unrecognized names yield -/// `None` and are ignored. -fn ecosystem_from_manual_name(name: &str) -> Option { - match name.to_ascii_lowercase().as_str() { - "npm" | "yarn" | "pnpm" | "bun" | "vlt" => Some(Ecosystem::Npm), - "pypi" | "python" => Some(Ecosystem::Pypi), - "gem" | "ruby" => Some(Ecosystem::Gem), - "cargo" | "rust" => Some(Ecosystem::Cargo), - "golang" | "go" => Some(Ecosystem::Golang), - "composer" | "php" => Some(Ecosystem::Composer), - // The apply-only ecosystems are the primary use of `manual` (hand-applied - // patches with no auto-install hook); they must map too. - "maven" | "java" => Some(Ecosystem::Maven), - "nuget" | "dotnet" => Some(Ecosystem::Nuget), - "deno" | "jsr" => Some(Ecosystem::Deno), - _ => None, - } -} - /// Core VEX pipeline shared by the standalone `vex` command and the /// embedded `apply`/`vendor`/`scan` `--vex` paths: resolve the product, verify the /// plan's record view against disk (unless `no_verify`), build the OpenVEX @@ -715,72 +682,13 @@ async fn generate_vex( ); } - // Property 7: attest a patch only for an ecosystem that is actually set up — - // or explicitly declared `manual` in the manifest. Patches for an ecosystem - // that is neither are dropped regardless of verification mode (so even - // `--no-verify` won't attest an un-set-up ecosystem's patches). - // Exemption: VENDORED patches bypass the filter — the committed - // `.socket/vendor/` artifact + lockfile wiring IS the persistence - // mechanism, so no install hook exists (or is needed) by construction. - let vendored_set: std::collections::HashSet = - outcome.vendored.iter().cloned().collect(); - // Redirected (hosted) patches bypass the filter for the same reason: the - // committed lockfile rewrite IS the persistence mechanism. - let redirected_set: std::collections::HashSet<&str> = - redirected.iter().map(|s| s.as_str()).collect(); - let mut allowed = crate::commands::setup::configured_ecosystems(common).await; - if let Some(s) = &manifest.setup { - for name in &s.manual { - if let Some(e) = ecosystem_from_manual_name(name) { - allowed.insert(e); - } - } - } - let mut setup_filtered: Vec = Vec::new(); - outcome.applied.retain(|purl| { - let keep = vendored_set.contains(purl) - || redirected_set.contains(purl.as_str()) - || Ecosystem::from_purl(purl) - .map(|e| allowed.contains(&e)) - .unwrap_or(false); - if !keep { - setup_filtered.push(purl.clone()); - } - keep - }); - let any_setup_filtered = !setup_filtered.is_empty(); - // The filter drops join the omission channel (`failed`) with their own - // routing tag so they surface as per-purl `skipped` events in the - // envelope — success and error paths alike. - outcome - .failed - .extend(setup_filtered.into_iter().map(|purl| FailedPatch { - purl, - reason: ECOSYSTEM_NOT_SETUP.to_string(), - })); // `failed` is built by iterating the record view's HashMap: without a // sort the omission order (stderr, the error list and the JSON // `skipped` events) changes run to run. outcome .failed .sort_by(|a, b| (&a.purl, &a.reason).cmp(&(&b.purl, &b.reason))); - - // When nothing attests and EVERY omission was the property-7 filter, - // the run fails with a message that explains the setup cause itself - // (see below), so the generic note would only repeat it. - let all_setup_drops = outcome.applied.is_empty() - && !outcome.failed.is_empty() - && outcome - .failed - .iter() - .all(|f| f.reason == ECOSYSTEM_NOT_SETUP); if !common.silent && !common.json { - if any_setup_filtered && !all_setup_drops { - eprintln!( - "Note: patches for ecosystems that are not set up (and not declared `manual` \ - in .socket/manifest.json's `setup.manual`) are omitted from VEX." - ); - } for f in &outcome.failed { eprintln!("{}", format_omission_warning(&f.purl, &f.reason)); } @@ -810,18 +718,7 @@ async fn generate_vex( None => { let (token, org) = common.telemetry_credentials(); track_vex_failed("no_applicable_patches", token.as_deref(), org.as_deref()).await; - // When nothing attested and EVERY omission was the property-7 - // filter, say so: those patches ARE applied with vulnerability - // metadata, and the generic message below would read as "not - // patched" to a human. The code stays `no_applicable_patches` — - // it is the documented exit-1 routing tag consumers already - // branch on; the per-event `ecosystem_not_setup` errorCode is - // the machine-readable discriminator. - let message = if all_setup_drops { - format_setup_drops_message(outcome.failed.len()) - } else { - "No applied patches with vulnerability metadata to attest.".to_string() - }; + let message = "No applied patches with vulnerability metadata to attest.".to_string(); return Err(VexGenError { code: "no_applicable_patches", message, @@ -1145,7 +1042,7 @@ async fn generate_vex_from_manifest_path_inner( /// 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 +/// `list` (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 { let (token, org) = common.telemetry_credentials(); @@ -1227,78 +1124,7 @@ fn join_and(items: &[&str]) -> String { } } -/// The one `unreadable vendor state` advisory (contract: `setup --check` -/// surfaces a ledger it cannot read or parse as this line, muted by -/// `--silent`): a read-only consumer degrades to "nothing vendored" and says -/// so, on stderr, so the operator learns why nothing verifies. (`vex` itself -/// refuses an unreadable ledger outright — `vendor_ledger_corrupt` — since -/// the ledger's entries gate what attests.) -pub(crate) fn warn_unreadable_vendor_state(common: &GlobalArgs, e: &std::io::Error) { - if !common.silent { - eprintln!( - "Warning: {}", - vendor_state_unreadable_message(&e.to_string()) - ); - } -} - -/// Build the [`VendorContext`] for `setup --check`'s patch-consistency pass -/// from `ledger` — the caller's ONE `load_state` of -/// `.socket/vendor/state.json` (it also fed the vendor-record fold) — plus -/// synthesized entries for the legacy `.socket/go-patches/` redirect -/// backend: a vendored patch is judged by the committed artifact, never the -/// installed tree. (`vex` itself builds its context from the gated plan -/// instead, so a ledger entry no lockfile wires never routes its -/// verification.) -/// -/// Go-patches synthesis: an apply-redirected Go patch leaves the module -/// cache pristine (the `replace` directive routes the build at the copy -/// dir), so the copy dir is what verification must hash. -/// -/// An unreadable/corrupt vendor ledger degrades to "no vendor entries": -/// vendored PURLs then fall through to the installed tree, fail -/// verification there, and are omitted — fail-closed, never falsely -/// attested. The degrade is returned as a warning detail for the caller to -/// report in its own channel. The context is `None` when there is nothing -/// vendored and no redirect to synthesize (the common case). -pub(crate) async fn vendor_context_from( - common: &GlobalArgs, - manifest: &PatchManifest, - ledger: std::io::Result, -) -> (Option, Option) { - let (entries, warning) = match ledger { - Ok(state) => (state.entries, None), - Err(e) => ( - HashMap::new(), - Some(vendor_state_unreadable_message(&e.to_string())), - ), - }; - - let go_patches = synthesize_go_patches(common, manifest, &entries).await; - - if entries.is_empty() && go_patches.is_empty() { - return (None, warning); - } - let context = VendorContext { - project_root: common.cwd.clone(), - entries, - go_patches, - hosted: HashMap::new(), - }; - (Some(context), warning) -} - -/// The unreadable-`.socket/vendor/state.json` advisory (`cause` is -/// `load_state`'s error, which names the file). -pub(crate) fn vendor_state_unreadable_message(cause: &str) -> String { - format!( - "Unreadable vendor state ({cause}); vendored patches cannot be verified from the \ - committed artifact" - ) -} - -/// Synthesize go-patches redirect targets for [`vendor_context_from`] and -/// `vex`: for every socket-owned (`.socket/go-patches/`) `replace` in +/// Synthesize go-patches redirect targets for `vex`: for every socket-owned (`.socket/go-patches/`) `replace` in /// `go.mod` whose module+version maps to a golang PURL of the record view /// (`manifest` — for `vex` the merged manifest + ledger + lockfile view, so /// a manifest-less project's ledger-recorded Go patch verifies too) with no @@ -1389,10 +1215,6 @@ fn emit_envelope_error( /// machine-readable `errorCode`). fn omission_phrase(reason: &str) -> &'static str { match reason { - ECOSYSTEM_NOT_SETUP => { - "applied, but its ecosystem has no install hook set up and is not declared \ - `manual` in setup.manual" - } "package_not_found" => "the package is not installed", "not_applied" => "the patched files still hold the original content", "hash_mismatch" => "a patched file matches neither the original nor the patched content", @@ -1448,29 +1270,7 @@ fn format_omission_warning(purl: &str, reason: &str) -> String { /// Human `reason` string for an omission event; the routing tag rides /// `errorCode`. fn omission_reason_message(reason: &str) -> String { - if reason == ECOSYSTEM_NOT_SETUP { - "applied patch omitted from VEX: its ecosystem has no install hook set up and is not \ - declared `manual` in setup.manual" - .to_string() - } else { - format!("patch omitted from VEX: {}", omission_phrase(reason)) - } -} - -/// The `no_applicable_patches` message when every omission was the -/// property-7 setup filter. -fn format_setup_drops_message(n: usize) -> String { - let (subject, verb, their, ecosystems) = if n == 1 { - ("applied patch", "was", "its", "ecosystem is") - } else { - ("applied patches", "were", "their", "ecosystems are") - }; - format!( - "{n} {subject} with vulnerability metadata {verb} omitted from VEX because {their} \ - {ecosystems} not set up (no install hook) and not declared `manual` in \ - .socket/manifest.json's `setup.manual`. Run `socket-patch setup`, or add the \ - ecosystem to `setup.manual`, then re-run." - ) + format!("patch omitted from VEX: {}", omission_phrase(reason)) } fn emit_envelope_success(summary: &VexWriteSummary, dry_run: bool) { @@ -1511,44 +1311,6 @@ mod tests { use super::*; use clap::Parser; - // Property 7: every ecosystem a PURL can classify to must also be - // declarable `manual` (apply-only maven/nuget/deno are its primary use). - #[test] - fn ecosystem_from_manual_name_maps_every_ecosystem() { - assert_eq!(ecosystem_from_manual_name("npm"), Some(Ecosystem::Npm)); - assert_eq!(ecosystem_from_manual_name("vlt"), Some(Ecosystem::Npm)); - assert_eq!(ecosystem_from_manual_name("PyPI"), Some(Ecosystem::Pypi)); // case-insensitive - assert_eq!(ecosystem_from_manual_name("python"), Some(Ecosystem::Pypi)); - assert_eq!(ecosystem_from_manual_name("ruby"), Some(Ecosystem::Gem)); - assert_eq!(ecosystem_from_manual_name("nonsense"), None); - assert_eq!(ecosystem_from_manual_name("cargo"), Some(Ecosystem::Cargo)); - assert_eq!(ecosystem_from_manual_name("go"), Some(Ecosystem::Golang)); - assert_eq!( - ecosystem_from_manual_name("composer"), - Some(Ecosystem::Composer) - ); - assert_eq!(ecosystem_from_manual_name("maven"), Some(Ecosystem::Maven)); - assert_eq!(ecosystem_from_manual_name("nuget"), Some(Ecosystem::Nuget)); - assert_eq!(ecosystem_from_manual_name("deno"), Some(Ecosystem::Deno)); - } - - // Every `Ecosystem::all()` variant must round-trip through its `cli_name` - // via `ecosystem_from_manual_name`, or its `manual`-declared patches are - // silently dropped by the `retain` in `generate_vex`. - #[test] - fn every_compiled_ecosystem_is_declarable_manual_via_cli_name() { - for &e in Ecosystem::all() { - assert_eq!( - ecosystem_from_manual_name(e.cli_name()), - Some(e), - "ecosystem {:?} (cli_name {:?}) is not reachable via ecosystem_from_manual_name — \ - its `manual`-declared patches would be silently dropped from VEX", - e, - e.cli_name(), - ); - } - } - /// The go-patches synthesis guards its copy-dir keys with core's /// `are_safe_redirect_coords`; pin the accept/reject set from the CLI /// side — a regression here would let a tampered go.mod `replace` key @@ -1636,30 +1398,6 @@ mod tests { assert_eq!(format_vex_emitted(12), "Emitted 12 VEX statements"); } - #[test] - fn setup_drops_message_agrees_in_number() { - let one = format_setup_drops_message(1); - assert!( - one.starts_with( - "1 applied patch with vulnerability metadata was omitted from VEX because its \ - ecosystem is not set up (no install hook)" - ), - "{one}" - ); - let two = format_setup_drops_message(2); - assert!( - two.starts_with( - "2 applied patches with vulnerability metadata were omitted from VEX because \ - their ecosystems are not set up (no install hook)" - ), - "{two}" - ); - for m in [&one, &two] { - assert!(!m.contains("(s)"), "{m}"); - assert!(m.ends_with("then re-run."), "{m}"); - } - } - #[test] fn omission_warning_names_phrase_and_tag() { assert_eq!( @@ -1679,7 +1417,6 @@ mod tests { (brand_new_tag)" ); for tag in [ - ECOSYSTEM_NOT_SETUP, "package_not_found", "not_applied", "hash_mismatch", @@ -1751,15 +1488,6 @@ mod tests { assert!(org_looks_like_path(Some("OUT.JSON")).is_some()); } - #[test] - fn vendor_state_message_is_capitalized_and_keeps_cause() { - assert_eq!( - vendor_state_unreadable_message("corrupt x/state.json: eof"), - "Unreadable vendor state (corrupt x/state.json: eof); vendored patches cannot be \ - verified from the committed artifact" - ); - } - #[derive(Parser)] struct Wrap { #[command(subcommand)] diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index d0bc39fe..22e5fd75 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -60,7 +60,7 @@ use crate::ecosystem_dispatch::{ /// Resolve [`HostedCopies`] for every hosted-basis purl of `hosted` (see the /// module docs), under the same crawler options and `--ecosystems` scope as /// the installed-tree lookup. `installed` is that lookup's every-copy -/// result ([`crate::ecosystem_dispatch::find_manifest_package_copies`] over +/// result ([`crate::ecosystem_dispatch::find_manifest_package_copies_reusing`] over /// the record view, which holds every hosted purl): the shared-location /// ecosystems read it instead of crawling the tree a second time. `prior` /// (embedded hosted `scan --vex` only) is scan's npm crawl of the same diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 0d6c08d5..5810b943 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -141,8 +141,8 @@ impl Sources { /// The resolved attestation inputs. pub(crate) struct Plan { - /// purl → record for every candidate that passed the gates (with the - /// manifest file's `setup` block, which property 7 reads). + /// purl → record for every candidate that passed the gates (carrying + /// the manifest file's legacy `setup` block through unchanged). pub view: PatchManifest, /// Vendored-basis entries, keyed by view purl — the verification /// routing for `applied_patches_with_vendor`. diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index f5e3221c..89b9d3a7 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -395,7 +395,7 @@ async fn dispatch_find( /// Collapse a multi-copy map to one representative path per PURL (the /// first-discovered — root-copy-first for npm). Consumers that only need /// "is it installed / where is a representative copy" (`vendor`, `vex`, -/// `setup`, `get`, `repair vendor`) use the collapsing wrappers below +/// `get`, `repair vendor`) use the collapsing wrappers below /// (`HashMap`). `apply` and /// `rollback` — which must touch EVERY copy — use the `_all` variants. pub(crate) fn collapse_to_first(multi: HashMap>) -> HashMap { @@ -582,9 +582,9 @@ pub(crate) fn npm_paths_by_identity_in( out } -/// Resolve manifest PURLs to their installed on-disk paths (partition, -/// build crawler options from the global args, dispatch). Uses the -/// rollback (qualified-aware) resolver, never a base-keyed collapse of +/// Resolve manifest PURLs to every installed on-disk copy (crawl order; +/// partition, build crawler options from the global args, dispatch). Uses +/// the rollback (qualified-aware) resolver, never a base-keyed collapse of /// [`find_all_packages_for_purls`]: release-variant ecosystems (PyPI / /// RubyGems / Maven) key the manifest by *qualified* PURLs /// (`?artifact_id=`, `?platform=`, `?classifier=&ext=`), but the crawler @@ -593,30 +593,11 @@ pub(crate) fn npm_paths_by_identity_in( /// patch would silently resolve as `package_not_found`. The rollback /// variant fans each base path back out to every qualified manifest PURL /// — the same mapping the manifest was written with (`get` uses the same -/// resolver). -pub async fn find_manifest_package_paths( - purls: &[String], - common: &GlobalArgs, - quiet: bool, -) -> HashMap { - collapse_to_first(find_manifest_package_copies(purls, common, quiet).await) -} - -/// [`find_manifest_package_paths`] keeping EVERY installed copy of each -/// purl (crawl order): one lookup a caller can take both the first-copy -/// view and the every-copy view from (`vex` hashes the first copy of a -/// manifest purl and every copy of a hosted one), so the tree is crawled — -/// and a `--global` run's "Using … at:" banner printed — once. -pub async fn find_manifest_package_copies( - purls: &[String], - common: &GlobalArgs, - quiet: bool, -) -> HashMap> { - find_manifest_package_copies_reusing(purls, common, quiet, None).await -} - -/// [`find_manifest_package_copies`], taking the npm `node_modules` roots -/// from `prior` (a crawl of the same options earlier in this process, over +/// resolver). `vex` hashes the first copy of a manifest purl and every copy +/// of a hosted one from this one lookup. +/// +/// With `prior`, the npm `node_modules` roots come +/// from it (a crawl of the same options earlier in this process, over /// a tree whose directories nothing has touched since) instead of walking /// the tree for them again — the every-copy twin of /// [`find_packages_for_rollback_reusing`]. Only the root discovery is diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 42b0d950..a0eaabe5 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -3,7 +3,7 @@ //! The `--json` output of `apply`, `list`, `remove`, `repair`/`gc`, //! `vendor`, `self-update` and `vex --json --output` (and every command's //! lock-contention error) uses this top-level shape; `scan`, `get`, -//! `rollback` and `setup` still emit their legacy shapes (see +//! and `rollback` still emit their legacy shapes (see //! CLI_CONTRACT.md's migration status): //! //! ```json @@ -374,7 +374,6 @@ pub enum Command { Apply, Vex, Vendor, - Setup, Rollback, Get, List, @@ -910,7 +909,6 @@ mod tests { (Command::Apply, "apply"), (Command::Vex, "vex"), (Command::Vendor, "vendor"), - (Command::Setup, "setup"), (Command::Rollback, "rollback"), (Command::Get, "get"), (Command::List, "list"), diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index cd18d33d..51f400d3 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -31,7 +31,7 @@ use clap::{Parser, Subcommand}; socket-patch vex Emit an OpenVEX document for your vulnerability scanner\n \ socket-patch vendor Eject the patches into .socket/vendor/ for offline installs\n \ socket-patch list Show the patches in this project\n\n\ - get, apply, setup, rollback, remove and repair are the older agent-mode commands." + get, apply, rollback, remove and repair are the older agent-mode commands." )] pub struct Cli { #[command(subcommand)] @@ -82,10 +82,6 @@ pub enum Commands { /// Agent mode: apply the patches in `.socket/manifest.json` in place Apply(commands::apply::ApplyArgs), - /// Agent mode: wire install hooks (npm, Python, Bundler, Composer) that - /// re-apply patches after install - Setup(commands::setup::SetupArgs), - /// Undo patches: restore original files and unwind hosted or vendored /// lockfile wiring Rollback(commands::rollback::RollbackArgs), @@ -136,7 +132,6 @@ impl Commands { Commands::Apply(a) => &a.common, Commands::Vex(a) => &a.common, Commands::Vendor(a) => &a.common, - Commands::Setup(a) => &a.common, Commands::Rollback(a) => &a.common, Commands::Get(a) => &a.common, Commands::List(a) => &a.common, diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index b50bff6b..57947511 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -100,7 +100,6 @@ async fn main() { Commands::Apply(args) => commands::apply::run(args).await, Commands::Vex(args) => commands::vex::run(args).await, Commands::Vendor(args) => commands::vendor::run(args).await, - Commands::Setup(args) => commands::setup::run(args).await, Commands::Rollback(args) => commands::rollback::run(args).await, Commands::Get(args) => commands::get::run(args).await, Commands::List(args) => commands::list::run(args).await, diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index 3f0f651e..0cffadf0 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -1,8 +1,7 @@ //! Terminal UI: everything that decides *how* human output looks. //! //! - [`StatusLine`]: the one self-rewriting progress line. -//! - [`confirm`], [`confirm_or_proceed`], [`select_one`]: -//! prompts. +//! - [`confirm`], [`select_one`]: prompts. //! - [`print_json`]: the one `--json` document writer. //! - [`plural`], [`truncate`]: text shaping. //! - [`color_enabled`], [`paint`], [`severity`], [`pad`]: color policy and @@ -21,7 +20,7 @@ use std::io::IsTerminal; use crate::args::GlobalArgs; -pub(crate) use prompt::{confirm, confirm_or_proceed}; +pub(crate) use prompt::confirm; pub use prompt::{select_one, SelectError}; pub(crate) use status::StatusLine; pub(crate) use text::{plural, truncate}; diff --git a/crates/socket-patch-cli/src/ui/prompt.rs b/crates/socket-patch-cli/src/ui/prompt.rs index 1895f0e1..70f8cbd0 100644 --- a/crates/socket-patch-cli/src/ui/prompt.rs +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -47,24 +47,6 @@ pub(crate) fn confirm_waits(common: &GlobalArgs) -> bool { !(common.yes || common.json) && io::stdin().is_terminal() } -/// A default-**no** confirmation that still proceeds when nobody can be -/// asked (stdin not a terminal): `setup`'s mutation gate. `--yes`/`--json` -/// proceed without asking. -pub(crate) fn confirm_or_proceed(prompt: &str, common: &GlobalArgs) -> bool { - if common.yes || common.json { - return true; - } - ask( - prompt, - Ask { - default_yes: false, - non_interactive_answer: true, - interactive: io::stdin().is_terminal(), - silent: common.silent, - }, - ) -} - /// How a yes/no question is answered (see [`confirm_with`]). #[derive(Clone, Copy, Debug)] pub(crate) struct Ask { diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index 6c41a148..ba9861ed 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -548,7 +548,7 @@ fn apply_with_unreadable_socket_dir_fails_closed() { 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}" + "\"cannot read\" is not \"no manifest\"; envelope: {v}" ); assert_eq!( v["error"]["code"], "manifest_unreadable", diff --git a/crates/socket-patch-cli/tests/cli_argv_non_utf8.rs b/crates/socket-patch-cli/tests/cli_argv_non_utf8.rs index f669539a..08cecb3f 100644 --- a/crates/socket-patch-cli/tests/cli_argv_non_utf8.rs +++ b/crates/socket-patch-cli/tests/cli_argv_non_utf8.rs @@ -6,8 +6,8 @@ //! non-Unicode argument: the binary died with a Rust panic message and exit //! code 101 ("please report this bug" territory) before clap ever saw the //! command line. The contract treats malformed invocations as clap usage -//! errors (exit `2`, message on stderr) — see `setup --check --remove` in -//! `CLI_CONTRACT.md` — so a bad byte in argv must take that path too. +//! errors (exit `2`, message on stderr; see `CLI_CONTRACT.md`) — so a bad +//! byte in argv must take that path too. //! //! These tests run the compiled binary as a subprocess because the bug lives //! in `main.rs` itself (the argv collection step), upstream of everything the diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs index a919ab99..df19585d 100644 --- a/crates/socket-patch-cli/tests/cli_config_fallback.rs +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -562,94 +562,3 @@ async fn list_telemetry_honors_no_api_token_veto() { 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_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index e3741e59..c4bbc574 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -34,7 +34,6 @@ const SUBCOMMANDS_NO_POSITIONAL: &[&str] = &[ "apply", "list", "scan", - "setup", "repair", "rollback", "vendor", @@ -137,7 +136,6 @@ fn common_of(cli: &Cli) -> &GlobalArgs { Scan(a) => &a.common, List(a) => &a.common, Remove(a) => &a.common, - Setup(a) => &a.common, Repair(a) => &a.common, Vendor(a) => &a.common, Vex(a) => &a.common, diff --git a/crates/socket-patch-cli/tests/cli_parse_main.rs b/crates/socket-patch-cli/tests/cli_parse_main.rs index 239e59a0..905e6f18 100644 --- a/crates/socket-patch-cli/tests/cli_parse_main.rs +++ b/crates/socket-patch-cli/tests/cli_parse_main.rs @@ -76,7 +76,7 @@ fn help_flag_triggers_display_help() { // listed in the rendered help. let help = err.to_string(); for name in [ - "scan", "apply", "vex", "vendor", "setup", "rollback", "get", "list", "remove", "repair", + "scan", "apply", "vex", "vendor", "rollback", "get", "list", "remove", "repair", ] { assert!( help.contains(name), @@ -155,9 +155,12 @@ fn remove_subcommand_parses_with_identifier() { } #[test] -fn setup_subcommand_parses() { - let cli = parse(&["socket-patch", "setup"]).expect("setup must parse with no positional"); - assert!(matches!(cli.command, Commands::Setup(_))); +fn setup_subcommand_is_removed() { + // BREAKING (5.0): `setup` (the npm/Python/Bundler/Composer install + // hooks) was removed. Agent mode wires `socket-patch apply` into CI + // instead. Pin the removal so the name can't quietly come back. + let err = expect_err(parse(&["socket-patch", "setup"])); + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_setup.rs b/crates/socket-patch-cli/tests/cli_parse_setup.rs deleted file mode 100644 index 085df8ce..00000000 --- a/crates/socket-patch-cli/tests/cli_parse_setup.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! Parser-level contract tests for `socket-patch setup`. -//! -//! Locks in every flag in the `SetupArgs` table from -//! `crates/socket-patch-cli/CLI_CONTRACT.md` (long + short forms, defaults) -//! and exercises two no-network `run()` paths: -//! -//! 1. Calling `run()` directly against an empty tempdir → exit 0. -//! 2. Spawning the binary against the same empty tempdir with `--json` and -//! asserting the documented `status: "no_files"` shape. -//! -//! These tests deliberately stay off the network so they run in the default -//! `cargo test` set (no `--ignored` required). - -use clap::Parser; -use socket_patch_cli::commands::setup::{run, SetupArgs}; -use socket_patch_cli::{Cli, Commands}; -use std::path::PathBuf; -use std::process::Command; - -fn parse_setup(extra: &[&str]) -> SetupArgs { - let mut argv = vec!["socket-patch", "setup"]; - argv.extend_from_slice(extra); - let cli = Cli::try_parse_from(&argv).expect("parse"); - match cli.command { - Commands::Setup(a) => a, - _ => panic!("expected Setup"), - } -} - -// --------------------------------------------------------------------------- -// Defaults -// --------------------------------------------------------------------------- - -#[test] -fn defaults_with_no_flags() { - let args = parse_setup(&[]); - assert_eq!(args.common.cwd, PathBuf::from(".")); - assert!(!args.common.dry_run); - assert!(!args.common.yes); - assert!(!args.common.json); -} - -// --------------------------------------------------------------------------- -// Flag forms — each one in the contract table must have a test -// --------------------------------------------------------------------------- - -#[test] -fn dry_run_long_form() { - let args = parse_setup(&["--dry-run"]); - assert!(args.common.dry_run); -} - -#[test] -fn yes_short_form() { - let args = parse_setup(&["-y"]); - assert!(args.common.yes); -} - -#[test] -fn yes_long_form() { - let args = parse_setup(&["--yes"]); - assert!(args.common.yes); -} - -#[test] -fn cwd_long_form() { - let args = parse_setup(&["--cwd", "/tmp/x"]); - assert_eq!(args.common.cwd, PathBuf::from("/tmp/x")); -} - -#[test] -fn json_long_form() { - let args = parse_setup(&["--json"]); - assert!(args.common.json); -} - -#[test] -fn check_long_form() { - let args = parse_setup(&["--check"]); - assert!(args.check); - assert!(!args.remove); -} - -#[test] -fn remove_long_form() { - let args = parse_setup(&["--remove"]); - assert!(args.remove); - assert!(!args.check); -} - -#[test] -fn ecosystems_flag_parses_on_setup() { - // Setup command contract, property 2 ("ecosystem-scoped"): `setup` accepts - // the global `--ecosystems` filter (long form + the `-e` short form, CSV - // split). This pins the *parse* surface only; whether `setup` restricts - // its work to the named ecosystems at runtime is covered by - // setup_contract_gaps::setup_ecosystems_filter_scopes_work_to_named_ecosystem. - let long = parse_setup(&["--ecosystems", "npm,cargo"]); - assert_eq!( - long.common.ecosystems.as_deref(), - Some(&["npm".to_string(), "cargo".to_string()][..]), - "setup must parse the CSV --ecosystems filter (long form)" - ); - let short = parse_setup(&["-e", "pypi"]); - assert_eq!( - short.common.ecosystems.as_deref(), - Some(&["pypi".to_string()][..]), - "setup must accept the -e short form" - ); - // Default: no filter ⇒ act on every detected ecosystem. - assert!( - parse_setup(&[]).common.ecosystems.is_none(), - "no --ecosystems ⇒ None" - ); -} - -#[test] -fn exclude_flag_parses_csv_on_setup() { - // Setup command contract, property 9 ("with exclude"): `setup` accepts - // `--exclude` as a comma-split list of workspace-member paths. Pins the - // parse surface (CSV delimiter); the persist + skip behavior is exercised in - // setup_contract_gaps::setup_honors_exclude_for_a_workspace_member. - let csv = parse_setup(&["--exclude", "packages/a,packages/b"]); - assert_eq!( - csv.exclude, - vec!["packages/a".to_string(), "packages/b".to_string()], - "setup must split --exclude on commas" - ); - // Repeated flags accumulate too. - let repeated = parse_setup(&["--exclude", "packages/a", "--exclude", "packages/b"]); - assert_eq!( - repeated.exclude, - vec!["packages/a".to_string(), "packages/b".to_string()] - ); - // Default: empty (no exclusions). - assert!(parse_setup(&[]).exclude.is_empty(), "no --exclude ⇒ empty"); -} - -#[test] -fn check_and_remove_conflict() { - let result = Cli::try_parse_from(["socket-patch", "setup", "--check", "--remove"]); - let err = match result { - Ok(_) => panic!("--check + --remove must conflict"), - Err(e) => e, - }; - assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); -} - -#[test] -fn defaults_check_and_remove_false() { - let args = parse_setup(&[]); - assert!(!args.check); - assert!(!args.remove); -} - -#[test] -fn all_flags_combined() { - let args = parse_setup(&["--cwd", "/tmp/x", "--dry-run", "-y", "--json"]); - assert_eq!(args.common.cwd, PathBuf::from("/tmp/x")); - assert!(args.common.dry_run); - assert!(args.common.yes); - assert!(args.common.json); -} - -// --------------------------------------------------------------------------- -// Failure paths -// --------------------------------------------------------------------------- - -#[test] -fn unknown_flag_is_error() { - let result = Cli::try_parse_from(["socket-patch", "setup", "--not-a-real-flag"]); - let err = match result { - Ok(_) => panic!("unknown flag must fail"), - Err(e) => e, - }; - assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); -} - -// --------------------------------------------------------------------------- -// Async run() — empty tempdir, no package.json files → exit 0 -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn run_empty_tempdir_exits_zero() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let args = SetupArgs { - check: false, - remove: false, - exclude: Vec::new(), - common: socket_patch_cli::args::GlobalArgs { - cwd: tempdir.path().to_path_buf(), - dry_run: false, - yes: true, - json: true, - ..socket_patch_cli::args::GlobalArgs::default() - }, - }; - let exit = run(args).await; - assert_eq!( - exit, 0, - "empty tempdir (no package.json) must exit 0 with status 'no_files'" - ); -} - -// --------------------------------------------------------------------------- -// Subprocess: lock the JSON contract shape for `status: no_files`. -// --------------------------------------------------------------------------- - -#[test] -fn subprocess_no_files_json_shape() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let exe = env!("CARGO_BIN_EXE_socket-patch"); - let output = Command::new(exe) - .arg("setup") - .arg("--cwd") - .arg(tempdir.path()) - .arg("--json") - .arg("--yes") - .output() - .expect("spawn socket-patch"); - assert!( - output.status.success(), - "setup against empty tempdir must succeed, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("stdout must be JSON, got {stdout:?}: {e}"); - }); - assert_eq!( - v["status"], "no_files", - "status must be 'no_files' for empty tempdir; full payload: {v}" - ); - assert_eq!(v["updated"], 0); - assert_eq!(v["alreadyConfigured"], 0); - assert_eq!(v["errors"], 0); - assert!(v["files"].is_array(), "'files' must be an array"); - assert_eq!( - v["files"].as_array().expect("array").len(), - 0, - "'files' must be an empty array for status 'no_files'" - ); -} - -// --------------------------------------------------------------------------- -// Subprocess: the REAL setup path — a package.json present must actually be -// configured (status "success", count incremented) AND the file on disk must -// gain the postinstall hook. Without this, an impl that always short-circuits -// to `no_files` (or reports success without writing) would pass every other -// test in this file. -// --------------------------------------------------------------------------- - -#[test] -fn subprocess_configures_real_package_json() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let pkg_path = tempdir.path().join("package.json"); - std::fs::write(&pkg_path, r#"{"name":"demo","version":"1.0.0"}"#).expect("write package.json"); - - let exe = env!("CARGO_BIN_EXE_socket-patch"); - let output = Command::new(exe) - .arg("setup") - .arg("--cwd") - .arg(tempdir.path()) - .arg("--json") - .arg("--yes") - // Keep this test off the network: a successful setup fires telemetry. - .env("SOCKET_TELEMETRY_DISABLED", "1") - .output() - .expect("spawn socket-patch"); - - assert!( - output.status.success(), - "setup on a real package.json must exit 0, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let v: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON, got {stdout:?}: {e}")); - - // The envelope must reflect a real change, not a no-op / no_files. - assert_eq!( - v["status"], "success", - "a package.json that needed setup must report status 'success'; payload: {v}" - ); - assert_eq!( - v["updated"], 1, - "exactly one manifest must be updated; payload: {v}" - ); - assert_eq!(v["alreadyConfigured"], 0, "payload: {v}"); - assert_eq!(v["errors"], 0, "payload: {v}"); - assert_eq!( - v["packageManager"], "npm", - "default manager for a bare package.json is npm; payload: {v}" - ); - - let files = v["files"].as_array().expect("'files' must be an array"); - let pkg_entries: Vec<&serde_json::Value> = files - .iter() - .filter(|f| f["kind"] == "package_json") - .collect(); - assert_eq!( - pkg_entries.len(), - 1, - "exactly one package_json file entry expected; payload: {v}" - ); - let entry = pkg_entries[0]; - assert_eq!( - entry["status"], "updated", - "the package.json entry must report status 'updated'; entry: {entry}" - ); - assert!( - entry["error"].is_null(), - "a successful update must carry no error; entry: {entry}" - ); - assert!( - entry["path"] - .as_str() - .map(|p| p.ends_with("package.json")) - .unwrap_or(false), - "the entry path must point at the package.json; entry: {entry}" - ); - - // The decisive check: the file on disk must actually carry the hook now. - let after = std::fs::read_to_string(&pkg_path).expect("read package.json back"); - let parsed: serde_json::Value = - serde_json::from_str(&after).expect("package.json must stay valid JSON after setup"); - let postinstall = parsed["scripts"]["postinstall"] - .as_str() - .unwrap_or_else(|| panic!("scripts.postinstall must be set after setup; file: {after}")); - assert!( - postinstall.contains("socket-patch apply"), - "postinstall must invoke `socket-patch apply`, got {postinstall:?}" - ); - // Original metadata must be preserved, not clobbered. - assert_eq!( - parsed["name"], "demo", - "setup must preserve existing fields" - ); - assert_eq!( - parsed["version"], "1.0.0", - "setup must preserve existing fields" - ); -} - -// --------------------------------------------------------------------------- -// Subprocess: --dry-run must PREVIEW only — report what it would do but leave -// the package.json byte-for-byte unchanged. `dry_run_long_form` only proves the -// flag parses; nothing here proved it is actually honoured at runtime. An impl -// that ignored --dry-run and wrote the hook anyway would still emit a -// "dry_run" envelope (that string comes from a separate branch) and pass every -// other test — so the decisive guard is reading the file back and asserting it -// did NOT gain the postinstall hook. -// --------------------------------------------------------------------------- - -#[test] -fn subprocess_dry_run_previews_without_writing() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let pkg_path = tempdir.path().join("package.json"); - let original = r#"{"name":"demo","version":"1.0.0"}"#; - std::fs::write(&pkg_path, original).expect("write package.json"); - - let exe = env!("CARGO_BIN_EXE_socket-patch"); - let output = Command::new(exe) - .arg("setup") - .arg("--cwd") - .arg(tempdir.path()) - .arg("--dry-run") - .arg("--json") - .arg("--yes") - .env("SOCKET_TELEMETRY_DISABLED", "1") - .output() - .expect("spawn socket-patch"); - - assert!( - output.status.success(), - "dry-run setup must exit 0, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let v: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON, got {stdout:?}: {e}")); - - // The envelope must announce a preview of a real change — not no_files, - // not already_configured, not success. - assert_eq!( - v["status"], "dry_run", - "dry-run on a configurable package.json must report status 'dry_run'; payload: {v}" - ); - assert_eq!(v["dryRun"], true, "dryRun flag must be set; payload: {v}"); - assert_eq!( - v["wouldUpdate"], 1, - "dry-run must report exactly one would-be update; payload: {v}" - ); - assert_eq!( - v["updated"], 1, - "the preview counts the manifest it would touch; payload: {v}" - ); - assert_eq!(v["errors"], 0, "payload: {v}"); - let files = v["files"].as_array().expect("'files' must be an array"); - let pkg_entries: Vec<&serde_json::Value> = files - .iter() - .filter(|f| f["kind"] == "package_json") - .collect(); - assert_eq!( - pkg_entries.len(), - 1, - "exactly one package_json preview entry expected; payload: {v}" - ); - assert_eq!( - pkg_entries[0]["status"], "updated", - "the previewed entry must report it would be 'updated'; payload: {v}" - ); - - // The decisive check: dry-run must NOT have touched the file on disk. - let after = std::fs::read_to_string(&pkg_path).expect("read package.json back"); - assert_eq!( - after, original, - "--dry-run must leave package.json byte-for-byte unchanged (no write)" - ); - let parsed: serde_json::Value = - serde_json::from_str(&after).expect("package.json must stay valid JSON"); - assert!( - parsed["scripts"]["postinstall"].is_null(), - "--dry-run must NOT add the postinstall hook to disk; file: {after}" - ); -} - -// --------------------------------------------------------------------------- -// Subprocess: idempotency — running setup against an already-configured -// project must report `already_configured` (updated 0), not re-write or claim -// a fresh success. Guards against an impl that can't tell configured from not. -// --------------------------------------------------------------------------- - -#[test] -fn subprocess_already_configured_is_idempotent() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let pkg_path = tempdir.path().join("package.json"); - std::fs::write(&pkg_path, r#"{"name":"demo","version":"1.0.0"}"#).expect("write package.json"); - - let exe = env!("CARGO_BIN_EXE_socket-patch"); - let run = || { - Command::new(exe) - .arg("setup") - .arg("--cwd") - .arg(tempdir.path()) - .arg("--json") - .arg("--yes") - .env("SOCKET_TELEMETRY_DISABLED", "1") - .output() - .expect("spawn socket-patch") - }; - - // First run configures it. - let first = run(); - assert!(first.status.success(), "first setup must succeed"); - let v1: serde_json::Value = - serde_json::from_str(&String::from_utf8(first.stdout).expect("utf8")).expect("json"); - assert_eq!(v1["status"], "success", "first run must configure: {v1}"); - - let before_second = std::fs::read_to_string(&pkg_path).expect("read"); - - // Second run must be a no-op. - let second = run(); - assert!(second.status.success(), "second setup must succeed"); - let v2: serde_json::Value = - serde_json::from_str(&String::from_utf8(second.stdout).expect("utf8")).expect("json"); - assert_eq!( - v2["status"], "already_configured", - "re-running setup on a configured project must report 'already_configured'; payload: {v2}" - ); - assert_eq!( - v2["updated"], 0, - "no further updates expected; payload: {v2}" - ); - - let after_second = std::fs::read_to_string(&pkg_path).expect("read"); - assert_eq!( - before_second, after_second, - "an idempotent re-run must not rewrite package.json" - ); -} - -// --------------------------------------------------------------------------- -// vlt: `packageManager` gains the additive value "vlt", and the hook is the -// same `npx` command npm projects get. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn run_vlt_project_dry_run_writes_nothing() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let original = "{\n \"name\": \"demo\"\n}\n"; - std::fs::write(tempdir.path().join("package.json"), original).unwrap(); - std::fs::write( - tempdir.path().join("vlt-lock.json"), - "{\"lockfileVersion\":1}", - ) - .unwrap(); - let args = SetupArgs { - check: false, - remove: false, - exclude: Vec::new(), - common: socket_patch_cli::args::GlobalArgs { - cwd: tempdir.path().to_path_buf(), - dry_run: true, - yes: true, - json: true, - ..socket_patch_cli::args::GlobalArgs::default() - }, - }; - assert_eq!(run(args).await, 0); - assert_eq!( - std::fs::read_to_string(tempdir.path().join("package.json")).unwrap(), - original - ); -} - -#[test] -fn subprocess_vlt_project_json_shape() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let empty_path = tempfile::tempdir().expect("PATH dir"); - std::fs::write( - tempdir.path().join("package.json"), - r#"{"name":"demo","version":"1.0.0"}"#, - ) - .unwrap(); - std::fs::write(tempdir.path().join("vlt.json"), "{}").unwrap(); - - let output = Command::new(env!("CARGO_BIN_EXE_socket-patch")) - .arg("setup") - .arg("--cwd") - .arg(tempdir.path()) - .arg("--json") - .arg("--yes") - .env("SOCKET_TELEMETRY_DISABLED", "1") - .env("PATH", empty_path.path()) - .output() - .expect("spawn socket-patch"); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let v: serde_json::Value = serde_json::from_slice(&output.stdout).expect("JSON stdout"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - assert_eq!(v["updated"], 1, "{v}"); - assert!(v.get("warnings").is_none(), "{v}"); - let pkg: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tempdir.path().join("package.json")).unwrap(), - ) - .unwrap(); - assert_eq!( - pkg["scripts"]["postinstall"], - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - ); -} diff --git a/crates/socket-patch-cli/tests/cli_setup_silent.rs b/crates/socket-patch-cli/tests/cli_setup_silent.rs deleted file mode 100644 index e5d37ee7..00000000 --- a/crates/socket-patch-cli/tests/cli_setup_silent.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! `setup --silent` contract tests. -//! -//! CLI_CONTRACT.md defines `--silent` as "Errors only": `setup` (and its -//! `--check` / `--remove` modes) must mute the "Configuring..." / -//! "Searching..." headers, the previews, the summaries, the -//! configuration-status report, and the commit hints (not just gate them -//! on `!json`). -//! -//! `--silent` suppresses informational output only: the mutation still -//! happens, exit codes still distinguish states, and (matching the -//! shared `confirm()` helper) prompting is unaffected — these tests -//! pass `--yes` like the scan/remove silent suites. Runs fully offline: -//! npm-only fixtures, no API calls. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use socket_patch_cli::args::GLOBAL_ARG_ENV_VARS; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -const UNCONFIGURED_PACKAGE_JSON: &str = r#"{ - "name": "setup-silent-test", - "version": "0.0.0" -}"#; - -fn write_root(root: &Path) { - std::fs::write(root.join("package.json"), UNCONFIGURED_PACKAGE_JSON).unwrap(); -} - -/// Run `socket-patch setup` in `cwd` with a scrubbed SOCKET_* environment -/// so ambient developer/CI configuration (tokens, silent toggles) can't -/// change the branch under test. -fn run_setup(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.arg("setup").args(args).current_dir(cwd); - for var in GLOBAL_ARG_ENV_VARS { - cmd.env_remove(var); - } - cmd.env_remove("SOCKET_SETUP_EXCLUDE"); - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - let out = cmd.output().expect("run socket-patch setup"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) -} - -/// Non-error stderr lines: drop the unconditional core API-token warning -/// (printed by shared client/telemetry plumbing, out of scope for -/// `setup`'s `--silent` gating) and blank lines, keep everything else. -fn stderr_chatter(stderr: &str) -> Vec { - stderr - .lines() - .filter(|l| { - !l.contains("SOCKET_API_TOKEN") - && !l.contains("Continuing anyway") - && !l.trim().is_empty() - }) - .map(|l| l.to_string()) - .collect() -} - -/// `setup --silent --yes` must wire the postinstall hook without printing -/// anything: no "Configuring socket-patch install hooks..." header, no -/// preview, no summary, no commit hints. -#[test] -fn setup_silent_configures_but_prints_nothing() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_root(tmp.path()); - - let (code, stdout, stderr) = run_setup(tmp.path(), &["--silent", "--yes"]); - assert_eq!( - code, 0, - "setup must succeed; stdout={stdout:?} stderr={stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "--silent must produce no stdout; got {stdout:?}" - ); - let chatter = stderr_chatter(&stderr); - assert!( - chatter.is_empty(), - "--silent must produce no stderr chatter on success; got {chatter:?}" - ); - - // Silent suppresses output, not the mutation: the hook must be wired. - let pkg = std::fs::read_to_string(tmp.path().join("package.json")).expect("read package.json"); - assert!( - pkg.contains("socket-patch"), - "the postinstall hook must still be wired under --silent; got {pkg:?}" - ); - - // Control run: the same scenario WITHOUT --silent must print the - // header and summary — otherwise the assertions above pass vacuously. - let tmp2 = tempfile::tempdir().expect("tempdir"); - write_root(tmp2.path()); - let (loud_code, loud_stdout, loud_stderr) = run_setup(tmp2.path(), &["--yes"]); - assert_eq!(loud_code, 0); - // (Its progress is a transient status line, so a piped stderr stays - // empty even without --silent; the stdout summary is the control.) - assert!( - !loud_stderr.contains("Configuring socket-patch install hooks"), - "the progress line is transient; got {loud_stderr:?}" - ); - assert!( - loud_stdout.contains("1 item updated"), - "non-silent setup must print the summary; got {loud_stdout:?}" - ); -} - -/// `setup --check --silent` must print nothing in both states; the exit -/// code alone distinguishes configured (0) from needs-configuration (1), -/// mirroring the `list --silent` fix. -#[test] -fn setup_check_silent_prints_nothing_in_both_states() { - // Unconfigured: exit 1, no output. - let tmp = tempfile::tempdir().expect("tempdir"); - write_root(tmp.path()); - let (code, stdout, stderr) = run_setup(tmp.path(), &["--check", "--silent"]); - assert_eq!( - code, 1, - "unconfigured --check must exit 1; stdout={stdout:?}" - ); - assert!( - stdout.trim().is_empty(), - "--check --silent must produce no stdout; got {stdout:?}" - ); - let chatter = stderr_chatter(&stderr); - assert!( - chatter.is_empty(), - "--check --silent must produce no stderr chatter; got {chatter:?}" - ); - - // Configured (after a real setup): exit 0, no output. - let (setup_code, _, _) = run_setup(tmp.path(), &["--silent", "--yes"]); - assert_eq!( - setup_code, 0, - "setup must succeed before the configured check" - ); - let (code2, stdout2, _) = run_setup(tmp.path(), &["--check", "--silent"]); - assert_eq!( - code2, 0, - "configured --check must exit 0; stdout={stdout2:?}" - ); - assert!( - stdout2.trim().is_empty(), - "configured --check --silent must produce no stdout; got {stdout2:?}" - ); - - // Control run: without --silent the status report must print. - let (loud_code, loud_stdout, _) = run_setup(tmp.path(), &["--check"]); - assert_eq!(loud_code, 0); - assert!( - loud_stdout.contains("Configuration status"), - "non-silent --check must print the status report; got {loud_stdout:?}" - ); -} - -/// `setup --remove --silent --yes` must revert the hook without printing -/// anything: no "Searching..." header, no proposed-changes preview, no -/// summary, no pip-uninstall hint. -#[test] -fn setup_remove_silent_prints_nothing_but_removes() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_root(tmp.path()); - let (setup_code, _, _) = run_setup(tmp.path(), &["--silent", "--yes"]); - assert_eq!(setup_code, 0, "setup must succeed before remove"); - - let (code, stdout, stderr) = run_setup(tmp.path(), &["--remove", "--silent", "--yes"]); - assert_eq!( - code, 0, - "remove must succeed; stdout={stdout:?} stderr={stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "--remove --silent must produce no stdout; got {stdout:?}" - ); - let chatter = stderr_chatter(&stderr); - assert!( - chatter.is_empty(), - "--remove --silent must produce no stderr chatter on success; got {chatter:?}" - ); - - // Silent suppresses output, not the mutation: the hook must be gone. - let pkg = std::fs::read_to_string(tmp.path().join("package.json")).expect("read package.json"); - assert!( - !pkg.contains("socket-patch"), - "the postinstall hook must still be removed under --silent; got {pkg:?}" - ); - - // Control run: a non-silent remove on a configured repo must print - // the preview and summary — otherwise the assertions above pass - // vacuously. - let tmp2 = tempfile::tempdir().expect("tempdir"); - write_root(tmp2.path()); - let (_, _, _) = run_setup(tmp2.path(), &["--silent", "--yes"]); - let (loud_code, loud_stdout, _) = run_setup(tmp2.path(), &["--remove", "--yes"]); - assert_eq!(loud_code, 0); - assert!( - loud_stdout.contains("Proposed changes"), - "non-silent remove must print the preview; got {loud_stdout:?}" - ); - assert!( - loud_stdout.contains("1 item had socket-patch removed"), - "non-silent remove must print the summary; got {loud_stdout:?}" - ); -} - -/// Errors must still print under `--silent` ("errors only", not "nothing"), -/// mirroring the remove/scan silent suites: an invalid package.json keeps -/// its error message on stderr and exit 1 in all three modes, while the -/// informational stdout stays suppressed. -#[test] -fn setup_silent_keeps_error_output() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write(tmp.path().join("package.json"), "{ not json").unwrap(); - - for mode in [&[][..], &["--check"][..], &["--remove"][..]] { - let mut args: Vec<&str> = mode.to_vec(); - args.extend(["--silent", "--yes"]); - let (code, stdout, stderr) = run_setup(tmp.path(), &args); - assert_eq!( - code, 1, - "invalid package.json must exit 1 for {mode:?}; stdout={stdout:?} stderr={stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "--silent must still suppress informational stdout for {mode:?}; got {stdout:?}" - ); - assert!( - stderr.contains("Invalid package.json"), - "--silent must NOT suppress error output for {mode:?}; got {stderr:?}" - ); - } -} - -/// Same contract for the apply-phase (post-preview) failures: a package.json -/// that previews fine but cannot be rewritten (read-only directory) must -/// surface its write error on stderr under `--silent`, not just exit 1. -#[cfg(unix)] -#[test] -fn setup_silent_keeps_apply_phase_error_output() { - use std::os::unix::fs::PermissionsExt; - - let tmp = tempfile::tempdir().expect("tempdir"); - write_root(tmp.path()); - std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); - - let (code, stdout, stderr) = run_setup(tmp.path(), &["--silent", "--yes"]); - - // Restore so the tempdir can clean up regardless of the assertions. - std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); - - assert_eq!( - code, 1, - "unwritable package.json must exit 1; stdout={stdout:?} stderr={stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "--silent must still suppress informational stdout; got {stdout:?}" - ); - assert!( - stderr_chatter(&stderr) - .iter() - .any(|l| l.starts_with("Error:")), - "--silent must NOT suppress the apply-phase write error; got {stderr:?}" - ); -} - -/// The `no_files` path (no project found at all) is informational, not an -/// error: under `--silent` it must print nothing and exit 0. Covers both -/// the plain-setup inline branch and the shared `report_no_files` helper -/// that `--check` / `--remove` use. -#[test] -fn setup_silent_no_files_prints_nothing() { - let tmp = tempfile::tempdir().expect("tempdir"); - - for mode in [&[][..], &["--check"][..], &["--remove"][..]] { - let mut args: Vec<&str> = mode.to_vec(); - args.push("--silent"); - let (code, stdout, stderr) = run_setup(tmp.path(), &args); - assert_eq!( - code, 0, - "no_files must exit 0 for {mode:?}; stderr={stderr:?}" - ); - assert!( - stdout.trim().is_empty(), - "--silent no_files must produce no stdout for {mode:?}; got {stdout:?}" - ); - let chatter = stderr_chatter(&stderr); - assert!( - chatter.is_empty(), - "--silent no_files must produce no stderr chatter for {mode:?}; got {chatter:?}" - ); - } - - // Control run: without --silent the hint must print. - let (loud_code, loud_stdout, _) = run_setup(tmp.path(), &[]); - assert_eq!(loud_code, 0); - assert!( - loud_stdout.contains("No package.json, Python, Bundler, or Composer project found"), - "non-silent no_files must print the hint; got {loud_stdout:?}" - ); -} diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index bfa15851..082b15c4 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -149,7 +149,10 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], + // Read only by the macOS-only blob-retention test. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] before_hash: String, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] after_hash: String, } diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index e34a34b1..57da62ea 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1120,9 +1120,6 @@ fn scan_human_vex_success_prints_wrote_line() { std::fs::write( socket.join("manifest.json"), serde_json::to_string_pretty(&serde_json::json!({ - // npm declared `manual` so VEX generation does not omit the - // patch (ecosystem_not_setup) and fail the run. - "setup": { "exclude": [], "manual": ["npm"] }, "patches": { "pkg:npm/vuln-pkg@1.0.0": { "uuid": UUID, diff --git a/crates/socket-patch-cli/tests/covgap_commands_setup.rs b/crates/socket-patch-cli/tests/covgap_commands_setup.rs deleted file mode 100644 index db0ce3f9..00000000 --- a/crates/socket-patch-cli/tests/covgap_commands_setup.rs +++ /dev/null @@ -1,1979 +0,0 @@ -//! Coverage-gap tests for `commands/setup.rs`. Each section's "Covers NNN" -//! refs are setup.rs line numbers at the d5e1815 audit and have drifted -//! since; use the named functions/messages to locate the code. -//! -//! Everything here runs on the host with no Docker and no ecosystem -//! toolchain: `setup` edits package.json / requirements.txt / pyproject.toml / -//! Gemfile / composer.json directly. The gem/composer round trips mirror the -//! feature-gated `setup_matrix_{gem,composer}.rs` host guards, which the -//! default (non-`setup-e2e`) test configuration never compiles — these -//! always-on ports are what actually count for coverage. - -#[path = "common/pty_io.rs"] -mod pty_io; -use std::path::Path; - -#[path = "common/mod.rs"] -mod common; - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const UNWIRED_PACKAGE_JSON: &str = "{ \"name\": \"covgap\", \"version\": \"1.0.0\" }\n"; - -/// A package.json `setup` reports `already_configured` (the same wired form -/// `setup_check_tolerates_bom_like_npm` pins, minus the BOM). -const WIRED_SCRIPTS_FRAGMENT: &str = "\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}"; - -const GEMFILE_FIXTURE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n"; - -/// Same shape as the feature-gated composer matrix fixture: 4-space indented, -/// so the byte-for-byte restore assertion has real formatting to preserve. -const COMPOSER_JSON: &str = - "{\n \"name\": \"acme/widget\",\n \"require\": {\n \"monolog/monolog\": \"3.5.0\"\n }\n}\n"; - -/// A Gemfile.lock as `bundle install` under bundler 1.17.3 writes it — the -/// deterministic lock-probe input for the bundler version floor (the probe -/// classifies from `BUNDLED WITH` before ever spawning `bundle`). -const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ - colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ - colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; - -/// The supported-floor twin of [`LOCK_1X`]. Every fixture that wires (or -/// checks) a Gemfile pins the probe through this lock: without one on disk -/// the floor probe falls through to the host's real `bundle --version`, and -/// a machine whose first-on-PATH bundler is below the 2.2 floor (stock macOS -/// ships /usr/bin/bundle = 1.17.2) would make `setup` refuse to wire. -const LOCK_2X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ - colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ - colorize (= 1.1.0)\n\nBUNDLED WITH\n 2.5.22\n"; - -/// Classic-Poetry pyproject (no `[project]` table) — `detect_python_pm` -/// classifies `[tool.poetry]` as Poetry with no poetry.lock on disk, and -/// `add_hook_dependency` edits `[tool.poetry.dependencies]` (the same fixture -/// `setup_pth_invariants.rs` wires through its shims). -const POETRY_PYPROJECT: &str = "[tool.poetry]\nname = \"x\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = []\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n"; - -const REQUIREMENTS_NO_HOOK: &str = "requests==2.31.0\n"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read file") -} - -/// Run the binary through the shared hermetic runner (`SOCKET_*` scrubbed, -/// telemetry disabled). Returns `(code, stdout, stderr)`. -fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - common::run_with_env(cwd, args, &[("SOCKET_TELEMETRY_DISABLED", "1")]) -} - -/// [`run`] + extra child-only env (e.g. a PATH override for the lockfile -/// refresh spawn). The parent process env is never mutated, so no -/// serialization is needed. -fn run_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { - let mut merged: Vec<(&str, &str)> = vec![("SOCKET_TELEMETRY_DISABLED", "1")]; - merged.extend_from_slice(env); - common::run_with_env(cwd, args, &merged) -} - -/// [`run`], asserting stdout is one JSON document. -fn run_json(cwd: &Path, args: &[&str]) -> (i32, serde_json::Value) { - let (code, stdout, stderr) = run(cwd, args); - let v = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("stdout must be JSON ({e}); stdout=\n{stdout}\nstderr=\n{stderr}") - }); - (code, v) -} - -/// All `files[]` entries with the given `kind`. -fn entries_of<'a>(v: &'a serde_json::Value, kind: &str) -> Vec<&'a serde_json::Value> { - v["files"] - .as_array() - .unwrap_or_else(|| panic!("files must be an array: {v}")) - .iter() - .filter(|f| f["kind"] == kind) - .collect() -} - -/// The single `files[]` entry with the given `kind` (panics on 0 or >1). -fn entry_of<'a>(v: &'a serde_json::Value, kind: &str) -> &'a serde_json::Value { - let matches = entries_of(v, kind); - assert_eq!( - matches.len(), - 1, - "expected exactly one `{kind}` entry, got {}: {v}", - matches.len() - ); - matches[0] -} - -/// Wire a fixture with `setup --yes --json`, asserting it succeeded. -fn wire(cwd: &Path) { - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 0, "fixture wiring must succeed: {v}"); - assert_eq!(v["status"], "success", "fixture wiring must succeed: {v}"); -} - -#[cfg(unix)] -fn running_as_root() -> bool { - std::process::Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) -} - -#[cfg(unix)] -fn chmod(path: &Path, mode: u32) { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod"); -} - -// --------------------------------------------------------------------------- -// Composer setup/check/remove round trip — always-on port of the -// feature-gated setup_matrix_composer host guard. -// Covers 59 (composer telemetry tag), 674, 678-702, 705-718, 721-729 -// (build_composer_outcome add+remove, status vocabulary), 743, 746-757 -// (append_composer_check_entries probe arms). -// --------------------------------------------------------------------------- - -#[test] -fn composer_setup_check_remove_round_trip() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("composer.json"), COMPOSER_JSON); - - // check (pristine): the composer entry needs configuration, exit 1. - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 1, "pre-setup check must fail: {v}"); - assert_eq!(v["status"], "needs_configuration", "{v}"); - assert_eq!(entry_of(&v, "composer")["status"], "needs_configuration"); - - // setup: wires the hook into both script events. - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 0, "composer setup must succeed: {v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["updated"], 1, "{v}"); - assert_eq!(entry_of(&v, "composer")["status"], "updated"); - let wired: serde_json::Value = - serde_json::from_str(&read(&cwd.join("composer.json"))).expect("valid composer.json"); - for event in ["post-install-cmd", "post-update-cmd"] { - assert!( - wired["scripts"][event] - .as_array() - .unwrap_or_else(|| panic!("{event} missing: {wired}")) - .iter() - .any(|c| c.as_str().is_some_and(|s| s.contains("socket-patch apply"))), - "{event} must carry the re-apply command: {wired}" - ); - } - - // idempotent second setup: already_configured. - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 0); - assert_eq!(v["status"], "already_configured", "{v}"); - assert_eq!(entry_of(&v, "composer")["status"], "already_configured"); - - // check (wired): configured, exit 0. - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 0, "post-setup check must pass: {v}"); - assert_eq!(entry_of(&v, "composer")["status"], "configured"); - - // remove: strips the hook and restores composer.json byte-for-byte. - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 0, "composer remove must succeed: {v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["removed"], 1, "{v}"); - assert_eq!(entry_of(&v, "composer")["status"], "removed"); - assert_eq!( - read(&cwd.join("composer.json")), - COMPOSER_JSON, - "remove must restore composer.json byte-for-byte" - ); - - // second remove: nothing left to unwire → not_configured. - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "not_configured", "{v}"); - assert_eq!(entry_of(&v, "composer")["status"], "not_configured"); - - // check (post-remove): needs configuration again. - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 1, "post-remove check must fail again: {v}"); - assert_eq!(entry_of(&v, "composer")["status"], "needs_configuration"); -} - -// --------------------------------------------------------------------------- -// `--ecosystems` scope-outs. -// Covers 740 (composer scoped out of check), 822 (gem scoped out of check), -// and 117 (npm scoped out of discover). -// --------------------------------------------------------------------------- - -#[test] -fn check_ecosystems_scopes_gem_and_composer_out() { - // A gem+composer project checked with npm-only scope: both branches - // return before probing, so no manifest at all is in scope → no_files. - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("composer.json"), COMPOSER_JSON); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json", "--ecosystems", "npm"]); - assert_eq!(code, 0, "nothing in scope must exit 0: {v}"); - assert_eq!(v["status"], "no_files", "{v}"); - assert!( - v["files"].as_array().is_some_and(|a| a.is_empty()), - "no entry may leak past the scope filter: {v}" - ); -} - -#[test] -fn check_ecosystems_scopes_npm_out() { - // npm OUT of scope (the reverse of setup_contract_gaps' - // `setup_ecosystems_filter_scopes_work_to_named_ecosystem`): discover() - // must return empty without walking, leaving only the pth entry. - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json", "--ecosystems", "pypi"]); - assert_eq!(code, 1, "the unwired pth entry must fail the check: {v}"); - let files = v["files"].as_array().expect("files[]"); - assert_eq!(files.len(), 1, "only the pth entry may appear: {v}"); - assert_eq!(files[0]["kind"], "pth", "{v}"); - - // And a real scoped run must leave the out-of-scope package.json alone. - let (code, v) = run_json(cwd, &["setup", "--yes", "--json", "--ecosystems", "pypi"]); - assert_eq!(code, 0, "{v}"); - assert_eq!( - read(&cwd.join("package.json")), - UNWIRED_PACKAGE_JSON, - "`--ecosystems pypi` must not touch package.json" - ); - assert!( - read(&cwd.join("requirements.txt")).contains("socket-patch[hook]"), - "the in-scope python manifest must be wired" - ); -} - -// --------------------------------------------------------------------------- -// Gem check + remove round trip and the bundler version floor — always-on -// port of the feature-gated setup_matrix_gem host guards. -// Covers 613, 625-626, 647, 655-658 (remove path + gem_status_str), 825, -// 836-858 (probe arms incl. the Unsupported wired/unwired errors), 863-875 -// (gemfile + gem_plugin check entries). -// --------------------------------------------------------------------------- - -#[test] -fn gem_check_setup_remove_round_trip() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_2X); - - // check (pristine): Gemfile + plugin dir both need configuration. - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 1, "pre-setup gem check must fail: {v}"); - assert_eq!(entry_of(&v, "gemfile")["status"], "needs_configuration"); - assert_eq!(entry_of(&v, "gem_plugin")["status"], "needs_configuration"); - assert_eq!(v["needsConfiguration"], 2, "{v}"); - - wire(cwd); - assert!( - read(&cwd.join("Gemfile")).contains("plugin 'socket-patch'"), - "setup must wire the plugin directive" - ); - - // check (wired): both entries configured, exit 0. - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 0, "post-setup gem check must pass: {v}"); - assert_eq!(entry_of(&v, "gemfile")["status"], "configured"); - assert_eq!(entry_of(&v, "gem_plugin")["status"], "configured"); - - // remove: Gemfile restored byte-for-byte, plugin dir deleted. - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 0, "gem remove must succeed: {v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(entry_of(&v, "gemfile")["status"], "removed"); - assert_eq!(entry_of(&v, "gem_plugin")["status"], "removed"); - assert_eq!( - read(&cwd.join("Gemfile")), - GEMFILE_FIXTURE, - "remove must restore the Gemfile byte-for-byte" - ); - assert!( - !cwd.join(".socket/bundler-plugin").exists(), - "remove must delete the generated plugin dir" - ); - - // second remove: nothing wired → not_configured vocabulary. - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "not_configured", "{v}"); - assert_eq!(entry_of(&v, "gemfile")["status"], "not_configured"); - assert_eq!(entry_of(&v, "gem_plugin")["status"], "not_configured"); -} - -#[test] -fn gem_check_bundler_1x_lock_unwired_reports_error() { - // The lock probe (`BUNDLED WITH 1.17.3`) classifies without spawning - // bundler, so this is deterministic on every host. An UNWIRED project - // below the floor is an error (running `setup` cannot help — it refuses - // to wire), not needs_configuration. - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_1X); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 1, "an unsupported bundler must fail the check: {v}"); - assert_eq!(v["status"], "error", "{v}"); - let gemfile = entry_of(&v, "gemfile"); - assert_eq!(gemfile["status"], "error", "{v}"); - let err = gemfile["error"].as_str().expect("gemfile error message"); - assert!( - err.contains("1.17.3") && err.contains(">= 2.2"), - "the error must name the detected bundler and the floor: {err}" - ); - assert!( - err.contains("cannot load the socket-patch Bundler plugin"), - "the error must explain WHY the floor exists: {err}" - ); -} - -#[test] -fn gem_check_bundler_1x_lock_wired_points_at_remove() { - // Wire under a supported 2.x lock, then downgrade the lock to bundler - // 1.x: `--check` must red-flag the WIRED project and point at - // `setup --remove` as the way out. - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_2X); - wire(cwd); - write(&cwd.join("Gemfile.lock"), LOCK_1X); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 1, "wired-below-floor must fail the check: {v}"); - let gemfile = entry_of(&v, "gemfile"); - assert_eq!(gemfile["status"], "error", "{v}"); - let err = gemfile["error"].as_str().expect("gemfile error message"); - assert!( - err.contains("setup --remove") && err.contains("unwire"), - "the wired-project error must name the unwire recovery: {err}" - ); - assert!( - err.contains("1.17.3"), - "must name the detected version: {err}" - ); - // The plugin dir was still generated by the wiring; its entry stays - // independent of the version gate. - assert_eq!(entry_of(&v, "gem_plugin")["status"], "configured"); -} - -#[cfg(unix)] -#[test] -fn gem_check_unreadable_gemfile_reports_error() { - // Covers 861: the Gemfile read error arm of append_gem_check_entries. - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_2X); - wire(cwd); - chmod(&cwd.join("Gemfile"), 0o000); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - chmod(&cwd.join("Gemfile"), 0o644); - - assert_eq!(code, 1, "an unreadable Gemfile must fail the check: {v}"); - let gemfile = entry_of(&v, "gemfile"); - assert_eq!(gemfile["status"], "error", "{v}"); - assert!( - gemfile["error"].as_str().is_some_and(|e| !e.is_empty()), - "the io error must be carried on the entry: {v}" - ); -} - -// --------------------------------------------------------------------------- -// Property 7 — the on-disk hook probe (`configured_ecosystems`) positive -// inserts. Every e2e_vex fixture opts in via `setup.manual`; here the -// manifest declares NO manual ecosystems, so a statement can only exist -// because the probe found the hook wired on disk. -// Covers 359-362 (npm), 374-377 (pypi), 385-387 (gem), 391-395 (composer). -// --------------------------------------------------------------------------- - -/// One patch record per ecosystem, mirroring e2e_vex's `make_record`. -fn covgap_patch_manifest() -> socket_patch_core::manifest::schema::PatchManifest { - use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, - }; - use std::collections::HashMap; - - let cases: &[(&str, &str, &str)] = &[ - ( - "pkg:npm/left-pad@1.3.0", - "GHSA-cov-npm", - "11111111-1111-4111-8111-111111111111", - ), - ( - "pkg:pypi/six@1.16.0", - "GHSA-cov-pypi", - "22222222-2222-4222-8222-222222222222", - ), - ( - "pkg:gem/rack@2.2.3", - "GHSA-cov-gem", - "33333333-3333-4333-8333-333333333333", - ), - ( - "pkg:composer/monolog/monolog@2.0.0", - "GHSA-cov-composer", - "44444444-4444-4444-8444-444444444444", - ), - ]; - let mut manifest = PatchManifest::new(); - for (purl, ghsa, uuid) in cases { - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "a".repeat(64), - after_hash: "b".repeat(64), - }, - ); - let mut vulns = HashMap::new(); - vulns.insert( - (*ghsa).to_string(), - VulnerabilityInfo { - cves: vec!["CVE-2024-1".to_string()], - summary: "s".to_string(), - severity: "high".to_string(), - description: "d".to_string(), - }, - ); - manifest.patches.insert( - (*purl).to_string(), - PatchRecord { - uuid: (*uuid).to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files, - vulnerabilities: vulns, - description: format!("Patch {uuid}"), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - } - manifest -} - -/// Write the 4-ecosystem patch manifest with NO `setup` block — i.e. zero -/// `manual` declarations, so only the on-disk probe can admit a patch. -fn write_covgap_manifest(cwd: &Path) { - let manifest = covgap_patch_manifest(); - write( - &cwd.join(".socket/manifest.json"), - &serde_json::to_string_pretty(&manifest).expect("serialize manifest"), - ); -} - -#[test] -fn vex_attests_ecosystems_wired_on_disk_without_manual() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_2X); - write(&cwd.join("composer.json"), COMPOSER_JSON); - // Wire all four hooks on disk with a real setup run. - wire(cwd); - write_covgap_manifest(cwd); - - let (code, stdout, stderr) = run( - cwd, - &["vex", "--no-verify", "--product", "pkg:npm/app@1.0.0"], - ); - assert_eq!( - code, 0, - "all four wired ecosystems must attest; stdout=\n{stdout}\nstderr=\n{stderr}" - ); - let doc: serde_json::Value = serde_json::from_str(&stdout).expect("VEX JSON on stdout"); - let stmts = doc["statements"].as_array().expect("statements[]"); - assert_eq!( - stmts.len(), - 4, - "one statement per wired ecosystem (npm/pypi/gem/composer): {doc}" - ); - for purl in [ - "pkg:npm/left-pad@1.3.0", - "pkg:pypi/six@1.16.0", - "pkg:gem/rack@2.2.3", - "pkg:composer/monolog/monolog@2.0.0", - ] { - assert!( - stmts - .iter() - .any(|s| s["products"][0]["subcomponents"][0]["@id"] == purl), - "missing statement for {purl}: {doc}" - ); - } -} - -#[test] -fn vex_drops_all_patches_when_projects_present_but_unwired() { - // Negative control for the probe: the SAME four project manifests exist - // but no hook is wired and no `manual` is declared, so every patch must - // be filtered — proving the positive test's statements came from the - // probe, not from project presence. - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("composer.json"), COMPOSER_JSON); - write_covgap_manifest(cwd); - - let out = cwd.join("out.json"); - let (code, stdout, _stderr) = run( - cwd, - &[ - "vex", - "--no-verify", - "--product", - "pkg:npm/app@1.0.0", - "--output", - out.to_str().unwrap(), - ], - ); - let statements = std::fs::read_to_string(&out) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v["statements"].as_array().map(|a| a.len())) - .unwrap_or(0); - assert_eq!( - statements, 0, - "unwired ecosystems must not attest (property 7); stdout=\n{stdout}" - ); - assert_eq!( - code, 1, - "with every patch filtered, vex must report no-applicable-patches; stdout=\n{stdout}" - ); -} - -// --------------------------------------------------------------------------- -// ui::confirm_or_proceed's non-TTY branch: piped stdin, no --yes, no -// --json — the normal CI shape. Auto-proceeds with a stderr note. -// --------------------------------------------------------------------------- - -#[test] -fn setup_non_tty_auto_proceeds_without_yes() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - - // `Command::output()` (inside the shared runner) closes the child's - // stdin, so stdin is not a terminal. - let (code, stdout, stderr) = run(cwd, &["setup"]); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stderr.contains("Non-interactive mode detected, proceeding automatically."), - "the auto-proceed note must reach stderr; stderr=\n{stderr}" - ); - assert!( - stdout.contains("Summary:"), - "the run must have proceeded to the summary; stdout=\n{stdout}" - ); - assert!( - read(&cwd.join("package.json")).contains("socket-patch"), - "the non-TTY run must actually wire the hook" - ); -} - -#[test] -fn setup_non_tty_auto_proceed_note_is_muted_under_silent() { - // `--silent` is errors-only: the non-TTY auto-proceed note is - // informational, so it is muted like the rest of the human report — - // while the run still proceeds and wires the hook. - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - - let (code, stdout, stderr) = run(cwd, &["setup", "--silent"]); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stdout.trim().is_empty(), - "--silent must mute stdout; got: {stdout:?}" - ); - assert!( - !stderr.contains("Non-interactive mode detected"), - "--silent must mute the auto-proceed note; stderr=\n{stderr}" - ); - assert!( - read(&cwd.join("package.json")).contains("socket-patch"), - "the silent non-TTY run must still wire the hook" - ); -} - -// --------------------------------------------------------------------------- -// `setup --remove` interactive decline (1237-1238) — the remove twin of -// interactive_prompts_e2e's setup abort, driven through a PTY. -// --------------------------------------------------------------------------- - -#[cfg(unix)] -mod pty { - use std::path::Path; - use std::time::Duration; - - use portable_pty::{native_pty_system, CommandBuilder, PtySize}; - - /// Trimmed copy of interactive_prompts_e2e's PTY runner (that file is a - /// separate test binary, so its helper cannot be imported): spawn the - /// binary in a PTY with the SOCKET_* env scrubbed, send `input`, collect - /// output until exit. A watchdog kills the child after `timeout`. - pub fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) { - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .expect("openpty"); - - let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_socket-patch")); - for a in args { - cmd.arg(a); - } - cmd.cwd(cwd); - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") - && !name.contains("TELEMETRY") - && name != "SOCKET_NO_CONFIG" - && name != "SOCKET_NO_UPDATE_CHECK" - { - cmd.env_remove(&key); - } - } - // PTY children have a real terminal on stderr; force the notifier - // kill-switch like the sibling suite does. - cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); - - let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); - drop(pair.slave); - - let reader_handle = - crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); - - let mut killer = child.clone_killer(); - std::thread::spawn(move || { - std::thread::sleep(timeout); - let _ = killer.kill(); - }); - - let mut writer = pair.master.take_writer().expect("take writer"); - crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); - drop(writer); - - let status = child.wait().expect("child.wait"); - drop(pair.master); - let output = reader_handle.finish(); - ( - status.exit_code() as i32, - String::from_utf8_lossy(&output).to_string(), - ) - } -} - -#[cfg(unix)] -#[test] -fn remove_interactive_decline_aborts_without_change() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - wire(cwd); - let wired = read(&cwd.join("package.json")); - - let (code, output) = pty::run_in_pty( - &["setup", "--remove"], - cwd, - "n\n", - std::time::Duration::from_secs(15), - ); - assert_eq!( - code, 0, - "declining the remove must exit cleanly; got: {output}" - ); - assert!( - output.contains("Remove these install hooks? [y/N]"), - "the interactive remove confirm must have been shown; got: {output}" - ); - assert!( - !output.contains("Non-interactive mode detected"), - "a PTY child must take the interactive branch; got: {output}" - ); - assert!( - output.contains("Hook removal cancelled."), - "declining must print the cancel message; got: {output}" - ); - assert!( - !output.contains("Removing changes..."), - "declining must abort before mutating; got: {output}" - ); - assert_eq!( - read(&cwd.join("package.json")), - wired, - "the wired package.json must be untouched after the decline" - ); -} - -// --------------------------------------------------------------------------- -// persist_setup_excludes' already-persisted no-rewrite branch (323): a -// flag-less rerun whose effective excludes equal the persisted set must not -// rewrite .socket/manifest.json (the byte-stability the doc comment -// promises). Detected via a cosmetic (JSON-equivalent) formatting marker -// that any rewrite would normalize away. -// --------------------------------------------------------------------------- - -#[test] -fn exclude_already_persisted_skips_manifest_rewrite() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write( - &cwd.join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write(&cwd.join("packages/a/package.json"), UNWIRED_PACKAGE_JSON); - write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); - - let (code, v) = run_json( - cwd, - &["setup", "--yes", "--json", "--exclude", "packages/a"], - ); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "success", "{v}"); - let manifest_path = cwd.join(".socket/manifest.json"); - let persisted = read(&manifest_path); - assert!( - persisted.contains("packages/a"), - "the exclude must be persisted: {persisted}" - ); - - // Cosmetic marker: trailing whitespace is JSON-equivalent, so a re-read - // still parses the same set — but a rewrite (serde pretty-print) would - // drop it. If the no-rewrite branch regresses, the marker vanishes. - let marked = format!("{persisted}\n \n"); - write(&manifest_path, &marked); - - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 0, "{v}"); - assert_eq!( - read(&manifest_path), - marked, - "a rerun whose excludes are already persisted exactly must not \ - rewrite the manifest" - ); - // The persisted exclusion still holds without the flag. Entry paths render - // via Path::display() (`\` on Windows) — normalize the separator so this - // guard cannot pass vacuously there. - let files = v["files"].as_array().expect("files[]"); - let names_packages_a = |f: &serde_json::Value| { - f["path"] - .as_str() - .is_some_and(|p| p.replace('\\', "/").contains("packages/a")) - }; - assert!( - !files.iter().any(names_packages_a), - "the persisted exclude must keep packages/a out of the run: {v}" - ); -} - -// --------------------------------------------------------------------------- -// `--exclude` persistence waits for the mutation gate: a directory with no -// project writes nothing, an already-configured project still persists an -// explicit exclusion, a dry run persists nothing, and a failed write is -// reported — never silently lost. -// --------------------------------------------------------------------------- - -/// `setup --exclude` in a directory with no project files reports `no_files` -/// and must NOT leave a `.socket/manifest.json` behind (the exclude list is -/// persisted only after discovery). -#[test] -fn setup_exclude_in_empty_dir_writes_no_socket_dir() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - - let (code, v) = run_json( - cwd, - &["setup", "--yes", "--json", "--exclude", "packages/x"], - ); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "no_files", "{v}"); - assert!( - !cwd.join(".socket").exists(), - "no project → nothing to set up → nothing to persist, no .socket/" - ); -} - -/// An explicit `--exclude` on an already-configured project (nothing to -/// preview or confirm) is still the user's stated intent: it is persisted, -/// under the manifest lock, which is released and removed again. -#[test] -fn setup_exclude_persists_when_hooks_are_already_configured() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - // A real `packages/b` member: an `--exclude` that matches nothing is - // dropped before persistence (a typo must not be persisted). - write( - &cwd.join("package.json"), - &format!( - "{{ \"name\": \"root\", \"version\": \"1.0.0\", \"workspaces\": [\"packages/*\"], \ - {WIRED_SCRIPTS_FRAGMENT} }}" - ), - ); - write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); - - let (code, v) = run_json( - cwd, - &["setup", "--yes", "--json", "--exclude", "packages/b"], - ); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "already_configured", "{v}"); - let manifest = read(&cwd.join(".socket/manifest.json")); - let mv: serde_json::Value = serde_json::from_str(&manifest).expect("manifest JSON"); - assert_eq!( - mv["setup"]["exclude"], - serde_json::json!(["packages/b"]), - "the explicit exclusion must be persisted: {manifest}" - ); - assert!( - !cwd.join(".socket/apply.lock").exists(), - "the persistence lock is released and its file removed" - ); -} - -/// `--dry-run` with `--exclude` previews and persists NOTHING. -#[test] -fn setup_dry_run_exclude_persists_nothing() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - - let (code, v) = run_json( - cwd, - &["setup", "--dry-run", "--json", "--exclude", "packages/b"], - ); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "dry_run", "{v}"); - assert!( - !cwd.join(".socket").exists(), - "a dry run must not persist the exclude list" - ); -} - -/// A persistence step that cannot write reports the skip instead of -/// claiming success: a read-only `.socket/` refuses the manifest lock and -/// write alike, the bytes on disk stay untouched, and the `--json` -/// envelope carries the fail-closed warning (same channel as the corrupt- -/// manifest skip). -#[cfg(unix)] -#[test] -fn setup_exclude_write_failure_surfaces_persist_warning() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - // A real `packages/b` member: an `--exclude` that matches nothing is - // dropped before persistence and never reaches the write. - write( - &cwd.join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); - let manifest_path = cwd.join(".socket/manifest.json"); - let original = r#"{"patches":{}}"#; - write(&manifest_path, original); - let socket = cwd.join(".socket"); - std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)).unwrap(); - - let (code, v) = run_json( - cwd, - &["setup", "--yes", "--json", "--exclude", "packages/b"], - ); - std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); - - assert_eq!( - code, 0, - "the hooks were written; only persistence was skipped: {v}" - ); - assert_eq!(v["status"], "success", "{v}"); - assert!( - 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: {v}" - ); - assert_eq!( - read(&manifest_path), - original, - "the manifest must be untouched when it cannot be rewritten" - ); -} - -// --------------------------------------------------------------------------- -// Python edge matrix. -// --------------------------------------------------------------------------- - -/// Covers 463: pip project with neither requirements.txt nor pyproject — -/// setup creates requirements.txt carrying the hook. -#[test] -fn setup_pip_from_scratch_creates_requirements() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write( - &cwd.join("setup.py"), - "from setuptools import setup\nsetup()\n", - ); - - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["pythonPackageManager"], "pip", "{v}"); - let pth = entry_of(&v, "pth"); - assert_eq!(pth["status"], "updated", "{v}"); - assert!( - pth["path"] - .as_str() - .is_some_and(|p| p.ends_with("requirements.txt")), - "{v}" - ); - let created = read(&cwd.join("requirements.txt")); - assert!( - created.contains("socket-patch[hook]"), - "the created requirements.txt must carry the hook dep; got:\n{created}" - ); - // The user's own file is untouched. - assert!( - read(&cwd.join("setup.py")).starts_with("from setuptools"), - "setup.py must not be modified" - ); -} - -/// Covers 452 + 479: a pyproject-based manager (uv, detected from a bare -/// uv.lock) with no pyproject.toml has nothing to edit → the whole run is -/// `no_files` (the partial-checkout shape). -#[test] -fn setup_uv_lock_without_pyproject_reports_no_files() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("uv.lock"), "version = 1\n"); - - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "no_files", "{v}"); - assert_eq!(v["updated"], 0, "{v}"); - assert_eq!(v["alreadyConfigured"], 0, "{v}"); - assert_eq!(v["errors"], 0, "{v}"); - assert!(v["files"].as_array().is_some_and(|a| a.is_empty()), "{v}"); - // Nothing conjured on disk either. - assert!(!cwd.join("pyproject.toml").exists()); - assert!(!cwd.join("requirements.txt").exists()); -} - -/// Covers 510: when the python manifest is already configured and only -/// ANOTHER ecosystem changed, finalize_python must return before the lock -/// refresh. The empty PATH makes the guard sharp: a regression that reaches -/// the refresh spawns `poetry`, fails to find it, and surfaces a warning. -#[test] -fn setup_python_already_configured_skips_lock_refresh() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("pyproject.toml"), POETRY_PYPROJECT); - wire(cwd); // wires the poetry hook (no poetry.lock yet → no refresh) - - // Now a lockfile exists and an npm manifest still needs wiring. - write(&cwd.join("poetry.lock"), "# stub lock\n"); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - let empty = tempfile::tempdir().expect("empty PATH dir"); - let (code, stdout, stderr) = run_env( - cwd, - &["setup", "--yes", "--json"], - &[("PATH", empty.path().to_str().unwrap())], - ); - 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["status"], "success", "{v}"); - assert_eq!(entry_of(&v, "package_json")["status"], "updated", "{v}"); - assert_eq!(entry_of(&v, "pth")["status"], "already_configured", "{v}"); - assert!( - v.get("warnings").is_none(), - "an unchanged python manifest must skip the lock refresh entirely \ - (no `poetry` spawn, no warning): {v}" - ); -} - -/// Covers 565: a Poetry project (detected via [tool.poetry]) with NO -/// poetry.lock — setup edits the manifest and must not attempt (or warn -/// about) a lock refresh. Empty PATH again makes a regression observable. -#[test] -fn setup_poetry_without_lock_skips_refresh() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("pyproject.toml"), POETRY_PYPROJECT); - - let empty = tempfile::tempdir().expect("empty PATH dir"); - let (code, stdout, stderr) = run_env( - cwd, - &["setup", "--yes", "--json"], - &[("PATH", empty.path().to_str().unwrap())], - ); - 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["status"], "success", "{v}"); - assert_eq!(v["pythonPackageManager"], "poetry", "{v}"); - assert_eq!(entry_of(&v, "pth")["status"], "updated", "{v}"); - assert!( - v.get("warnings").is_none(), - "no poetry.lock on disk → no refresh attempt, no warning: {v}" - ); - assert!( - read(&cwd.join("pyproject.toml")).contains("socket-patch"), - "the manifest edit itself must still happen" - ); -} - -/// Covers 1000 (manifest present without the hook → needs_configuration) and -/// 1005-1006 (pip with no requirements.txt yet → NotFound → needs_configuration). -#[test] -fn check_python_states_without_hook_and_missing_requirements() { - // (i) requirements.txt present, hook absent. - let a = tempfile::tempdir().expect("tempdir"); - write(&a.path().join("requirements.txt"), REQUIREMENTS_NO_HOOK); - let (code, v) = run_json(a.path(), &["setup", "--check", "--json"]); - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "needs_configuration", "{v}"); - assert_eq!(entry_of(&v, "pth")["status"], "needs_configuration", "{v}"); - - // (ii) python project (setup.py) whose requirements.txt does not exist - // yet: the NotFound arm for Requirements is "simply needs setup". - let b = tempfile::tempdir().expect("tempdir"); - write( - &b.path().join("setup.py"), - "from setuptools import setup\nsetup()\n", - ); - let (code, v) = run_json(b.path(), &["setup", "--check", "--json"]); - assert_eq!(code, 1, "{v}"); - let pth = entry_of(&v, "pth"); - assert_eq!(pth["status"], "needs_configuration", "{v}"); - assert!( - pth["path"] - .as_str() - .is_some_and(|p| p.ends_with("requirements.txt")), - "the not-yet-created requirements.txt is the manifest to create: {v}" - ); - assert!( - pth["error"].is_null(), - "NotFound-for-Requirements is not an error: {v}" - ); -} - -/// Covers 1009: an unreadable python manifest is a check ERROR (distinct from -/// the NotFound arm above). -#[cfg(unix)] -#[test] -fn check_unreadable_python_manifest_reports_error() { - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - chmod(&cwd.join("requirements.txt"), 0o000); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - chmod(&cwd.join("requirements.txt"), 0o644); - - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "error", "{v}"); - let pth = entry_of(&v, "pth"); - assert_eq!(pth["status"], "error", "{v}"); - assert!( - pth["error"].as_str().is_some_and(|e| !e.is_empty()), - "the io error must be carried on the entry: {v}" - ); -} - -/// Covers 1903: setup --json on an unreadable python manifest — the pth -/// files[] entry carries status "error" inside the error envelope. -#[cfg(unix)] -#[test] -fn setup_json_unreadable_python_manifest_error_envelope() { - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - chmod(&cwd.join("requirements.txt"), 0o000); - - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - chmod(&cwd.join("requirements.txt"), 0o644); - - assert_eq!(code, 1, "an unprocessable manifest must exit 1: {v}"); - assert_eq!(v["status"], "error", "{v}"); - assert_eq!(v["errors"], 1, "{v}"); - let pth = entry_of(&v, "pth"); - assert_eq!(pth["status"], "error", "{v}"); - assert!(pth["error"].is_string(), "{v}"); -} - -// --------------------------------------------------------------------------- -// `--check` needs/error rendering (1067, 1081, 1083, 1091-1094, 988). -// --------------------------------------------------------------------------- - -fn mixed_needs_error_fixture() -> tempfile::TempDir { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write(&tmp.path().join("packages/bad/package.json"), "{"); - tmp -} - -#[test] -fn check_human_report_renders_needs_and_error_lines() { - let tmp = mixed_needs_error_fixture(); - let (code, stdout, _stderr) = run(tmp.path(), &["setup", "--check"]); - assert_eq!(code, 1, "stdout=\n{stdout}"); - assert!( - stdout.contains("✗ package.json (needs setup)"), - "the unconfigured root must render as ✗ needs-setup; stdout=\n{stdout}" - ); - // The relative path renders via Path::display(), which uses `\` on - // Windows — normalize so the assertion is separator-agnostic. - let stdout_norm = stdout.replace('\\', "/"); - assert!( - stdout_norm.contains("! packages/bad/package.json: Invalid package.json"), - "the unparseable member must render its error; stdout=\n{stdout}" - ); - assert!( - stdout.contains( - "1 manifest needs configuration, 1 error. Run `socket-patch setup` to add the \ - missing install hooks. Fix the errors above, then re-run `socket-patch setup \ - --check`." - ), - "the summary must count needs and errors; stdout=\n{stdout}" - ); -} - -#[test] -fn check_json_mixed_needs_and_error_status() { - let tmp = mixed_needs_error_fixture(); - let (code, v) = run_json(tmp.path(), &["setup", "--check", "--json"]); - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "error", "errors dominate the status: {v}"); - assert_eq!(v["needsConfiguration"], 1, "{v}"); - assert_eq!(v["errors"], 1, "{v}"); - let statuses: Vec<&str> = entries_of(&v, "package_json") - .iter() - .filter_map(|f| f["status"].as_str()) - .collect(); - assert!( - statuses.contains(&"needs_configuration") && statuses.contains(&"error"), - "both per-file states must be rendered: {v}" - ); -} - -/// Covers 988: an unreadable package.json in `--check` carries the io error -/// (PermissionDenied — distinct from the invalid-JSON arm above). -#[cfg(unix)] -#[test] -fn check_json_unreadable_package_json_reports_io_error() { - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - let locked = tmp.path().join("packages/locked/package.json"); - write(&locked, UNWIRED_PACKAGE_JSON); - chmod(&locked, 0o000); - - let (code, v) = run_json(tmp.path(), &["setup", "--check", "--json"]); - chmod(&locked, 0o644); - - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "error", "{v}"); - let entry = entries_of(&v, "package_json") - .into_iter() - .find(|f| f["path"].as_str().is_some_and(|p| p.contains("locked"))) - .unwrap_or_else(|| panic!("no entry for the locked member: {v}")); - assert_eq!(entry["status"], "error", "{v}"); - assert!( - entry["error"] - .as_str() - .is_some_and(|e| e.contains("denied")), - "the entry must carry the read error, not the JSON-parse one: {v}" - ); -} - -// --------------------------------------------------------------------------- -// `setup --remove` human/json outcome matrix. -// --------------------------------------------------------------------------- - -/// Covers 1210-1211: the most common interactive remove outcome — nothing -/// wired, human mode. -#[test] -fn remove_human_nothing_wired_message() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), UNWIRED_PACKAGE_JSON); - - let (code, stdout, _stderr) = run(tmp.path(), &["setup", "--remove", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}"); - assert!( - stdout.contains("No socket-patch install hooks found to remove."), - "human mode must say nothing was wired; stdout=\n{stdout}" - ); -} - -/// Covers 1225-1227 (human dry-run remove summary) and 1124-1125 (BOTH -/// render_removed arms: a restored pre-existing script renders quoted, a -/// deleted key renders `(removed)`). -#[test] -fn remove_human_dry_run_summary_renders_both_removed_forms() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - // A pre-existing postinstall so removal RESTORES it (Some arm); the - // dependencies script is setup's own, so removal DELETES it (None arm). - write( - &cwd.join("package.json"), - r#"{ "name": "x", "scripts": { "postinstall": "echo hi" } }"#, - ); - wire(cwd); - let wired = read(&cwd.join("package.json")); - - let (code, stdout, _stderr) = run(cwd, &["setup", "--remove", "--dry-run"]); - assert_eq!(code, 0, "stdout=\n{stdout}"); - assert!( - stdout.contains("Will remove socket-patch from:"), - "the remove preview header must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("-> postinstall: \"echo hi\""), - "a restored user script must render quoted; stdout=\n{stdout}" - ); - assert!( - stdout.contains("-> dependencies: (removed)"), - "a deleted lifecycle key must render as (removed); stdout=\n{stdout}" - ); - assert!( - stdout.contains("1 item would have socket-patch removed"), - "the human dry-run summary must count the pending removals; stdout=\n{stdout}" - ); - assert_eq!( - read(&cwd.join("package.json")), - wired, - "--dry-run must not modify the file" - ); -} - -/// Covers the human remove preview sections for python (1426-1430) and -/// gem/composer (1432-1436), the pip uninstall hint (1302), and the Bundler -/// reversal note (1305-1308) — a real (non-dry-run) human remove across -/// npm + python + gem + composer. -#[test] -fn remove_human_real_run_prints_python_gem_composer_sections() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_2X); - write(&cwd.join("composer.json"), COMPOSER_JSON); - wire(cwd); - - let (code, stdout, stderr) = run(cwd, &["setup", "--remove", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stdout.contains("Will remove the socket-patch[hook] dependency from:"), - "the python remove preview section must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Gem: remove the socket-patch Bundler plugin wiring from:"), - "the gem remove preview lines must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Composer: remove the socket-patch re-apply hook from:"), - "the composer remove preview lines must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Also run `pip uninstall socket-patch-hook`"), - "the post-remove pip hint must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("the Bundler plugin wiring was removed"), - "the Bundler reversal note must print; stdout=\n{stdout}" - ); - // The removal really happened on every manifest. - assert_eq!(read(&cwd.join("Gemfile")), GEMFILE_FIXTURE); - assert_eq!(read(&cwd.join("composer.json")), COMPOSER_JSON); - assert!(!read(&cwd.join("package.json")).contains("socket-patch")); - assert!(!read(&cwd.join("requirements.txt")).contains("socket-patch")); -} - -/// Covers 1197: remove --json when nothing is removable AND the preview -/// errored → status "error", exit 1. -#[cfg(unix)] -#[test] -fn remove_json_preview_stage_unreadable_reports_error_status() { - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - chmod(&cwd.join("package.json"), 0o000); - - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - chmod(&cwd.join("package.json"), 0o644); - - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "error", "{v}"); - assert_eq!(v["removed"], 0, "{v}"); - assert_eq!(v["errors"], 1, "{v}"); - assert_eq!(entry_of(&v, "package_json")["status"], "error", "{v}"); -} - -/// Covers 1296 + 1318: human remove whose write stage fails (clean preview, -/// unwritable directory) → "N error(s)" and exit 1. -#[cfg(unix)] -#[test] -fn remove_human_write_stage_error_counts_and_exits_nonzero() { - if running_as_root() { - eprintln!("SKIP: root bypasses directory permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - wire(cwd); - chmod(cwd, 0o555); - - let (code, stdout, _stderr) = run(cwd, &["setup", "--remove", "--yes"]); - chmod(cwd, 0o755); - - assert_eq!(code, 1, "a failed write must exit 1; stdout=\n{stdout}"); - assert!( - stdout.contains(" 1 error\n"), - "the human summary must count the write failure; stdout=\n{stdout}" - ); - assert!( - read(&cwd.join("package.json")).contains("socket-patch"), - "the hook must still be wired after the failed write" - ); -} - -/// Covers 1274 + 1488: the --json twin — status partial_failure with the -/// npm files[] entry carrying status "error". -#[cfg(unix)] -#[test] -fn remove_json_write_stage_error_reports_partial_failure() { - if running_as_root() { - eprintln!("SKIP: root bypasses directory permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - wire(cwd); - chmod(cwd, 0o555); - - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - chmod(cwd, 0o755); - - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "partial_failure", "{v}"); - assert_eq!(v["removed"], 0, "{v}"); - assert_eq!(v["errors"], 1, "{v}"); - let entry = entry_of(&v, "package_json"); - assert_eq!(entry["status"], "error", "{v}"); - assert!(entry["error"].is_string(), "{v}"); -} - -/// Covers 1500: remove --json python status "not_configured" (the hook was -/// never in the python manifest while npm had something to remove). -#[test] -fn remove_json_python_not_configured_status() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - wire(cwd); - // The python manifest appears AFTER wiring, so it never got the hook. - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(entry_of(&v, "package_json")["status"], "removed", "{v}"); - assert_eq!(entry_of(&v, "pth")["status"], "not_configured", "{v}"); - assert_eq!( - read(&cwd.join("requirements.txt")), - REQUIREMENTS_NO_HOOK, - "a not-configured python manifest must be untouched" - ); -} - -/// Covers 1501: remove --json python status "error" (unreadable manifest) -/// riding a partial_failure envelope next to a successful npm removal. -#[cfg(unix)] -#[test] -fn remove_json_python_error_status() { - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - wire(cwd); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - chmod(&cwd.join("requirements.txt"), 0o000); - - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - chmod(&cwd.join("requirements.txt"), 0o644); - - assert_eq!(code, 1, "{v}"); - assert_eq!(v["status"], "partial_failure", "{v}"); - assert_eq!(entry_of(&v, "package_json")["status"], "removed", "{v}"); - let pth = entry_of(&v, "pth"); - assert_eq!(pth["status"], "error", "{v}"); - assert!(pth["error"].is_string(), "{v}"); -} - -// --------------------------------------------------------------------------- -// setup human-mode output: idempotent message, previews, commit notes, -// warnings, error counts. -// --------------------------------------------------------------------------- - -/// Covers 1653-1654: the idempotent second human run. -#[test] -fn setup_human_second_run_reports_all_configured() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), UNWIRED_PACKAGE_JSON); - wire(tmp.path()); - - let (code, stdout, _stderr) = run(tmp.path(), &["setup", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}"); - assert!( - stdout.contains("All install hooks are already configured with socket-patch!"), - "the idempotent second run must say so; stdout=\n{stdout}" - ); -} - -/// Covers 1814-1817 (python preview section), 1820-1823 (gem preview lines), -/// 1763-1767 (python commit note naming the detected manager), and -/// 1770-1775 (Gemfile commit note) in one real human setup run. -#[test] -fn setup_human_preview_and_commit_notes_for_python_and_gem() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - write(&cwd.join("requirements.txt"), REQUIREMENTS_NO_HOOK); - write(&cwd.join("Gemfile"), GEMFILE_FIXTURE); - write(&cwd.join("Gemfile.lock"), LOCK_2X); - - let (code, stdout, stderr) = run(cwd, &["setup", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stdout.contains("Python manifests to update (socket-patch[hook]):"), - "the python preview section must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Gem: add the socket-patch Bundler plugin wiring to:"), - "the gem preview lines must print; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Commit the pip dependency change"), - "the python commit note must name the detected manager; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Commit the Gemfile"), - "the Gemfile commit note must print; stdout=\n{stdout}" - ); -} - -/// Covers 1835-1838: the "Already configured (will skip)" preview count — -/// needs a mixed tree with one wired and one unwired manifest. -#[test] -fn setup_human_preview_counts_already_configured() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write( - &cwd.join("package.json"), - &format!("{{\"name\":\"root\",\"workspaces\":[\"packages/*\"],{WIRED_SCRIPTS_FRAGMENT}}}"), - ); - write(&cwd.join("packages/a/package.json"), UNWIRED_PACKAGE_JSON); - - let (code, stdout, _stderr) = run(cwd, &["setup", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}"); - assert!( - stdout.contains("Already configured (will skip): 1"), - "the wired root must be counted as a skip; stdout=\n{stdout}" - ); - assert!( - stdout.contains("1 item updated"), - "the unwired member must still be updated; stdout=\n{stdout}" - ); - assert!( - read(&cwd.join("packages/a/package.json")).contains("socket-patch"), - "the unwired member must gain the hook" - ); -} - -/// Covers 1760-1761: the human summary warning line, driven hermetically by -/// the fail-closed --exclude persistence over a corrupt manifest (the JSON -/// twin lives in setup_contract_gaps.rs). -#[test] -fn setup_human_summary_surfaces_persist_warning() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - // A real `packages/b` member: an `--exclude` that matches nothing is - // dropped before persistence and never reaches the fail-closed read. - write( - &cwd.join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); - let corrupt = "not json {{{"; - write(&cwd.join(".socket/manifest.json"), corrupt); - - let (code, stdout, stderr) = run(cwd, &["setup", "--yes", "--exclude", "packages/b"]); - assert_eq!( - code, 0, - "the skip is a warning, not an error; stdout=\n{stdout}" - ); - assert!( - stderr.contains("Warning: Not persisting --exclude"), - "stderr must surface the fail-closed persistence skip; stderr=\n{stderr}" - ); - assert_eq!( - stderr.matches("Not persisting --exclude").count(), - 1, - "reported once, not again in the summary; stderr=\n{stderr}" - ); - assert!( - !stdout.to_lowercase().contains("not persisting"), - "warnings stay off stdout; stdout=\n{stdout}" - ); - assert_eq!( - read(&cwd.join(".socket/manifest.json")), - corrupt, - "the corrupt manifest must survive byte-identical" - ); -} - -/// Covers 1757: the human summary "N error(s)" line on a partial failure. -#[cfg(unix)] -#[test] -fn setup_human_summary_counts_errors() { - if running_as_root() { - eprintln!("SKIP: root bypasses file permission checks"); - return; - } - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write( - &cwd.join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - let locked = cwd.join("packages/locked/package.json"); - write(&locked, UNWIRED_PACKAGE_JSON); - chmod(&locked, 0o000); - - let (code, stdout, _stderr) = run(cwd, &["setup", "--yes"]); - chmod(&locked, 0o644); - - assert_eq!(code, 1, "a partial failure must exit 1; stdout=\n{stdout}"); - assert!( - stdout.contains("1 item updated"), - "the readable root must still be updated; stdout=\n{stdout}" - ); - assert!( - stdout.contains(" 1 error\n"), - "the human summary must count the unreadable member; stdout=\n{stdout}" - ); -} - -// --------------------------------------------------------------------------- -// Remove-mode lockfile-refresh warnings (1299-1300 human, 1522 --json): -// a Poetry project whose lock refresh cannot spawn `poetry` (empty PATH). -// --------------------------------------------------------------------------- - -/// Wire the poetry hook (no lock → no refresh during wiring), then plant an -/// empty poetry.lock so the REMOVE run attempts the refresh. -fn poetry_remove_warning_fixture() -> tempfile::TempDir { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("pyproject.toml"), POETRY_PYPROJECT); - wire(tmp.path()); - assert!( - read(&tmp.path().join("pyproject.toml")).contains("socket-patch"), - "fixture: the hook must be wired before the remove run" - ); - write(&tmp.path().join("poetry.lock"), "# stub lock\n"); - tmp -} - -#[test] -fn remove_human_surfaces_poetry_lock_refresh_warning() { - let tmp = poetry_remove_warning_fixture(); - let empty = tempfile::tempdir().expect("empty PATH dir"); - - let (code, stdout, stderr) = run_env( - tmp.path(), - &["setup", "--remove", "--yes"], - &[("PATH", empty.path().to_str().unwrap())], - ); - assert_eq!( - code, 0, - "a failed lock refresh is a warning, not an error; stdout=\n{stdout}\nstderr=\n{stderr}" - ); - assert!( - stderr.contains("Warning: Could not run `poetry"), - "stderr must warn that the refresh could not run; stderr=\n{stderr}" - ); - // The edit itself must still have happened: the hook extra is gone. (For - // the classic-Poetry inline-table form, current remove semantics strip - // `extras = ["hook"]` but keep a bare `socket-patch = { version = "*" }` - // dependency line — asserting on the extra, not the whole line, pins the - // part that defines "hook removed".) - let pyproject = read(&tmp.path().join("pyproject.toml")); - assert!( - !pyproject.contains("hook"), - "the hook extra must be stripped by the remove; got:\n{pyproject}" - ); -} - -#[test] -fn remove_json_surfaces_poetry_lock_refresh_warning() { - let tmp = poetry_remove_warning_fixture(); - let empty = tempfile::tempdir().expect("empty PATH dir"); - - let (code, stdout, stderr) = run_env( - tmp.path(), - &["setup", "--remove", "--yes", "--json"], - &[("PATH", empty.path().to_str().unwrap())], - ); - 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["status"], "success", "{v}"); - assert_eq!(entry_of(&v, "pth")["status"], "removed", "{v}"); - let warnings = v["warnings"] - .as_array() - .unwrap_or_else(|| panic!("the envelope must carry a warnings array: {v}")); - assert!( - warnings.iter().any(|w| w - .as_str() - .is_some_and(|w| w.contains("could not run `poetry"))), - "the refresh failure must ride the --json warnings: {v}" - ); -} - -/// `--yes` shows no prompt, so no prompt separator either: the progress -/// ("Applying changes..." / "Removing install hooks...") is a transient -/// status line, and a piped `--yes` run leaves stderr empty. -#[test] -fn setup_and_remove_with_yes_print_no_double_blank_line() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - - let (code, stdout, stderr) = run(cwd, &["setup", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert_eq!(stderr, "", "stderr=\n{stderr:?}"); - assert!(!stdout.contains("\n\n\n"), "stdout=\n{stdout:?}"); - - let (code, stdout, stderr) = run(cwd, &["setup", "--remove", "--yes"]); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert_eq!(stderr, "", "stderr=\n{stderr:?}"); - assert!(!stdout.contains("\n\n\n"), "stdout=\n{stdout:?}"); -} - -/// A mistyped `--exclude` is warned about and NOT persisted, so later runs -/// and clones do not inherit the typo; a matching value in the same run is. -#[test] -fn setup_does_not_persist_an_unmatched_exclude() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write( - &cwd.join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write(&cwd.join("packages/a/package.json"), UNWIRED_PACKAGE_JSON); - - let (code, stdout, stderr) = run( - cwd, - &[ - "setup", - "--yes", - "--exclude", - "nope", - "--exclude", - "packages/a", - ], - ); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert_eq!( - stderr - .matches("Warning: --exclude \"nope\" matched no workspace member") - .count(), - 1, - "stderr=\n{stderr}" - ); - let manifest: serde_json::Value = - serde_json::from_str(&read(&cwd.join(".socket/manifest.json"))).expect("manifest json"); - assert_eq!( - manifest["setup"]["exclude"], - serde_json::json!(["packages/a"]), - "only the matching exclude is persisted" - ); - assert!( - !read(&cwd.join("packages/a/package.json")).contains("socket-patch"), - "the excluded member stays unwired" - ); -} - -// ───────────── --check: unreadable vendor ledger on a manifest-free project ───────────── - -/// Contract §5: `--check` (property 4) reads the vendor ledger even without a -/// manifest, and a ledger it cannot read or parse is surfaced as the -/// `Warning: Unreadable vendor state (…)` line plus a `vendor_ledger` error -/// entry — verdict `error`, exit 1 — never as a `configured` verdict. The -/// manifest-free vendored project (the only `scan`/`get --mode vendored` -/// posture) is exactly where a swallowed corrupt ledger would read as -/// hooks wired, no manifest, garbage `state.json` → `configured`, exit 0. -#[test] -fn check_reports_an_unreadable_vendor_ledger_instead_of_configured() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); - let (code, _stdout, stderr) = run(cwd, &["setup", "--json", "--yes"]); - assert_eq!(code, 0, "precondition: the hook wires; stderr=\n{stderr}"); - assert!(!cwd.join(".socket/manifest.json").exists()); - let vendor = cwd.join(".socket/vendor"); - std::fs::create_dir_all(&vendor).unwrap(); - std::fs::write(vendor.join("state.json"), b"not json").unwrap(); - - let (code, doc) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!( - code, 1, - "an unreadable ledger is never a configured verdict: {doc}" - ); - assert_eq!(doc["status"], "error", "{doc}"); - let entry = doc["files"] - .as_array() - .unwrap() - .iter() - .find(|f| f["kind"] == "vendor_ledger") - .cloned() - .unwrap_or_else(|| panic!("a vendor_ledger entry must be reported: {doc}")); - assert_eq!(entry["status"], "error", "{doc}"); - assert!( - entry["path"] - .as_str() - .unwrap_or_default() - .ends_with(".socket/vendor/state.json"), - "{doc}" - ); - assert!( - entry["error"] - .as_str() - .unwrap_or_default() - .contains("corrupt"), - "the entry carries load_state's detail: {doc}" - ); - let (_code, _stdout, stderr) = run(cwd, &["setup", "--check", "--json"]); - assert!( - stderr.contains("Warning: Unreadable vendor state") && stderr.contains("corrupt"), - "the contract's warning line reaches stderr; stderr=\n{stderr}" - ); - - // Human arm: same verdict, the `!` error row names the ledger. - let (code, stdout, stderr) = run(cwd, &["setup", "--check"]); - assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stdout.contains(".socket/vendor/state.json") && stdout.contains("1 error."), - "stdout=\n{stdout}" - ); - assert!( - stderr.contains("Warning: Unreadable vendor state"), - "stderr=\n{stderr}" - ); - - // --silent: errors only — the warning is muted, the error row is not. - let (code, stdout, stderr) = run(cwd, &["setup", "--check", "--silent"]); - assert_eq!(code, 1); - assert!( - stdout.is_empty(), - "--silent mutes the report; stdout=\n{stdout}" - ); - assert!( - !stderr.contains("Warning:"), - "--silent mutes the advisory; stderr=\n{stderr}" - ); - assert!( - stderr.contains("Error:") && stderr.contains(".socket/vendor/state.json"), - "the error row survives --silent; stderr=\n{stderr}" - ); - assert_eq!( - std::fs::read(vendor.join("state.json")).unwrap(), - b"not json", - "--check never rewrites or quarantines the ledger" - ); -} - -// --------------------------------------------------------------------------- -// vlt: the human advisory line, its muting, scope gating, and the byte-exact -// remove round trip of the npx hook. -// --------------------------------------------------------------------------- - -/// A PATH directory whose only tool is a `vlt` reporting `version`. -fn old_vlt_path(version: &str) -> tempfile::TempDir { - let dir = tempfile::tempdir().expect("PATH dir"); - if cfg!(windows) { - write( - &dir.path().join("vlt.cmd"), - &format!("@echo off\r\necho {version}\r\n"), - ); - } else { - let shim = dir.path().join("vlt"); - write(&shim, &format!("#!/bin/sh\necho {version}\n")); - #[cfg(unix)] - chmod(&shim, 0o755); - } - dir -} - -const VLT_PACKAGE_JSON: &str = "{\n \"name\": \"covgap\",\n \"version\": \"1.0.0\"\n}\n"; - -fn vlt_fixture(cwd: &Path) { - write(&cwd.join("package.json"), VLT_PACKAGE_JSON); - write(&cwd.join("vlt.json"), "{}\n"); -} - -const VLT_ADVISORY_LINE: &str = "Warning (vlt_root_scripts_not_run): vlt before 1.0.0-rc.13 does \ - not run the root postinstall hook; upgrade vlt or run \ - `socket-patch apply` after `vlt ci` (`vlt --version` reports \ - 0.0.0-32)"; - -#[test] -fn setup_vlt_advisory_prints_on_stderr_in_human_mode() { - let tmp = tempfile::tempdir().expect("tempdir"); - vlt_fixture(tmp.path()); - let path = old_vlt_path("0.0.0-32"); - let path_env = [("PATH", path.path().to_str().unwrap())]; - for args in [ - &["setup", "--dry-run"][..], - &["setup", "--yes"], - &["setup", "--yes"], - ] { - let (code, stdout, stderr) = run_env(tmp.path(), args, &path_env); - assert_eq!(code, 0, "{args:?}: stdout=\n{stdout}\nstderr=\n{stderr}"); - assert_eq!( - stderr.matches(VLT_ADVISORY_LINE).count(), - 1, - "{args:?}: stderr=\n{stderr}" - ); - assert!(!stdout.contains("vlt_root_scripts_not_run"), "{stdout}"); - } - - let (code, stdout, stderr) = run_env(tmp.path(), &["setup", "--yes", "--silent"], &path_env); - assert_eq!(code, 0); - assert!(stdout.is_empty() && stderr.is_empty(), "{stdout}{stderr}"); - - let (code, stdout, stderr) = run_env(tmp.path(), &["setup", "--yes", "--json"], &path_env); - assert_eq!(code, 0); - assert!( - stderr.is_empty(), - "--json keeps it in the envelope: {stderr}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "already_configured", "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - assert_eq!( - v["warnings"][0].as_str().unwrap(), - VLT_ADVISORY_LINE - .strip_prefix("Warning (vlt_root_scripts_not_run): ") - .map(|d| format!("vlt_root_scripts_not_run: {d}")) - .unwrap() - ); -} - -#[test] -fn setup_vlt_advisory_needs_npm_in_scope() { - let tmp = tempfile::tempdir().expect("tempdir"); - vlt_fixture(tmp.path()); - write(&tmp.path().join("requirements.txt"), REQUIREMENTS_NO_HOOK); - let path = old_vlt_path("0.0.0-32"); - let (code, stdout, stderr) = run_env( - tmp.path(), - &["setup", "--yes", "--json", "-e", "pypi"], - &[("PATH", path.path().to_str().unwrap())], - ); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert!(entries_of(&v, "package_json").is_empty(), "{v}"); - assert!(v.get("warnings").is_none(), "{v}"); - assert_eq!(read(&tmp.path().join("package.json")), VLT_PACKAGE_JSON); -} - -#[test] -fn setup_vlt_check_and_remove_round_trip_byte_exact() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - vlt_fixture(cwd); - let path = old_vlt_path("1.2.0"); - let path_env = [("PATH", path.path().to_str().unwrap())]; - - let (code, ..) = run_env(cwd, &["setup", "--check"], &path_env); - assert_eq!(code, 1); - let (code, stdout, stderr) = run_env(cwd, &["setup", "--yes", "--json"], &path_env); - 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["packageManager"], "vlt", "{v}"); - assert!(v.get("warnings").is_none(), "vlt 1.2.0 runs the hook: {v}"); - assert!(read(&cwd.join("package.json")).contains( - "\"postinstall\": \"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"" - )); - let (code, ..) = run_env(cwd, &["setup", "--check"], &path_env); - assert_eq!(code, 0); - let (code, stdout, stderr) = run_env(cwd, &["setup", "--remove", "--yes"], &path_env); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert_eq!(read(&cwd.join("package.json")), VLT_PACKAGE_JSON); - assert_eq!(read(&cwd.join("vlt.json")), "{}\n"); -} diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index a1bed29c..de577d38 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -16,7 +16,7 @@ use std::process::Command; use serde_json::Value; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; use socket_patch_core::vendor::state::{ VendorArtifact, VendorEntry, VendorState, WiringAction, WiringRecord, @@ -26,10 +26,6 @@ use socket_patch_core::vendor::state::{ /// the uuid path level, so fixtures must use the real shape). const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; -/// Every setup-supported ecosystem, declared `manual` so the property-7 -/// setup-state filter doesn't interfere with tests that aren't about it. -const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"]; - /// A prior successful run's minimal-but-recognizable OpenVEX document (the /// `@context` names openvex.dev, which is what `remove_stale_vex_doc` keys /// its deletion guard on). Mirrors the stale-doc fixture in @@ -56,19 +52,13 @@ fn cli() -> Command { cmd } -/// Write `manifest` to `/.socket/manifest.json`, declaring every -/// setup-supported ecosystem `manual` so property 7 keeps the patches. +/// Write `manifest` to `/.socket/manifest.json`. fn write_manifest(cwd: &Path, manifest: &PatchManifest) { let dir = cwd.join(".socket"); std::fs::create_dir_all(&dir).unwrap(); - let mut m = manifest.clone(); - m.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: ALL_MANUAL.iter().map(|s| s.to_string()).collect(), - }); std::fs::write( dir.join("manifest.json"), - serde_json::to_string_pretty(&m).unwrap(), + serde_json::to_string_pretty(manifest).unwrap(), ) .unwrap(); } diff --git a/crates/socket-patch-cli/tests/covgap_output.rs b/crates/socket-patch-cli/tests/covgap_output.rs index eb964cc2..4a25175f 100644 --- a/crates/socket-patch-cli/tests/covgap_output.rs +++ b/crates/socket-patch-cli/tests/covgap_output.rs @@ -4,8 +4,7 @@ //! caller passes `default_yes = true`, so this IS the "Enter proceeds //! with the destructive action" contract — the sibling pty suite drives //! `y`, `n`, and non-UTF-8 answers through `ui::confirm` but never a -//! bare Enter (its bare-Enter test hits `setup`'s default-no -//! `confirm_or_proceed`). +//! bare Enter. //! * `select_one()`'s `dialoguer::Select` branch, whose //! sole production caller is `get`'s free-user multi-patch selection: //! the Enter-accepts-first-ranked-option happy path and the diff --git a/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs b/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs deleted file mode 100644 index 5b056b87..00000000 --- a/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Coverage-gap tests for `setup/composer/mod.rs` driven through the built -//! binary: the malformed-composer.json contract. -//! -//! `is_hook_present` deliberately reads unparseable JSON as "not configured" -//! (mod.rs line 75) while `composer_add` / `composer_remove` error loudly on -//! the same bytes — so on one and the same broken file `setup --check` reports -//! needs-configuration (run setup to fix) and `setup` / `setup --remove` -//! refuse with an error instead of a silent success. That asymmetry is only -//! observable end-to-end at the binary level; the healthy setup/check/remove -//! round trip already lives in -//! `covgap_commands_setup.rs::composer_setup_check_remove_round_trip`. - -use std::path::Path; - -#[path = "common/mod.rs"] -mod common; - -/// A composer.json that does not parse. The check probe must classify it, the -/// editing paths must refuse it — and nobody may crash, wedge, or rewrite it. -const MALFORMED_COMPOSER_JSON: &str = "{ oops, this is not JSON\n"; - -fn write(path: &Path, content: &str) { - std::fs::write(path, content).expect("write file"); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read file") -} - -/// Run the binary through the shared hermetic runner (`SOCKET_*` scrubbed, -/// telemetry disabled), asserting stdout is one JSON document. -fn run_json(cwd: &Path, args: &[&str]) -> (i32, serde_json::Value) { - let (code, stdout, stderr) = - common::run_with_env(cwd, args, &[("SOCKET_TELEMETRY_DISABLED", "1")]); - let v = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("stdout must be JSON ({e}); stdout=\n{stdout}\nstderr=\n{stderr}") - }); - (code, v) -} - -/// The single `files[]` entry with kind `composer` (panics on 0 or >1). -fn composer_entry(v: &serde_json::Value) -> &serde_json::Value { - let matches: Vec<&serde_json::Value> = v["files"] - .as_array() - .unwrap_or_else(|| panic!("files must be an array: {v}")) - .iter() - .filter(|f| f["kind"] == "composer") - .collect(); - assert_eq!( - matches.len(), - 1, - "expected exactly one composer entry, got {}: {v}", - matches.len() - ); - matches[0] -} - -/// `setup --check` on a malformed composer.json: the probe (is_hook_present) -/// classifies the unparseable file as needs-configuration — no crash, no -/// error entry (the read succeeded; only the parse failed), exit 1 so CI -/// still flags the project as unwired. -#[test] -fn check_malformed_composer_json_reports_needs_configuration() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("composer.json"), MALFORMED_COMPOSER_JSON); - - let (code, v) = run_json(cwd, &["setup", "--check", "--json"]); - assert_eq!(code, 1, "an unwired project must fail the check: {v}"); - assert_eq!(v["status"], "needs_configuration", "{v}"); - let entry = composer_entry(&v); - assert_eq!( - entry["status"], "needs_configuration", - "malformed JSON must read as not-configured, not crash the probe: {v}" - ); - assert!( - entry["error"].is_null(), - "the check probe carries no error for a readable-but-unparseable file \ - (the parse failure surfaces when `setup` runs): {v}" - ); - assert_eq!( - read(&cwd.join("composer.json")), - MALFORMED_COMPOSER_JSON, - "--check must never write" - ); -} - -/// `setup` on the very same malformed file: the edit path refuses loudly — -/// entry status `error` naming the parse failure, envelope status `error`, -/// exit 1 — and leaves the user's broken file byte-identical (no clobber, -/// no "helpful" rewrite). -#[test] -fn setup_malformed_composer_json_errors_loudly_and_leaves_file_untouched() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("composer.json"), MALFORMED_COMPOSER_JSON); - - let (code, v) = run_json(cwd, &["setup", "--yes", "--json"]); - assert_eq!(code, 1, "setup on a malformed composer.json must fail: {v}"); - assert_eq!(v["status"], "error", "{v}"); - assert_eq!(v["updated"], 0, "{v}"); - assert_eq!(v["errors"], 1, "{v}"); - let entry = composer_entry(&v); - assert_eq!(entry["status"], "error", "{v}"); - assert!( - entry["error"] - .as_str() - .is_some_and(|e| e.contains("Invalid composer.json")), - "the entry must carry the parse error, naming the file kind: {v}" - ); - assert_eq!( - read(&cwd.join("composer.json")), - MALFORMED_COMPOSER_JSON, - "a refused setup must leave the malformed file byte-identical" - ); -} - -/// `setup --remove` on a malformed composer.json is the same loud refusal — -/// NOT a silent "nothing to remove" no-op (which would report success while -/// `--check` on the same file says needs-configuration). Mirrors the inline -/// `test_remove_non_object_root_is_error` contract at the binary level. -#[test] -fn remove_malformed_composer_json_errors_not_silent_noop() { - let tmp = tempfile::tempdir().expect("tempdir"); - let cwd = tmp.path(); - write(&cwd.join("composer.json"), MALFORMED_COMPOSER_JSON); - - let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 1, "remove on a malformed composer.json must fail: {v}"); - assert_eq!(v["status"], "error", "{v}"); - assert_eq!(v["removed"], 0, "{v}"); - assert_eq!(v["errors"], 1, "{v}"); - let entry = composer_entry(&v); - assert_eq!(entry["status"], "error", "{v}"); - assert!( - entry["error"] - .as_str() - .is_some_and(|e| e.contains("Invalid composer.json")), - "the entry must carry the parse error: {v}" - ); - assert_eq!( - read(&cwd.join("composer.json")), - MALFORMED_COMPOSER_JSON, - "a refused remove must leave the malformed file byte-identical" - ); -} diff --git a/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs b/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs deleted file mode 100644 index 44eefd78..00000000 --- a/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Coverage-gap integration test for `setup/gem/mod.rs`: -//! the binary-level gem `setup` → `setup --remove` round trip that clears -//! bundler's machine-local plugin registration under `BUNDLE_APP_CONFIG`. -//! -//! The env var is read once at the pub entry point -//! (`gem/update.rs::remove_plugin_directive`) and threaded into the inner -//! `_at` functions — the inline tests all inject it explicitly, so the real -//! process-env resolution (a relative value resolving against the PROJECT -//! root, exactly like `Bundler.app_config_path`) only runs through the built -//! binary. The feature-gated `setup_matrix_gem.rs` is the only other place -//! this is driven end-to-end, and it never compiles in the default test -//! configuration. - -use std::path::Path; - -#[path = "common/mod.rs"] -mod common; - -const GEMFILE_FIXTURE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n"; - -/// A Gemfile.lock whose `BUNDLED WITH` pins a supported bundler: the version -/// probe classifies from the lock BEFORE ever spawning `bundle`, so no host -/// `bundle --version` (a 1.x machine bundler, or none at all) can steer this -/// test. -const LOCK_2X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ - colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ - colorize (= 1.1.0)\n\nBUNDLED WITH\n 2.7.2\n"; - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -/// The single `files[]` entry with the given `kind` (panics on 0 or >1). -fn entry_of<'a>(v: &'a serde_json::Value, kind: &str) -> &'a serde_json::Value { - let matches: Vec<&serde_json::Value> = v["files"] - .as_array() - .unwrap_or_else(|| panic!("files must be an array: {v}")) - .iter() - .filter(|f| f["kind"] == kind) - .collect(); - assert_eq!( - matches.len(), - 1, - "expected exactly one `{kind}` entry, got {}: {v}", - matches.len() - ); - matches[0] -} - -/// setup → plant bundler's machine-local registration at the -/// `BUNDLE_APP_CONFIG` location → `setup --remove` with that env var set. -/// The remove must resolve the relative value against the project root -/// (`/bundle-config/plugin/index`), clear the registration there, -/// report it as a `gem_plugin_registration` envelope entry, and leave a -/// decoy index at the DEFAULT `.bundle` location untouched. -#[test] -fn setup_remove_clears_bundler_registration_under_bundle_app_config() { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path(); - write(&root.join("Gemfile"), GEMFILE_FIXTURE); - write(&root.join("Gemfile.lock"), LOCK_2X); - - // Step 1: wire. (No manifest on disk: the nested materialization apply - // treats a missing manifest as a clean exit-0 no-op, so stdout stays one - // JSON document.) - let (code, stdout, stderr) = common::run_with_env( - root, - &["setup", "--yes", "--json", "--ecosystems", "gem"], - &[], - ); - assert_eq!(code, 0, "gem setup must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); - let v = common::parse_json_envelope(&stdout); - assert_eq!(v["status"], "success", "{v}"); - assert!( - std::fs::read_to_string(root.join("Gemfile")) - .unwrap() - .contains("plugin 'socket-patch'"), - "setup must wire the plugin directive" - ); - assert!( - root.join(".socket/bundler-plugin/plugins.rb").is_file(), - "setup must generate the plugin files" - ); - - // Step 2: plant the registration bundler would write on first install — - // at the BUNDLE_APP_CONFIG location (relative → resolves against the - // project root), with the recorded plugin path pointing at the generated - // plugin dir, exactly like a real `path:`-sourced install. - let plugin_dir = root.join(".socket/bundler-plugin").display().to_string(); - let index = root.join("bundle-config/plugin/index"); - write( - &index, - &format!( - "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n \ - after-install-all:\n - \"socket-patch\"\nload_paths:\n socket-patch:\n \ - - \"{plugin_dir}/.\"\nplugin_paths:\n socket-patch: \"{plugin_dir}\"\nsources:\n" - ), - ); - // A decoy at the DEFAULT app-config location: with BUNDLE_APP_CONFIG - // pointing elsewhere it is out of scope and must survive byte-identical. - let decoy = root.join(".bundle/plugin/index"); - let decoy_body = "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n\ - load_paths:\n socket-patch:\n - \"/elsewhere/.\"\nplugin_paths:\n \ - socket-patch: \"/elsewhere\"\nsources:\n"; - write(&decoy, decoy_body); - - // Step 3: unwire with BUNDLE_APP_CONFIG set (child-only env injection). - let (code, stdout, stderr) = common::run_with_env( - root, - &["setup", "--remove", "--yes", "--json", "--ecosystems", "gem"], - &[("BUNDLE_APP_CONFIG", "bundle-config")], - ); - assert_eq!( - code, 0, - "gem remove must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - let v = common::parse_json_envelope(&stdout); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(entry_of(&v, "gemfile")["status"], "removed", "{v}"); - assert_eq!(entry_of(&v, "gem_plugin")["status"], "removed", "{v}"); - let reg = entry_of(&v, "gem_plugin_registration"); - assert_eq!( - reg["status"], "removed", - "the registration cleanup must be reported: {v}" - ); - assert!( - reg["path"] - .as_str() - .expect("registration entry carries the index path") - .ends_with(&format!( - "bundle-config{0}plugin{0}index", - std::path::MAIN_SEPARATOR - )), - "the cleaned index must be the BUNDLE_APP_CONFIG one: {v}" - ); - - // On disk: the env-resolved index is cleared and its emptied dirs pruned; - // the default-location decoy is out of scope and untouched. - assert!(!index.exists(), "the app-config index must be deleted"); - assert!( - !root.join("bundle-config").exists(), - "the emptied app-config dir must be pruned" - ); - assert_eq!( - std::fs::read_to_string(&decoy).unwrap(), - decoy_body, - "the default-location index is out of scope when BUNDLE_APP_CONFIG points elsewhere" - ); - - // And the rest of the unwire held: Gemfile restored byte-for-byte, - // generated plugin dir gone. - assert_eq!( - std::fs::read_to_string(root.join("Gemfile")).unwrap(), - GEMFILE_FIXTURE, - "remove must restore the Gemfile byte-for-byte" - ); - assert!( - !root.join(".socket/bundler-plugin").exists(), - "remove must delete the generated plugin dir" - ); -} diff --git a/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs b/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs deleted file mode 100644 index d4000795..00000000 --- a/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Coverage-gap integration tests for the gem bundler-version MACHINE probe -//! (`setup/gem/version.rs`): the `bundle --version` PATH fallback that runs -//! when no lockfile pins a `BUNDLED WITH` version. The fallback cannot be -//! exercised deterministically in-process (PATH cannot be injected into -//! `probe_bundler`, and the parallel test runner forbids `set_var`), so these -//! tests drive the built binary with a PATH-shimmed fake `bundle` — the -//! `write_pm_shim` pattern from setup_pth_invariants.rs — pinning all three -//! spawn outcomes on any host: -//! * parseable 1.x version on stdout → setup REFUSES to wire (a `gemfile` -//! files[] error naming the version and the `bundle --version` source); -//! * exit 0 but unparseable stdout → the probe fails OPEN, setup wires; -//! * nonzero exit → the probe fails OPEN, setup wires. -//! -//! Sibling host-run gem tests live in setup_invariants.rs. Shims are sh scripts, so -//! the whole file is unix-only — matching every other PATH-shim suite. -#![cfg(unix)] - -#[path = "common/mod.rs"] -mod common; - -use std::path::Path; - -const GEMFILE_FIXTURE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n"; - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -/// Lay a fake `bundle` executable into `bin_dir` that appends its argv to -/// `log` (so the test can prove the machine probe actually spawned it), -/// optionally prints `stdout_line`, and exits with `exit_code`. Never reads -/// stdin, so it can't block the probe. -fn write_bundle_shim(bin_dir: &Path, log: &Path, stdout_line: Option<&str>, exit_code: i32) { - use std::os::unix::fs::PermissionsExt; - std::fs::create_dir_all(bin_dir).expect("create shim dir"); - let echo = match stdout_line { - Some(line) => format!("printf '%s\\n' '{line}'\n"), - None => String::new(), - }; - let body = format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{echo}exit {exit_code}\n", - log.display() - ); - let p = bin_dir.join("bundle"); - std::fs::write(&p, body).expect("write bundle shim"); - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod shim"); -} - -/// `setup --json --yes` with the shim dir prepended to PATH so the probe's -/// `bundle --version` spawn resolves to the fake. The ambient `SOCKET_*` -/// surface is scrubbed by `common::run_with_env` (load-bearing: an ambient -/// SOCKET_DRY_RUN/SOCKET_ECOSYSTEMS would silently flip what these exercise). -fn run_setup_with_bundle_shim(cwd: &Path, bin_dir: &Path) -> (i32, serde_json::Value) { - let path_env = format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - let (code, stdout, _stderr) = common::run_with_env( - cwd, - &["setup", "--json", "--yes"], - &[("SOCKET_TELEMETRY_DISABLED", "1"), ("PATH", &path_env)], - ); - let v = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (code, v) -} - -/// Assert the shim's argv log shows the probe ran `bundle --version` exactly -/// ONCE — proof the verdict under test came from the machine probe, not from -/// some other source (or from the real host bundler further down PATH), and -/// that setup probes once per run: the dry-run preview and the real edit -/// share the one probe instead of each spawning their own. -fn assert_probe_spawned_bundle_version(log: &Path) { - let argvs = std::fs::read_to_string(log) - .expect("the bundle shim must have been invoked (argv log missing)"); - assert_eq!( - argvs.lines().filter(|l| *l == "--version").count(), - 1, - "the machine probe must spawn `bundle --version` exactly once per run; argv log:\n{argvs}" - ); -} - -// --------------------------------------------------------------------------- -// Parseable 1.x version → refusal (the classify path off a real spawn). -// --------------------------------------------------------------------------- - -#[test] -fn machine_probe_bundler_1x_refuses_to_wire() { - let tmp = tempfile::tempdir().expect("tempdir"); - // Gemfile only — NO Gemfile.lock, so the lock branch cannot answer and - // the machine probe is the sole version source. - write(&tmp.path().join("Gemfile"), GEMFILE_FIXTURE); - let log = tmp.path().join("bundle-argv.log"); - write_bundle_shim( - &tmp.path().join("bin"), - &log, - Some("Bundler version 1.17.3"), - 0, - ); - - let (code, v) = run_setup_with_bundle_shim(tmp.path(), &tmp.path().join("bin")); - assert_eq!(code, 1, "a detected 1.x bundler must fail setup: {v}"); - assert_eq!(v["status"], "error", "envelope: {v}"); - - let files = v["files"].as_array().expect("files[]"); - let gemfile = files - .iter() - .find(|f| f["kind"] == "gemfile") - .unwrap_or_else(|| panic!("gemfile entry missing from envelope: {v}")); - assert_eq!(gemfile["status"], "error", "envelope: {v}"); - let msg = gemfile["error"] - .as_str() - .unwrap_or_else(|| panic!("gemfile error message missing: {v}")); - assert!( - msg.contains("1.17.3"), - "refusal must name the detected version: {msg}" - ); - assert!( - msg.contains("`bundle --version`"), - "refusal must name the probe source: {msg}" - ); - - assert_probe_spawned_bundle_version(&log); - - // Refusal means NOTHING was wired: Gemfile byte-identical, no plugin dir. - assert_eq!( - std::fs::read_to_string(tmp.path().join("Gemfile")).unwrap(), - GEMFILE_FIXTURE, - "a refused setup must not modify the Gemfile" - ); - assert!( - !tmp.path().join(".socket/bundler-plugin").exists(), - "a refused setup must not generate the plugin dir" - ); -} - -// --------------------------------------------------------------------------- -// Fail-open regions: `bundle --version` succeeded with unparseable stdout, -// or exited nonzero → Unknown → setup wires as if no probe ran. -// --------------------------------------------------------------------------- - -/// Shared assertions for the two fail-open cases: exit 0, `success` status, -/// both gem artifacts reported updated, and the wiring actually on disk. -fn assert_failed_open_and_wired(tmp: &Path, code: i32, v: &serde_json::Value) { - assert_eq!(code, 0, "the probe must fail OPEN (wire as before): {v}"); - assert_eq!(v["status"], "success", "envelope: {v}"); - - let files = v["files"].as_array().expect("files[]"); - for kind in ["gemfile", "gem_plugin"] { - let entry = files - .iter() - .find(|f| f["kind"] == kind) - .unwrap_or_else(|| panic!("{kind} entry missing from envelope: {v}")); - assert_eq!(entry["status"], "updated", "{kind} entry: {v}"); - } - - let gemfile = std::fs::read_to_string(tmp.join("Gemfile")).unwrap(); - assert!( - gemfile.contains("plugin 'socket-patch'"), - "fail-open setup must wire the Gemfile:\n{gemfile}" - ); - assert!( - tmp.join(".socket/bundler-plugin").exists(), - "fail-open setup must generate the plugin dir" - ); -} - -#[test] -fn machine_probe_unparseable_output_fails_open_and_wires() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("Gemfile"), GEMFILE_FIXTURE); - let log = tmp.path().join("bundle-argv.log"); - // Exit 0 but no `digits.digits` token anywhere on stdout — the - // parse-of-real-spawn else-region. - write_bundle_shim(&tmp.path().join("bin"), &log, Some("no version here"), 0); - - let (code, v) = run_setup_with_bundle_shim(tmp.path(), &tmp.path().join("bin")); - assert_probe_spawned_bundle_version(&log); - assert_failed_open_and_wired(tmp.path(), code, &v); -} - -#[test] -fn machine_probe_nonzero_exit_fails_open_and_wires() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("Gemfile"), GEMFILE_FIXTURE); - let log = tmp.path().join("bundle-argv.log"); - // `bundle --version` exits 1 (broken RubyGems install, missing gemset): - // the status-success gate's else-region. No stdout at all. - write_bundle_shim(&tmp.path().join("bin"), &log, None, 1); - - let (code, v) = run_setup_with_bundle_shim(tmp.path(), &tmp.path().join("bin")); - assert_probe_spawned_bundle_version(&log); - assert_failed_open_and_wired(tmp.path(), code, &v); -} diff --git a/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs b/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs deleted file mode 100644 index 814a55ef..00000000 --- a/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! Coverage-gap integration tests for `setup`'s Hatch detection branch, -//! driven end-to-end through the built binary. -//! -//! Hatch is the one pyproject-table manager with NO lockfile and NO lock -//! refresh (`lock_commands()` is `None` — hatch resolves from the manifest at -//! install time), so it was the only `PythonPackageManager` variant never -//! produced by any host-run test: `detect_python_pm`'s `[tool.hatch]` branch -//! and `as_str()`'s `"hatch"` arm were dead in coverage. These tests pin the -//! full contract: detection from a realistic `[tool.hatch.envs.*]` SUB-table -//! (namespace-prefix resolution), the PEP 621 dependency edit, the envelope's -//! `pythonPackageManager` field, and — the Hatch-specific half — that no -//! package manager is ever spawned for a lock refresh, proven with PATH shims -//! for every manager that could have been. -//! -//! Shims are `sh` scripts, hence the file-wide unix gate. - -#![cfg(unix)] - -use std::collections::BTreeSet; -use std::path::Path; - -#[path = "common/mod.rs"] -mod common; - -/// A hatch project: PEP 621 `[project]` surface plus hatch config in a -/// sub-table only (no bare `[tool.hatch]` header — real hatch projects keep -/// config in `[tool.hatch.envs.*]` / `[tool.hatch.build.*]`), and no lockfile -/// of any kind, so the `[tool.hatch]` namespace branch is the only signal. -const HATCH_PYPROJECT: &str = "[project]\nname = \"x\"\nversion = \"0.1.0\"\ndependencies = [\n \"requests\",\n]\n\n[tool.hatch.envs.default]\ntype = \"virtual\"\n"; - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read file") -} - -/// Lay a fake `name` executable into `bin_dir` that appends its argv to `log` -/// and exits 0. Same shape as `setup_pth_invariants::write_pm_shim` (that -/// helper lives in a sibling test crate and cannot be imported). -fn write_pm_shim(bin_dir: &Path, name: &str, log: &Path) { - use std::os::unix::fs::PermissionsExt; - std::fs::create_dir_all(bin_dir).expect("create shim dir"); - let body = format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", log.display()); - let p = bin_dir.join(name); - std::fs::write(&p, body).expect("write shim"); - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod shim"); -} - -/// Shim EVERY Python package manager `setup` can spawn for a lock refresh -/// (uv / poetry / pdm — Hatch and pip have no lock command), each logging to -/// `/-argv.log`. If detection mis-routes a hatch project to -/// any lockfile-refreshing manager AND a refresh runs, the log appears. -fn shim_all_pms(project: &Path) -> std::path::PathBuf { - let bin = project.join("bin"); - for name in ["uv", "poetry", "pdm"] { - write_pm_shim(&bin, name, &project.join(format!("{name}-argv.log"))); - } - bin -} - -/// Assert none of the shims installed by [`shim_all_pms`] ever ran. -fn assert_no_pm_spawned(project: &Path, context: &str) { - for name in ["uv", "poetry", "pdm"] { - let log = project.join(format!("{name}-argv.log")); - assert!( - !log.exists(), - "{context}: hatch has no lock refresh, so `{name}` must never be \ - spawned; it ran with argv:\n{}", - read(&log) - ); - } -} - -/// Run `setup --json --yes [extra]` in `cwd` with `bin_dir` prepended to PATH -/// through the shared hermetic runner (the seed-then-scrub of the ambient -/// `SOCKET_*` surface is load-bearing: SOCKET_DRY_RUN=true would fake every -/// edit, SOCKET_ECOSYSTEMS=npm would hide the Python branch entirely). -fn run_setup_with_shims( - cwd: &Path, - bin_dir: &Path, - extra: &[&str], -) -> (i32, serde_json::Value) { - let path_env = format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - let mut args = vec!["setup", "--json", "--yes"]; - args.extend_from_slice(extra); - let (code, stdout, _stderr) = common::run_with_env( - cwd, - &args, - &[("SOCKET_TELEMETRY_DISABLED", "1"), ("PATH", &path_env)], - ); - let v = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (code, v) -} - -/// The single `files[]` entry with `kind == kind` (panics unless exactly one). -fn file_entry<'a>(v: &'a serde_json::Value, kind: &str) -> &'a serde_json::Value { - let arr = v["files"] - .as_array() - .unwrap_or_else(|| panic!("files must be an array: {v}")); - let matches: Vec<&serde_json::Value> = arr.iter().filter(|f| f["kind"] == kind).collect(); - assert_eq!( - matches.len(), - 1, - "expected exactly one `{kind}` file entry, got {}: {v}", - matches.len() - ); - matches[0] -} - -#[test] -fn hatch_project_detected_edited_and_never_spawns_a_lock_refresh() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("pyproject.toml"), HATCH_PYPROJECT); - let bin = shim_all_pms(tmp.path()); - - let (code, v) = run_setup_with_shims(tmp.path(), &bin, &[]); - assert_eq!(code, 0, "setup must succeed: {v}"); - assert_eq!(v["status"], "success", "payload={v}"); - assert_eq!(v["updated"], 1); - assert_eq!(v["errors"], 0); - // The load-bearing detection assertion: the envelope surfaces the manager - // detect_python_pm resolved — the `[tool.hatch.envs.default]` sub-table - // must route to Hatch (as_str's "hatch" arm), not fall through to pip or - // mis-match a sibling table. - assert_eq!( - v["pythonPackageManager"], "hatch", - "a [tool.hatch.envs.*]-only project must be detected as hatch: {v}" - ); - - let entry = file_entry(&v, "pth"); - assert_eq!(entry["status"], "updated"); - assert!( - entry["path"].as_str().unwrap().ends_with("pyproject.toml"), - "hatch edits pyproject.toml: {entry}" - ); - - // The hook dep landed in the PEP 621 dependencies array; the existing dep - // (with its 4-space indentation) and the hatch table survive verbatim. - let py = read(&tmp.path().join("pyproject.toml")); - assert_eq!( - py.matches("socket-patch[hook]").count(), - 1, - "hook dep must appear exactly once:\n{py}" - ); - assert!( - py.contains(" \"requests\""), - "existing dep + indentation preserved:\n{py}" - ); - assert!( - py.contains("[tool.hatch.envs.default]\ntype = \"virtual\"\n"), - "the hatch config table must survive the edit verbatim:\n{py}" - ); - - // Hatch resolves deps from the manifest at install time: lock_commands() - // is None, so NO package manager may be spawned after the edit. - assert_no_pm_spawned(tmp.path(), "after add"); - - // Idempotent re-run still detects hatch and does not re-edit. - let (code2, v2) = run_setup_with_shims(tmp.path(), &bin, &[]); - assert_eq!(code2, 0); - assert_eq!( - v2["status"], "already_configured", - "re-run must see the dep it just wrote: {v2}" - ); - assert_eq!(v2["pythonPackageManager"], "hatch", "payload={v2}"); - assert_eq!( - read(&tmp.path().join("pyproject.toml")), - py, - "already-configured re-run must not rewrite the manifest" - ); - assert_no_pm_spawned(tmp.path(), "after already-configured re-run"); -} - -#[test] -fn hatch_remove_restores_manifest_and_never_spawns_a_lock_refresh() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("pyproject.toml"), HATCH_PYPROJECT); - let bin = shim_all_pms(tmp.path()); - - // Configure first. - let (code, v) = run_setup_with_shims(tmp.path(), &bin, &[]); - assert_eq!(code, 0, "precondition setup must succeed: {v}"); - assert_eq!(v["status"], "success", "payload={v}"); - assert!( - read(&tmp.path().join("pyproject.toml")).contains("socket-patch[hook]"), - "precondition: setup added the hook dep" - ); - - let (code, v) = run_setup_with_shims(tmp.path(), &bin, &["--remove"]); - assert_eq!(code, 0, "payload={v}"); - assert_eq!(v["status"], "success", "remove must report success: {v}"); - assert_eq!(v["removed"], 1, "exactly one manifest reverted: {v}"); - assert_eq!(v["errors"], 0); - let entry = file_entry(&v, "pth"); - assert_eq!(entry["status"], "removed"); - - // Byte-preservation-sensitive subsystem: add → remove must restore the - // committed manifest byte-for-byte, hatch table included. - let py = read(&tmp.path().join("pyproject.toml")); - assert_eq!( - py, HATCH_PYPROJECT, - "remove must restore the pre-setup pyproject.toml byte-for-byte" - ); - - // Neither the remove edit nor the preceding add may spawn a lock refresh - // on a hatch project. - assert_no_pm_spawned(tmp.path(), "after add + remove"); - - // Only the fixture files remain in the project root — no lockfile, no - // marker file conjured beside the manifest. - let entries: BTreeSet = std::fs::read_dir(tmp.path()) - .expect("read_dir") - .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) - .collect(); - assert_eq!( - entries, - BTreeSet::from(["pyproject.toml".to_string(), "bin".to_string()]), - "setup/remove on a hatch project must touch only pyproject.toml" - ); -} diff --git a/crates/socket-patch-cli/tests/docker_e2e_cargo.rs b/crates/socket-patch-cli/tests/docker_e2e_cargo.rs index ac6678b7..c5259a3c 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_cargo.rs @@ -187,13 +187,10 @@ fi # too in case anything else stomps on it. chmod u+w "$LIB_RS" || true -# Pre-seed setup.manual so the agent-mode VEX leg keeps the cargo patch -# through property 7 (cargo has no auto-install setup hook; agent patches are -# applied by hand/CI — exactly what `manual` declares). scan --sync merges the -# downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["cargo"] }} }} +{{ "patches": {{}} }} MANIFEST # scan --sync writes manifest + blob; the cargo crawler with --global diff --git a/crates/socket-patch-cli/tests/docker_e2e_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_composer.rs index d21f2074..c050905b 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_composer.rs @@ -235,13 +235,10 @@ PHP_FILE="vendor/monolog/monolog/src/Monolog/Logger.php" [ -f "$PHP_FILE" ] || {{ echo "FAIL: $PHP_FILE missing" >&2; ls vendor/monolog/monolog/src/Monolog/ >&2 || true; exit 1; }} echo "Installed to: $PHP_FILE" >&2 -# Pre-seed setup.manual so the agent-mode VEX leg keeps the composer patch -# through property 7 (this project isn't `socket-patch setup`-configured; agent -# patches are applied by hand/CI — exactly what `manual` declares). scan --sync -# merges the downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["composer"] }} }} +{{ "patches": {{}} }} MANIFEST # pristine pre-check: the freshly-installed upstream file must NOT already diff --git a/crates/socket-patch-cli/tests/docker_e2e_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_gem.rs index 830a4996..039183d8 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_gem.rs @@ -273,13 +273,10 @@ echo "Installed to: $GEM_FILE" >&2 # after scan --sync's own nested apply has already patched the live file. cp "$GEM_FILE" /tmp/pristine.rb -# Pre-seed setup.manual so the agent-mode VEX leg keeps the gem patch through -# property 7 (this project isn't `socket-patch setup`-configured; agent patches -# are applied by hand/CI — exactly what `manual` declares). scan --sync merges -# the downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["gem"] }} }} +{{ "patches": {{}} }} MANIFEST # scan exit code is intentionally not gated (see verify_snippet); capture JSON. diff --git a/crates/socket-patch-cli/tests/docker_e2e_golang.rs b/crates/socket-patch-cli/tests/docker_e2e_golang.rs index 67cb3831..6f96d385 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_golang.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_golang.rs @@ -172,13 +172,10 @@ fi # handles it but we pre-chmod for robustness. chmod u+w "$GIN_GO" || true -# Pre-seed setup.manual so the agent-mode VEX leg keeps the golang patch -# through property 7 (golang has no auto-install setup hook; agent patches are -# applied by hand/CI — exactly what `manual` declares). scan --sync merges the -# downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["golang"] }} }} +{{ "patches": {{}} }} MANIFEST # scan --sync writes manifest + blob; the go crawler with --global probes diff --git a/crates/socket-patch-cli/tests/docker_e2e_maven.rs b/crates/socket-patch-cli/tests/docker_e2e_maven.rs index 7baf52cc..c102a5b7 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_maven.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_maven.rs @@ -197,13 +197,10 @@ fi # Defensive: ensure the cached file is writable before apply. chmod u+w "$POM_FILE" || true -# Pre-seed setup.manual so the agent-mode VEX leg keeps the maven patch -# through property 7 (maven has no auto-install setup hook; agent patches are -# applied by hand/CI — exactly what `manual` declares). scan --sync merges the -# downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["maven"] }} }} +{{ "patches": {{}} }} MANIFEST # scan --sync writes manifest + blob; the maven crawler with --global diff --git a/crates/socket-patch-cli/tests/docker_e2e_npm.rs b/crates/socket-patch-cli/tests/docker_e2e_npm.rs index ee3455a7..e9a13669 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_npm.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_npm.rs @@ -226,14 +226,10 @@ mkdir -p /workspace/proj && cd /workspace/proj echo '{{ "name": "e2e-proj", "version": "0.0.0" }}' > package.json npm install --silent --no-audit --no-fund minimist@1.2.2 -# Pre-seed setup.manual so the agent-mode VEX leg (step 5b) keeps the npm -# patch through property 7: this project isn't `socket-patch setup`-configured, -# and an agent patch is applied by hand/CI — exactly what `manual` declares. -# scan --sync merges the downloaded patch into this manifest and preserves the -# setup block, so the manifest the VEX leg reads carries both. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["npm"] }} }} +{{ "patches": {{}} }} MANIFEST # 2. scan --json: must discover the patch via the real batch API. A @@ -835,10 +831,10 @@ yarn install >/tmp/yi.out 2>/tmp/yi.err || {{ echo "FAIL: yarn berry install"; c TARGET=node_modules/minimist/index.js [ -f "$TARGET" ] || {{ echo "FAIL: $TARGET missing after berry install (PnP layout?)" >&2; ls -la node_modules >&2; exit 1; }} -# Pre-seed setup.manual so the patch survives property-7 filtering. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["npm"] }} }} +{{ "patches": {{}} }} MANIFEST # 2. scan --sync then forced offline apply (placeholder beforeHash fixture). @@ -1038,9 +1034,8 @@ async fn npm_berry_vendor_frozen_install_chain() { assert!(stdout.contains("===E2E PASS==="), "stdout=\n{stdout}"); } -/// The npm image carries vlt 1.2.0 for the setup-matrix `pm: vlt` cases -/// (a non-gating extra: the gating vlt assertions are the real-vlt -/// capstones). +/// The npm image carries vlt 1.2.0 (a non-gating extra: the gating vlt +/// assertions are the real-vlt capstones). #[test] fn npm_image_vlt_smoke() { let out = if host_mode() { diff --git a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs index cf9c48df..9ea858d1 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs @@ -174,13 +174,10 @@ LICENSE_FILE="$NUGET_PACKAGES/newtonsoft.json/13.0.3/LICENSE.md" [ -f "$LICENSE_FILE" ] || {{ echo "FAIL: $LICENSE_FILE missing" >&2; ls "$NUGET_PACKAGES/newtonsoft.json/13.0.3/" >&2 || true; exit 1; }} echo "Installed to: $LICENSE_FILE" >&2 -# Pre-seed setup.manual so the agent-mode VEX leg keeps the nuget patch through -# property 7 (nuget has no auto-install setup hook; agent patches are applied -# by hand/CI — exactly what `manual` declares). scan --sync merges the -# downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["nuget"] }} }} +{{ "patches": {{}} }} MANIFEST # The unpatched LICENSE must NOT already contain our synthetic marker — diff --git a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs index edaee421..c80e045c 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs @@ -215,13 +215,10 @@ pip install --disable-pip-version-check --quiet --no-cache-dir six==1.16.0 mkdir -p /workspace/proj && cd /workspace/proj ln -sf /workspace/venv .venv -# Pre-seed setup.manual so the agent-mode VEX leg keeps the pypi patch through -# property 7 (this venv project isn't `socket-patch setup`-configured; agent -# patches are applied by hand/CI — exactly what `manual` declares). scan --sync -# merges the downloaded patch into this manifest and preserves the setup block. +# Pre-seed an empty manifest; scan --sync merges the downloaded patch into it. mkdir -p .socket cat > .socket/manifest.json <<'MANIFEST' -{{ "patches": {{}}, "setup": {{ "manual": ["pypi"] }} }} +{{ "patches": {{}} }} MANIFEST # Locate the installed six.py file. diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 4d619aa1..4c145d33 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -17,14 +17,9 @@ use std::process::Command; use serde_json::Value; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; -/// Declare every ecosystem `manual` in fixtures so the property-7 setup-state -/// filter doesn't drop these patches — these tests exercise embedded-VEX -/// generation, not setup state. -const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"]; - fn binary() -> &'static str { env!("CARGO_BIN_EXE_socket-patch") } @@ -58,14 +53,9 @@ fn cli() -> Command { fn write_manifest(cwd: &Path, manifest: &PatchManifest) { let dir = cwd.join(".socket"); std::fs::create_dir_all(&dir).unwrap(); - let mut m = manifest.clone(); - m.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: ALL_MANUAL.iter().map(|s| s.to_string()).collect(), - }); std::fs::write( dir.join("manifest.json"), - serde_json::to_string_pretty(&m).unwrap(), + serde_json::to_string_pretty(manifest).unwrap(), ) .unwrap(); } @@ -274,10 +264,9 @@ fn apply_vex_writes_document_on_success() { } /// vlt: agent `apply --vex` patches every `.vlt` store copy (a peer -/// variant included) and attests it, with the ecosystem declared manual -/// under its package-manager name `vlt`. +/// variant included) and attests it. #[test] -fn apply_vex_attests_a_vlt_store_install_declared_manual_as_vlt() { +fn apply_vex_attests_a_vlt_store_install() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); let before = b"before contents\n"; @@ -316,10 +305,6 @@ fn apply_vex_attests_a_vlt_store_install_declared_manual_as_vlt() { &["CVE-2024-0001"], ), ); - manifest.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: vec!["vlt".to_string()], - }); let socket = cwd.join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); std::fs::write( diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index 328441d8..ae4f7b57 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -40,10 +40,9 @@ //! with `.socket/manifest.json` deleted, `vex` finds nothing to attest //! (`manifest_not_found`, exit 2, zero API requests, no document), //! offline or online, and `apply --vex` stays the calm `noManifest` -//! exit 0 without a document. (With the manifest the patch is still -//! omitted, `ecosystem_not_setup`: cargo has no install hook, so an -//! agent-mode cargo patch attests only when `setup.manual` declares it.) -//! The patched bytes on disk never attest by themselves. +//! exit 0 without a document. (With the manifest the applied, verified +//! agent-mode patch attests.) The patched bytes on disk never attest by +//! themselves. //! //! Network: no. Toolchain: cargo (already on every e2e CI runner); the //! `cargo_e2e_matrix` knobs (`SOCKET_PATCH_CARGO_E2E_TOOLCHAIN` / @@ -399,18 +398,21 @@ fn apply_then_cargo_check_succeeds() { /// the user's own), so VEX has nothing to attest and makes no request. fn manifestless_agent_patch_is_not_attested(consumer: &Path, cargo_home: &Path) { use vex_e2e_common::{ - assert_not_attested, run_vex, strip_ledgers, strip_manifest, PatchApi, VexRun, VexVia, + run_vex, statements_for, strip_ledgers, strip_manifest, PatchApi, VexRun, VexVia, }; let bin = vex_e2e_common::binary(); let api = PatchApi::empty(); let run = VexRun::online(&api).env("CARGO_HOME", cargo_home); - // Baseline, manifest present: cargo has no install hook, so the - // agent-mode patch is omitted until `setup.manual` declares it. + // Baseline, manifest present: the applied, verified agent-mode patch + // attests. let out = run_vex(&bin, consumer, &run); - assert_eq!(out.code, Some(1), "manifest-backed vex:\n{out}"); - assert_not_attested(&out.envelope, FIXTURE_PURL, "ecosystem_not_setup"); + assert_eq!(out.code, Some(0), "manifest-backed vex:\n{out}"); + assert!( + !statements_for(out.doc(), FIXTURE_PURL).is_empty(), + "manifest-backed vex attests the agent-mode patch:\n{out}" + ); strip_manifest(consumer); strip_ledgers(consumer); diff --git a/crates/socket-patch-cli/tests/e2e_safety_lock.rs b/crates/socket-patch-cli/tests/e2e_safety_lock.rs index 653a5e40..e712a9b8 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_lock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_lock.rs @@ -186,7 +186,7 @@ fn lock_held_human_mode_mentions_other_process() { /// `--silent` is "errors only" (CLI_CONTRACT.md), never "nothing": /// a lock_held contention under `apply --silent` must still put the /// error line on stderr. Exit 1 with zero output is undiagnosable — -/// the same violation fixed for setup/scan/apply's other error exits. +/// the same violation fixed for scan/apply's other error exits. #[test] fn lock_held_silent_mode_still_reports_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_safety_vlt.rs b/crates/socket-patch-cli/tests/e2e_safety_vlt.rs index 595ce1d0..6ba8f60e 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_vlt.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_vlt.rs @@ -211,9 +211,8 @@ fn apply_in_p1(pair: &Pair) { // ── linker legs ─────────────────────────────────────────────────────────── /// Linux `auto` (hardlink): the default-flip canary (nlink ≥ 2 after the -/// second install), apply CoW, p2 reinstalls pristine, `vlt install ` -/// keeps the patch, and `vlt ci` restores pristine bytes and re-applies -/// through the setup hook. +/// second install), apply CoW, p2 reinstalls pristine, and `vlt install ` +/// keeps the patch. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_safety_linux_auto() { @@ -309,21 +308,6 @@ async fn linker_sequence(leg: Leg) { State::Patched, "vlt install keeps it" ); - let out = agent(&fx.proj, "setup", &["--json"]); - assert_eq!(out.code, 0, "{out}"); - let before = pair.witness(); - let run = VltRun::default().with_shims(); - fx.leg.vlt_ok_with(&fx.proj, &["ci"], &run); - assert!( - !fx.leg.npx_log().is_empty(), - "the postinstall hook ran through the npx shim" - ); - assert_eq!( - state(&fx.proj, fx.t()), - State::Patched, - "vlt ci + the hook re-apply" - ); - pair.assert_untouched(&before, "vlt ci + hook"); pair.fx.leg.ran(); } 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 d97ebfe8..0b408476 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -272,7 +272,7 @@ fn yarn_pnp_refuses_in_human_mode() { /// the yarn-PnP refusal is an error exit, so it must still print the /// refusal to stderr under `--silent`. Without this, `apply --silent` /// on a PnP checkout exits 1 with zero output — undiagnosable in CI -/// logs (the same contract violation class fixed in `setup`/`scan`). +/// logs (the same contract violation class fixed in `scan`). #[test] fn yarn_pnp_refusal_still_prints_error_under_silent() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 8aa793cd..2afa3bd1 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -193,6 +193,20 @@ const GEM_PATCHES: &[(&str, &str)] = &[ "c1a1cd3c-b670-4e44-b4fa-1a63ecd42db6", "lib/active_storage/transformers/image_processing_transformer.rb", ), + // GHSA-xr9x-r78c-5hrm / CVE-2026-66066, published 2026-08-21T19:07Z + // (also adds lib/active_storage/vips.rb). + ( + "9c2b4925-b413-4a3a-bb3a-9990440fb446", + "lib/active_storage/transformers/image_processing_transformer.rb", + ), + // MERGED patch (GHSA-w749-p3v6-hccq + GHSA-r4mg-4433-c7g3 + + // GHSA-xr9x-r78c-5hrm), published 2026-09-04T21:23Z; the merge rung in + // `api::ranking` selects it. Also touches engine.rb, active_storage.rb and + // adds vips.rb — every file carries the marker. + ( + "01019627-b481-4bae-bc09-e93b5a5e4481", + "lib/active_storage/transformers/image_processing_transformer.rb", + ), ]; /// Header the patch service injects into patched npm / PyPI source files. diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index be7c2583..90c3adc3 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -21,27 +21,9 @@ use std::process::Command; use serde_json::Value; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; -/// Ecosystems opted in via `setup.manual` in test fixtures so the -/// property-7 setup-state filter (`commands/setup::configured_ecosystems`) -/// keeps these patches — these tests exercise VEX document GENERATION, not -/// setup state. Only npm/pypi/gem/composer are setup-capable; cargo, golang, -/// maven and nuget have no setup hook and get in only through `manual` -/// (maven/nuget are appended by [`all_manual`]). -const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"]; - -/// [`ALL_MANUAL`] plus the apply-only ecosystems (maven/nuget), so the -/// all-ecosystem agent matrix below can declare every non-Deno ecosystem -/// (8 of the 9). -fn all_manual() -> Vec { - let mut names: Vec = ALL_MANUAL.iter().map(|s| (*s).to_string()).collect(); - names.push("maven".to_string()); - names.push("nuget".to_string()); - names -} - fn binary() -> &'static str { env!("CARGO_BIN_EXE_socket-patch") } @@ -77,14 +59,9 @@ fn cli() -> Command { fn write_manifest(cwd: &Path, manifest: &PatchManifest) { let dir = cwd.join(".socket"); std::fs::create_dir_all(&dir).unwrap(); - let mut m = manifest.clone(); - m.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: all_manual(), - }); std::fs::write( dir.join("manifest.json"), - serde_json::to_string_pretty(&m).unwrap(), + serde_json::to_string_pretty(manifest).unwrap(), ) .unwrap(); } @@ -275,13 +252,12 @@ fn two_patches_sharing_ghsa_merge_subcomponents() { // `e2e_vex_redirect::no_verify_attests_redirected_patches_across_ecosystems`. // One manifest patch per non-Deno ecosystem (qualified PURLs for the // release-variant ones: pypi `?artifact_id=`, gem `?platform=`, maven -// `?classifier=&ext=`), `setup.manual` declaring every ecosystem (via -// `all_manual`) so property 7 keeps them all, and `--no-verify` attests -// straight from the manifest with no installed tree. +// `?classifier=&ext=`), and `--no-verify` attests straight from the +// manifest with no installed tree. // -// Unlike the redirect matrix — whose patches bypass BOTH property 7 and -// `Ecosystem::from_purl` via the `redirected` set — an agent patch routes -// through `Ecosystem::from_purl` + the `manual` allowlist. Each statement must +// Unlike the redirect matrix — whose patches bypass `Ecosystem::from_purl` +// via the `redirected` set — an agent patch routes through +// `Ecosystem::from_purl`. Each statement must // carry a PLAIN impact statement (NO `(vendored)`/`(redirected)` marker — that // is what distinguishes agent provenance) and preserve the (possibly // qualified) PURL verbatim as the subcomponent id. @@ -349,8 +325,6 @@ fn no_verify_attests_agent_patches_across_ecosystems() { ), ); } - // write_manifest stamps setup.manual = all_manual(), which declares - // every non-Deno ecosystem (8 of the 9). write_manifest(cwd, &manifest); let out = cli() diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs b/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs index e40287c2..9434b896 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs @@ -483,9 +483,6 @@ fn deno_hosted_and_vendored_never_attest_manifest_mode_unchanged() { } }, "description": "deno agent-mode patch", "license": "MIT", "tier": "free", } }, - // Deno projects run no npm install hook: declare the ecosystem - // manual so agent-mode statements are emitted (property 7). - "setup": { "manual": ["npm"] }, })) .unwrap(), ) diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs index a8681db1..620e311b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs @@ -346,10 +346,8 @@ fn deno_jsr_agent_patch_attests_only_with_the_manifest() { let (key, before, after) = files()[0]; put(&pkg, key.strip_prefix("package/").unwrap(), before); let rec = record(UUID); - let mut manifest = serde_json::json!({ + let manifest = serde_json::json!({ "patches": { JSR_PURL: serde_json::to_value(&rec).unwrap() }, - // Deno has no install hook: declare it manual (property 7). - "setup": { "manual": ["deno"] }, }); fx.put(".socket/manifest.json", manifest.to_string()); fx.put( @@ -404,13 +402,4 @@ fn deno_jsr_agent_patch_attests_only_with_the_manifest() { assert_nothing_to_attest(&out, &[JSR_PURL], &format!("no manifest {extra:?}")); } quiet.assert_no_requests(); - - // And a manifest WITHOUT the `setup.manual` declaration keeps the - // pre-existing property-7 omission (unchanged by manifest-less VEX: - // the Deno patch is not lockfile-persisted, so it does not bypass it). - manifest["setup"] = serde_json::json!({}); - fx.put(".socket/manifest.json", manifest.to_string()); - let out = fx.vex(&["--offline"]); - assert_eq!(out.code, Some(1), "undeclared deno ecosystem: {out}"); - assert_absent(out.doc.as_ref(), JSR_PURL); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs index 4683f019..3d8521e4 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs @@ -1230,16 +1230,6 @@ impl Fx { } } - /// Declare `eco` in the manifest's `setup.manual` (CLI_CONTRACT property - /// 7): maven has no install hook, so agent-mode patches are attested - /// only for an ecosystem the user declares they `apply` by hand. - fn declare_manual(&self, eco: &str) { - let path = self.cwd.join(".socket/manifest.json"); - let mut manifest: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - manifest["setup"] = serde_json::json!({ "manual": [eco] }); - std::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap(); - } - fn embedded_doc(&self) -> Value { doc_at(&self.cwd.join("embedded.vex.json")) } @@ -1523,7 +1513,6 @@ fn maven_apply_vex_attests_but_agent_mode_needs_the_manifest() { put(&base, "slf4j-api-1.7.36.pom", b"org.slf4jslf4j-api1.7.36"); put(&base, MVN_JAR_KEY, MVN_PRISTINE); fx.stage_manifest(MVN_PURL, MVN_HOSTED_UUID, &mvn_files()); - fx.declare_manual("maven"); let embedded = fx.cwd.join("embedded.vex.json"); let (code, env, stderr) = fx.run(&[ "apply", diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs index 007fcd40..ecc81fd3 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs @@ -1416,13 +1416,6 @@ fn nuget_apply_vex_attests_but_agent_mode_needs_the_manifest() { put(&pkg, "newtonsoft.json.nuspec", b""); put(&pkg, NUGET_FILE_KEY, NUGET_PRISTINE); fx.stage_manifest(NUGET_PURL, NUGET_HOSTED_UUID, &nuget_files()); - // nuget has no install hook: agent-mode statements need the ecosystem - // declared `setup.manual` (property 7) — the hosted/vendored cells - // above never do, their wiring IS the persistence. - let manifest = fx.cwd.join(".socket/manifest.json"); - let mut m: Value = serde_json::from_slice(&std::fs::read(&manifest).unwrap()).unwrap(); - m["setup"] = serde_json::json!({ "manual": ["nuget"] }); - fx.put(".socket/manifest.json", m.to_string()); let embedded = fx.cwd.join("embedded.vex.json"); let (code, env, stderr) = fx.run(&[ "apply", diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 785015c1..e9b1caf0 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -9,9 +9,9 @@ //! //! 1. redirected PURL attested against the installed tree, `(redirected)` //! marker (the post-install verified path) -//! 2. property-7 exemption: a redirected patch bypasses the configured/manual -//! ecosystem filter (the lockfile rewrite is the persistence), while a -//! plain unconfigured control is dropped +//! 2. a redirected patch and a plain agent-mode control both attest with +//! no manifest `setup` section; only the redirected one is marked +//! `(redirected)` //! 3. tampered installed file → omitted with skip reason `hash_mismatch` //! (fail-closed) //! 4. `--no-verify` attests from the ledger records with NO installed tree @@ -230,15 +230,15 @@ fn redirected_purl_attested_against_installed_tree() { } // ────────────────────────────────────────────────────────────────────── -// 2. property-7 exemption — a redirected patch bypasses the filter +// 2. redirected + agent-mode patches attest together // ────────────────────────────────────────────────────────────────────── #[test] -fn redirected_purl_bypasses_property7_filter() { +fn redirected_and_agent_patches_attest_without_setup_config() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - // Redirected npm patch: verifies + bypasses property 7. + // Redirected npm patch: verifies from the redirect state. let patched = b"redirected patched index\n"; let after = compute_git_sha256_from_bytes(patched); let purl = scaffold_npm(cwd, "left-pad", "1.3.0", patched); @@ -249,9 +249,8 @@ fn redirected_purl_bypasses_property7_filter() { ); write_hosted_package_lock(cwd, &[("left-pad", "1.3.0", UUID)], true); - // Control: a plain manifest npm patch that VERIFIES against node_modules - // but is neither redirected nor set up / manual — property 7 must drop it, - // proving the filter ran while the redirected patch sailed through. + // Control: a plain manifest (agent-mode) npm patch that VERIFIES against + // node_modules, with no install hook — it attests too, unmarked. let ctrl_patched = b"control patched index\n"; let ctrl_after = compute_git_sha256_from_bytes(ctrl_patched); let ctrl_pkg = cwd.join("node_modules/control-pkg"); @@ -273,7 +272,6 @@ fn redirected_purl_bypasses_property7_filter() { &["CVE-2024-3"], ), ); - // NO setup section: nothing configured, nothing manual. let dir = cwd.join(".socket"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( @@ -288,23 +286,29 @@ fn redirected_purl_bypasses_property7_filter() { .expect("invoke vex"); assert!( out.status.success(), - "the redirected patch must be attested without setup/manual. stderr:\n{}", + "both patches must attest. stderr:\n{}", String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8(out.stdout).unwrap(); let doc: Value = serde_json::from_str(&stdout).unwrap(); let stmts = doc["statements"].as_array().unwrap(); - assert_eq!( - stmts.len(), - 1, - "only the redirected patch bypasses property 7; the unconfigured npm \ - control must be dropped. doc:\n{stdout}" + assert_eq!(stmts.len(), 2, "both patches attest. doc:\n{stdout}"); + let impact = |vuln: &str| -> String { + let hit: Vec<&Value> = stmts + .iter() + .filter(|s| s["vulnerability"]["name"] == vuln) + .collect(); + assert_eq!(hit.len(), 1, "one statement for {vuln}:\n{stdout}"); + hit[0]["impact_statement"].as_str().unwrap().to_string() + }; + assert!( + impact("GHSA-rdir-keep").contains("(redirected)"), + "the redirected patch carries its marker:\n{stdout}" ); - assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-rdir-keep"); assert!( - !stdout.contains("GHSA-npm-control"), - "the non-redirected, non-configured control must be filtered:\n{stdout}" + !impact("GHSA-npm-control").contains("(redirected)"), + "the agent-mode control is not redirected:\n{stdout}" ); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 5cbd7118..e944c38b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -10,8 +10,8 @@ //! carries the "(vendored)" marker //! 2. tampered vendored artifact → omitted, envelope skip reason //! `vendor_hash_mismatch` -//! 3. Property-7 exemption: a vendored patch needs no install hook by -//! construction, so it bypasses the configured/manual ecosystem filter +//! 3. vendored and agent-mode patches attest side by side with no +//! manifest `setup` section, the vendored one marked "(vendored)" //! 4. legacy `.socket/go-patches/` redirect regression: an apply-redirected //! Go patch verifies against the redirect copy dir, not the (pristine) //! module cache @@ -29,7 +29,7 @@ use std::process::Command; use serde_json::Value; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; use socket_patch_core::vendor::state::{ VendorArtifact, VendorEntry, VendorState, WiringAction, WiringRecord, @@ -39,10 +39,6 @@ use socket_patch_core::vendor::state::{ /// the uuid path level, so fixtures must use the real shape. const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; -/// Every setup-supported ecosystem, declared `manual` so the property-7 -/// filter doesn't interfere with the tests that aren't about it. -const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"]; - fn binary() -> &'static str { env!("CARGO_BIN_EXE_socket-patch") } @@ -60,21 +56,13 @@ fn cli() -> Command { cmd } -/// Write `manifest` to `/.socket/manifest.json`, optionally declaring -/// every ecosystem `manual` (tests of the property-7 exemption pass `false`). -fn write_manifest(cwd: &Path, manifest: &PatchManifest, declare_manual: bool) { +/// Write `manifest` to `/.socket/manifest.json`. +fn write_manifest(cwd: &Path, manifest: &PatchManifest) { let dir = cwd.join(".socket"); std::fs::create_dir_all(&dir).unwrap(); - let mut m = manifest.clone(); - if declare_manual { - m.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: ALL_MANUAL.iter().map(|s| s.to_string()).collect(), - }); - } std::fs::write( dir.join("manifest.json"), - serde_json::to_string_pretty(&m).unwrap(), + serde_json::to_string_pretty(manifest).unwrap(), ) .unwrap(); } @@ -232,7 +220,7 @@ fn vendored_purl_attested_with_no_installed_tree() { &["CVE-2024-1"], ), ); - write_manifest(cwd, &manifest, true); + write_manifest(cwd, &manifest); let out = cli() .args([ @@ -291,7 +279,7 @@ fn tampered_vendored_artifact_omitted_with_vendor_hash_mismatch() { &["CVE-2024-2"], ), ); - write_manifest(cwd, &manifest, true); + write_manifest(cwd, &manifest); let vex_path = cwd.join("out.vex.json"); let out = cli() @@ -336,11 +324,12 @@ fn tampered_vendored_artifact_omitted_with_vendor_hash_mismatch() { } // ────────────────────────────────────────────────────────────────────── -// 3. Property-7 exemption — vendored patches need no install hook +// 3. vendored + agent-mode patches attest together — no manifest `setup` +// section is needed for either // ────────────────────────────────────────────────────────────────────── #[test] -fn property7_vendored_purl_bypasses_setup_manual_filter() { +fn vendored_and_agent_patches_attest_without_setup_config() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); let vendored_purl = "pkg:cargo/serde@1.0.0"; @@ -350,10 +339,8 @@ fn property7_vendored_purl_bypasses_setup_manual_filter() { let rel = write_vendored_dir(cwd, patched); write_vendor_state(cwd, vendored_purl, &rel); - // Control: an npm patch that VERIFIES against node_modules but whose - // ecosystem is neither set up (no postinstall hook anywhere) nor manual - // — property 7 must drop it, proving the filter ran while the vendored - // patch sailed through. + // An agent-mode npm patch that VERIFIES against node_modules, with no + // install hook and no manifest `setup` section: it attests too. let nm_pkg = cwd.join("node_modules/applied-pkg"); std::fs::create_dir_all(&nm_pkg).unwrap(); std::fs::write( @@ -386,8 +373,7 @@ fn property7_vendored_purl_bypasses_setup_manual_filter() { &["CVE-2024-4"], ), ); - // NO setup section: nothing configured, nothing manual. - write_manifest(cwd, &manifest, false); + write_manifest(cwd, &manifest); let out = cli() .args([ @@ -401,7 +387,7 @@ fn property7_vendored_purl_bypasses_setup_manual_filter() { .expect("invoke vex"); assert!( out.status.success(), - "the vendored patch must be attested without any setup/manual config. stderr:\n{}", + "both patches must attest without any manifest setup section. stderr:\n{}", String::from_utf8_lossy(&out.stderr) ); @@ -410,26 +396,36 @@ fn property7_vendored_purl_bypasses_setup_manual_filter() { let stmts = doc["statements"].as_array().unwrap(); assert_eq!( stmts.len(), - 1, - "only the vendored patch bypasses property 7; the unconfigured npm \ - control must be dropped. doc:\n{stdout}" + 2, + "the vendored patch and the applied agent-mode npm patch both attest. doc:\n{stdout}" + ); + assert_eq!( + impact_for(stmts, "GHSA-vend-cccc"), + format!("Patched via Socket patch {UUID} (vendored)") ); - assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-vend-cccc"); assert!( - !stdout.contains("GHSA-npm-control"), - "the non-vendored, non-configured npm patch must be filtered:\n{stdout}" + !impact_for(stmts, "GHSA-npm-control").contains("(vendored)"), + "the agent-mode npm patch is not vendored:\n{stdout}" ); } -/// The property-7 vendored exemption (and the "(vendored)" phrasing) must -/// survive `--no-verify`: the exemption's rationale — the committed -/// `.socket/vendor/` artifact + lockfile wiring IS the persistence -/// mechanism — is about how the patch persists, not about whether this run -/// hashed it. The vendored classification comes from the committed ledger, +/// The impact statement of the one statement naming `vuln`. +fn impact_for(stmts: &[Value], vuln: &str) -> String { + let hits: Vec<&Value> = stmts + .iter() + .filter(|s| s["vulnerability"]["name"] == vuln) + .collect(); + assert_eq!(hits.len(), 1, "exactly one statement for {vuln}: {stmts:?}"); + hits[0]["impact_statement"].as_str().unwrap().to_string() +} + +/// The "(vendored)" phrasing must survive `--no-verify`: it is about how +/// the patch persists — the committed `.socket/vendor/` artifact + lockfile +/// wiring — not about whether this run hashed it. The vendored classification comes from the committed ledger, /// which `--no-verify` can read without hashing anything (the artifact dir /// is deliberately ABSENT here to pin that no hashing happens). #[test] -fn property7_vendored_exemption_survives_no_verify() { +fn vendored_marker_survives_no_verify() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); let vendored_purl = "pkg:cargo/serde@1.0.0"; @@ -451,9 +447,8 @@ fn property7_vendored_exemption_survives_no_verify() { &["CVE-2024-6"], ), ); - // Control: an npm patch with no hook configured and no `manual` - // declaration — property 7 must still drop it under `--no-verify` - // (the filter runs regardless of verification mode). + // An agent-mode npm patch: `--no-verify` trusts the manifest, so it + // attests too — without the vendored marker. manifest.patches.insert( "pkg:npm/unconfigured-pkg@1.0.0".to_string(), make_record( @@ -464,8 +459,7 @@ fn property7_vendored_exemption_survives_no_verify() { &["CVE-2024-7"], ), ); - // NO setup section: nothing configured, nothing manual. - write_manifest(cwd, &manifest, false); + write_manifest(cwd, &manifest); let out = cli() .args([ @@ -480,7 +474,7 @@ fn property7_vendored_exemption_survives_no_verify() { .expect("invoke vex"); assert!( out.status.success(), - "--no-verify must keep the vendored patch's property-7 exemption. stderr:\n{}", + "--no-verify must attest the vendored patch. stderr:\n{}", String::from_utf8_lossy(&out.stderr) ); @@ -489,21 +483,17 @@ fn property7_vendored_exemption_survives_no_verify() { let stmts = doc["statements"].as_array().unwrap(); assert_eq!( stmts.len(), - 1, - "only the vendored patch bypasses property 7 under --no-verify; the \ - unconfigured npm control must still be dropped. doc:\n{stdout}" + 2, + "--no-verify attests every manifest patch. doc:\n{stdout}" ); - assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-vend-dddd"); - let impact = stmts[0]["impact_statement"].as_str().unwrap(); assert_eq!( - impact, + impact_for(stmts, "GHSA-vend-dddd"), format!("Patched via Socket patch {UUID} (vendored)"), "--no-verify must not lose the (vendored) provenance marker" ); assert!( - !stdout.contains("GHSA-npm-control"), - "the non-vendored, non-configured npm patch must be filtered even \ - under --no-verify:\n{stdout}" + !impact_for(stmts, "GHSA-npm-control").contains("(vendored)"), + "the agent-mode npm patch is not vendored:\n{stdout}" ); } @@ -561,7 +551,7 @@ fn golang_go_patches_redirect_attested_without_module_cache() { &["CVE-2024-5"], ), ); - write_manifest(cwd, &manifest, true); + write_manifest(cwd, &manifest); // Hermetic, EMPTY module cache: the pristine module is nowhere on disk, // exactly like a fresh checkout that only ran the redirect apply. @@ -649,8 +639,7 @@ fn write_detached_vendor_state(cwd: &Path, purl: &str, rel_path: &str, record: P /// A detached vendored patch has NO manifest record — `vex` must attest it /// from the ledger's embedded record + the committed artifact, even when -/// `.socket/manifest.json` does not exist at all. The vendored property-7 -/// exemption applies (no setup/manual declaration anywhere). +/// `.socket/manifest.json` does not exist at all. #[test] fn detached_entry_attested_without_manifest() { let tmp = tempfile::tempdir().unwrap(); @@ -1022,8 +1011,7 @@ fn detached_matrix_entry( /// maven (maven2-layout `.jar`) — laid down in each ecosystem's REAL /// artifact shape (dir / tarball / zip-family) with matching afterHashes, /// then ONE `vex` run must attest all of them `(vendored)` from the ledger -/// alone: no manifest, no installed trees, no setup/manual declarations -/// (the vendored property-7 exemption covers every row). +/// alone: no manifest, no installed trees. #[test] fn detached_vendor_matrix_attests_every_vendor_ecosystem() { let tmp = tempfile::tempdir().unwrap(); @@ -1353,28 +1341,21 @@ fn vendored_live_tree_out_of_sync_warns_but_attests() { } // ────────────────────────────────────────────────────────────────────── -// 8. property-7 filter drops are machine-visible — a byte-verified applied -// patch omitted ONLY by the ecosystem-setup filter must surface as a -// per-purl skipped event (errorCode `ecosystem_not_setup`), and an all- -// drops failure must say so in the top-level error message instead of the -// generic (and factually wrong) "No applied patches ... to attest." -// Confirmed against real pnpm projects 2026-08-18. +// 8. an applied, byte-verified agent-mode patch attests whether or not its +// ecosystem has an install hook (there is no setup-state filter). // ────────────────────────────────────────────────────────────────────── -/// All-drops case: the ONLY patch is applied + byte-verified but its -/// ecosystem is neither set up nor `manual`. Exit stays 1 with code -/// `no_applicable_patches`, but the envelope must carry the skipped event -/// and the message must name the setup filter. A stale OpenVEX doc parked -/// at `--output` from a previous run must also be removed — a failed run -/// leaves no attestation behind. +/// The ONLY patch is applied + byte-verified in a project with no install +/// hook and no manifest `setup` section: it attests (exit 0, a `verified` +/// event), and the fresh doc replaces a stale one parked at `--output`. #[test] -fn setup_filter_drop_surfaces_skipped_event_and_removes_stale_doc() { +fn agent_patch_without_install_hook_attests_and_replaces_stale_doc() { let tmp = tempfile::tempdir().expect("create tempdir"); let cwd = tmp.path(); let purl = "pkg:npm/applied-pkg@1.0.0"; - // Applied + verifiable in node_modules; no root package.json → no npm - // hook configured; manifest carries NO setup section. + // Applied + verifiable in node_modules; no root package.json (so no + // install hook anywhere); the manifest carries no setup section. let nm = cwd.join("node_modules/applied-pkg"); std::fs::create_dir_all(&nm).expect("create node_modules entry"); std::fs::write( @@ -1397,7 +1378,7 @@ fn setup_filter_drop_surfaces_skipped_event_and_removes_stale_doc() { &["CVE-2026-20"], ), ); - write_manifest(cwd, &manifest, false); + write_manifest(cwd, &manifest); // A previous successful run's doc sits at --output. let vex_path = cwd.join("out.vex.json"); @@ -1420,49 +1401,42 @@ fn setup_filter_drop_surfaces_skipped_event_and_removes_stale_doc() { ]) .output() .expect("invoke vex"); - assert_eq!( - out.status.code(), - Some(1), - "all patches filtered ⇒ soft exit 1. stdout:\n{}", + assert!( + out.status.success(), + "an applied agent-mode patch attests. stdout:\n{}", String::from_utf8_lossy(&out.stdout) ); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["status"], "error", "{env}"); - assert_eq!(env["error"]["code"], "no_applicable_patches", "{env}"); - // The message must name the ACTUAL cause — the patch IS applied with - // vulnerability metadata; only the setup filter dropped it. - let msg = env["error"]["message"].as_str().unwrap(); - assert!( - msg.contains("not set up") && msg.contains("setup.manual"), - "an all-drops failure must name the setup filter, got {msg:?}" - ); - // Machine-visible per-purl drop. + assert_eq!(env["status"], "success", "{env}"); let events = env["events"].as_array().unwrap(); - let skipped = events - .iter() - .find(|e| e["action"] == "skipped" && e["purl"] == purl) - .unwrap_or_else(|| panic!("expected a skipped event for the filtered purl: {env}")); - assert_eq!( - skipped["errorCode"], "ecosystem_not_setup", - "the filter drop must carry its routing tag: {skipped}" + assert!( + events + .iter() + .any(|e| e["action"] == "verified" && e["purl"] == purl), + "expected a verified event for {purl}: {env}" ); - // Failed-run hygiene: the stale prior doc must be gone. assert!( - !vex_path.exists(), - "a failed run must not leave a previous run's attestation at --output" + !events.iter().any(|e| e["action"] == "skipped"), + "nothing is omitted: {env}" ); + let doc: Value = + serde_json::from_str(&std::fs::read_to_string(&vex_path).expect("read emitted VEX doc")) + .expect("parse emitted VEX doc"); + assert_ne!(doc["@id"], "urn:uuid:stale", "the stale doc is replaced: {doc}"); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "{doc}"); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-drop-aaaa", "{doc}"); } -/// Partial case: a vendored patch attests while an npm patch is filter- -/// dropped. Exit 0 (a doc was produced), envelope `partialFailure`, and the -/// drop is a skipped event alongside the vendored purl's verified event. +/// A vendored patch and an applied agent-mode npm patch (no install hook) +/// both attest: exit 0, envelope `success`, a verified event for each. #[test] -fn setup_filter_drop_alongside_success_is_partial_failure_event() { +fn agent_patch_alongside_vendored_patch_both_attest() { let tmp = tempfile::tempdir().expect("create tempdir"); let cwd = tmp.path(); let vendored_purl = "pkg:cargo/serde@1.0.0"; - let dropped_purl = "pkg:npm/applied-pkg@1.0.0"; + let agent_purl = "pkg:npm/applied-pkg@1.0.0"; let patched = b"patched vendored source\n"; let after_hash = compute_git_sha256_from_bytes(patched); @@ -1492,7 +1466,7 @@ fn setup_filter_drop_alongside_success_is_partial_failure_event() { ), ); manifest.patches.insert( - dropped_purl.to_string(), + agent_purl.to_string(), make_record( "11111111-1111-4111-8111-111111111111", "package/index.js", @@ -1501,7 +1475,7 @@ fn setup_filter_drop_alongside_success_is_partial_failure_event() { &["CVE-2026-22"], ), ); - write_manifest(cwd, &manifest, false); + write_manifest(cwd, &manifest); let vex_path = cwd.join("out.vex.json"); let out = cli() @@ -1519,33 +1493,26 @@ fn setup_filter_drop_alongside_success_is_partial_failure_event() { .expect("invoke vex"); assert!( out.status.success(), - "a produced doc keeps exit 0 even with filter drops. stdout:\n{}", + "both patches attest. stdout:\n{}", String::from_utf8_lossy(&out.stdout) ); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!( - env["status"], "partialFailure", - "an omission alongside a success is partialFailure: {env}" - ); + assert_eq!(env["status"], "success", "{env}"); let events = env["events"].as_array().unwrap(); - assert!( - events - .iter() - .any(|e| e["action"] == "verified" && e["purl"] == vendored_purl), - "the vendored purl must attest: {env}" - ); - let skipped = events - .iter() - .find(|e| e["action"] == "skipped" && e["purl"] == dropped_purl) - .unwrap_or_else(|| panic!("expected a skipped event for the filtered purl: {env}")); - assert_eq!(skipped["errorCode"], "ecosystem_not_setup", "{skipped}"); + for purl in [vendored_purl, agent_purl] { + assert!( + events + .iter() + .any(|e| e["action"] == "verified" && e["purl"] == purl), + "{purl} must attest: {env}" + ); + } - // The doc holds exactly the vendored statement. let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).expect("read emitted VEX doc")) .expect("parse emitted VEX doc"); - assert_eq!(doc["statements"].as_array().unwrap().len(), 1, "{doc}"); + assert_eq!(doc["statements"].as_array().unwrap().len(), 2, "{doc}"); } // ────────────────────────────────────────────────────────────────────── @@ -1578,7 +1545,7 @@ fn product_override_non_iri_warns_in_envelope() { &["CVE-2026-30"], ), ); - write_manifest(cwd, &manifest, true); + write_manifest(cwd, &manifest); let vex_path = cwd.join("out.vex.json"); let out = cli() @@ -1665,7 +1632,7 @@ fn standalone_output_write_failure_names_path_and_exits_2() { &["CVE-2026-31"], ), ); - write_manifest(cwd, &manifest, true); + write_manifest(cwd, &manifest); let bad_path = cwd.join("no-such-dir/out.vex.json"); let out = cli() @@ -1769,7 +1736,7 @@ fn unwired_vendor_ledger_entry_is_not_attested() { &["CVE-2026-20"], ), ); - write_manifest(cwd, &manifest, true); + write_manifest(cwd, &manifest); // The lockfile/config wiring is reverted by hand; the artifact and the // ledger entry stay behind. std::fs::write(cwd.join("Cargo.toml"), CARGO_MANIFEST_HEAD).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_vlt.rs b/crates/socket-patch-cli/tests/e2e_vlt.rs index dc9e45e5..9f02cca0 100644 --- a/crates/socket-patch-cli/tests/e2e_vlt.rs +++ b/crates/socket-patch-cli/tests/e2e_vlt.rs @@ -1,11 +1,8 @@ -//! Real-vlt agent mode and setup (suites `agent` and `setup`, DESIGN §5, -//! §8.3). +//! Real-vlt agent mode (suite `agent`, DESIGN §5, §8.3). //! //! Agent legs patch a REAL vlt tree in place (importer links, the `.vlt` //! store, transitive, scoped, alias and peer copies) and pin how long the -//! patch persists across vlt commands (T23). Setup legs wire the root -//! `postinstall` hook and run vlt with a leg-private `npx` shim that execs -//! the socket-patch under test, counting hook invocations (T17/T18/T22). +//! patch persists across vlt commands (T23). //! Each leg is `vlt_pinned_matrix__` and prints one `VLT-LEG` //! line. @@ -23,16 +20,11 @@ const DEBUG: (&str, &str) = ("debug", "4.3.4"); const MS2: (&str, &str) = ("ms", "2.1.2"); const USX: (&str, &str) = ("use-sync-external-store", "1.2.0"); const UUID_MS2: &str = "c9c9c9c9-9999-4999-8999-999999999999"; -const HOOK: &str = "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"; fn agent_leg(name: &'static str) -> Option { Leg::start("agent", name) } -fn setup_leg(name: &'static str) -> Option { - Leg::start("setup", name) -} - fn cwd(dir: &Path) -> String { dir.to_str().unwrap().to_string() } @@ -447,8 +439,8 @@ async fn vlt_pinned_matrix_agent_persistence_reverted_by_reinstall() { } /// `apply` rerun is idempotent, `rollback` rerun is a no-op, and `vex` -/// (the setup hook wired) attests the agent-patched store copy but not -/// after a `vlt ci` that ran without the hook. +/// attests the agent-patched store copy but not after a `vlt ci` restored +/// the pristine bytes. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_agent_reruns_and_vex() { @@ -465,8 +457,6 @@ async fn vlt_pinned_matrix_agent_reruns_and_vex() { "apply rerun" ); assert_eq!(state(&fx.proj, fx.t()), State::Patched); - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); let vex = |fx: &Fixture| { let _ = std::fs::remove_file(fx.proj.join("out.vex.json")); let out = offline( @@ -479,13 +469,9 @@ async fn vlt_pinned_matrix_agent_reruns_and_vex() { let (attested, out) = vex(&fx); assert!(attested, "{out}"); if fx.leg.at_least(HAS_CI_FROM) { - let out = setup(&fx.proj, &fx, &["--remove"]); - assert_eq!(out.code, 0, "{out}"); fx.vlt_ok(&fx.proj, &["ci"]); - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); let (attested, out) = vex(&fx); - assert!(!attested, "pristine after vlt ci without the hook: {out}"); + assert!(!attested, "pristine after vlt ci: {out}"); let out = offline(&fx.proj, &["apply"], &[]); assert_eq!(out.code, 0, "{out}"); } @@ -503,266 +489,6 @@ async fn vlt_pinned_matrix_agent_reruns_and_vex() { fx.leg.ran(); } -// ── setup ───────────────────────────────────────────────────────────────── - -fn setup(dir: &Path, fx: &Fixture, extra: &[&str]) -> SocketOut { - let bin = fx.leg.shim_dir(); - let path = std::env::var_os("PATH").unwrap_or_default(); - let mut parts = vec![bin]; - parts.extend(std::env::split_paths(&path)); - let joined = std::env::join_paths(parts).unwrap(); - let c = cwd(dir); - let mut args = vec!["setup", "--json", "--yes", "--cwd", &c]; - args.extend_from_slice(extra); - socket(dir, &args, &[("PATH", joined.to_str().unwrap())]) -} - -fn with_hook_env(fx: &Fixture) -> VltRun { - let mut run = fx.run.clone().with_shims(); - run.env.push(("SOCKET_NO_CONFIG".into(), "1".into())); - run.env.push(("SOCKET_NO_UPDATE_CHECK".into(), "1".into())); - run.env - .push(("SOCKET_TELEMETRY_DISABLED".into(), "1".into())); - run.env.push(("SOCKET_OFFLINE".into(), "1".into())); - run -} - -fn hook_count(fx: &Fixture) -> usize { - fx.leg - .npx_log() - .iter() - .filter(|l| l.contains("apply")) - .count() -} - -/// With the hook wired: every diff-bearing reify (fresh install, `install -/// `, `ci`) runs it exactly once from rc.13, and the patched bytes land -/// in `.vlt//node_modules/`; a no-op install runs nothing. -#[tokio::test(flavor = "multi_thread")] -#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] -async fn vlt_pinned_matrix_setup_hook_fires_per_reify() { - let Some(leg) = setup_leg("hook_fires_per_reify") else { - return; - }; - if !leg.at_least(ROOT_POSTINSTALL_FROM) { - return leg.skip("root-postinstall-not-run"); - } - let mut shape = Shape::with_bystander(); - shape.pins.push(SCOPED); - let fx = Fixture::build(leg, shape).await; - stage_manifest(&fx.proj, &[fx.t()]); - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - let pkg = std::fs::read_to_string(fx.proj.join("package.json")).unwrap(); - assert!(pkg.contains(HOOK), "{pkg}"); - let run = with_hook_env(&fx); - fx.leg.vlt_ok_with(&fx.proj, &["install"], &run); - assert_eq!(hook_count(&fx), 1, "fresh install: {:?}", fx.leg.npx_log()); - assert_every_copy(&fx.proj, fx.t(), State::Patched); - fx.leg.vlt_ok_with(&fx.proj, &["install"], &run); - assert_eq!(hook_count(&fx), 1, "a no-op install runs no hook"); - fx.leg.vlt_ok_with( - &fx.proj, - &["install", "@isaacs/string-locale-compare@1.1.0"], - &run, - ); - assert_eq!(hook_count(&fx), 2, "install "); - fx.leg.vlt_ok_with(&fx.proj, &["ci"], &run); - assert_eq!(hook_count(&fx), 3, "ci"); - assert_every_copy(&fx.proj, fx.t(), State::Patched); - fx.leg.ran(); -} - -/// `vlt_root_scripts_not_run`: definite through the `vlt` shim before -/// rc.13, absent from rc.13. -#[tokio::test(flavor = "multi_thread")] -#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] -async fn vlt_pinned_matrix_setup_root_scripts_advisory() { - let Some(leg) = setup_leg("root_scripts_advisory") else { - return; - }; - let fx = Fixture::build(leg, Shape::left_pad()).await; - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - let warned = out.stdout.contains("vlt_root_scripts_not_run"); - if fx.leg.at_least(ROOT_POSTINSTALL_FROM) { - assert!(!warned, "{out}"); - } else { - assert!(warned, "{out}"); - assert!( - out.stdout - .contains(&format!("(`vlt --version` reports {})", fx.leg.tc.raw)), - "the definite wording: {out}" - ); - } - fx.leg.ran(); -} - -/// A workspace gets the hook at the root only, and a member-dir `vlt ci` -/// runs the root hook once. -#[tokio::test(flavor = "multi_thread")] -#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] -async fn vlt_pinned_matrix_setup_workspace_root_only() { - let Some(leg) = setup_leg("workspace_root_only") else { - return; - }; - if !leg.at_least(ROOT_POSTINSTALL_FROM) { - return leg.skip("root-postinstall-not-run"); - } - let mut shape = Shape::left_pad(); - shape.deps = vec![]; - shape.vlt_json.workspaces = Some(json!("packages/*")); - shape.files = vec![("packages/a/package.json".into(), package_json("a", &[LP]))]; - let fx = Fixture::build(leg, shape).await; - stage_manifest(&fx.proj, &[fx.t()]); - let member = std::fs::read_to_string(fx.proj.join("packages/a/package.json")).unwrap(); - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - assert!(std::fs::read_to_string(fx.proj.join("package.json")) - .unwrap() - .contains(HOOK)); - assert_eq!( - std::fs::read_to_string(fx.proj.join("packages/a/package.json")).unwrap(), - member, - "the member is untouched" - ); - let run = with_hook_env(&fx); - fx.leg - .vlt_ok_with(&fx.proj.join("packages/a"), &["ci"], &run); - assert_eq!(hook_count(&fx), 1, "{:?}", fx.leg.npx_log()); - assert_eq!( - state_at(&importer_dir(&fx.proj, "packages/a", LP.0), fx.t()), - State::Patched - ); - fx.leg.ran(); -} - -/// `setup` twice adds no duplicate hook. -#[tokio::test(flavor = "multi_thread")] -#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] -async fn vlt_pinned_matrix_setup_twice_no_duplicate() { - let Some(leg) = setup_leg("twice_no_duplicate") else { - return; - }; - let fx = Fixture::build(leg, Shape::left_pad()).await; - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - let once = std::fs::read(fx.proj.join("package.json")).unwrap(); - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - assert_eq!(std::fs::read(fx.proj.join("package.json")).unwrap(), once); - let pkg: Value = serde_json::from_slice(&once).unwrap(); - assert_eq!(pkg["scripts"]["postinstall"], HOOK, "{pkg:#}"); - fx.leg.ran(); -} - -/// With the hook present: a project with no patches installs cleanly; -/// with the patch API unreachable and the blobs not local, the hook's -/// `apply --silent` exits with the contract's `sources_download_failed` -/// code and vlt rolls the install back (a non-zero root script aborts it). -#[tokio::test(flavor = "multi_thread")] -#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] -async fn vlt_pinned_matrix_setup_hook_failure() { - let Some(leg) = setup_leg("hook_failure") else { - return; - }; - if !leg.at_least(ROOT_POSTINSTALL_FROM) { - return leg.skip("root-postinstall-not-run"); - } - let fx = Fixture::build(leg, Shape::with_bystander()).await; - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - let run = with_hook_env(&fx); - fx.leg.vlt_ok_with(&fx.proj, &["install"], &run); - assert_eq!(hook_count(&fx), 1, "the empty hook ran"); - stage_manifest(&fx.proj, &[fx.t()]); - std::fs::remove_dir_all(fx.proj.join(".socket/blobs")).unwrap(); - let dead = "http://127.0.0.1:9"; - let mut cmd = socket_cmd(); - let c = cwd(&fx.proj); - cmd.args([ - "apply", - "--silent", - "--json", - "--ecosystems", - "npm", - "--cwd", - &c, - ]) - .env("SOCKET_PROXY_URL", dead) - .env("SOCKET_API_URL", dead); - let direct = cmd.output().unwrap(); - let text = String::from_utf8_lossy(&direct.stdout).into_owned(); - assert!( - text.contains("sources_download_failed") && text.contains("partialFailure"), - "the contract's warning: {text}" - ); - assert_eq!( - direct.status.code(), - Some(1), - "partialFailure exits 1: {}", - out_text(&direct) - ); - remove_tree(&fx.proj); - let mut run = with_hook_env(&fx); - run.env.retain(|(k, _)| k != "SOCKET_OFFLINE"); - run.env.push(("SOCKET_PROXY_URL".into(), dead.into())); - run.env.push(("SOCKET_API_URL".into(), dead.into())); - let out = fx.leg.vlt_with(&fx.proj, &["install"], &run); - assert_eq!(hook_count(&fx), 2, "the failing hook ran"); - assert!( - !out.status.success(), - "the failing hook aborts the install: {}", - out_text(&out) - ); - fx.leg.ran(); -} - -/// Windows from 1.0.5 (the rollback EBUSY fix): a failing hook's abort -/// leaves no `.VLT.DELETE.*` staging dir the next crawl could misread. -#[tokio::test(flavor = "multi_thread")] -#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] -async fn vlt_pinned_matrix_setup_hook_abort_leaves_no_staging() { - let Some(leg) = setup_leg("hook_abort_leaves_no_staging") else { - return; - }; - if !cfg!(windows) { - return leg.skip("windows-only"); - } - if !leg.at_least(REGISTRIES_NPM_REQUIRED_FROM) { - return leg.skip("pre-ebusy-fix"); - } - let fx = Fixture::build(leg, Shape::with_bystander()).await; - let out = setup(&fx.proj, &fx, &[]); - assert_eq!(out.code, 0, "{out}"); - stage_manifest(&fx.proj, &[fx.t()]); - std::fs::remove_dir_all(fx.proj.join(".socket/blobs")).unwrap(); - let dead = "http://127.0.0.1:9"; - let mut run = with_hook_env(&fx); - run.env.retain(|(k, _)| k != "SOCKET_OFFLINE"); - run.env.push(("SOCKET_PROXY_URL".into(), dead.into())); - run.env.push(("SOCKET_API_URL".into(), dead.into())); - let out = fx.leg.vlt_with(&fx.proj, &["install"], &run); - assert!( - !out.status.success(), - "the failing hook aborts: {}", - out_text(&out) - ); - let store = fx.proj.join("node_modules/.vlt"); - let staging: Vec = std::fs::read_dir(&store) - .map(|rd| { - rd.flatten() - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|n| n.starts_with(".VLT.DELETE")) - .collect() - }) - .unwrap_or_default(); - assert!(staging.is_empty(), "{staging:?}"); - let out = socket_api(&fx.proj, &fx.svc, &["scan"], &[]); - assert!(!out.stdout.contains(".VLT.DELETE"), "{out}"); - fx.leg.ran(); -} - // ── harness ─────────────────────────────────────────────────────────────── /// A stand-in `vlt.js` that spawns one detached child living `child_ms` diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 35bf0a01..a10666ce 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -827,8 +827,7 @@ fn rollback_dispatch_branch_composer() { // The scan macro in `ecosystem_dispatch` prints "Using at: " on // global crawls (`--global` / `--global-prefix`); it goes to stderr so it // can never reach a `--json` envelope or a VEX document on stdout. These -// tests pin that stdout stays pure at the `vex` and `setup --check` call -// sites. `--global-prefix` makes the check deterministic: the npm crawler returns +// tests pin that stdout stays pure at the `vex` call site. `--global-prefix` makes the check deterministic: the npm crawler returns // the prefix verbatim as a node_modules root, so `paths` is never empty. // --------------------------------------------------------------------------- @@ -850,7 +849,6 @@ fn run_scrubbed(cwd: &Path, args: &[&str]) -> (i32, String, String) { "SOCKET_VEX_NO_VERIFY", "SOCKET_VEX_DOC_ID", "SOCKET_VEX_COMPACT", - "SOCKET_SETUP_EXCLUDE", ] { cmd.env_remove(var); } @@ -949,39 +947,6 @@ fn vex_doc_to_stdout_global_prefix_emits_no_chrome_on_stdout() { ); } -/// `setup --check --json` prints its JSON report to stdout after the patch -/// consistency pass, which crawls via the dispatch. The chrome line must -/// not precede (and corrupt) the report. -#[test] -fn setup_check_json_global_prefix_stdout_is_pure_json() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - write_manifest(tmp.path(), "pkg:npm/__dispatch_test__@1.0.0"); - let gp = tmp.path().join("gprefix"); - std::fs::create_dir_all(&gp).unwrap(); - - let (_code, stdout, stderr) = run_scrubbed( - tmp.path(), - &[ - "setup", - "--check", - "--json", - "--global-prefix", - gp.to_str().unwrap(), - ], - ); - - let report: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!( - "setup --check --json stdout must be exactly the JSON report — the \ - dispatch's 'Using at:' chrome must not leak onto stdout ({e}); \ - stdout={stdout:?} stderr={stderr:?}" - ) - }); - assert!(report["status"].is_string(), "stdout={stdout:?}"); - assert!(report["files"].is_array(), "stdout={stdout:?}"); -} - #[test] fn rollback_dispatch_branch_nuget() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 9b2be79a..a2e095e3 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -151,8 +151,10 @@ fn root_command_list_uses_the_verb_form() { "{text}" ); assert!(!text.contains("Rollback patches"), "{text}"); + // v5 removed `setup`; its install-hook summary must not come back. + assert!(!text.contains("install hooks"), "{text}"); assert!( - text.contains("Agent mode: wire install hooks (npm, Python, Bundler, Composer)"), + !text.lines().any(|l| l.trim_start().starts_with("setup ")), "{text}" ); } @@ -167,18 +169,14 @@ fn root_command_list_leads_with_the_v5_workflow() { .filter_map(|l| l.split_whitespace().next()) .filter(|w| { [ - "scan", "vex", "vendor", "list", "get", "apply", "setup", "rollback", "remove", - "repair", + "scan", "vex", "vendor", "list", "get", "apply", "rollback", "remove", "repair", ] .contains(w) }) .collect(); assert_eq!( - &order[..10], - [ - "scan", "vex", "vendor", "list", "get", "apply", "setup", "rollback", "remove", - "repair" - ], + &order[..9], + ["scan", "vex", "vendor", "list", "get", "apply", "rollback", "remove", "repair"], "{text}" ); assert!(text.contains("Typical workflow:"), "{text}"); diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 9b6ff057..6e0141e0 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -13,7 +13,7 @@ use serial_test::serial; use socket_patch_cli::commands::scan::{run, ScanArgs}; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -472,12 +472,7 @@ async fn redirect_vex_verifies_manifest_patches_normally() { "GHSA-ctrl-bad", ), ); - // npm declared `manual` so property-7 admits the controls — what drops - // GHSA-ctrl-bad must be VERIFICATION, not the ecosystem filter. - manifest.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: vec!["npm".to_string()], - }); + // What drops GHSA-ctrl-bad must be VERIFICATION. let socket_dir = tmp.path().join(".socket"); std::fs::create_dir_all(&socket_dir).unwrap(); std::fs::write( diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 859bb611..bfc0d72c 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -1485,8 +1485,7 @@ async fn scan_vendor_dry_run_with_vex_does_not_write_attestation_file() { write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); // A manifest whose sole record WOULD attest successfully: vulnerability - // metadata for the statement, `setup.manual: ["npm"]` to pass the - // property-7 ecosystem filter, and `--vex-no-verify` below to skip the + // metadata for the statement and `--vex-no-verify` below to skip the // on-disk hash check, so only the dry-run gate keeps the document off // disk. let socket = tmp.path().join(".socket"); @@ -1504,7 +1503,7 @@ async fn scan_vendor_dry_run_with_vex_does_not_write_attestation_file() { }}, "description": "x", "license": "MIT", "tier": "free" } - }, "setup": { "manual": ["npm"] } }"#, + } }"#, ) .unwrap(); let before = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); @@ -1581,7 +1580,7 @@ async fn scan_apply_json_dry_run_with_vex_does_not_write_attestation() { write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); // Attestable manifest (same fixture as the vendor twin above): metadata - // for the statement, `setup.manual: ["npm"]`, `--vex-no-verify` below. + // for the statement, `--vex-no-verify` below. let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); std::fs::write( @@ -1597,7 +1596,7 @@ async fn scan_apply_json_dry_run_with_vex_does_not_write_attestation() { }}, "description": "x", "license": "MIT", "tier": "free" } - }, "setup": { "manual": ["npm"] } }"#, + } }"#, ) .unwrap(); diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index ce1fae11..f9b2898d 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -1,8 +1,7 @@ -//! End-to-end tests that drive interactive prompts (`ui::confirm`, -//! `ui::confirm_or_proceed`) via a pseudo-terminal. These exercise the -//! stdin-is-a-terminal-gated confirmation paths in `setup` and `remove` -//! (plus `apply`'s no-manifest message) that subprocess-with-piped-stdin -//! tests can't reach. +//! End-to-end tests that drive interactive prompts (`ui::confirm`) via a +//! pseudo-terminal. These exercise the stdin-is-a-terminal-gated +//! confirmation paths in `remove` (plus `apply`'s no-manifest message) +//! that subprocess-with-piped-stdin tests can't reach. //! //! PTY support: macOS + Linux. Skipped on Windows. @@ -149,163 +148,6 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) (code, String::from_utf8_lossy(&output).to_string()) } -// --------------------------------------------------------------------------- -// `setup` interactive confirmation -// --------------------------------------------------------------------------- - -#[test] -fn setup_interactive_y_proceeds_with_update() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write( - tmp.path().join("package.json"), - r#"{ "name": "p", "version": "1.0.0" }"#, - ) - .unwrap(); - - // Without --yes, setup prompts "Proceed with these changes? [y/N] ". - // Sending "y\n" should make it proceed with the update. - let (code, output) = run_in_pty(&["setup"], tmp.path(), "y\n", Duration::from_secs(15)); - assert_eq!(code, 0, "setup with 'y' must succeed"); - - // The interactive prompt MUST have actually run — otherwise this test - // would pass against a regression that drops the TTY gate and - // auto-proceeds, never exercising the path this file is named for. - assert!( - output.contains("Proceed with these changes?"), - "setup must have shown the interactive confirm prompt; got: {output}" - ); - // A regression that took the non-interactive auto-proceed branch would - // print this banner instead of prompting; it must NOT appear. - assert!( - !output.contains("Non-interactive mode detected"), - "setup must NOT have taken the non-interactive branch in a PTY; got: {output}" - ); - - // package.json should have been updated with a real postinstall hook - // that invokes socket-patch (not merely mention the string somewhere). - let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&pkg) - .unwrap_or_else(|e| panic!("setup must leave valid JSON; err={e}; got: {pkg}")); - let postinstall = parsed["scripts"]["postinstall"] - .as_str() - .unwrap_or_else(|| panic!("setup must write scripts.postinstall; got: {pkg}")); - assert!( - postinstall.contains("socket-patch"), - "postinstall must invoke socket-patch; got: {postinstall}" - ); -} - -#[test] -fn setup_interactive_n_aborts_without_update() { - let tmp = tempfile::tempdir().unwrap(); - let original = r#"{ "name": "p", "version": "1.0.0" } -"#; - std::fs::write(tmp.path().join("package.json"), original).unwrap(); - - let (code, output) = run_in_pty(&["setup"], tmp.path(), "n\n", Duration::from_secs(15)); - assert_eq!(code, 0, "setup with 'n' must exit cleanly"); - // The interactive prompt MUST have run, then aborted. - assert!( - output.contains("Proceed with these changes?"), - "setup must have shown the interactive confirm prompt; got: {output}" - ); - assert!( - !output.contains("Non-interactive mode detected"), - "setup must NOT have taken the non-interactive branch in a PTY; got: {output}" - ); - assert!( - output.contains("Setup cancelled."), - "setup must print abort message; got: {output}" - ); - // It must NOT have started applying changes. - assert!( - !output.contains("Applying changes..."), - "setup 'n' must abort before applying; got: {output}" - ); - - // package.json must be unchanged. - let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert_eq!(pkg, original, "setup 'n' must not modify package.json"); -} - -#[test] -fn setup_interactive_default_no_aborts() { - // Pressing just Enter at the prompt defaults to N (abort). - let tmp = tempfile::tempdir().unwrap(); - let original = r#"{ "name": "p", "version": "1.0.0" } -"#; - std::fs::write(tmp.path().join("package.json"), original).unwrap(); - - let (code, output) = run_in_pty(&["setup"], tmp.path(), "\n", Duration::from_secs(15)); - assert_eq!(code, 0); - // The prompt MUST have run; bare Enter must hit the default-N abort. - // Without these, the test passes vacuously if setup never prompts and - // simply no-ops, never proving the default is "No". - assert!( - output.contains("Proceed with these changes?"), - "setup must have shown the interactive confirm prompt; got: {output}" - ); - assert!( - !output.contains("Non-interactive mode detected"), - "setup must NOT have taken the non-interactive branch in a PTY; got: {output}" - ); - assert!( - output.contains("Setup cancelled."), - "bare-Enter must default to N and print abort; got: {output}" - ); - assert!( - !output.contains("Applying changes..."), - "default-N must abort before applying; got: {output}" - ); - let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert_eq!(pkg, original, "default-N must not modify package.json"); -} - -#[test] -fn setup_interactive_non_utf8_answer_aborts_without_panic() { - // Same regression class as remove_interactive_non_utf8_answer_ - // declines_without_panic below, but for setup's default-no gate - // (`ui::confirm_or_proceed`): a Latin-1 paste (`é` = 0xE9) at - // "Proceed with these changes? [y/N] " makes `read_line` return - // InvalidData, and unwrapping it panics the CLI (exit 101) instead - // of treating the unreadable answer as "not yes" (abort). - let tmp = tempfile::tempdir().unwrap(); - let original = r#"{ "name": "p", "version": "1.0.0" } -"#; - std::fs::write(tmp.path().join("package.json"), original).unwrap(); - - let (code, output) = - run_in_pty_bytes(&["setup"], tmp.path(), b"\xE9\n", Duration::from_secs(15)); - assert!( - !output.contains("panicked"), - "non-UTF-8 answer must not panic the CLI; got: {output}" - ); - assert_eq!( - code, 0, - "non-UTF-8 answer must abort cleanly, not crash; got: {output}" - ); - // The interactive prompt MUST have run (vacuity guard as above), and - // the unreadable answer must land on the default-N abort path. - assert!( - output.contains("Proceed with these changes?"), - "setup must have shown the interactive confirm prompt; got: {output}" - ); - assert!( - !output.contains("Non-interactive mode detected"), - "setup must NOT have taken the non-interactive branch in a PTY; got: {output}" - ); - assert!( - output.contains("Setup cancelled."), - "non-UTF-8 answer must be treated as 'no' and abort; got: {output}" - ); - assert!( - !output.contains("Applying changes..."), - "non-UTF-8 answer must abort before applying; got: {output}" - ); - let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert_eq!(pkg, original, "aborted setup must not modify package.json"); -} - // --------------------------------------------------------------------------- // `remove` interactive confirmation // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index 4d5bba66..6d45eef5 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -647,49 +647,6 @@ fn get_with_explicit_package_flag_works() { assert_eq!(v["found"], 0, "got: {stdout}"); } -// --------------------------------------------------------------------------- -// setup — non-JSON paths -// --------------------------------------------------------------------------- - -#[test] -fn setup_no_files_non_json_prints_friendly_message() { - let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["setup"], &[]); - assert_eq!(code, 0); - assert!( - stdout.contains("No package.json"), - "non-JSON setup must report missing package.json; got: {stdout}" - ); -} - -#[test] -fn setup_dry_run_non_json_prints_preview() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write( - tmp.path().join("package.json"), - r#"{ "name": "p", "version": "1.0.0" }"#, - ) - .unwrap(); - let before = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - let (code, stdout, _stderr) = - common::run_with_env(tmp.path(), &["setup", "--dry-run", "--yes"], &[]); - assert_eq!(code, 0); - assert!( - stdout.contains("would be updated") && stdout.contains("postinstall"), - "non-JSON setup dry-run should preview the postinstall hook; got: {stdout}" - ); - // Dry-run must NOT actually write the postinstall hook into the file. - let after = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert_eq!( - before, after, - "setup --dry-run must leave package.json untouched" - ); - assert!( - !after.contains("postinstall"), - "setup --dry-run must not write a postinstall hook; got: {after}" - ); -} - // --------------------------------------------------------------------------- // Bare-UUID fallback — `socket-patch ` rewrites to `get ` // --------------------------------------------------------------------------- @@ -737,7 +694,7 @@ fn bare_uuid_fallback_treats_uuid_as_get_identifier() { fn each_subcommand_help_prints_usage() { let tmp = tempfile::tempdir().unwrap(); let subcommands = [ - "apply", "rollback", "get", "scan", "list", "remove", "setup", "repair", "gc", + "apply", "rollback", "get", "scan", "list", "remove", "repair", "gc", ]; for sub in subcommands { let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &[sub, "--help"], &[]); @@ -755,7 +712,7 @@ fn top_level_help_prints_all_subcommands() { let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["--help"], &[]); assert_eq!(code, 0); for sub in [ - "apply", "rollback", "get", "scan", "list", "remove", "setup", "repair", + "apply", "rollback", "get", "scan", "list", "remove", "repair", ] { assert!( stdout.contains(sub), diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs deleted file mode 100644 index c314af77..00000000 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ /dev/null @@ -1,843 +0,0 @@ -//! **Executable spec for the once-unimplemented parts of the `setup` contract.** -//! -//! Every test in this file encodes a property from the "Setup command contract" -//! section of `crates/socket-patch-cli/CLI_CONTRACT.md` that the binary did not -//! originally satisfy. They began life intentionally RED (executable -//! documentation of the open gaps); every property here has since SHIPPED — -//! see the per-section comments — so today these are ordinary, active -//! regression guards. A failure now IS a regression. Do not "fix" one by -//! weakening the assertions. -//! -//! Each test names the property it guards. - -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -/// Run the binary with every ambient `SOCKET_*` var scrubbed (prefix scrub — -/// a fixed list rots as flags grow: ambient `SOCKET_GLOBAL=true` alone sent -/// the patch-consistency crawl to the global prefix, hiding the drifted -/// package and turning the prop-4 test green-for-the-wrong-reason; ambient -/// `SOCKET_ECOSYSTEMS` would likewise defeat the prop-2 scoping test), -/// telemetry off, and HOME pointed at `home`. Returns (exit code, stdout). -fn run(cwd: &Path, home: &Path, args: &[&str]) -> (i32, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - for (name, _) in std::env::vars() { - if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { - cmd.env_remove(name); - } - } - cmd.env("HOME", home); - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - let out = cmd.output().expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - ) -} - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -/// git-style blob SHA-256 (matches the manifest's beforeHash/afterHash scheme). -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()) -} - -// =========================================================================== -// Property 2 — ecosystem-scoped. `setup --ecosystems npm` must act on ONLY the -// npm manifest, leaving the python (and cargo) manifests untouched. -// -// SHIPPED: `setup` now honors `--ecosystems` via the `eco_in_scope` gating in -// commands/setup.rs (discover / plan_python / build_*_outcome / append_*_check). -// This pin is now an active (non-ignored) regression guard. -// =========================================================================== - -#[test] -fn setup_ecosystems_filter_scopes_work_to_named_ecosystem() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write( - &proj.path().join("package.json"), - r#"{ "name": "x", "version": "1.0.0" }"#, - ); - let original_requirements = "requests==2.31.0\n"; - write(&proj.path().join("requirements.txt"), original_requirements); - - let (code, stdout) = run( - proj.path(), - home.path(), - &["setup", "--json", "--yes", "--ecosystems", "npm"], - ); - assert_eq!( - code, 0, - "scoped setup should still succeed; stdout=\n{stdout}" - ); - - // The npm side IS in scope and must be configured (proves the run happened). - assert!( - std::fs::read_to_string(proj.path().join("package.json")) - .unwrap() - .contains("socket-patch"), - "the in-scope npm manifest must be configured" - ); - - // The python manifest is OUT of scope and must be left byte-for-byte. - let req = std::fs::read_to_string(proj.path().join("requirements.txt")).unwrap(); - assert_eq!( - req, original_requirements, - "`--ecosystems npm` must NOT touch the python manifest (property 2); got:\n{req}" - ); -} - -// =========================================================================== -// Property 4 — `check` proves a correctly-patched state. With the install hook -// present but a manifest patch NOT applied on disk (file hash != afterHash), -// `setup --check` must report needs-configuration / exit non-zero. -// -// SHIPPED: `run_check` now also verifies on-disk patch consistency via -// `append_patch_consistency_entries` (reads `.socket/manifest.json`, resolves -// installed package paths, and runs the `applied_patches` afterHash check), so a -// hooked-but-unpatched repo reports `needs_configuration` / exit 1. This pin is -// now an active (non-ignored) regression guard. -// =========================================================================== - -#[test] -fn setup_check_detects_unapplied_manifest_patch() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - - // Wire the npm install hook (so hook-presence alone would say "configured"). - write( - &proj.path().join("package.json"), - r#"{ "name": "x", "version": "1.0.0" }"#, - ); - let (c, _) = run(proj.path(), home.path(), &["setup", "--json", "--yes"]); - assert_eq!(c, 0, "precondition: initial setup wires the hook"); - - // An installed npm package whose on-disk file does NOT match the manifest's - // afterHash — i.e. the patch is present in the manifest but not applied. - let original = b"original\n"; - let patched = b"patched\n"; - let on_disk = b"DRIFTED-not-the-patched-content\n"; - let pkg = proj.path().join("node_modules/badpkg"); - write( - &pkg.join("package.json"), - r#"{ "name": "badpkg", "version": "1.0.0" }"#, - ); - write(&pkg.join("index.js"), &String::from_utf8_lossy(on_disk)); - - write( - &proj.path().join(".socket/manifest.json"), - &format!( - r#"{{ "patches": {{ - "pkg:npm/badpkg@1.0.0": {{ - "uuid": "11111111-1111-4111-8111-111111111111", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} }}, - "vulnerabilities": {{ "GHSA-aaaa-bbbb-cccc": {{ "cves": ["CVE-2024-0001"], "summary": "x", "severity": "high", "description": "d" }} }}, - "description": "d", "license": "MIT", "tier": "free" - }} -}} }}"#, - before = git_sha256(original), - after = git_sha256(patched), - ), - ); - - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - // A repo with the hook wired but the patch NOT applied on disk is NOT in a - // correctly-patched state, so --check must fail. - assert_eq!( - code, 1, - "check must fail when a manifest patch is unapplied on disk (property 4); stdout=\n{stdout}" - ); - assert_ne!( - v["status"], "configured", - "check must NOT report `configured` for a hooked-but-unpatched repo; stdout=\n{stdout}" - ); -} - -// =========================================================================== -// Property 4 (vendored) — the patch-consistency pass must judge a VENDORED -// patch by its committed `.socket/vendor/` artifact, which core's -// `applied_patches_with_vendor` contract makes the SOLE evidence: an -// unpatched installed tree is EXPECTED after vendoring (the next install -// re-materializes it from the artifact; go redirects leave the module cache -// pristine forever), and a patched-looking installed tree must not launder a -// tampered artifact. `vex` builds that vendor context -// (commands/vex.rs::vendor_context_from); `setup --check` must too — without -// it a healthy vendored repo false-fails `--check` with `not_applied`, and a -// tampered artifact passes. -// =========================================================================== - -/// Canonical-grammar patch UUID — the vendored-artifact verifier validates -/// the uuid path level against the record, so fixtures must use the real -/// shape (mirrors e2e_vex_vendor.rs). -const VENDOR_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; - -/// Lay down the shared vendored fixture: hook wired, an installed -/// `node_modules/vendpkg` at `installed` bytes, a committed dir-shaped -/// vendored artifact at `vendored` bytes, the `.socket/vendor/state.json` -/// ledger entry binding the purl to it, and the patch record whose -/// afterHash is the hash of `patched` — in `.socket/manifest.json` for the -/// legacy manifest-tracked shape, or (`detached`) embedded in the ledger -/// entry with NO manifest at all: the manifest-free posture every -/// `scan`/`get --mode vendored` run writes. -fn setup_vendored_fixture( - proj: &Path, - home: &Path, - installed: &[u8], - vendored: &[u8], - detached: bool, -) { - use socket_patch_core::manifest::schema::PatchRecord; - use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; - - write( - &proj.join("package.json"), - r#"{ "name": "x", "version": "1.0.0" }"#, - ); - let (c, _) = run(proj, home, &["setup", "--json", "--yes"]); - assert_eq!(c, 0, "precondition: initial setup wires the hook"); - - let original = b"original\n"; - let patched = b"patched\n"; - - let pkg = proj.join("node_modules/vendpkg"); - write( - &pkg.join("package.json"), - r#"{ "name": "vendpkg", "version": "1.0.0" }"#, - ); - write(&pkg.join("index.js"), &String::from_utf8_lossy(installed)); - - // The committed vendored artifact (dir-shaped copy). - let rel = format!(".socket/vendor/npm/{VENDOR_UUID}/vendpkg-1.0.0"); - write( - &proj.join(&rel).join("index.js"), - &String::from_utf8_lossy(vendored), - ); - - let record_json = format!( - r#"{{ - "uuid": "{VENDOR_UUID}", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} }}, - "vulnerabilities": {{ "GHSA-aaaa-bbbb-cccc": {{ "cves": ["CVE-2024-0001"], "summary": "x", "severity": "high", "description": "d" }} }}, - "description": "d", "license": "MIT", "tier": "free" - }}"#, - before = git_sha256(original), - after = git_sha256(patched), - ); - - let mut state = VendorState::new(); - state.entries.insert( - "pkg:npm/vendpkg@1.0.0".to_string(), - VendorEntry { - ecosystem: "npm".to_string(), - base_purl: "pkg:npm/vendpkg@1.0.0".to_string(), - uuid: VENDOR_UUID.to_string(), - artifact: VendorArtifact { - path: rel, - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - wiring: Vec::new(), - lock: None, - took_over_go_patches: false, - detached, - record: detached.then(|| { - serde_json::from_str::(&record_json).expect("record fixture") - }), - flavor: None, - uv: None, - pnpm: None, - poetry: None, - pdm: None, - pipenv: None, - }, - ); - write( - &proj.join(".socket/vendor/state.json"), - &serde_json::to_string_pretty(&state).unwrap(), - ); - - if detached { - assert!( - !proj.join(".socket/manifest.json").exists(), - "the detached fixture is manifest-free by construction" - ); - } else { - write( - &proj.join(".socket/manifest.json"), - &format!(r#"{{ "patches": {{ "pkg:npm/vendpkg@1.0.0": {record_json} }} }}"#), - ); - } -} - -#[test] -fn setup_check_judges_vendored_patch_by_committed_artifact() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - - // Healthy vendored state: the artifact carries the patch; the installed - // tree still holds the ORIGINAL bytes (expected until the next install). - setup_vendored_fixture(proj.path(), home.path(), b"original\n", b"patched\n", false); - - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - code, 0, - "check must trust the committed vendored artifact — an unpatched \ - installed tree is EXPECTED after vendoring, not drift; stdout=\n{stdout}" - ); - assert_eq!( - v["status"], "configured", - "a healthy vendored repo must report `configured`; stdout=\n{stdout}" - ); -} - -#[test] -fn setup_check_flags_tampered_vendored_artifact_despite_patched_tree() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - - // Laundering attempt: the committed artifact was tampered with, but the - // installed tree LOOKS patched. The artifact is the sole evidence — the - // consumed bytes on the next install — so check must fail. - setup_vendored_fixture(proj.path(), home.path(), b"patched\n", b"TAMPERED\n", false); - - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - code, 1, - "a tampered vendored artifact must fail check even when the installed \ - tree looks patched; stdout=\n{stdout}" - ); - assert_ne!( - v["status"], "configured", - "a patched-looking installed tree must not launder a tampered vendor \ - artifact; stdout=\n{stdout}" - ); -} - -// =========================================================================== -// Property 4 (vendored, manifest-free) — vendored mode writes NO manifest: -// the ledger entry is `detached` and carries the only copy of the patch -// record. `setup --check` must judge those exactly like manifest-backed -// vendored patches (it folds the ledger's embedded records in, as `vex` -// does); without the fold a vendored-only project with a missing or -// tampered committed artifact reported `configured`. -// =========================================================================== - -#[test] -fn setup_check_judges_detached_vendored_patch_without_manifest() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - setup_vendored_fixture(proj.path(), home.path(), b"original\n", b"patched\n", true); - - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - code, 0, - "a healthy detached vendored patch (committed artifact carries the patch) \ - is a correctly-patched state; stdout=\n{stdout}" - ); - assert_eq!(v["status"], "configured", "stdout=\n{stdout}"); -} - -#[test] -fn setup_check_flags_tampered_detached_vendored_artifact_without_manifest() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - setup_vendored_fixture(proj.path(), home.path(), b"patched\n", b"TAMPERED\n", true); - - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - code, 1, - "a tampered detached vendored artifact must fail check even with no \ - manifest and a patched-looking installed tree; stdout=\n{stdout}" - ); - assert_eq!(v["status"], "needs_configuration", "stdout=\n{stdout}"); - assert!( - v["files"] - .as_array() - .is_some_and(|files| files.iter().any(|f| { - f["kind"] == "patch" - && f["path"] == "pkg:npm/vendpkg@1.0.0" - && f["status"] == "needs_configuration" - })), - "the drifted vendored purl must be named as a `patch` entry; stdout=\n{stdout}" - ); -} - -// =========================================================================== -// Property 7 — reflected in VEX. A patch contributes a VEX statement only for an -// ecosystem that is actually set up (or declared `manual`). Here the manifest -// has a pypi patch but pypi is NOT set up (no requirements.txt / pyproject hook), -// so the document must contain zero statements (exit 1, no applicable patches). -// -// SHIPPED: VEX now filters by setup state — `generate_vex` drops patches whose -// ecosystem is neither set up (`commands/setup::configured_ecosystems`) nor -// declared `manual` in the manifest's `setup.manual`. With pypi un-set-up and -// not manual, the only patch is dropped → no applicable patches → exit 1. This -// pin is now an active (non-ignored) regression guard. -// -// (The converse — declaring pypi `manual` to re-include it — is exercised by the -// `manual` escape hatch the e2e_vex / e2e_embedded_vex fixtures rely on.) -// =========================================================================== - -#[test] -fn vex_omits_patches_for_unconfigured_ecosystem() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - - // A pypi patch in the manifest, but NOTHING is set up in this repo (no - // package.json, no requirements.txt, no pyproject.toml). - write( - &proj.path().join(".socket/manifest.json"), - r#"{ "patches": { - "pkg:pypi/badpkg@1.0.0": { - "uuid": "11111111-1111-4111-8111-111111111111", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "badpkg/__init__.py": { "beforeHash": "aaaa", "afterHash": "bbbb" } }, - "vulnerabilities": { "GHSA-xxxx-xxxx-xxxx": { "cves": ["CVE-2024-0001"], "summary": "x", "severity": "high", "description": "d" } }, - "description": "d", "license": "MIT", "tier": "free" - } -} }"#, - ); - - let out = proj.path().join("out.json"); - let (code, stdout) = run( - proj.path(), - home.path(), - &[ - "vex", - "--no-verify", - "--product", - "pkg:pypi/myapp@1.0.0", - "--output", - out.to_str().unwrap(), - ], - ); - - // pypi is not set up here, so its patch must not be attested. With no other - // patches that means no applicable patches at all → exit 1, no document. - let statements = std::fs::read_to_string(&out) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v["statements"].as_array().map(|a| a.len())) - .unwrap_or(0); - assert_eq!( - statements, 0, - "VEX must omit patches for an un-set-up ecosystem (property 7); stdout=\n{stdout}" - ); - assert_eq!( - code, 1, - "with the only patch belonging to an un-set-up ecosystem, vex must report \ - no-applicable-patches (exit 1); stdout=\n{stdout}" - ); -} - -// =========================================================================== -// Property 9 (exclude) — SHIPPED. `setup --exclude ` skips that member -// and PERSISTS the exclusion under `.socket/manifest.json`'s `setup.exclude`, so -// a later `--check` (and a fresh clone) honor it without re-passing the flag. -// This pin is now an active (non-ignored) regression guard. -// =========================================================================== - -#[test] -fn setup_honors_exclude_for_a_workspace_member() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - // npm workspace: root + two members. - write( - &proj.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write( - &proj.path().join("packages/a/package.json"), - r#"{ "name": "a", "version": "1.0.0" }"#, - ); - write( - &proj.path().join("packages/b/package.json"), - r#"{ "name": "b", "version": "1.0.0" }"#, - ); - - let read = |p: PathBuf| std::fs::read_to_string(p).unwrap(); - - // Setup, excluding packages/b. - let (code, stdout) = run( - proj.path(), - home.path(), - &["setup", "--json", "--yes", "--exclude", "packages/b"], - ); - assert_eq!(code, 0, "scoped setup should succeed:\n{stdout}"); - - // Root + packages/a configured; packages/b left untouched. - assert!( - read(proj.path().join("package.json")).contains("socket-patch"), - "the root must be configured (never excludable)" - ); - assert!( - read(proj.path().join("packages/a/package.json")).contains("socket-patch"), - "the included member packages/a must be configured" - ); - assert!( - !read(proj.path().join("packages/b/package.json")).contains("socket-patch"), - "the EXCLUDED member packages/b must NOT be configured" - ); - - // The exclusion is persisted under `setup.exclude` in the manifest. - 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 = mv["setup"]["exclude"] - .as_array() - .unwrap_or_else(|| panic!("manifest must carry setup.exclude:\n{manifest}")); - assert!( - excl.iter().any(|v| v == "packages/b"), - "the exclusion must persist in the manifest:\n{manifest}" - ); - - // A fresh `--check` WITHOUT re-passing --exclude honors the persisted set: - // the excluded member must not count as needing configuration → `configured`. - let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); - assert_eq!( - code, 0, - "check must pass — the excluded member must not be flagged as needing setup:\n{stdout}" - ); - let cv: serde_json::Value = serde_json::from_str(&stdout).expect("check JSON"); - assert_eq!( - cv["status"], "configured", - "check must report `configured`, honoring the persisted exclude:\n{stdout}" - ); - assert!( - !cv["files"] - .as_array() - .unwrap() - .iter() - .any(|f| f["path"].as_str().is_some_and(|p| p.contains("packages/b"))), - "the excluded member must not appear among the checked files:\n{stdout}" - ); -} - -/// `--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(); - // A real `packages/b` member: an `--exclude` that matches nothing is - // dropped before persistence, which would make this test vacuous. - write( - &proj.path().join("package.json"), - r#"{ "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }"#, - ); - write( - &proj.path().join("packages/b/package.json"), - r#"{ "name": "b", "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}" - ); - // 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!( - 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(); - // A real `packages/b` member: an `--exclude` that matches nothing is - // dropped before persistence, which would make this test vacuous. - write( - &proj.path().join("package.json"), - r#"{ "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }"#, - ); - write( - &proj.path().join("packages/b/package.json"), - r#"{ "name": "b", "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}" - ); -} - -/// 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}" - ); -} - -// =========================================================================== -// Additive contract values for vlt: the setup envelope's `packageManager` -// and the `patch_setup` telemetry `manager` both say "vlt" for a vlt project -// (the hook itself is npm's `npx` form). -// =========================================================================== - -#[tokio::test] -async fn setup_reports_vlt_in_the_envelope_and_telemetry() { - use wiremock::MockServer; - - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - let path = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("package.json"), - "{\n \"name\": \"vlt-proj\",\n \"version\": \"1.0.0\"\n}\n", - ); - write(&tmp.path().join("vlt.json"), "{}\n"); - - let mut cmd = Command::new(binary()); - cmd.args(["setup", "--json", "--yes"]) - .current_dir(tmp.path()); - for (name, _) in std::env::vars() { - if name.starts_with("SOCKET_") || name == "VITEST" { - cmd.env_remove(name); - } - } - cmd.env("HOME", home.path()) - .env("PATH", path.path()) - .env("SOCKET_NO_CONFIG", "1") - .env("SOCKET_NO_UPDATE_CHECK", "1") - .env("SOCKET_TELEMETRY_DISABLED", "0") - .env("SOCKET_PROXY_URL", server.uri()); - let out = cmd.output().expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(out.status.code(), Some(0), "stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - - let reqs = server.received_requests().await.unwrap_or_default(); - let events: Vec = reqs - .iter() - .filter(|r| r.url.path() == "/patch/telemetry") - .map(|r| serde_json::from_slice(&r.body).expect("telemetry body is JSON")) - .collect(); - assert_eq!(events.len(), 1, "one patch_setup event: {events:?}"); - assert_eq!(events[0]["event_type"], "patch_setup", "{}", events[0]); - assert_eq!(events[0]["metadata"]["manager"], "vlt", "{}", events[0]); -} diff --git a/crates/socket-patch-cli/tests/setup_invariants.rs b/crates/socket-patch-cli/tests/setup_invariants.rs deleted file mode 100644 index a69f945a..00000000 --- a/crates/socket-patch-cli/tests/setup_invariants.rs +++ /dev/null @@ -1,1427 +0,0 @@ -//! Integration tests for `setup` against handcrafted `package.json` -//! fixtures. `setup` operates entirely on disk (lockfile detection + -//! package.json mutation) so every path is runnable without network. - -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -/// Recursively collect every regular-file path under `dir`, relative to `dir`. -/// Used to prove `setup` writes nothing outside the repo (property 5) and to -/// snapshot a "clone" (property 6). -fn files_under(dir: &Path) -> BTreeSet { - fn walk(base: &Path, dir: &Path, out: &mut BTreeSet) { - if let Ok(rd) = std::fs::read_dir(dir) { - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - walk(base, &p, out); - } else { - out.insert(p.strip_prefix(base).unwrap().to_string_lossy().to_string()); - } - } - } - } - let mut out = BTreeSet::new(); - walk(dir, dir, &mut out); - out -} - -/// Copy every file under `src` into `dst` (recreating directories). Simulates a -/// fresh `git clone` of the committed tree onto another host. -fn copy_tree(src: &Path, dst: &Path) { - for rel in files_under(src) { - let from = src.join(&rel); - let to = dst.join(&rel); - if let Some(parent) = to.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::copy(&from, &to).expect("copy file"); - } -} - -/// Build a `setup` invocation with every ambient `SOCKET_*` env var scrubbed -/// by prefix. These tests drive `setup` purely through flags and on-disk -/// fixtures, so ANY `SOCKET_*` fallback leaking in from the developer's shell -/// or CI would let an assertion pass (or fail) for the wrong reason — e.g. an -/// ambient `SOCKET_DRY_RUN=true` would keep a regressed `--check`/`--yes` path -/// from writing (satisfying the "must not modify" checks vacuously), and an -/// ambient `SOCKET_ECOSYSTEMS`/`SOCKET_YES`/`SOCKET_CWD` would silently change -/// which manifest is touched and how the script is rendered. A fixed name -/// list is the trap the sibling suites already fell into (remove_network.rs -/// missed `SOCKET_SKIP_ROLLBACK`; this file's list missed -/// `SOCKET_SETUP_EXCLUDE`, setup's own env-bound `--exclude` fallback, which -/// silently dropped workspace members AND persisted the ambient exclude into -/// `.socket/manifest.json`), so scrub by prefix like common::run_with_env. -/// Telemetry opt-outs are deliberately kept: they only suppress phone-home -/// and cannot steer the behaviour under test, and an opted-out dev should -/// stay opted out. -fn setup_command(cwd: &Path, args: &[&str]) -> Command { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" - { - cmd.env_remove(&key); - } - } - cmd -} - -fn run_setup(cwd: &Path, extra: &[&str]) -> (i32, String) { - let mut args = vec!["setup", "--json"]; - args.extend_from_slice(extra); - let out = setup_command(cwd, &args) - .output() - .expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - ) -} - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -// --------------------------------------------------------------------------- -// Empty project -// --------------------------------------------------------------------------- - -#[test] -fn setup_no_package_json_emits_no_files_status() { - let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0, "no files should still exit 0; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "no_files"); - assert_eq!(v["updated"], 0); - assert_eq!(v["alreadyConfigured"], 0); - assert_eq!(v["errors"], 0); -} - -// --------------------------------------------------------------------------- -// Single package.json without socket-patch -// --------------------------------------------------------------------------- - -#[test] -fn setup_dry_run_does_not_modify_package_json() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - let original = r#"{ - "name": "test-proj", - "version": "1.0.0" -} -"#; - write(&pkg, original); - - let (code, stdout) = run_setup(tmp.path(), &["--dry-run"]); - assert_eq!(code, 0, "dry-run should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "dry_run"); - assert_eq!(v["dryRun"], true); - assert_eq!(v["wouldUpdate"], 1); - - // package.json must be byte-identical after dry-run. - let after = std::fs::read_to_string(&pkg).expect("read package.json"); - assert_eq!(after, original, "dry-run must not modify package.json"); -} - -#[test] -fn setup_yes_writes_postinstall_script() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - write( - &pkg, - r#"{ "name": "test-proj", "version": "1.0.0" } -"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "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_eq!(v["updated"], 1); - - let after = std::fs::read_to_string(&pkg).expect("read package.json"); - let parsed: serde_json::Value = serde_json::from_str(&after).expect("valid package.json"); - let postinstall = parsed["scripts"]["postinstall"] - .as_str() - .expect("postinstall script must be set"); - // No lockfile present → npm, which invokes the patch via `npx` and applies - // the npm ecosystem. Lock the actual command so a no-op/garbage script - // can't pass on a bare substring. - assert!( - postinstall.contains("npx @socketsecurity/socket-patch apply"), - "npm postinstall must invoke the patch via npx; got: {postinstall}" - ); - assert!( - postinstall.contains("--ecosystems npm"), - "npm postinstall must scope to the npm ecosystem; got: {postinstall}" - ); - // setup also wires the `dependencies` lifecycle script (covers `npm install - // ` which skips postinstall); it must be present and equal. - let deps = parsed["scripts"]["dependencies"] - .as_str() - .expect("dependencies lifecycle script must be set"); - assert_eq!( - deps, postinstall, - "the dependencies hook must mirror the postinstall hook; got: {deps}" - ); - // The original `name`/`version` must be preserved, not clobbered. - assert_eq!(parsed["name"], "test-proj"); - assert_eq!(parsed["version"], "1.0.0"); -} - -#[test] -fn setup_already_configured_returns_idempotent_status() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - - // First setup run wires up the scripts. - write( - &pkg, - r#"{ "name": "test-proj", "version": "1.0.0" } -"#, - ); - let (code1, _) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code1, 0); - - // Second run should detect the config is already there. - let (code2, stdout2) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code2, 0, "second run should succeed; stdout=\n{stdout2}"); - let v: serde_json::Value = serde_json::from_str(&stdout2).expect("valid JSON"); - assert_eq!(v["status"], "already_configured"); - assert_eq!(v["updated"], 0); - assert_eq!(v["alreadyConfigured"], 1); -} - -// --------------------------------------------------------------------------- -// Package manager detection -// --------------------------------------------------------------------------- - -#[test] -fn setup_detects_pnpm_from_lockfile() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "test-proj", "version": "1.0.0" } -"#, - ); - write( - &tmp.path().join("pnpm-lock.yaml"), - "lockfileVersion: '9.0'\n", - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "setup should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["packageManager"], "pnpm"); - - // pnpm dlx should appear in the generated postinstall. - let after = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert!( - after.contains("pnpm dlx"), - "pnpm projects should use `pnpm dlx`; got: {after}" - ); -} - -#[test] -fn setup_defaults_to_npm_when_no_lockfile() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "test-proj", "version": "1.0.0" } -"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "setup should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["packageManager"], "npm"); - assert_eq!(v["status"], "success"); - - // The written script must use npm's `npx`, never `pnpm dlx` — otherwise - // "detected npm" in the envelope wouldn't match what got written. - let after = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert!( - after.contains("npx @socketsecurity/socket-patch"), - "npm projects must use `npx`; got: {after}" - ); - assert!( - !after.contains("pnpm dlx"), - "npm projects must NOT use `pnpm dlx`; got: {after}" - ); -} - -// --------------------------------------------------------------------------- -// Monorepo handling -// --------------------------------------------------------------------------- - -#[test] -fn setup_pnpm_monorepo_only_updates_root() { - // pnpm workspaces: setup intentionally skips workspace-level - // package.json files (their postinstall would fail because the - // workspace pkg doesn't depend on @socketsecurity/socket-patch). - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "monorepo-root", "version": "1.0.0" } -"#, - ); - write( - &tmp.path().join("pnpm-lock.yaml"), - "lockfileVersion: '9.0'\n", - ); - write( - &tmp.path().join("pnpm-workspace.yaml"), - "packages:\n - 'packages/*'\n", - ); - write( - &tmp.path().join("packages/a/package.json"), - r#"{ "name": "a", "version": "1.0.0" } -"#, - ); - write( - &tmp.path().join("packages/b/package.json"), - r#"{ "name": "b", "version": "1.0.0" } -"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "monorepo setup should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - v["updated"], 1, - "only the root package.json should be touched in a pnpm monorepo" - ); - - // The envelope must list exactly the root entry, not the workspace members. - let files = v["files"].as_array().expect("files array"); - assert_eq!( - files.len(), - 1, - "only the root package.json should appear in files[]; got: {files:?}" - ); - let touched = files[0]["path"].as_str().unwrap(); - assert!( - !touched.contains("packages/a") && !touched.contains("packages/b"), - "the touched file must be the root, not a workspace member; got: {touched}" - ); - - // Both workspace packages must NOT have been modified. - for member in ["packages/a/package.json", "packages/b/package.json"] { - let content = std::fs::read_to_string(tmp.path().join(member)).unwrap(); - assert!( - !content.contains("socket-patch"), - "workspace package.json {member} must not be touched; got: {content}" - ); - } -} - -// --------------------------------------------------------------------------- -// Per-file JSON shape — locks the schema of `files[*]` entries -// --------------------------------------------------------------------------- - -#[test] -fn setup_yes_json_files_entry_has_expected_keys() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "test-proj", "version": "1.0.0" } -"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "setup should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - let files = v["files"].as_array().expect("files array"); - assert_eq!(files.len(), 1); - let entry = &files[0]; - // Lock the actual values, not just the types — an entry of - // {"path": "", "status": "error"} would satisfy `is_string()`. - assert_eq!(entry["kind"], "package_json", "entry: {entry}"); - assert_eq!( - entry["status"], "updated", - "the single updated file must report status=updated; entry: {entry}" - ); - let path = entry["path"].as_str().expect("path string"); - assert!( - path.ends_with("package.json"), - "path must point at the package.json we wrote; got: {path}" - ); - assert!( - entry["error"].is_null(), - "a successfully updated file must carry no error; entry: {entry}" - ); -} - -// --------------------------------------------------------------------------- -// Error handling — a malformed package.json must NOT be reported as success. -// -// When nothing was updatable but a file errored (e.g. invalid JSON), `setup` -// must not emit `status: "already_configured"` with exit 0; a parse error -// must surface as a non-zero exit. -// --------------------------------------------------------------------------- - -#[test] -fn setup_malformed_package_json_reports_error_and_exits_nonzero() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), "not valid json!!!"); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!( - code, 1, - "a malformed package.json must exit non-zero; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - v["status"], "error", - "must not be reported as already_configured" - ); - assert_eq!(v["updated"], 0); - assert_eq!(v["alreadyConfigured"], 0); - assert_eq!(v["errors"], 1); - let files = v["files"].as_array().expect("files array"); - assert_eq!(files[0]["status"], "error"); - assert!(files[0]["error"].is_string()); -} - -#[test] -fn setup_malformed_does_not_claim_already_configured_in_human_mode() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), "not valid json!!!"); - - // Human (non-JSON) mode: the misleading "All package.json files are - // already configured" line must not appear when a file errored. - let out = setup_command(tmp.path(), &["setup", "--yes"]) - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!( - out.status.code(), - Some(1), - "human mode must exit 1; stdout=\n{stdout}" - ); - assert!( - !stdout.contains("already configured with socket-patch"), - "must not falsely claim everything is already configured; stdout=\n{stdout}" - ); - // And it must positively surface that the file could not be processed — - // otherwise a silent (but still exit-1) run would slip past the negative - // check above. - assert!( - stdout.contains("could not be processed"), - "human mode must report the unprocessable file; stdout=\n{stdout}" - ); -} - -#[test] -fn setup_dry_run_with_error_exits_nonzero() { - // A valid root (would-update) alongside a malformed workspace member: - // dry-run must still surface the parse error via a non-zero exit rather - // than masking it behind the `dry_run` status. - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] } -"#, - ); - write(&tmp.path().join("packages/a/package.json"), "{bad json"); - - let (code, stdout) = run_setup(tmp.path(), &["--dry-run"]); - assert_eq!( - code, 1, - "dry-run with an error must exit non-zero; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "dry_run"); - assert_eq!(v["errors"], 1); - assert_eq!(v["wouldUpdate"], 1); - - // dry-run must not have written anything. - let root = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert!( - !root.contains("socket-patch"), - "dry-run must not modify files" - ); -} - -#[test] -fn setup_partial_failure_exits_nonzero_when_applying() { - // One updatable file + one malformed file, applied for real (--yes): - // the run must report partial_failure and exit 1. - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] } -"#, - ); - write(&tmp.path().join("packages/a/package.json"), "{bad json"); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!( - code, 1, - "partial failure must exit non-zero; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "partial_failure"); - assert_eq!(v["updated"], 1); - assert_eq!(v["errors"], 1); - - // The valid root file should have been written. - let root = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - assert!( - root.contains("socket-patch"), - "valid file should still be updated" - ); -} - -// --------------------------------------------------------------------------- -// `setup --check` — read-only verification -// --------------------------------------------------------------------------- - -#[test] -fn setup_check_configured_project_exits_zero() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - write(&pkg, r#"{ "name": "x", "version": "1.0.0" }"#); - // Configure it first. - let (c, _) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(c, 0); - - let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!( - code, 0, - "configured project should pass --check; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "configured"); - assert_eq!(v["needsConfiguration"], 0); - assert_eq!(v["errors"], 0); - // The package.json must be counted as configured, not silently absent. - assert_eq!( - v["configured"], 1, - "the lone manifest must be counted; stdout=\n{stdout}" - ); - let files = v["files"].as_array().expect("files array"); - assert_eq!(files.len(), 1); - assert_eq!(files[0]["status"], "configured"); -} - -/// npm and Node strip a UTF-8 BOM in package.json — files saved by Windows -/// editors commonly carry one — and every other setup surface already -/// tolerates it: `setup` wires a BOM'd file (update.rs pins) and reports a -/// BOM'd configured file `already_configured`. `--check` must agree: a BOM'd, -/// fully-configured file is `configured` (exit 0), NOT `Error: Invalid -/// package.json` (exit 1). Regression guard: `run_check` raw-parsed the file -/// without stripping the BOM, so the same file `setup` calls configured -/// failed `--check`. -#[test] -fn setup_check_tolerates_bom_like_npm() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - "\u{feff}{\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}", - ); - - // `setup` itself treats the file as valid and already configured (and - // therefore leaves it byte-identical, BOM included). - let (setup_code, setup_stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!( - setup_code, 0, - "setup must accept a BOM'd configured package.json; stdout=\n{setup_stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&setup_stdout).expect("valid JSON"); - assert_eq!(v["status"], "already_configured"); - - // `--check` must reach the same verdict npm (and `setup`) do. - let (code, stdout) = run_setup(tmp.path(), &["--check"]); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!( - code, 0, - "a BOM'd configured package.json must pass --check; stdout=\n{stdout}" - ); - assert_eq!(v["status"], "configured", "stdout=\n{stdout}"); - assert_eq!(v["errors"], 0, "stdout=\n{stdout}"); - assert_eq!(v["files"][0]["status"], "configured", "stdout=\n{stdout}"); -} - -#[test] -fn setup_check_unconfigured_project_exits_nonzero() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "x", "scripts": { "build": "tsc" } }"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!( - code, 1, - "unconfigured project must fail --check; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "needs_configuration"); - assert_eq!(v["needsConfiguration"], 1); -} - -#[test] -fn setup_check_no_files_exits_zero() { - let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!(code, 0, "no files should still exit 0; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "no_files"); - // The `no_files` envelope must keep the documented `--check` shape - // (CLI_CONTRACT "Setup command contract") — the summary counts are - // always-present, zero-valued fields, NOT dropped. A consumer reading - // `.needsConfiguration` must see 0, not null. - assert_eq!( - v["configured"], 0, - "missing/`null` configured; stdout=\n{stdout}" - ); - assert_eq!( - v["needsConfiguration"], 0, - "missing/`null` needsConfiguration; stdout=\n{stdout}" - ); - assert_eq!(v["errors"], 0, "missing/`null` errors; stdout=\n{stdout}"); - assert!(v["files"].as_array().is_some_and(|a| a.is_empty())); -} - -#[test] -fn setup_remove_no_files_exits_zero_with_full_envelope() { - let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout) = run_setup(tmp.path(), &["--remove", "--yes"]); - assert_eq!(code, 0, "no files should still exit 0; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "no_files"); - // The `no_files` envelope must keep the documented `--remove` shape - // (removed/notConfigured/errors), present and zero — not dropped. This - // mirrors the plain-`setup` `no_files` envelope, which already carries its - // own counts; the `--remove`/`--check` variants must not diverge. - assert_eq!(v["removed"], 0, "missing/`null` removed; stdout=\n{stdout}"); - assert_eq!( - v["notConfigured"], 0, - "missing/`null` notConfigured; stdout=\n{stdout}" - ); - assert_eq!(v["errors"], 0, "missing/`null` errors; stdout=\n{stdout}"); - assert!(v["files"].as_array().is_some_and(|a| a.is_empty())); -} - -#[test] -fn setup_check_does_not_modify_file() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - let original = "{ \"name\": \"x\", \"scripts\": { \"build\": \"tsc\" } }"; - write(&pkg, original); - // The check must actually run and report this unconfigured manifest (exit - // 1) — discarding the outcome would let a no-op binary pass the - // "didn't write" assertion vacuously. - let (code, stdout) = run_setup(tmp.path(), &["--check"]); - assert_eq!( - code, 1, - "unconfigured --check must exit 1; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "needs_configuration"); - assert_eq!( - std::fs::read_to_string(&pkg).unwrap(), - original, - "--check must never write" - ); -} - -// --------------------------------------------------------------------------- -// `setup --remove` — revert the install hooks -// --------------------------------------------------------------------------- - -#[test] -fn setup_remove_round_trips_and_preserves_other_scripts() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - write(&pkg, r#"{ "name": "x", "scripts": { "build": "tsc" } }"#); - - // Configure, then remove. - let (c1, _) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(c1, 0); - let after_setup = std::fs::read_to_string(&pkg).unwrap(); - assert!(after_setup.contains("socket-patch")); - - let (code, stdout) = run_setup(tmp.path(), &["--remove", "--yes"]); - assert_eq!(code, 0, "remove should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "success"); - assert_eq!(v["removed"], 1); - - let after = std::fs::read_to_string(&pkg).unwrap(); - assert!( - !after.contains("socket-patch"), - "socket-patch must be gone; got:\n{after}" - ); - let parsed: serde_json::Value = serde_json::from_str(&after).expect("valid JSON"); - // Full revert: lifecycle keys gone, sibling script preserved. - assert_eq!(parsed["scripts"]["build"], "tsc"); - assert!(parsed["scripts"].get("postinstall").is_none()); - assert!(parsed["scripts"].get("dependencies").is_none()); - - // And --check now reports it needs configuration again. - let (c2, _) = run_setup(tmp.path(), &["--check"]); - assert_eq!(c2, 1, "after remove, --check must fail again"); -} - -#[test] -fn setup_remove_dry_run_does_not_modify_file() { - let tmp = tempfile::tempdir().expect("tempdir"); - let pkg = tmp.path().join("package.json"); - write(&pkg, r#"{ "name": "x", "version": "1.0.0" }"#); - let (c1, _) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(c1, 0); - let configured = std::fs::read_to_string(&pkg).unwrap(); - - let (code, stdout) = run_setup(tmp.path(), &["--remove", "--dry-run"]); - assert_eq!(code, 0, "remove dry-run should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "dry_run"); - assert_eq!(v["dryRun"], true); - assert_eq!(v["wouldRemove"], 1); - - assert_eq!( - std::fs::read_to_string(&pkg).unwrap(), - configured, - "remove --dry-run must not modify package.json" - ); -} - -#[test] -fn setup_remove_nothing_to_remove_exits_zero() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "x", "scripts": { "build": "tsc" } }"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--remove", "--yes"]); - assert_eq!( - code, 0, - "nothing to remove should exit 0; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "not_configured"); - assert_eq!(v["removed"], 0); -} - -// In human (non-JSON) mode `setup --remove` ends with -// "Nothing removed; N item(s) could not be processed (see errors above)." -// when a manifest fails to parse, so `print_remove_preview` must surface the -// per-file error for that message to be truthful. (The companion setup-path -// check is `setup_malformed_does_not_claim_already_configured_in_human_mode`.) -#[test] -fn remove_human_mode_surfaces_unprocessable_file_error() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), "not valid json!!!"); - - let out = setup_command(tmp.path(), &["setup", "--remove", "--yes"]) - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!( - out.status.code(), - Some(1), - "a malformed manifest must exit 1; stdout=\n{stdout}" - ); - - // The "(see errors above)" trailer is only honest if the error was actually - // printed above it. - assert!( - stdout.contains("could not be processed (see errors above)"), - "remove must report the unprocessable file; stdout=\n{stdout}" - ); - assert!( - stdout.contains("Errors:"), - "the preview must include an Errors: section so '(see errors above)' is truthful; stdout=\n{stdout}" - ); - // The concrete parse error (not just a header) must be shown — a bare - // "Errors:" header with no detail would still be a regression. - assert!( - stdout.contains("Invalid package.json"), - "the actual per-file error detail must be shown above the trailer; stdout=\n{stdout}" - ); - // The Errors: section must precede the trailer it references. - let errors_at = stdout.find("Errors:").expect("Errors header present"); - let trailer_at = stdout.find("see errors above").expect("trailer present"); - assert!( - errors_at < trailer_at, - "the Errors: section must appear ABOVE the '(see errors above)' trailer; stdout=\n{stdout}" - ); -} - -#[test] -fn setup_check_and_remove_are_mutually_exclusive() { - let tmp = tempfile::tempdir().expect("tempdir"); - write(&tmp.path().join("package.json"), r#"{ "name": "x" }"#); - - // clap conflict → usage error (exit 2), not a normal run. - let out = setup_command(tmp.path(), &["setup", "--check", "--remove"]) - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - // Must be a clap *usage* error (exit 2), not a normal run that happened to - // fail (exit 1) — `assert_ne!(.., 0)` would accept either and mask a - // dropped `conflicts_with` constraint. - assert_eq!( - out.status.code(), - Some(2), - "--check + --remove must be a clap usage error (exit 2); stdout=\n{stdout}\nstderr=\n{stderr}" - ); - // clap reports the conflict on stderr and must not have run setup. - assert!( - stderr.contains("--check") && stderr.contains("--remove"), - "usage error must name the conflicting flags; stderr=\n{stderr}" - ); - assert!( - stdout.trim().is_empty(), - "rejected invocation must not emit a normal result envelope; stdout=\n{stdout}" - ); -} - -// --------------------------------------------------------------------------- -// Property 5 — in-repo and committable. `setup` writes only inside the working -// tree, never to `$HOME` or any global location. -// (CLI_CONTRACT.md → "Setup command contract", property 5.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_writes_only_inside_repo() { - let proj = tempfile::tempdir().expect("proj"); - let home = tempfile::tempdir().expect("home"); - let pkg = proj.path().join("package.json"); - write(&pkg, r#"{ "name": "x", "version": "1.0.0" }"#); - - // Sentinel HOME starts empty; setup must leave it empty. - assert!( - files_under(home.path()).is_empty(), - "sentinel HOME must start empty" - ); - - let mut cmd = setup_command(proj.path(), &["setup", "--json", "--yes"]); - // Redirect HOME at the sentinel and disable telemetry so the only writes we - // could observe are setup's own manifest edits. (Seed after the scrub — - // the helper keeps TELEMETRY vars anyway, but the write matters here.) - 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 should succeed; stderr=\n{stderr}" - ); - - // Nothing was written outside the repo. - assert!( - files_under(home.path()).is_empty(), - "setup must not write outside --cwd; HOME gained: {:?}", - files_under(home.path()) - ); - // The only file in the project is the package.json it edited — no marker or - // auxiliary files conjured beside it. - assert_eq!( - files_under(proj.path()), - BTreeSet::from(["package.json".to_string()]), - "setup must touch only in-repo manifests" - ); - // Not vacuous: it really did wire the hook into that in-repo file. - assert!( - std::fs::read_to_string(&pkg) - .unwrap() - .contains("socket-patch"), - "setup must have edited the in-repo package.json" - ); -} - -// --------------------------------------------------------------------------- -// Property 6 — clone-portable. Setup state is committed files only, so a fresh -// checkout on another host inherits it; `--check` passes on the clone with no -// re-run and no writes. (CLI_CONTRACT.md → "Setup command contract", property 6.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_state_is_clone_portable() { - let a = tempfile::tempdir().expect("a"); - write( - &a.path().join("package.json"), - r#"{ "name": "x", "version": "1.0.0" }"#, - ); - let (c, _) = run_setup(a.path(), &["--yes"]); - assert_eq!(c, 0, "initial setup must succeed"); - - // "Clone": copy the committed tree into a brand-new directory on a notional - // other host. (node_modules isn't committed, so only manifests travel.) - let b = tempfile::tempdir().expect("b"); - copy_tree(a.path(), b.path()); - - let before = std::fs::read_to_string(b.path().join("package.json")).unwrap(); - let (code, stdout) = run_setup(b.path(), &["--check"]); - assert_eq!( - code, 0, - "the clone must already be configured; stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "configured"); - assert_eq!(v["needsConfiguration"], 0); - // `--check` on the clone is read-only. - assert_eq!( - std::fs::read_to_string(b.path().join("package.json")).unwrap(), - before, - "--check must not modify the clone" - ); -} - -// --------------------------------------------------------------------------- -// Property 9 (base case) — nested workspaces. For a non-pnpm npm workspace, the -// root AND every member package.json are configured. (The pnpm root-only carve- -// out is covered by `setup_pnpm_monorepo_only_updates_root`.) -// (CLI_CONTRACT.md → "Setup command contract", property 9.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_configures_npm_workspace_members() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write( - &tmp.path().join("packages/a/package.json"), - r#"{ "name": "a", "version": "1.0.0" }"#, - ); - write( - &tmp.path().join("packages/b/package.json"), - r#"{ "name": "b", "version": "1.0.0" }"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "workspace 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_eq!( - v["updated"], 3, - "root + both members must each be configured; stdout=\n{stdout}" - ); - for member in [ - "package.json", - "packages/a/package.json", - "packages/b/package.json", - ] { - let content = std::fs::read_to_string(tmp.path().join(member)).unwrap(); - assert!( - content.contains("socket-patch"), - "workspace member {member} must gain the hook; got:\n{content}" - ); - } -} - -// --------------------------------------------------------------------------- -// Gem (Bundler) — wires a committed plugin into the Gemfile (property 3). -// The full check/remove round-trip + plugins.rb content lives in -// setup_matrix_gem.rs; these pin the dry-run no-op and the mixed-ecosystem -// dispatch alongside npm. -// --------------------------------------------------------------------------- - -const GEMFILE_FIXTURE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n"; - -fn write_supported_gem_project(root: &Path) { - write(&root.join("Gemfile"), GEMFILE_FIXTURE); - // These tests exercise setup, not discovery of the host's Bundler version. - write(&root.join("Gemfile.lock"), "BUNDLED WITH\n 2.7.2\n"); -} - -#[test] -fn setup_gem_dry_run_does_not_modify_gemfile() { - let tmp = tempfile::tempdir().expect("tempdir"); - let gemfile = tmp.path().join("Gemfile"); - write_supported_gem_project(tmp.path()); - - let (code, stdout) = run_setup(tmp.path(), &["--dry-run"]); - assert_eq!(code, 0, "dry-run should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "dry_run"); - assert_eq!(v["dryRun"], true); - - // The Gemfile must be byte-identical and no plugin dir created. - assert_eq!( - std::fs::read_to_string(&gemfile).unwrap(), - GEMFILE_FIXTURE, - "dry-run must not modify the Gemfile" - ); - assert!( - !tmp.path().join(".socket/bundler-plugin").exists(), - "dry-run must not generate the plugin dir" - ); -} - -#[test] -fn setup_configures_gem_alongside_npm() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_supported_gem_project(tmp.path()); - write( - &tmp.path().join("package.json"), - r#"{ "name": "mixed", "version": "1.0.0" } -"#, - ); - - let (code, stdout) = run_setup(tmp.path(), &["--yes"]); - assert_eq!(code, 0, "mixed setup should succeed; stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "success"); - - // The envelope must carry both an npm package_json entry and the gem - // entries (gemfile + gem_plugin) — proof gem dispatch runs next to npm. - let kinds: BTreeSet<&str> = v["files"] - .as_array() - .expect("files[]") - .iter() - .filter_map(|f| f["kind"].as_str()) - .collect(); - assert!( - kinds.contains("package_json"), - "npm entry missing; kinds={kinds:?}" - ); - assert!( - kinds.contains("gemfile"), - "gem Gemfile entry missing; kinds={kinds:?}" - ); - assert!( - kinds.contains("gem_plugin"), - "gem plugin entry missing; kinds={kinds:?}" - ); - - // On disk: both manifests are wired. - assert!(std::fs::read_to_string(tmp.path().join("Gemfile")) - .unwrap() - .contains("plugin 'socket-patch'")); - assert!(std::fs::read_to_string(tmp.path().join("package.json")) - .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_supported_gem_project(control.path()); - 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_supported_gem_project(tmp.path()); - 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}" - ); -} - -// --------------------------------------------------------------------------- -// vlt: npm's npx hook, root-only workspaces, and the -// `vlt_root_scripts_not_run` advisory. Every run pins PATH to a directory -// holding at most a fake `vlt`, so the host's own vlt never decides. -// --------------------------------------------------------------------------- - -const NPX_HOOK: &str = "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"; -const VLT_ADVISORY_PREFIX: &str = "vlt_root_scripts_not_run: vlt before 1.0.0-rc.13 does not run \ - the root postinstall hook; upgrade vlt or run `socket-patch \ - apply` after `vlt ci`"; - -/// A PATH directory with a `vlt` that runs `unix` (a `/bin/sh` body) or, -/// on Windows, `windows` (a `vlt.cmd` body). `None` leaves it empty. -fn vlt_path(bodies: Option<(&str, &str)>) -> tempfile::TempDir { - let dir = tempfile::tempdir().expect("tempdir"); - if let Some((unix, windows)) = bodies { - if cfg!(windows) { - write( - &dir.path().join("vlt.cmd"), - &format!("@echo off\r\n{windows}\r\n"), - ); - } else { - let shim = dir.path().join("vlt"); - write(&shim, &format!("#!/bin/sh\n{unix}\n")); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)) - .expect("chmod shim"); - } - } - } - dir -} - -/// A `vlt --version` that reports `version`, but only when the probe -/// disabled vlt's telemetry for the child. -fn vlt_reporting(version: &str) -> tempfile::TempDir { - vlt_path(Some(( - &format!("if [ \"$VLT_TELEMETRY\" = 0 ]; then echo {version}; else echo telemetry-on; fi"), - &format!("if \"%VLT_TELEMETRY%\"==\"0\" (echo {version}) else (echo telemetry-on)"), - ))) -} - -fn run_setup_with_path(cwd: &Path, path: &Path, extra: &[&str]) -> (i32, serde_json::Value) { - let mut args = vec!["setup", "--json"]; - args.extend_from_slice(extra); - let out = setup_command(cwd, &args) - .env("PATH", path) - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let v = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!( - "stdout must be JSON ({e}); stdout=\n{stdout}\nstderr=\n{}", - String::from_utf8_lossy(&out.stderr) - ) - }); - (out.status.code().unwrap_or(-1), v) -} - -fn warnings(v: &serde_json::Value) -> Vec { - v["warnings"] - .as_array() - .map(|w| { - w.iter() - .map(|s| s.as_str().expect("warning string").to_string()) - .collect() - }) - .unwrap_or_default() -} - -fn vlt_project(lock: Option<&str>) -> tempfile::TempDir { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - "{ \"name\": \"vlt-proj\", \"version\": \"1.0.0\" }\n", - ); - match lock { - Some(lock) => write(&tmp.path().join("vlt-lock.json"), lock), - None => write(&tmp.path().join("vlt.json"), "{}\n"), - } - tmp -} - -const VLT_LOCK_V1: &str = - "{\n \"lockfileVersion\": 1,\n \"options\": {},\n \"nodes\": {},\n \"edges\": {}\n}\n"; -const VLT_LOCK_V0: &str = - "{\n \"lockfileVersion\": 0,\n \"options\": {},\n \"nodes\": {},\n \"edges\": {}\n}\n"; - -fn assert_npx_hooks(root: &Path) { - let pkg: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(root.join("package.json")).unwrap()).unwrap(); - assert_eq!(pkg["scripts"]["postinstall"], NPX_HOOK, "{pkg}"); - assert_eq!(pkg["scripts"]["dependencies"], NPX_HOOK, "{pkg}"); -} - -#[test] -fn setup_detects_vlt_from_lockfile() { - let tmp = vlt_project(Some(VLT_LOCK_V1)); - let path = vlt_path(None); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - assert_npx_hooks(tmp.path()); - assert!( - warnings(&v).is_empty(), - "no vlt on PATH and a v1 lock: no advisory; {v}" - ); -} - -#[test] -fn setup_detects_vlt_from_vlt_json() { - let tmp = vlt_project(None); - // pnpm markers lose to vlt's. - write( - &tmp.path().join("pnpm-lock.yaml"), - "lockfileVersion: '9.0'\n", - ); - let path = vlt_path(None); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - assert_npx_hooks(tmp.path()); - assert!(warnings(&v).is_empty(), "{v}"); -} - -#[test] -fn setup_detects_vlt_from_install_state() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - "{ \"name\": \"vlt-proj\", \"version\": \"1.0.0\" }\n", - ); - std::fs::create_dir_all(tmp.path().join("node_modules/.vlt")).unwrap(); - let path = vlt_path(None); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - assert_npx_hooks(tmp.path()); -} - -#[test] -fn setup_vlt_workspace_only_updates_root() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - "{ \"name\": \"vlt-root\", \"version\": \"1.0.0\" }\n", - ); - write( - &tmp.path().join("vlt.json"), - "{ \"workspaces\": { \"apps\": \"packages/*\" } }\n", - ); - let members = ["packages/a/package.json", "packages/b/package.json"]; - for member in members { - write( - &tmp.path().join(member), - "{ \"name\": \"m\", \"version\": \"1.0.0\" }\n", - ); - } - let path = vlt_path(None); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["packageManager"], "vlt", "{v}"); - assert_eq!(v["updated"], 1, "{v}"); - let files = v["files"].as_array().expect("files array"); - assert_eq!(files.len(), 1, "{v}"); - assert_npx_hooks(tmp.path()); - for member in members { - let content = std::fs::read_to_string(tmp.path().join(member)).unwrap(); - assert!(!content.contains("socket-patch"), "{member}: {content}"); - } - - let out = setup_command(tmp.path(), &["setup", "--check", "--json"]) - .env("PATH", path.path()) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(0)); - let check: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); - assert_eq!(check["status"], "configured", "{check}"); - assert_eq!(check["configured"], 1, "root only: {check}"); -} - -#[test] -fn setup_vlt_before_rc13_emits_the_definite_advisory() { - let tmp = vlt_project(Some(VLT_LOCK_V1)); - let path = vlt_reporting("1.0.0-rc.12"); - let definite = format!("{VLT_ADVISORY_PREFIX} (`vlt --version` reports 1.0.0-rc.12)"); - for extra in [&["--dry-run"][..], &["--yes"], &["--yes"]] { - let (code, v) = run_setup_with_path(tmp.path(), path.path(), extra); - assert_eq!(code, 0, "{extra:?}: {v}"); - assert_eq!(warnings(&v), vec![definite.clone()], "{extra:?}: {v}"); - } - assert_npx_hooks(tmp.path()); -} - -#[test] -fn setup_vlt_rc13_and_later_reads_the_lock_it_would_not_write() { - let may_v0 = format!( - "{VLT_ADVISORY_PREFIX} (vlt-lock.json has lockfileVersion 0, so this project may be \ - installed by such a vlt)" - ); - let may_a0 = format!( - "{VLT_ADVISORY_PREFIX} (vlt-lock.json has no lockfileVersion, so this project may be \ - installed by such a vlt)" - ); - let a0 = "{\"nodes\":{},\"edges\":{}}\n"; - for (version, lock, want) in [ - ("1.0.0-rc.13", VLT_LOCK_V0, None), - ("1.0.0-rc.14", VLT_LOCK_V0, None), - ("1.0.0-rc.15", VLT_LOCK_V0, Some(&may_v0)), - ("1.2.0", VLT_LOCK_V0, Some(&may_v0)), - ("1.0.0-rc.14", a0, Some(&may_a0)), - ("1.2.0", a0, Some(&may_a0)), - ("1.0.0-rc.13", VLT_LOCK_V1, None), - ("1.2.0", VLT_LOCK_V1, None), - ] { - let tmp = vlt_project(Some(lock)); - let path = vlt_reporting(version); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{version} {lock}: {v}"); - let want: Vec = want.into_iter().cloned().collect(); - assert_eq!(warnings(&v), want, "{version} {lock}: {v}"); - } -} - -#[test] -fn setup_vlt_unparseable_version_emits_no_advisory() { - let tmp = vlt_project(Some(VLT_LOCK_V0)); - let path = vlt_reporting("not-a-version"); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{v}"); - assert!(warnings(&v).is_empty(), "{v}"); -} - -#[test] -fn setup_vlt_without_a_usable_vlt_reads_the_lock() { - let may_v0 = format!( - "{VLT_ADVISORY_PREFIX} (vlt-lock.json has lockfileVersion 0, so this project may be \ - installed by such a vlt)" - ); - let may_a0 = format!( - "{VLT_ADVISORY_PREFIX} (vlt-lock.json has no lockfileVersion, so this project may be \ - installed by such a vlt)" - ); - let failing = vlt_path(Some(( - "echo 1.0.0-rc.12; exit 3", - "echo 1.0.0-rc.12\r\nexit /b 3", - ))); - let missing = vlt_path(None); - for (path, lock, want) in [ - (&missing, VLT_LOCK_V0, Some(&may_v0)), - (&missing, "{\"nodes\":{},\"edges\":{}}\n", Some(&may_a0)), - (&missing, VLT_LOCK_V1, None), - (&failing, VLT_LOCK_V0, Some(&may_v0)), - (&failing, VLT_LOCK_V1, None), - ] { - let tmp = vlt_project(Some(lock)); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{lock}: {v}"); - let want: Vec = want.into_iter().cloned().collect(); - assert_eq!(warnings(&v), want, "{lock}: {v}"); - } -} - -#[cfg(unix)] -#[test] -fn setup_vlt_version_probe_times_out_to_the_lock_sniff() { - let tmp = vlt_project(Some(VLT_LOCK_V0)); - let path = vlt_path(Some(("exec /bin/sleep 60", ""))); - let started = std::time::Instant::now(); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - let elapsed = started.elapsed(); - assert_eq!(code, 0, "{v}"); - assert!( - elapsed >= std::time::Duration::from_secs(5) - && elapsed < std::time::Duration::from_secs(30), - "the probe must wait out its 5 s budget and no longer: {elapsed:?}" - ); - assert_eq!( - warnings(&v), - vec![format!( - "{VLT_ADVISORY_PREFIX} (vlt-lock.json has lockfileVersion 0, so this project may \ - be installed by such a vlt)" - )], - "{v}" - ); -} - -#[test] -fn setup_npm_project_never_probes_vlt() { - let tmp = tempfile::tempdir().expect("tempdir"); - write( - &tmp.path().join("package.json"), - "{ \"name\": \"npm-proj\", \"version\": \"1.0.0\" }\n", - ); - let path = vlt_reporting("1.0.0-rc.12"); - let (code, v) = run_setup_with_path(tmp.path(), path.path(), &["--yes"]); - assert_eq!(code, 0, "{v}"); - assert_eq!(v["packageManager"], "npm", "{v}"); - assert!(warnings(&v).is_empty(), "{v}"); -} - -#[test] -fn setup_remove_clears_hooks_older_releases_wrote_into_vlt_members() { - let tmp = tempfile::tempdir().expect("tempdir"); - let wired = format!( - "{{ \"name\": \"m\", \"version\": \"1.0.0\", \"scripts\": {{ \"postinstall\": \ - \"{NPX_HOOK}\", \"dependencies\": \"{NPX_HOOK}\" }} }}\n" - ); - write(&tmp.path().join("package.json"), &wired); - write( - &tmp.path().join("vlt.json"), - "{ \"workspaces\": \"packages/*\" }\n", - ); - write(&tmp.path().join("packages/a/package.json"), &wired); - write(&tmp.path().join("packages/excluded/package.json"), &wired); - let clean = "{ \"name\": \"c\", \"version\": \"1.0.0\" }\n"; - write(&tmp.path().join("packages/clean/package.json"), clean); - let path = vlt_path(None); - - let out = setup_command( - tmp.path(), - &[ - "setup", - "--remove", - "--yes", - "--json", - "--exclude", - "packages/excluded", - ], - ) - .env("PATH", path.path()) - .output() - .expect("run socket-patch"); - let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("remove JSON"); - assert_eq!(out.status.code(), Some(0), "{v}"); - assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["removed"], 2, "root and the hooked member: {v}"); - assert_eq!(v["notConfigured"], 0, "a clean member is not visited: {v}"); - for rel in ["package.json", "packages/a/package.json"] { - let content = std::fs::read_to_string(tmp.path().join(rel)).unwrap(); - assert!(!content.contains("socket-patch"), "{rel}: {content}"); - } - assert_eq!( - std::fs::read_to_string(tmp.path().join("packages/excluded/package.json")).unwrap(), - wired, - "an excluded member is never touched" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("packages/clean/package.json")).unwrap(), - clean - ); -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs deleted file mode 100644 index d12f572d..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs +++ /dev/null @@ -1,715 +0,0 @@ -//! Shared harness for the experimental `socket-patch setup` end-to-end -//! test matrix (`tests/setup_matrix_*.rs`, gated by the `setup-e2e` -//! feature). -//! -//! Each `setup_matrix_.rs` wrapper pulls this in with -//! `#[path = "setup_matrix_common/mod.rs"] mod smc;` and calls -//! [`run_pm`] for each package manager it covers. The wrappers are -//! thin; ALL the flow logic lives in the single bash driver -//! `tests/setup_matrix/run-case.sh`, which this module invokes either -//! inside a Docker container (default) or on the host -//! (`SOCKET_PATCH_TEST_HOST=1`). The declarative case list comes from -//! `tests/setup_matrix/matrix.json` — the same spec the -//! `scripts/setup-matrix.sh` orchestrator consumes. -//! -//! ASPIRATIONAL assertion: each case asserts the *ideal* — that after -//! `setup` + a native install, the patch is (or isn't) applied as the -//! scenario expects. For ecosystems whose install hooks `setup` does -//! not yet configure, the `baseline_with_setup` / `alt_content_patchset` -//! cases are EXPECTED to fail; the failure message tags them -//! `BASELINE GAP` so the red is understood as a TODO, not a surprise. -//! -//! `#![allow(dead_code)]` — wrappers use different subsets of this API. - -#![allow(dead_code)] - -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::OnceLock; - -/// Path to the built binary under test (host mode passes this to the -/// driver via `SOCKET_PATCH_BIN`). -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -/// Build the pure-python `socket-patch-hook` wheel once and cache the path. -/// The pypi cases need it to exercise the `.pth` post-install hook; returns -/// `None` if the build fails (those cases then degrade to a gap). Requires -/// `python3` on PATH (always present in the pypi image / host pypi runs). -fn hook_wheel() -> Option { - static CELL: OnceLock> = OnceLock::new(); - CELL.get_or_init(|| { - let root = workspace_root(); - let dist = root.join("target/setup-matrix-hook"); - std::fs::create_dir_all(&dist).ok()?; - let version = env!("CARGO_PKG_VERSION"); - let ok = Command::new("python3") - .arg(root.join("scripts/build-pypi-wheels.py")) - .args(["--version", version, "--hook-only", "--dist"]) - .arg(&dist) - .stdout(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if !ok { - return None; - } - let wheel = dist.join(format!("socket_patch_hook-{version}-py3-none-any.whl")); - wheel.exists().then_some(wheel) - }) - .clone() -} - -/// Workspace root = two levels up from this crate's manifest dir. -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .expect("workspace root") - .to_path_buf() -} - -fn driver_path() -> PathBuf { - workspace_root().join("tests/setup_matrix/run-case.sh") -} - -fn matrix_path() -> PathBuf { - workspace_root().join("tests/setup_matrix/matrix.json") -} - -/// Host mode runs the driver against host-installed toolchains instead -/// of a container. Mirrors the `docker_e2e_*` convention. -fn host_mode() -> bool { - std::env::var("SOCKET_PATCH_TEST_HOST") - .map(|v| v == "1") - .unwrap_or(false) -} - -fn docker_on_path() -> bool { - Command::new("docker") - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -fn image_present(image: &str) -> bool { - Command::new("docker") - .args([ - "image", - "inspect", - &format!("socket-patch-test-{image}:latest"), - ]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// One concrete case = a (target, scenario) pair from matrix.json. -/// `pub` (with private fields) so wrapper suites can obtain real cases via -/// [`load_section`] and regression-test the validators without docker. -#[derive(Clone)] -pub struct Case { - id: String, - ecosystem: String, - pm: String, - image: String, - scenario: String, - patchset: String, - run_setup: bool, - expect_applied: bool, - baseline_supported: bool, - /// On the temporary `known_regressions` allowlist in matrix.json: a case the - /// baseline says should work but currently doesn't — tracked + tolerated - /// (non-blocking), not a hard failure, until the underlying hook is fixed. - known_regression: bool, - package: String, - version: String, - purl: String, - manifest_key: String, - apply_ecosystems: String, - marker: String, - alt_marker: String, - layout: String, -} - -impl Case { - /// Baseline outcome: applied is expected only when the target - /// advertises `baseline_supported` (the PMs whose install hook `setup` - /// wires: npm family, pip/uv/hatch, bundler, composer) AND the scenario - /// aspires to apply. - fn baseline_applied(&self) -> bool { - self.expect_applied && self.baseline_supported - } - - /// npm-family package managers (plus the polyglot monorepo's npm slice) - /// are the cases where the driver runs the check/remove round-trip; - /// other supported ecosystems (pypi, gem, composer) are round-tripped by - /// their host_guard tests instead. - fn is_npm_family(&self) -> bool { - matches!(self.pm.as_str(), "npm" | "yarn" | "pnpm" | "bun" | "vlt") - || self.layout == "monorepo" - } - - fn sm_env(&self) -> Vec<(String, String)> { - vec![ - ("SM_ID".into(), self.id.clone()), - ("SM_ECOSYSTEM".into(), self.ecosystem.clone()), - ("SM_PM".into(), self.pm.clone()), - ("SM_SCENARIO".into(), self.scenario.clone()), - ("SM_PATCHSET".into(), self.patchset.clone()), - ( - "SM_RUN_SETUP".into(), - if self.run_setup { "1" } else { "0" }.into(), - ), - ( - "SM_EXPECT_APPLIED".into(), - if self.expect_applied { "1" } else { "0" }.into(), - ), - ("SM_PACKAGE".into(), self.package.clone()), - ("SM_VERSION".into(), self.version.clone()), - ("SM_PURL".into(), self.purl.clone()), - ("SM_MANIFEST_KEY".into(), self.manifest_key.clone()), - ("SM_APPLY_ECOSYSTEMS".into(), self.apply_ecosystems.clone()), - ("SM_MARKER".into(), self.marker.clone()), - ("SM_ALT_MARKER".into(), self.alt_marker.clone()), - ("SM_LAYOUT".into(), self.layout.clone()), - ] - } -} - -/// Load every case for a given (ecosystem, pm) by crossing the matching -/// target in `targets_key` with every scenario in `scenarios_key`, -/// tagging each with `layout`. `targets_key`/`scenarios_key` select the -/// spec section: ("targets","scenarios") for single projects, -/// ("workspace_targets","workspace_scenarios") for nested workspaces, -/// ("monorepo_targets","monorepo_scenarios") for the polyglot monorepo. -/// `pub` so wrapper suites can load real cases for validator regression tests. -pub fn load_section( - targets_key: &str, - scenarios_key: &str, - layout: &str, - ecosystem: &str, - pm: &str, -) -> Vec { - let text = - std::fs::read_to_string(matrix_path()).unwrap_or_else(|e| panic!("read matrix.json: {e}")); - let spec: serde_json::Value = serde_json::from_str(&text).expect("parse matrix.json"); - let marker = spec["marker"].as_str().unwrap_or("").to_string(); - let alt_marker = spec["alt_marker"].as_str().unwrap_or("").to_string(); - let known_regressions: std::collections::HashSet = spec["known_regressions"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let target = spec[targets_key] - .as_array() - .unwrap_or_else(|| panic!("{targets_key} array missing")) - .iter() - .find(|t| t["ecosystem"] == ecosystem && t["pm"] == pm) - .unwrap_or_else(|| panic!("no {targets_key} entry for {ecosystem}/{pm}")); - - let mut cases = Vec::new(); - for s in spec[scenarios_key].as_array().expect("scenarios array") { - let scenario = s["id"].as_str().unwrap().to_string(); - let case_id = format!("{ecosystem}/{pm}/{scenario}"); - cases.push(Case { - id: case_id.clone(), - ecosystem: ecosystem.to_string(), - pm: pm.to_string(), - image: target["image"].as_str().unwrap().to_string(), - scenario, - patchset: s["patchset"].as_str().unwrap().to_string(), - run_setup: s["run_setup"].as_bool().unwrap(), - expect_applied: s["expect_applied"].as_bool().unwrap(), - baseline_supported: target["baseline_supported"].as_bool().unwrap(), - known_regression: known_regressions.contains(&case_id), - package: target["package"].as_str().unwrap().to_string(), - version: target["version"].as_str().unwrap().to_string(), - purl: target["purl"].as_str().unwrap().to_string(), - manifest_key: target["manifest_key"].as_str().unwrap().to_string(), - apply_ecosystems: target["apply_ecosystems"].as_str().unwrap().to_string(), - marker: marker.clone(), - alt_marker: alt_marker.clone(), - layout: layout.to_string(), - }); - } - cases -} - -/// `pub` (like [`host_driver_command`]) so wrapper suites can synthesize -/// driver results and regression-test [`round_trip_failure`] without docker. -pub struct RunResult { - pub actual_applied: bool, - pub raw: String, - pub parsed: Option, -} - -/// Build the host-mode driver invocation: `bash run-case.sh` with the -/// case's `SM_*` env, the binary under test, and (for pypi) the hook -/// wheel. `pub` so the `setup_matrix_env_guard` wrapper can inspect the -/// exact `Command` `run_case` spawns. -pub fn host_driver_command(env: &[(String, String)], wheel: Option<&Path>) -> Command { - let mut cmd = Command::new("bash"); - cmd.arg(driver_path()); - // Host mode inherits the parent process's environment, and the driver - // passes it straight through to the binary under test AND the native - // package-manager installs. Scrub the ambient surface that can flip a - // verdict for the wrong reason BEFORE seeding the case env (docker mode - // is naturally immune — only the explicit `-e` vars cross over): - // * SOCKET_* — global-flag fallbacks of the binary under test: - // SOCKET_DRY_RUN=true no-ops the hook's apply, SOCKET_CWD recreates - // the workspace-breaking mode run-case.sh documents it must avoid, - // SOCKET_MANIFEST_PATH/SOCKET_GLOBAL retarget it. The driver - // re-exports the ones it needs (OFFLINE/FORCE/API_TOKEN/...); - // telemetry opt-outs are kept so an opted-out dev stays opted out. - // Also covers a stale ambient SOCKET_PATCH_HOOK_WHEEL. - // * SM_* — the driver's own contract; an ambient SM_WORKDIR - // would make every parallel case share one scratch dir (the races - // the driver's blob_tmp comment warns about). - // * npm_config_* / YARN_* — PM config that changes whether lifecycle - // hooks even fire (npm_config_ignore_scripts, YARN_ENABLE_SCRIPTS) - // or where installs resolve from. - // * VIRTUAL_ENV / SETUP_MATRIX_SHIM_DIR — hijack the python crawler / - // the shims' PATH-cleanup logic. - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - let hit = ["SOCKET_", "SM_", "YARN_"] - .iter() - .any(|p| name.starts_with(p)) - || name.to_ascii_lowercase().starts_with("npm_config_") - || name == "VIRTUAL_ENV" - || name == "SETUP_MATRIX_SHIM_DIR"; - if hit && !name.contains("TELEMETRY") { - cmd.env_remove(&key); - } - } - for (k, v) in env { - cmd.env(k, v); - } - cmd.env("SOCKET_PATCH_BIN", binary()); - if let Some(w) = wheel { - cmd.env("SOCKET_PATCH_HOOK_WHEEL", w); - } - cmd -} - -/// Execute one case via the bash driver (container or host) and parse -/// its JSON result line. -fn run_case(case: &Case) -> RunResult { - let driver = driver_path(); - let env = case.sm_env(); - - // The pypi cases need the prebuilt hook wheel to exercise the `.pth` - // post-install hook; other ecosystems ignore it. - let wheel = if case.ecosystem == "pypi" { - hook_wheel() - } else { - None - }; - - let output = if host_mode() { - host_driver_command(&env, wheel.as_deref()) - .output() - .expect("spawn bash driver") - } else { - let script = - std::fs::read_to_string(&driver).unwrap_or_else(|e| panic!("read driver: {e}")); - let mut cmd = Command::new("docker"); - cmd.args(["run", "--rm"]); - for (k, v) in &env { - cmd.args(["-e", &format!("{k}={v}")]); - } - // Mount the hook wheel into the container, PRESERVING its PEP 427 - // filename (pip/uv/pdm reject a wheel whose filename isn't a valid - // `{name}-{ver}-{tags}.whl`, so we must not rename it on mount). - if let Some(w) = &wheel { - let name = w - .file_name() - .and_then(|n| n.to_str()) - .expect("hook wheel filename"); - let dest = format!("/tmp/{name}"); - cmd.args([ - "-v", - &format!("{}:{}:ro", w.display(), dest), - "-e", - &format!("SOCKET_PATCH_HOOK_WHEEL={dest}"), - ]); - } - cmd.arg(format!("socket-patch-test-{}:latest", case.image)); - cmd.args(["bash", "-c", &script]); - cmd.output().expect("spawn docker run") - }; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - // The driver prints its result JSON as the last matching stdout line. - let line = stdout - .lines() - .rev() - .find(|l| l.trim_start().starts_with('{') && l.contains("actual_applied")); - - let parsed = line.and_then(|l| serde_json::from_str::(l).ok()); - let actual_applied = parsed - .as_ref() - .and_then(|v| v["actual_applied"].as_bool()) - .unwrap_or(false); - - RunResult { - actual_applied, - raw: format!("stdout:\n{stdout}\nstderr:\n{stderr}"), - parsed, - } -} - -/// Run the single-project scenarios for one (ecosystem, pm). -pub fn run_pm(ecosystem: &str, pm: &str) { - run_cases( - &format!("{ecosystem}/{pm}"), - load_section("targets", "scenarios", "single", ecosystem, pm), - ); -} - -/// Run the nested-workspace scenarios for one (ecosystem, pm). -pub fn run_workspace_pm(ecosystem: &str, pm: &str) { - run_cases( - &format!("{ecosystem}/{pm} [workspace]"), - load_section( - "workspace_targets", - "workspace_scenarios", - "workspace", - ecosystem, - pm, - ), - ); -} - -/// Run the polyglot all-ecosystem monorepo scenarios. -pub fn run_monorepo() { - run_cases( - "monorepo", - load_section( - "monorepo_targets", - "monorepo_scenarios", - "monorepo", - "monorepo", - "mono", - ), - ); -} - -/// Execute a set of cases and assert each meets the ASPIRATIONAL -/// expectation. Soft-skips when Docker / the ecosystem image is -/// unavailable (container mode) — matching the `docker_e2e_*` convention -/// where Rust integration tests have no native "skipped". -fn run_cases(label: &str, cases: Vec) { - // A section with zero cases would make the final `failures.is_empty()` - // assertion pass having exercised nothing ("0 of 0 cases") — a vacuous - // green if matrix.json's scenarios/targets list is ever emptied or a key - // is renamed. `load_section` emits one case per scenario, so an empty - // vector here means the spec degenerated; fail loudly. Checked before the - // docker/image soft-skip because the spec is read regardless of runner. - assert!( - !cases.is_empty(), - "{label}: no setup-matrix cases were loaded from matrix.json — the \ - scenario/target list is empty (would make this suite pass vacuously)" - ); - - if !host_mode() && !docker_on_path() { - eprintln!("skip {label}: docker not on PATH (set SOCKET_PATCH_TEST_HOST=1 to run on host)"); - return; - } - if !host_mode() { - if let Some(c) = cases.first() { - if !image_present(&c.image) { - eprintln!( - "skip {label}: image socket-patch-test-{}:latest not present \ - (build it: scripts/setup-matrix.sh build --ecosystem {})", - c.image, c.image - ); - return; - } - } - } - - let mut failures = Vec::new(); - for case in &cases { - let res = run_case(case); - - // The bash driver MUST emit exactly one parseable result line carrying - // a real boolean `actual_applied`. If it does not (binary crashed, - // docker error, script aborted before `emit_result`, malformed JSON), - // the case never actually exercised setup+install. Without this guard - // `run_case` falls back to `actual_applied = false`, which silently - // satisfies EVERY `expect_applied == false` case — and makes the - // round-trip `?` no-op — turning a broken harness fully green for the - // wrong reason. Treat a missing/garbled result as a hard failure - // regardless of the aspirational expectation (allowlist included). - let applied = match res - .parsed - .as_ref() - .and_then(|v| v.get("actual_applied")) - .and_then(|v| v.as_bool()) - { - Some(b) => b, - None => { - failures.push(format!( - " - {}: driver emitted no parseable result line with a boolean \ - `actual_applied` — the case did not run to completion (this is a \ - harness/binary failure, NOT a baseline gap)\n{}", - case.id, - indent(&res.raw) - )); - continue; - } - }; - - if applied != case.expect_applied { - if case.known_regression { - // On the temporary allowlist (matrix.json `known_regressions`): - // a tracked, non-blocking regression — report it but don't fail. - eprintln!( - " - {}: expected applied={}, got {} [KNOWN REGRESSION (allowlisted in \ - matrix.json; non-blocking — fix the hook + remove from the list)]", - case.id, case.expect_applied, applied - ); - } else { - let tag = if case.baseline_applied() { - // We recorded this as working; failing now is a real regression. - "REGRESSION (baseline says this should apply)" - } else if case.expect_applied { - "BASELINE GAP (setup does not yet wire this package manager)" - } else { - "LEAK (patch applied without the hook configuring it)" - }; - failures.push(format!( - " - {}: expected applied={}, got {} [{}]\n{}", - case.id, - case.expect_applied, - applied, - tag, - indent(&res.raw) - )); - } - } - - // check/remove round-trip — only asserted for npm-family cases that - // ran setup. Other ecosystems either have no setup hook (no-op) or - // are round-tripped by their own host_guard tests. - if case.run_setup && case.is_npm_family() && !case.known_regression { - if let Some(msg) = round_trip_failure(case, &res) { - failures.push(msg); - } - } - } - - // In docker mode the binary under test is the one BAKED INTO the local - // socket-patch-test-* image, not the workspace build — a weeks-old - // image fails current expectations deterministically and looks like a - // mystery regression (both report the same crate version, so only the - // image age gives it away). Say so in the failure message instead of - // letting the next person rediscover it. - let mode_hint = if host_mode() { - String::new() - } else { - let image = cases - .first() - .map(|c| c.image.as_str()) - .unwrap_or(""); - format!( - "\nNOTE: docker mode ran the socket-patch binary baked into the \ - local image (check its age: `docker images socket-patch-test-*`). \ - A stale image fails current expectations without any real \ - regression — rebuild first:\n \ - docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest .\n \ - docker build -f tests/docker/Dockerfile.{image} -t socket-patch-test-{image}:latest ." - ) - }; - assert!( - failures.is_empty(), - "{}: {} of {} setup-matrix case(s) did not meet the aspirational \ - expectation. BASELINE GAP entries are the experimental TODO list \ - (this suite is non-blocking in CI); REGRESSION / LEAK entries are \ - real problems:\n{}{}", - label, - failures.len(), - cases.len(), - failures.join("\n"), - mode_hint - ); -} - -/// Validate the behavioral `(setup)·(install)` round-trip emitted by the driver. -/// Verifies — through real install cycles, not by reading package.json — that: -/// -/// 1. `setup --check` fails before setup, passes after the post-setup install -/// (hook present AND on-disk patch consistency, per contract property 4), -/// fails after `setup --remove` (and setup + remove themselves succeed); -/// 2. the patch is NOT applied before setup and NOT applied after remove -/// (the after-setup application is covered separately by the main -/// `actual_applied == expect_applied` assertion). -/// -/// Returns a failure message describing any violation, or `None` on success. -pub fn round_trip_failure(case: &Case, res: &RunResult) -> Option { - // The main loop already turns a missing result line into a hard failure - // and `continue`s before reaching here, so this branch is defensive: never - // silently treat an absent result as a passing round-trip. - let parsed = match res.parsed.as_ref() { - Some(p) => p, - None => { - return Some(format!( - " - {}: setup/install behavioral round-trip could not be evaluated \ - — driver produced no parseable result JSON\n{}", - case.id, - indent(&res.raw) - )) - } - }; - let int = |k: &str| parsed.get(k).and_then(|v| v.as_i64()); - let boolean = |k: &str| parsed.get(k).and_then(|v| v.as_bool()); - - let mut problems = Vec::new(); - - // This branch runs ONLY for npm-family cases that ran setup, i.e. exactly - // the driver's full (install)·(setup)·(install)·(remove)·(install) path, - // which records every field below as a real value (never null). So every - // probe must be PRESENT with the right value; a missing/null field means - // the stage never ran and must be flagged, not tolerated. - - // (2) patch-application bookends must be present AND false: the patch must - // NOT apply before any hook exists, and must NOT apply once it is removed. - let applied_before = boolean("applied_before_setup"); - if applied_before != Some(false) { - problems.push(format!( - "applied_before_setup={applied_before:?} (want false: patch must NOT apply \ - before a hook is configured)" - )); - } - let applied_after_remove = boolean("applied_after_remove"); - if applied_after_remove != Some(false) { - problems.push(format!( - "applied_after_remove={applied_after_remove:?} (want false: patch must NOT \ - apply once the hook is removed)" - )); - } - - // The native install of the patched package must itself have succeeded, - // and the canonical after-setup verification must have found a real - // on-disk copy to inspect (`primary_marker_present` is null only when NO - // candidate file was found — which would make every "not applied" verdict - // vacuous). Both guard against a green round-trip that inspected nothing. - let install = int("install_exit"); - if install != Some(0) { - problems.push(format!( - "install_exit={install:?} (want 0: the native install must succeed for the \ - before/after probes to mean anything)" - )); - } - if boolean("primary_marker_present").is_none() { - problems.push( - "primary_marker_present null/missing: no installed file was found to verify \ - (vacuous round-trip)" - .to_string(), - ); - } - - // `setup --yes` itself must succeed. `check-after-setup == 0` alone cannot - // catch a partial failure: setup aggregates errors across every manifest - // kind it edits (npm + python + gem + composer) and exits 1 on - // `partial_failure`, so it can land the npm hook (check passes, the patch - // applies) and still choke on another manifest — for the polyglot - // monorepo that IS the headline regression this suite exists to catch. - let setup = int("setup_exit"); - if setup != Some(0) { - problems.push(format!( - "setup exit={setup:?} (want 0: `setup --yes` must succeed; non-zero means setup \ - choked even if the npm hook landed)" - )); - } - - // (1) `setup --check` exit code must track the configured state: - // non-zero before setup → 0 after setup → non-zero after remove. Each - // must be present; a null exit means the check step never ran. - let check_before = int("check_before_setup_exit"); - let check_setup = int("check_after_setup_exit"); - let remove = int("remove_exit"); - let check_remove = int("check_after_remove_exit"); - - if !matches!(check_before, Some(n) if n != 0) { - problems.push(format!( - "check-before-setup exit={check_before:?} (want present & non-zero; not configured yet)" - )); - } - if check_setup != Some(0) { - problems.push(format!( - "check-after-setup exit={check_setup:?} (want 0; configured)" - )); - } - if remove != Some(0) { - problems.push(format!( - "remove exit={remove:?} (want 0; remove must succeed)" - )); - } - if !matches!(check_remove, Some(n) if n != 0) { - problems.push(format!( - "check-after-remove exit={check_remove:?} (want present & non-zero; hook still present)" - )); - } - - if problems.is_empty() { - return None; - } - Some(format!( - " - {}: setup/install behavioral round-trip failed [{}]\n{}", - case.id, - problems.join("; "), - indent(&res.raw) - )) -} - -fn indent(s: &str) -> String { - s.lines() - .map(|l| format!(" {l}")) - .collect::>() - .join("\n") -} - -/// RAII setter for hostile ambient env decoys: sets each pair process-wide -/// and removes them ALL on drop, so a panicking assertion mid-test can never -/// leave the process env poisoned for the tests that run after it. -/// -/// Process env is per-process, shared state. Any test that constructs this -/// guard — and every other test in the same binary that spawns the CLI — -/// must be `#[serial_test::serial]`: the child-env scrubs in the `run` -/// helpers snapshot `std::env::vars_os()` and then spawn, and a concurrent -/// `set_var` can land between the snapshot and the spawn, reaching the child -/// un-scrubbed (the 2026-07 `setup_matrix_pypi` CI flake — the decoys made -/// the sibling test's child abort at arg parse with exit 2). -pub struct DecoyGuard(&'static [(&'static str, &'static str)]); - -impl DecoyGuard { - pub fn set(pairs: &'static [(&'static str, &'static str)]) -> Self { - for (k, v) in pairs { - std::env::set_var(k, v); - } - Self(pairs) - } -} - -impl Drop for DecoyGuard { - fn drop(&mut self) { - for (k, _) in self.0 { - std::env::remove_var(k); - } - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_composer.rs b/crates/socket-patch-cli/tests/setup_matrix_composer.rs deleted file mode 100644 index 74c0dd81..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_composer.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! setup-matrix: composer ecosystem (PHP). `setup` wires `socket-patch -//! apply` into composer's `post-install-cmd` / `post-update-cmd` script -//! events. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("composer", "composer")` routes composer through the -//! shared Docker matrix harness, which *soft-skips and silently passes* -//! whenever Docker or the `composer` image is absent (the common case -//! locally and in this eval). composer is also NOT npm-family, so the -//! harness's check/remove behavioral round-trip is skipped entirely for -//! it. The net effect: the matrix call can never turn red for a genuine -//! composer `setup` regression. On its own it protects nothing. -//! -//! To close that loophole WITHOUT touching the shared harness, -//! [`host_guard::composer_setup_round_trips_host`] runs unconditionally -//! (no Docker, no network, no PHP / composer toolchain — `setup` edits -//! `composer.json` directly) and pins the full wiring contract: -//! `--check` fails pre-setup, `setup` wires the hook, `--check` then -//! passes, and `--remove` restores the manifest byte-for-byte. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_composer` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -#[path = "common/mod.rs"] -mod common; - -#[path = "vex_e2e_common/mod.rs"] -mod vex_e2e_common; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the composer -/// negative controls when Docker + the `composer` image are present. -/// NOTE: this is the path that silently no-ops on skip — it is NOT a -/// regression guard. The real teeth live in [`host_guard`] below. -#[test] -fn composer() { - smc::run_pm("composer", "composer"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for composer `setup`: the full -// wire → check → remove round-trip against a composer-only project, -// driven entirely on the host (no PHP toolchain — `setup` edits -// `composer.json` directly). -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - - /// A realistic composer-only project: a PHP manifest requiring the - /// same package the matrix targets, and nothing the npm/Python/Cargo - /// detectors would recognise. Indented with 4 spaces, the way composer - /// itself writes the file (PHP `JSON_PRETTY_PRINT`) — so the - /// byte-for-byte restore below also pins that `setup` does not reformat - /// a composer-authored manifest to serde's 2-space default. - const COMPOSER_JSON: &str = "{\n \"name\": \"acme/widget\",\n \"require\": {\n \"monolog/monolog\": \"3.5.0\"\n }\n}\n"; - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// Delegates to the shared `common::run_with_env`, which seeds-then-scrubs - /// the binary's entire ambient `SOCKET_*` surface — stripping only - /// `SOCKET_API_TOKEN` (as this helper originally did) left the guard at - /// the mercy of the parent shell: an ambient `SOCKET_ECOSYSTEMS=cargo` - /// filtered composer out of scope (first `--check` exits 0 as `no_files`), - /// and `SOCKET_DRY_RUN=true` no-ops the very write under test. - /// `SOCKET_TELEMETRY_DISABLED=1` is injected because this file promises - /// "no network": each real `setup` run otherwise fire-and-forgets a live - /// `patch_setup` POST to the telemetry endpoint. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - super::common::run_with_env(cwd, args, &[("SOCKET_TELEMETRY_DISABLED", "1")]) - } - - /// Parse the CLI's `--json` stdout into the single top-level object the - /// command promises. Panics (loudly) if stdout is not exactly that — a - /// non-JSON / multi-line dump means the command did not run the path we - /// think it did. - fn parse_obj(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") - }) - } - - /// Immediate entry names under `root`, sorted — for proving the directory - /// was not littered with foreign artifacts. - fn dir_entries(root: &Path) -> Vec { - let mut names: Vec = std::fs::read_dir(root) - .unwrap_or_else(|e| panic!("read_dir({}): {e}", root.display())) - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .collect(); - names.sort(); - names - } - - /// Assert composer.json is byte-for-byte what we wrote, AND that the - /// project directory still contains *only* composer.json. The directory - /// check is the real teeth: after `--remove` the project must hold - /// NOTHING else — not an npm `package.json` hook, not a `.socket/` - /// dir, not a lockfile, not a `.pth`, nothing. Probing for one specific - /// filename (`package.json`) would let any other foreign artifact through. - fn assert_manifest_pristine(root: &Path, who: &str) { - assert_eq!( - std::fs::read_to_string(root.join("composer.json")).unwrap(), - COMPOSER_JSON, - "{who}: composer.json must be left byte-for-byte unchanged" - ); - assert!( - !root.join("package.json").exists(), - "{who}: setup must NOT inject an npm package.json hook into a composer-only project" - ); - assert_eq!( - dir_entries(root), - vec!["composer.json".to_string()], - "{who}: a clean no-op must leave the project dir containing ONLY composer.json; \ - any extra entry means setup wrote a foreign artifact into a composer-only project" - ); - } - - /// Composer is a REAL setup - /// ecosystem: `setup` wires `socket-patch apply` into `composer.json`'s - /// post-install/post-update script events, `--check` reflects it, and - /// `--remove` restores the manifest byte-for-byte. Non-skippable (no Docker, - /// no PHP toolchain) — it edits composer.json directly. - #[test] - fn composer_setup_round_trips_host() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("composer.json"), COMPOSER_JSON).unwrap(); - let root_s = root.to_str().unwrap(); - - let status = - |v: &serde_json::Value| v.get("status").and_then(|s| s.as_str()).map(str::to_string); - - // ── check (pristine): not wired yet → needs_configuration / exit 1 ── - let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!(code, 1, "pre-setup check must fail:\n{out}"); - assert_eq!( - status(&parse_obj(&out, "check (pristine)")).as_deref(), - Some("needs_configuration") - ); - - // ── setup: wires the hook into composer.json → success / updated=1 ── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "composer setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_obj(&out, "setup"); - assert_eq!( - status(&v).as_deref(), - Some("success"), - "setup must report success:\n{out}" - ); - assert_eq!( - v.get("updated").and_then(|n| n.as_i64()), - Some(1), - "exactly the composer.json updated:\n{out}" - ); - // Exactly one `composer`-kind file entry, status `updated`. - let files = v["files"].as_array().expect("files array"); - assert_eq!(files.len(), 1, "one composer file entry:\n{out}"); - assert_eq!(files[0]["kind"], "composer"); - assert_eq!(files[0]["status"], "updated"); - // The command landed in BOTH script events on disk. - let on_disk = std::fs::read_to_string(root.join("composer.json")).unwrap(); - let cj: serde_json::Value = serde_json::from_str(&on_disk).unwrap(); - for event in ["post-install-cmd", "post-update-cmd"] { - let arr = cj["scripts"][event] - .as_array() - .unwrap_or_else(|| panic!("{event} missing:\n{on_disk}")); - assert!( - arr.iter() - .any(|c| c.as_str().is_some_and(|s| s.contains("socket-patch apply"))), - "{event} must carry the re-apply command:\n{on_disk}" - ); - } - assert!( - cj["require"]["monolog/monolog"] == "3.5.0", - "user require preserved:\n{on_disk}" - ); - - // ── idempotent re-setup: already_configured, no change ── - let (code, out, _) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0); - assert_eq!( - status(&parse_obj(&out, "re-setup")).as_deref(), - Some("already_configured"), - "{out}" - ); - - // ── check (post-setup): configured / exit 0 ── - let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!(code, 0, "post-setup check must pass:\n{out}"); - assert_eq!( - status(&parse_obj(&out, "check (post-setup)")).as_deref(), - Some("configured") - ); - - // ── remove: strips the hook, restoring composer.json byte-for-byte ── - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!( - code, 0, - "composer remove must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_eq!( - status(&parse_obj(&out, "remove")).as_deref(), - Some("success") - ); - // The `scripts` object we created is gone and the dir holds only composer.json. - assert_manifest_pristine(root, "after remove"); - - // ── check (post-remove): back to needs_configuration / exit 1 ── - let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!(code, 1, "post-remove check must fail again:\n{out}"); - assert_eq!( - status(&parse_obj(&out, "check (post-remove)")).as_deref(), - Some("needs_configuration") - ); - } - - /// The hook `setup` wires into a MANIFEST-LESS hosted / vendored - /// checkout (a depscan-opened PR, a `scan --mode vendored` or `scan - /// --mode hosted` project: the patches live in composer.lock, no - /// `.socket/manifest.json`, no ledger). composer runs the hook after - /// every install, so it must be a silent, write-free exit 0 there — and - /// the same `apply` with `--vex` must attest the lockfile-wired patch - /// from the patch API record (and, under the hook's own `--offline`, - /// report the record unavailable instead of attesting blind). - #[test] - fn composer_setup_hook_in_manifestless_hosted_and_vendored_checkouts() { - use super::vex_e2e_common::{ - assert_absent, assert_attested, binary, git_sha256, patch_view, run_vex, Marker, - PatchApi, VexRun, VexVia, - }; - const UUID: &str = "5e5e5e5e-1234-4abc-8def-5e5e5e5e5e5e"; - const PURL: &str = "pkg:composer/monolog/monolog@3.5.0"; - const PATCHED: &[u8] = b" = hook.split_whitespace().collect(); - assert_eq!(argv.remove(0), "socket-patch", "[{tag}] hook: {hook}"); - argv.extend(["--cwd", root_s]); - let (code, out, err) = run(root, &argv); - assert_eq!( - code, 0, - "[{tag}] the hook must not fail the install:\n{out}\n{err}" - ); - assert!( - out.trim().is_empty(), - "[{tag}] --silent hook prints nothing: {out}" - ); - assert!( - !root.join(".socket/manifest.json").exists(), - "[{tag}] no manifest written" - ); - assert_eq!( - std::fs::read_to_string(root.join("composer.lock")).unwrap(), - lock - ); - - // The hook's apply + --vex, online: the lock-wired patch attests. - let api = PatchApi::start(vec![( - UUID.to_string(), - patch_view( - UUID, - PURL, - &[("src/Monolog/Logger.php", &git_sha256(PATCHED))], - vulns, - ), - )]); - let flags: Vec = argv - .iter() - .filter(|a| !["apply", "--offline", "--silent", "--cwd", root_s].contains(*a)) - .map(|a| a.to_string()) - .collect(); - assert_eq!( - flags, - ["--ecosystems", "composer"], - "[{tag}] hook flags: {hook}" - ); - let base = VexRun { - product: Some("pkg:composer/acme/widget@1.0.0".to_string()), - extra_args: flags, - ..VexRun::online(&api) - } - .via(VexVia::Apply); - let out = run_vex(&binary(), root, &base); - assert_eq!(out.code, Some(0), "[{tag}] apply --vex:\n{out}"); - assert_eq!(out.envelope["status"], "noManifest", "[{tag}]:\n{out}"); - let marker = if vendored { - Marker::Vendored - } else { - Marker::Redirected - }; - assert_attested(out.doc(), PURL, UUID, marker, vulns); - - // Under the hook's own --offline there is no record to attest - // from: a VEX failure, never a blind attestation. - let seen = api.request_count(); - let out = run_vex( - &binary(), - root, - &VexRun { - offline: true, - ..base.clone() - }, - ); - assert_eq!(out.code, Some(1), "[{tag}] offline apply --vex:\n{out}"); - assert_eq!( - out.envelope["error"]["code"], "no_applicable_patches", - "{out}" - ); - assert_absent(out.doc.as_ref(), PURL); - assert_eq!(api.request_count(), seen, "[{tag}] --offline made requests"); - } - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_deno.rs b/crates/socket-patch-cli/tests/setup_matrix_deno.rs deleted file mode 100644 index 7c70beb7..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_deno.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! setup-matrix: deno ecosystem (deno install against a package.json, -//! npm-via-deno layout). `setup` DOES rewrite the package.json (deno -//! projects have one), but whether `deno install` runs the root -//! postinstall hook is uncertain — so the baseline records this as a -//! GAP. If it applies, the orchestrator flags it `progress`. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("deno", "deno")` routes deno through the shared Docker -//! matrix harness, which *soft-skips and silently passes* whenever Docker -//! or the `deno` image is absent (the common case locally and in this -//! eval). deno is also NOT npm-family (see `is_npm_family` in the harness -//! and `run-case.sh`), so the harness's check/remove behavioral -//! round-trip is skipped entirely for it; and because deno's -//! `baseline_supported` is false in matrix.json the only thing the matrix -//! could ever assert is the coarse `actual_applied == expect_applied` -//! verdict — which, on a crashed or never-run case, defaults to the same -//! `false` that satisfies every negative-control scenario. The net -//! effect: the matrix call can never turn red for a genuine deno `setup` -//! regression. On its own it protects nothing. -//! -//! To close that loophole WITHOUT touching the shared harness or the bash -//! driver, [`host_guard::deno_setup_roundtrip_host`] runs unconditionally -//! (no Docker, no network, no deno toolchain) and pins deno `setup`'s -//! *actual current contract*: a deno project HAS a package.json, so -//! `setup` must configure the npm-style postinstall hook in it exactly as -//! it does for npm — `setup --check` fails (exit 1) before, passes (exit -//! 0) after, fails again after `setup --remove`; the injected -//! `scripts.postinstall` must actually invoke `socket-patch apply`; remove -//! must delete it; and the sibling `deno.json` must be left byte-for-byte -//! untouched throughout. It verifies on-disk state with an *independent* -//! `serde_json` probe (the documented expectation of what setup should -//! write, not a copy of the writer's output) so the oracle can disagree -//! with a broken implementation. It fails loudly if deno `setup` / -//! `setup --check` / `setup --remove` ever regress, stop rewriting the -//! package.json, mangle `deno.json`, or mis-report the configured state. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_deno` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; -#[path = "vex_e2e_common/mod.rs"] -mod vex_e2e_common; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the deno negative -/// controls when Docker + the `deno` image are present. NOTE: this is the -/// path that silently no-ops on skip — it is NOT a regression guard. The -/// real teeth live in [`host_guard`] below. -#[test] -#[serial_test::serial] -// Experimental ecosystem (deno): the setup-matrix aspirational cases are a -// BASELINE GAP (setup wires the package.json postinstall, but `deno install` -// is not known to run it). This passes on CI -// only because the runners lack the `deno` toolchain (the cases soft-skip); on -// any host that HAS deno it fails. Ignore it so deno can never block the -// blocking --all-features jobs. The non-skippable setup round-trip is still -// guarded by `host_guard` below. Run with `--features setup-e2e -- --ignored`. -#[ignore = "experimental ecosystem (deno): not gating CI until the deno backend is implemented; run with --ignored"] -fn deno() { - smc::run_pm("deno", "deno"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for deno `setup`. -// -// A deno project carries a real package.json (the driver scaffolds one -// alongside deno.json), so deno is on the npm-package.json-hook surface -// that `setup` actually configures today: it must wire the postinstall -// hook into package.json, report state correctly via `--check`, undo it on -// `--remove`, and never touch the deno-native config. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - use std::process::Command; - - /// A faithful deno project fixture: a package.json declaring the same - /// dependency the matrix targets, plus a deno-native `deno.json` with - /// `nodeModulesDir` (mirrors `scaffold_project`'s deno branch in - /// `tests/setup_matrix/run-case.sh`). - const PACKAGE_JSON: &str = "{ \"name\": \"sm-proj\", \"version\": \"0.0.0\", \"private\": true, \"dependencies\": { \"minimist\": \"1.2.2\" } }\n"; - const DENO_JSON: &str = - "{ \"name\": \"sm-proj\", \"version\": \"0.0.0\", \"nodeModulesDir\": \"auto\" }\n"; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots - /// (this file's missed `SOCKET_STRICT` / `SOCKET_VENDOR_SOURCE`, both - /// parsed on every `setup` invocation; see [`HOSTILE_DECOYS`]) — so - /// behaviour reflects the explicit flags alone: nothing reaches authed - /// endpoints and no ambient var can stand in for a flag. Mirrors - /// `setup_matrix_pypi.rs`. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - 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); - } - } - // This guard's contract is "no network" (module docs): `setup` fires a - // usage-telemetry POST when telemetry is enabled, and the scrub above - // would strip a developer's own opt-out. Force it off for the child — - // no assertion here concerns telemetry. - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Parse the CLI's `--json` stdout into a single JSON object. Panics - /// (loudly) if stdout is not the single JSON object the command - /// promises — a non-JSON / multi-line dump means the command did not - /// run the path we think it did. - fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") - }) - } - - fn json_str_field(v: &serde_json::Value, key: &str, who: &str) -> String { - v.get(key) - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("{who}: JSON has no string `{key}` field:\n{v}")) - .to_string() - } - - /// Independent oracle: read package.json with `serde_json` and return - /// `scripts.postinstall` if present. Deliberately does NOT reuse the - /// production detection helpers (`is_setup_configured_str`) so the - /// oracle can disagree with a broken writer. - fn postinstall_script(root: &Path) -> Option { - let content = std::fs::read_to_string(root.join("package.json")).unwrap(); - let v: serde_json::Value = serde_json::from_str(&content) - .unwrap_or_else(|e| panic!("package.json is not valid JSON ({e}):\n{content}")); - v.get("scripts") - .and_then(|s| s.get("postinstall")) - .and_then(|p| p.as_str()) - .map(String::from) - } - - /// `deno.json` (the deno-native config) must be byte-for-byte what we - /// wrote — `setup` operates on package.json and must never mutate it. - fn assert_deno_json_pristine(root: &Path, who: &str) { - assert_eq!( - std::fs::read_to_string(root.join("deno.json")).unwrap(), - DENO_JSON, - "{who}: deno.json must be left byte-for-byte unchanged by setup" - ); - } - - /// Ambient decoys `run()`'s scrub must strip. Two failure classes: - /// clap parses env-bound values on EVERY invocation whether or not the - /// command uses the flag, so an invalid ambient `SOCKET_STRICT` / - /// `SOCKET_VENDOR_SOURCE` aborts the parse (exit 2) before `setup` even - /// runs; and `SOCKET_SETUP_EXCLUDE` stands in for `setup --exclude` — - /// the exact surface under test. Planted by the roundtrip test itself so - /// the scrub is exercised on every run, not only in hostile shells. - /// (Safe to set process-wide: the only other test in this binary routes - /// through `smc::host_driver_command`, which prefix-scrubs `SOCKET_*`.) - const HOSTILE_DECOYS: &[(&str, &str)] = &[ - ("SOCKET_STRICT", "banana"), - ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), - ("SOCKET_VENDOR_URL", "http://127.0.0.1:9/decoy"), - ("SOCKET_PATCH_SERVER_URL", "http://127.0.0.1:9/decoy"), - ("SOCKET_SETUP_EXCLUDE", "decoy-member"), - ]; - - /// Manifest-less VEX over a setup-only Deno project: `setup` wires only - /// the agent-mode `postinstall` hook — Deno has no hosted rewriter and - /// no vendored backend — so there is nothing to attest (exit 2, - /// `manifest_not_found`), no document, no patch-API request, and - /// `deno.json` stays untouched. - fn assert_manifestless_vex_has_nothing(root: &Path, who: &str) { - use crate::vex_e2e_common::{run_vex, PatchApi, VexRun}; - let out_dir = tempfile::tempdir().unwrap(); - let api = PatchApi::empty(); - for no_verify in [false, true] { - let mut r = VexRun::online(&api); - r.output = Some(out_dir.path().join("out.vex.json")); - r.product = Some("pkg:npm/deno-app@1.0.0".to_string()); - r.no_verify = no_verify; - let out = run_vex(&binary(), root, &r); - assert_eq!(out.code, Some(2), "{who} (no_verify={no_verify}): {out}"); - assert_eq!( - out.envelope["error"]["code"], "manifest_not_found", - "{who}: {out}" - ); - assert!( - out.doc.is_none(), - "{who}: no document may be written: {out}" - ); - } - api.assert_no_requests(); - assert_deno_json_pristine(root, who); - } - - #[test] - #[serial_test::serial] - fn deno_setup_roundtrip_host() { - let _decoys = crate::smc::DecoyGuard::set(HOSTILE_DECOYS); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("package.json"), PACKAGE_JSON).unwrap(); - std::fs::write(root.join("deno.json"), DENO_JSON).unwrap(); - let root_s = root.to_str().unwrap(); - - // ── check (before setup): unconfigured → must FAIL (exit 1) ───────── - // Proves `--check` reads real state instead of hardcoding success, - // and that a deno package.json is recognised as a configurable - // manifest (status needs_configuration, NOT no_files — a no_files - // here would mean setup silently ignores deno projects). - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) on a pristine, unconfigured deno project.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (pristine)"); - assert_eq!( - json_str_field(&v, "status", "check (pristine)"), - "needs_configuration", - "a deno project's package.json must report needs_configuration, not no_files/configured.\nstderr:\n{err}" - ); - assert_eq!( - v.get("needsConfiguration").and_then(|n| n.as_i64()), - Some(1), - "exactly the package.json must be counted as needing configuration.\n{out}" - ); - assert!( - postinstall_script(root).is_none(), - "no postinstall hook must exist before setup runs" - ); - assert_deno_json_pristine(root, "after check (pristine)"); - - // ── setup: must rewrite package.json with a real apply hook ───────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "setup must succeed (exit 0).\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "setup"); - assert_eq!( - json_str_field(&v, "status", "setup"), - "success", - "setup on a deno project must report status=success.\nstderr:\n{err}" - ); - assert_eq!( - v.get("updated").and_then(|n| n.as_i64()), - Some(1), - "setup must report updating exactly one manifest (the package.json).\n{out}" - ); - assert_eq!( - v.get("errors").and_then(|n| n.as_i64()), - Some(0), - "setup must report zero errors on a deno project.\n{out}" - ); - - // Independent on-disk verification: the postinstall hook must exist - // and must actually invoke `socket-patch apply` for the npm - // ecosystem — an empty/foreign/echo value would be a regression that - // a mere "key present" check would miss. - let hook = postinstall_script(root) - .unwrap_or_else(|| panic!("setup did not write scripts.postinstall into package.json")); - assert!( - hook.contains("socket-patch apply"), - "postinstall hook must invoke `socket-patch apply`, got: {hook:?}" - ); - assert!( - hook.contains("--ecosystems npm"), - "postinstall hook must target the npm ecosystem (deno installs npm deps via package.json), got: {hook:?}" - ); - // The committed `minimist` dependency must survive the rewrite. - let pkg = std::fs::read_to_string(root.join("package.json")).unwrap(); - let pkg_v: serde_json::Value = serde_json::from_str(&pkg).unwrap(); - assert_eq!( - pkg_v - .get("dependencies") - .and_then(|d| d.get("minimist")) - .and_then(|m| m.as_str()), - Some("1.2.2"), - "setup must preserve the project's existing dependencies.\n{pkg}" - ); - assert_deno_json_pristine(root, "after setup"); - - // ── check (configured): must PASS (exit 0) ────────────────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check must PASS (exit 0) after setup configured the deno project.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (configured)"); - assert_eq!( - json_str_field(&v, "status", "check (configured)"), - "configured", - "check must report the deno package.json as configured after setup.\nstderr:\n{err}" - ); - assert_eq!( - v.get("configured").and_then(|n| n.as_i64()), - Some(1), - "exactly one manifest (the package.json) must be reported configured.\n{out}" - ); - assert_eq!( - v.get("needsConfiguration").and_then(|n| n.as_i64()), - Some(0), - "no manifest may still need configuration after a successful setup.\n{out}" - ); - - // ── manifest-less VEX: the configured hook is not attestable ──────── - assert_manifestless_vex_has_nothing(root, "vex (configured)"); - - // ── remove: must delete the hook and succeed ──────────────────────── - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!( - code, 0, - "setup --remove must succeed (exit 0).\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "remove"); - assert_eq!( - json_str_field(&v, "status", "remove"), - "success", - "setup --remove must report status=success on a configured deno project.\nstderr:\n{err}" - ); - assert_eq!( - v.get("removed").and_then(|n| n.as_i64()), - Some(1), - "remove must report removing exactly one hook.\n{out}" - ); - assert!( - postinstall_script(root).is_none(), - "the postinstall hook must be gone from package.json after remove:\n{}", - std::fs::read_to_string(root.join("package.json")).unwrap() - ); - assert_deno_json_pristine(root, "after remove"); - - // ── check (after remove): back to needs-configuration (exit 1) ────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) again after remove.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (post-remove)"); - assert_eq!( - json_str_field(&v, "status", "check (post-remove)"), - "needs_configuration", - "check must report needs_configuration again after the hook is removed.\nstderr:\n{err}" - ); - assert_eq!( - v.get("needsConfiguration").and_then(|n| n.as_i64()), - Some(1), - "the package.json must count as needing configuration again after remove.\n{out}" - ); - assert_eq!( - v.get("configured").and_then(|n| n.as_i64()), - Some(0), - "no manifest may report configured after the hook is removed.\n{out}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_env_guard.rs b/crates/socket-patch-cli/tests/setup_matrix_env_guard.rs deleted file mode 100644 index 0309aa6a..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_env_guard.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Env-hygiene guard for setup-matrix HOST mode (`SOCKET_PATCH_TEST_HOST=1`). -//! -//! `run_case`'s host branch spawns `bash run-case.sh` as a plain child -//! process, so — unlike docker mode, where only the explicit `-e SM_*` -//! vars cross into the container — the driver, the binary under test, -//! and the native package-manager installs all inherit the parent -//! shell's environment. An ambient `SOCKET_DRY_RUN=true` turns the -//! install hook's apply into a no-op (every baseline case red for the -//! wrong reason), an ambient `SOCKET_CWD` recreates exactly the -//! workspace-breaking mode run-case.sh documents it must avoid, an -//! ambient `SM_WORKDIR` makes every parallel case share one scratch -//! dir (the blob/proj races the driver warns about), and an ambient -//! `npm_config_ignore_scripts=true` stops lifecycle hooks from firing -//! at all. This guard pins the scrub-then-seed contract of -//! `smc::host_driver_command` — the same `Command` `run_case` spawns — -//! by planting hostile decoys in the parent env and asserting each is -//! explicitly removed while the case's own seeds survive. -//! -//! Deliberately its own test binary: it mutates the process env, and -//! nothing else runs in this binary, so the decoys cannot race a live -//! matrix case in another test thread. -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -use std::collections::HashMap; -use std::ffi::{OsStr, OsString}; -use std::path::Path; -use std::process::Command; - -/// Hostile ambient vars: each one, if inherited by the driver, flips a -/// real verdict for the wrong reason (dry-run apply, retargeted cwd or -/// manifest, global mode, shared scratch dir, stale hook wheel, dead -/// registry, disabled lifecycle scripts, hijacked venv, poisoned shim -/// PATH cleanup). -const DECOYS: &[(&str, &str)] = &[ - ("SOCKET_DRY_RUN", "true"), - ("SOCKET_CWD", "/nonexistent/decoy"), - ("SOCKET_MANIFEST_PATH", "/nonexistent/decoy/manifest.json"), - ("SOCKET_GLOBAL", "true"), - ("SM_WORKDIR", "/nonexistent/decoy-shared-workdir"), - ("SOCKET_PATCH_HOOK_WHEEL", "/nonexistent/decoy.whl"), - ("npm_config_ignore_scripts", "true"), - ("NPM_CONFIG_REGISTRY", "http://127.0.0.1:9/decoy"), - ("YARN_ENABLE_SCRIPTS", "false"), - ("VIRTUAL_ENV", "/nonexistent/decoy-venv"), - ("SETUP_MATRIX_SHIM_DIR", "/nonexistent/decoy-shims"), -]; - -/// Snapshot the command's explicit env ops: `Some(value)` = seeded, -/// `None` = removed, absent key = silently inherited from the parent. -fn env_map(cmd: &Command) -> HashMap> { - cmd.get_envs() - .map(|(k, v)| (k.to_os_string(), v.map(OsStr::to_os_string))) - .collect() -} - -#[test] -fn host_driver_command_scrubs_ambient_env_and_keeps_case_seeds() { - for (k, v) in DECOYS { - std::env::set_var(k, v); - } - - let case_env = vec![ - ("SM_ID".to_string(), "guard/npm/decoy".to_string()), - ("SM_ECOSYSTEM".to_string(), "npm".to_string()), - ]; - - // No wheel: SOCKET_PATCH_HOOK_WHEEL must be scrubbed, not inherited - // stale from the shell. - let cmd = smc::host_driver_command(&case_env, None); - let envs = env_map(&cmd); - - for (k, _) in DECOYS { - assert_eq!( - envs.get(OsStr::new(k)), - Some(&None), - "ambient decoy {k} must be explicitly removed (env_remove) from the \ - host driver invocation; it currently leaks into run-case.sh, the \ - binary under test, and the native package-manager installs" - ); - } - - // Scrub-then-seed ordering: the SM_* case env and the binary path are - // set AFTER the prefix scrub, so they must survive as real values (a - // scrub running last would wipe its own seeds — last env call wins). - assert_eq!( - envs.get(OsStr::new("SM_ID")).cloned().flatten().as_deref(), - Some(OsStr::new("guard/npm/decoy")), - "case SM_* env must survive the SM_ prefix scrub (scrub must run before seeding)" - ); - assert!( - matches!(envs.get(OsStr::new("SOCKET_PATCH_BIN")), Some(Some(p)) if !p.is_empty()), - "SOCKET_PATCH_BIN must survive the SOCKET_ prefix scrub" - ); - - // With a wheel, the explicit seed must survive the scrub too. - let wheel = Path::new("/tmp/socket_patch_hook-0.0.0-py3-none-any.whl"); - let cmd = smc::host_driver_command(&case_env, Some(wheel)); - let envs = env_map(&cmd); - assert_eq!( - envs.get(OsStr::new("SOCKET_PATCH_HOOK_WHEEL")) - .cloned() - .flatten() - .as_deref(), - Some(wheel.as_os_str()), - "an explicitly provided hook wheel must survive the SOCKET_ prefix scrub" - ); - - for (k, _) in DECOYS { - std::env::remove_var(k); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs deleted file mode 100644 index efef1fc6..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ /dev/null @@ -1,1874 +0,0 @@ -//! setup-matrix: gem ecosystem (bundler). `setup` now has REAL bundler support -//! — it appends a managed `plugin "socket-patch"` block to the Gemfile and -//! generates a committed in-tree Bundler plugin under `.socket/bundler-plugin/` -//! whose `plugins.rb` re-runs `socket-patch apply --ecosystems gem` on every -//! `bundle install` (digest-gated load-time + per-gem `after-install` -//! triggers, forced `after-install-all` re-apply). -//! -//! The with-setup Docker cases (`baseline_with_setup`, `alt_content_patchset`) -//! depend on two things: (a) installing the plugin evaluates `plugins.rb` -//! BEFORE any project gems land, so the generated plugin warns-and-continues -//! on apply failures (`SOCKET_PATCH_STRICT=1` restores the raise) instead of -//! killing the FIRST `bundle install` of every fresh checkout — pinned by -//! [`plugin_runtime`] below; (b) hash-gated gem apply has no npm-style -//! mismatch-warn-and-apply path, so the fixture's beforeHash is the real -//! git-blob hash probed from the published .gem (`resolve_before_hash` in -//! `run-case.sh`, mirroring docker_e2e_gem). NOTE: in Docker mode the matrix -//! runs the binary BAKED INTO the local image; a stale image generates a -//! raising plugin and red-flags these cases — rebuild the image (or run with -//! `SOCKET_PATCH_TEST_HOST=1`). -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("gem", "bundler")` routes gem through the shared Docker -//! matrix harness, which *soft-skips and silently passes* whenever Docker -//! or the `gem` image is absent (the common case locally and in this -//! eval). gem is also NOT npm-family (see `is_npm_family` in the harness -//! and `run-case.sh`), so the harness's check/remove behavioral -//! round-trip is skipped entirely for it. When Docker + the image ARE -//! present the matrix does assert the coarse -//! `actual_applied == expect_applied` verdict against a real -//! `bundle install` (it caught the uncloneable `git:` plugin source), but -//! that protection is environment-conditional — a machine without the -//! image gets silent green. -//! -//! To close that loophole WITHOUT touching the shared harness or the bash -//! driver, [`host_guard::gem_setup_roundtrip_host`] runs unconditionally -//! (no Docker, no network, no ruby/bundler toolchain) and pins gem -//! `setup`'s contract with a full POSITIVE round-trip: `--check` fails on a -//! pristine Gemfile → `setup` wires the plugin → `--check` passes → `--remove` -//! restores the Gemfile *byte-for-byte* and deletes the generated plugin dir → -//! `--check` fails again. It reads on-disk state with *independent* probes -//! (hand-pinned constants + a marker scan, not a copy of any writer output) so -//! the oracle can disagree with a broken implementation. It fails loudly if -//! gem `setup` stops wiring the plugin, corrupts the Gemfile, mis-reports a -//! status / exit code, or leaves residue after `--remove`. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_gem` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; -#[path = "vex_e2e_common/mod.rs"] -mod vex_e2e_common; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the gem negative -/// controls when Docker + the `gem` image are present. NOTE: this is the -/// path that silently no-ops on skip — it is NOT a regression guard. The -/// real teeth live in [`host_guard`] below. -#[test] -fn bundler() { - smc::run_pm("gem", "bundler"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for gem `setup`. -// -// A bundler project carries a Gemfile; `setup` wires a committed Bundler -// plugin into it. The guard pins that round-trip precisely so a regression -// (plugin no longer wired, Gemfile corrupted on add/remove, wrong exit code, -// residue after remove) turns this suite red even with no Docker / ruby. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - use std::process::Command; - - /// A faithful bundler project fixture, mirroring `scaffold_project`'s - /// `bundler` branch in `tests/setup_matrix/run-case.sh` and the gem - /// target's package/version in matrix.json (`colorize` @ `1.1.0`). - const GEMFILE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n"; - - /// The relative path of the generated in-tree plugin (independent of any - /// production constant — a hand-pinned oracle). - const PLUGIN_DIR: &str = ".socket/bundler-plugin"; - /// The managed-block marker `setup` appends to the Gemfile. Pinned here so - /// the test disagrees with a renamed/removed marker rather than copying it. - const MANAGED_MARKER: &str = "# >>> socket-patch:managed"; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone — nothing reaches authed endpoints and no ambient - /// var can stand in for a flag. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - run_env(cwd, args, &[]) - } - - /// [`run`] with extra environment variables for the child (e.g. bundler's - /// `BUNDLE_APP_CONFIG`, which relocates the machine-local registration - /// `--remove` must clean). - fn run_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - // Prefix-scrub the whole ambient `SOCKET_*` surface (mirrors - // `tests/common::run_with_env`). clap binds ~30 `SOCKET_*` vars across - // the global + per-command flags and the set keeps growing, so an - // itemized list rots: `SOCKET_STRICT`, `SOCKET_VENDOR_SOURCE`, and - // setup's own `SOCKET_SETUP_EXCLUDE` were all missing from the list - // this replaced — an ambient `SOCKET_VENDOR_SOURCE=bogus` aborted - // every invocation with a clap parse error (exit 2) and turned this - // guard red for an environmental reason. - 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); - } - } - // This guard's contract is "no network" (module docs): `setup` fires a - // usage-telemetry POST when telemetry is enabled, and the scrub above - // would strip a developer's own opt-out. Force it off for the child — - // no assertion here concerns telemetry. - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - // An ambient BUNDLE_APP_CONFIG would relocate where `--remove` looks - // for bundler's plugin registration — strip it so only the explicit - // per-test env below can steer that resolution. - cmd.env_remove("BUNDLE_APP_CONFIG"); - for (k, v) in envs { - cmd.env(k, v); - } - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Parse the CLI's `--json` stdout into a single JSON object. Panics - /// (loudly) if stdout is not the single JSON object the command - /// promises — a non-JSON / multi-line dump means the command did not - /// run the path we think it did. - fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") - }) - } - - fn json_str(v: &serde_json::Value, key: &str, who: &str) -> String { - v.get(key) - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("{who}: JSON has no string `{key}` field:\n{v}")) - .to_string() - } - - fn json_i64(v: &serde_json::Value, key: &str, who: &str) -> i64 { - v.get(key) - .and_then(|n| n.as_i64()) - .unwrap_or_else(|| panic!("{who}: JSON has no integer `{key}` field:\n{v}")) - } - - fn gemfile_body(root: &Path) -> String { - std::fs::read_to_string(root.join("Gemfile")).unwrap() - } - - /// setup / setup --check / setup --remove against a real bundler project, - /// asserting REAL on-disk + JSON state at every stage. This is the - /// assertion the Docker matrix can never make for gem. - #[test] - fn gem_setup_roundtrip_host() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); - let root_s = root.to_str().unwrap(); - let plugins_rb = root.join(PLUGIN_DIR).join("plugins.rb"); - let gemspec = root.join(PLUGIN_DIR).join("socket-patch.gemspec"); - - // ── pristine precondition ────────────────────────────────────────── - assert_eq!(gemfile_body(root), GEMFILE, "fixture Gemfile"); - assert!( - !root.join(PLUGIN_DIR).exists(), - "fixture must not already contain the generated plugin dir" - ); - assert!( - !root.join("package.json").exists(), - "fixture must not contain a package.json (would change the path under test)" - ); - - // ── check (pristine): plugin not wired → needs_configuration, exit 1 ─ - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "check on an unconfigured bundler project must exit 1.\n{out}\n{err}" - ); - let v = parse_json(&out, "check (pristine)"); - assert_eq!( - json_str(&v, "status", "check (pristine)"), - "needs_configuration" - ); - // The Gemfile must be among the manifests reported as needing setup. - let files = v.get("files").and_then(|f| f.as_array()).expect("files[]"); - assert!( - files.iter().any( - |f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile") - && f.get("status").and_then(|s| s.as_str()) == Some("needs_configuration") - ), - "check must report the Gemfile as needs_configuration:\n{v}" - ); - - // ── setup: wire the plugin (Gemfile block + generated dir) ────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "setup must exit 0.\n{out}\n{err}"); - let v = parse_json(&out, "setup"); - assert_eq!(json_str(&v, "status", "setup"), "success"); - assert!( - json_i64(&v, "updated", "setup") >= 2, - "Gemfile + plugin dir updated:\n{v}" - ); - assert_eq!(json_i64(&v, "errors", "setup"), 0, "setup errors:\n{v}"); - - // On-disk, via independent probes (NOT a copy of the writer output): - // the managed block is appended (original bytes preserved as a prefix), - let body = gemfile_body(root); - assert!( - body.starts_with(GEMFILE), - "setup must only APPEND to the Gemfile:\n{body}" - ); - assert!( - body.contains(MANAGED_MARKER), - "managed block marker missing:\n{body}" - ); - assert!( - body.contains("plugin 'socket-patch'"), - "Gemfile must reference the socket-patch plugin:\n{body}" - ); - // The directive must use a `path:` source. A `git:` source makes - // Bundler `git clone` the directory, and `.socket/bundler-plugin/` is - // a plain generated dir (committing it to the PARENT repo does not - // give it a `.git`), so every `bundle install` on a wired project - // fails with "repository ... does not exist" (exit 11) and the plugin - // never loads. Verified against real Bundler in the gem Docker image. - assert!( - body.contains("plugin 'socket-patch', path:"), - "the plugin directive must be `path:`-sourced (a `git:` dir source \ - is uncloneable and breaks every `bundle install`):\n{body}" - ); - // and the generated plugin carries the two triggers + fail-loud applier. - assert!(plugins_rb.exists(), "plugins.rb must be generated"); - assert!(gemspec.exists(), "the plugin gemspec must be generated"); - // Bundler refuses to LOAD a plugin whose gemspec require paths are - // missing on disk ("The following plugin paths don't exist: .../lib. - // ... Continuing without installing plugin"). The plugin dir is flat - // (no lib/), so the gemspec must pin `require_paths = ["."]` or the - // plugin is silently skipped on every install. - let spec = std::fs::read_to_string(&gemspec).unwrap(); - assert!( - spec.contains("s.require_paths = [\".\"]"), - "gemspec must set require_paths to the flat plugin dir, or Bundler \ - silently skips loading the plugin:\n{spec}" - ); - let rb = std::fs::read_to_string(&plugins_rb).unwrap(); - assert!( - rb.contains("Bundler::Plugin.add_hook(\"after-install-all\")"), - "plugins.rb must register the after-install-all hook (fresh-install trigger):\n{rb}" - ); - assert!( - rb.contains("SocketPatch.apply!"), - "plugins.rb must call the applier at load time (cached/no-op-install trigger):\n{rb}" - ); - assert!( - rb.contains("\"--ecosystems\", \"gem\", \"--offline\""), - "plugins.rb must shell the gem-scoped offline apply:\n{rb}" - ); - // Tolerant by default (a raise at plugin registration deadlocks a - // fresh checkout's first `bundle install`), with the strict escape - // hatch still raising Bundler::BundlerError. - assert!( - rb.contains("SOCKET_PATCH_STRICT"), - "plugins.rb must carry the strict-mode escape hatch:\n{rb}" - ); - assert!( - rb.contains("BundlerError"), - "plugins.rb must still raise Bundler::BundlerError in strict mode:\n{rb}" - ); - // The plugin's digest stamp is machine-local, but it lives in the - // otherwise-committed .socket/ — setup must gitignore it, or every - // install litters `git status` and a blanket `git add .socket` - // commits one machine's stamp to every clone. - let gitignore = std::fs::read_to_string(root.join(".socket/.gitignore")).unwrap(); - assert!( - gitignore.lines().any(|l| l == "/gem-plugin-stamp"), - ".socket/.gitignore must carry the stamp entry:\n{gitignore}" - ); - - // ── check (after setup): configured, exit 0 ───────────────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "check on a configured project must exit 0.\n{out}\n{err}" - ); - assert_eq!( - json_str( - &parse_json(&out, "check (configured)"), - "status", - "check (configured)" - ), - "configured" - ); - - // ── idempotent re-setup: nothing changes ──────────────────────────── - let (code, out, _) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "idempotent re-setup must exit 0"); - let v = parse_json(&out, "re-setup"); - assert_eq!(json_str(&v, "status", "re-setup"), "already_configured"); - assert_eq!( - json_i64(&v, "updated", "re-setup"), - 0, - "re-setup must update nothing:\n{v}" - ); - - // ── remove: byte-for-byte restore + plugin dir gone ───────────────── - // A stamp left behind by a previous apply: `--remove`'s no-residue - // contract covers it (it sits in the committed .socket/ dir). - std::fs::write(root.join(".socket/gem-plugin-stamp"), "e".repeat(64)).unwrap(); - // Bundler's machine-local plugin registration, exactly as the first - // `bundle install` after `setup` writes it (bundler's YAMLSerializer - // dialect, verified against bundler 2.7.2 / 4.0.18): the hook - // subscriptions + load/plugin paths in `.bundle/plugin/index`. - // `--remove` must clear it too — a dangling registration makes every - // later `bundle install` print bundler's "The following plugin paths - // don't exist ... Continuing without installing plugin socket-patch" - // block (with a misleading reinstall suggestion) forever. - let plugin_reg_dir = root.join(".bundle").join("plugin"); - std::fs::create_dir_all(&plugin_reg_dir) - .expect("create the bundler plugin registration dir"); - let index_path = plugin_reg_dir.join("index"); - std::fs::write( - &index_path, - format!( - "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n \ - after-install-all:\n - \"socket-patch\"\nload_paths:\n socket-patch:\n \ - - \"{root_s}/.socket/bundler-plugin/.\"\nplugin_paths:\n \ - socket-patch: \"{root_s}/.socket/bundler-plugin\"\nsources:\n" - ), - ) - .expect("write the bundler plugin index fixture"); - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!(code, 0, "remove must exit 0.\n{out}\n{err}"); - let v = parse_json(&out, "remove"); - assert_eq!(json_str(&v, "status", "remove"), "success"); - assert!( - json_i64(&v, "removed", "remove") >= 2, - "Gemfile + plugin dir removed:\n{v}" - ); - assert_eq!( - gemfile_body(root), - GEMFILE, - "remove must restore the Gemfile byte-for-byte to its pre-setup state" - ); - assert!( - !root.join(PLUGIN_DIR).exists(), - "remove must delete the generated plugin dir" - ); - assert!( - !root.join(".socket/gem-plugin-stamp").exists(), - "remove must delete the plugin's digest stamp — unwiring must not \ - orphan it in the committed .socket/" - ); - assert!( - !root.join(".socket/.gitignore").exists(), - "remove must delete the .gitignore setup created (it held only our line)" - ); - // The machine-local registration must be gone too. socket-patch was - // the ONLY registered plugin, so nothing in the index is left worth - // keeping — the index (and thereby every socket-patch entry) must not - // survive. - let residue = std::fs::read_to_string(&index_path).unwrap_or_default(); - assert!( - !residue.contains("socket-patch"), - "remove must clear bundler's machine-local plugin registration \ - (.bundle/plugin/index) — a dangling entry makes every later \ - `bundle install` warn \"plugin paths don't exist ... Continuing \ - without installing plugin socket-patch\":\n{residue}" - ); - assert!( - !index_path.exists(), - "socket-patch was the only registered plugin: the emptied index \ - must be deleted, not left as an all-empty husk" - ); - assert!( - !root.join(".socket").exists(), - "remove must prune the emptied .socket/ (nothing else lived there): \ - --remove restores the pre-setup tree" - ); - - // ── check (after remove): needs_configuration again, exit 1 ───────── - let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!(code, 1, "check after remove must exit 1 again"); - assert_eq!( - json_str( - &parse_json(&out, "check (removed)"), - "status", - "check (removed)" - ), - "needs_configuration" - ); - } - - /// A Gemfile.lock pinning bundler 1.x, exactly as `bundle install` under - /// bundler 1.17.3 writes it (independent oracle for the version gate — - /// the lock probe works even where no `bundle` is on PATH). - const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ - colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ - colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; - - /// [P1 bundler floor — refuse side] Bundler 1.x cannot load the - /// `plugin ... path:` directive `setup` writes (Plugin::DSL undef's - /// `:path`; the 1.x plugin installer knows only git/rubygems sources), so - /// a wired project dies on EVERY later `bundle install` with exit 7 - /// ("Could not find gem 'socket-patch' ...") before plugin registration — - /// an error that never names socket-patch (campaign-confirmed on - /// 1.17.3). `setup` must refuse to wire such a project, loudly, and leave - /// the Gemfile untouched so `bundle install` keeps working. - #[test] - fn gem_setup_refuses_bundler_1x_locked_project() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); - std::fs::write(root.join("Gemfile.lock"), LOCK_1X).unwrap(); - let root_s = root.to_str().unwrap(); - - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 1, - "setup on a bundler-1.x-locked project must refuse (exit 1), not \ - wire an install-breaking directive.\n{out}\n{err}" - ); - let v = parse_json(&out, "setup (bundler 1.x)"); - assert_eq!( - json_str(&v, "status", "setup (bundler 1.x)"), - "error", - "the refusal must be an error status:\n{v}" - ); - // The refusal names the problem: the detected bundler and the floor. - let files = v.get("files").and_then(|f| f.as_array()).expect("files[]"); - let gem_err = files - .iter() - .filter(|f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile")) - .find_map(|f| f.get("error").and_then(|e| e.as_str())) - .unwrap_or_else(|| panic!("no gemfile error entry in files[]:\n{v}")); - assert!( - gem_err.contains("1.17.3") && gem_err.contains("2.2"), - "the error must name the detected bundler and the >= 2.2 floor:\n{gem_err}" - ); - // On disk: nothing was wired. - assert_eq!( - gemfile_body(root), - GEMFILE, - "the Gemfile must be byte-untouched after the refusal" - ); - assert!( - !root.join(PLUGIN_DIR).exists(), - "no plugin dir may be generated for a refused project" - ); - } - - /// [P1 bundler floor — check side] The campaign's worst variant: a - /// project wired elsewhere (older CLI, or a machine with bundler >= 2.2) - /// whose lock pins bundler 1.x. Every `bundle install` fails with exit 7, - /// yet `setup --check` said "configured" — the CI gate the check exists - /// to be went green while installs were broken. `--check` must red-flag - /// the wired-but-unloadable state and name the remedy. - #[test] - fn gem_check_red_flags_wired_but_unloadable_bundler_1x() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); - let root_s = root.to_str().unwrap(); - - // Wire for real with the actual CLI (no 1.x lock yet, so the host — - // bundler >= 2.2 or none — lets setup proceed) ... - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!(code, 0, "precondition: wiring must succeed.\n{out}\n{err}"); - assert!(gemfile_body(root).contains(MANAGED_MARKER)); - - // ... then the 1.x lock arrives (clone of a legacy project / CI image - // downgrade). This is the state the campaign reproduced. - std::fs::write(root.join("Gemfile.lock"), LOCK_1X).unwrap(); - - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "check must FAIL on a wired project whose bundler cannot load the \ - plugin — this exact state broke every `bundle install` while \ - check reported configured.\n{out}\n{err}" - ); - let v = parse_json(&out, "check (wired, bundler 1.x)"); - assert_eq!( - json_str(&v, "status", "check (wired, bundler 1.x)"), - "error", - "the unloadable wiring is an error, not a plain needs_configuration:\n{v}" - ); - let files = v.get("files").and_then(|f| f.as_array()).expect("files[]"); - let gem_err = files - .iter() - .filter(|f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile")) - .find_map(|f| f.get("error").and_then(|e| e.as_str())) - .unwrap_or_else(|| panic!("no gemfile error entry in files[]:\n{v}")); - assert!( - gem_err.contains("1.17.3") && gem_err.contains("2.2") && gem_err.contains("--remove"), - "the check error must name the detected bundler, the floor, and \ - the `setup --remove` recovery:\n{gem_err}" - ); - - // Recovery: `setup --remove` still un-wires (the gate must never - // block it), restoring the Gemfile byte-for-byte. - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!(code, 0, "recovery remove must work.\n{out}\n{err}"); - assert_eq!(gemfile_body(root), GEMFILE, "Gemfile restored"); - assert!(!root.join(PLUGIN_DIR).exists(), "plugin dir removed"); - } - - /// `setup --remove` must clear bundler's machine-local plugin - /// registration SURGICALLY: only the socket-patch entries leave the - /// index; another plugin's registration (its hook subscriptions and - /// paths) survives byte-intact. And the index location must follow - /// bundler's own `BUNDLE_APP_CONFIG` resolution — a relative value - /// resolves against the project root (`Bundler.app_config_path`), not - /// the process cwd or a hardcoded `.bundle`. - #[test] - fn gem_setup_remove_strips_registration_surgically_under_bundle_app_config() { - let tmp = tempfile::tempdir().expect("create tempdir for the test project"); - let root = tmp.path(); - std::fs::write(root.join("Gemfile"), GEMFILE).expect("write the fixture Gemfile"); - let root_s = root.to_str().expect("tempdir path is UTF-8"); - - // Wire the project (the registration below is what bundler would - // write on the first `bundle install` after this). - let (code, out, err) = run_env( - root, - &["setup", "--cwd", root_s, "--yes", "--json"], - &[("BUNDLE_APP_CONFIG", "bundle-config")], - ); - assert_eq!(code, 0, "setup must exit 0.\n{out}\n{err}"); - - // The registration lives under the RELATIVE app-config dir, resolved - // against the project root — bundler's own resolution rule. - let plugin_reg_dir = root.join("bundle-config").join("plugin"); - std::fs::create_dir_all(&plugin_reg_dir) - .expect("create the bundler plugin registration dir"); - let index_path = plugin_reg_dir.join("index"); - std::fs::write( - &index_path, - format!( - "---\ncommands:\nhooks:\n after-install:\n - \"other-plugin\"\n \ - - \"socket-patch\"\n after-install-all:\n - \"socket-patch\"\n \ - before-install-all:\n - \"other-plugin\"\nload_paths:\n other-plugin:\n \ - - \"{root_s}/plugins/other-plugin/.\"\n socket-patch:\n \ - - \"{root_s}/.socket/bundler-plugin/.\"\nplugin_paths:\n \ - other-plugin: \"{root_s}/plugins/other-plugin\"\n \ - socket-patch: \"{root_s}/.socket/bundler-plugin\"\nsources:\n" - ), - ) - .expect("write the bundler plugin index fixture"); - - let (code, out, err) = run_env( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - &[("BUNDLE_APP_CONFIG", "bundle-config")], - ); - assert_eq!(code, 0, "remove must exit 0.\n{out}\n{err}"); - assert_eq!( - json_str(&parse_json(&out, "remove"), "status", "remove"), - "success" - ); - - let index = std::fs::read_to_string(&index_path).unwrap_or_else(|e| { - panic!( - "the index must SURVIVE (another plugin is still registered), \ - not be deleted wholesale: {e}" - ) - }); - assert!( - !index.contains("socket-patch"), - "every socket-patch registration entry must be stripped:\n{index}" - ); - for kept in [ - " after-install:\n - \"other-plugin\"", - " before-install-all:\n - \"other-plugin\"", - &format!(" other-plugin:\n - \"{root_s}/plugins/other-plugin/.\"") as &str, - &format!(" other-plugin: \"{root_s}/plugins/other-plugin\"") as &str, - ] { - assert!( - index.contains(kept), - "the OTHER plugin's registration must survive verbatim — \ - missing {kept:?} in:\n{index}" - ); - } - assert!( - !index.contains("after-install-all:"), - "a hook event left with NO subscribers must be dropped, not left \ - as an empty key bundler chokes on:\n{index}" - ); - } - - /// `bundle` resolves the Gemfile by walking UP from the invocation dir, - /// and `discover_bundler_project` documents the same contract. Run from a - /// subdirectory with NO `--cwd` flag the CLI defaults to the RELATIVE - /// `--cwd .` — whose lexical `Path::parent()` chain is `Some("")` → `None` - /// without ever reaching the real parent directories — so the walk-up must - /// re-root itself on the process cwd to find the ancestor Gemfile, and the - /// wiring must land at the Gemfile's dir, never the invocation subdir. - #[test] - fn gem_setup_discovers_root_project_from_subdirectory() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); - let sub = root.join("lib").join("widgets"); - std::fs::create_dir_all(&sub).unwrap(); - - // check from the subdir: the (unconfigured) root project must be found. - let (code, out, err) = run(&sub, &["setup", "--check", "--json"]); - assert_eq!( - code, 1, - "check from a subdirectory must find the unconfigured ancestor \ - Gemfile (exit 1), not report no_files (exit 0).\n{out}\n{err}" - ); - assert_eq!( - json_str( - &parse_json(&out, "check (subdir)"), - "status", - "check (subdir)" - ), - "needs_configuration" - ); - - // setup from the subdir: wires the ROOT project. - let (code, out, err) = run(&sub, &["setup", "--yes", "--json"]); - assert_eq!( - code, 0, - "setup from a subdirectory must exit 0.\n{out}\n{err}" - ); - assert_eq!( - json_str( - &parse_json(&out, "setup (subdir)"), - "status", - "setup (subdir)" - ), - "success" - ); - let body = gemfile_body(root); - assert!( - body.contains(MANAGED_MARKER), - "managed block lands in the ROOT Gemfile:\n{body}" - ); - assert!( - root.join(PLUGIN_DIR).join("plugins.rb").exists(), - "plugin dir lands at the Gemfile's dir (the project root)" - ); - assert!( - !sub.join(PLUGIN_DIR).exists(), - "no plugin dir may be generated in the invocation subdir" - ); - assert!( - !sub.join("Gemfile").exists(), - "no Gemfile may be synthesized in the invocation subdir" - ); - } -} - -// ───────────────────────────────────────────────────────────────────────── -// Bundler-1.17 docker leg for the >= 2.2 plugin floor. -// -// The host guards above pin the LOCK-probe path (`BUNDLED WITH 1.17.3`) -// against the workspace binary; this leg drives a real bundler 1.17.3 -// toolchain (image: tests/docker/Dockerfile.gem-b1) end to end, covering the -// `bundle --version` fallback path too, and proves the property the floor -// exists for: after `setup` refuses, `bundle install` KEEPS WORKING. Before -// the floor, setup wired `plugin 'socket-patch', path: ...` and bundler 1.17 -// (whose Plugin::DSL undef's `:path`) resolved it as an ordinary gem — -// every later install exited 7 with an error that never named socket-patch -// (campaign-confirmed). -// -// Soft-skips loudly when Docker / the image is absent, mirroring the -// docker_e2e_* convention. NOTE: the leg runs the socket-patch binary BAKED -// INTO the image — an image built before this fix still wires the project -// and fails this leg deterministically; rebuild it (see the Dockerfile -// header), or point SOCKET_PATCH_GEM_B1_IMAGE at a fresh uniquely-tagged -// build. -// ───────────────────────────────────────────────────────────────────────── -mod bundler_floor_docker { - use std::process::Command; - - fn image() -> String { - std::env::var("SOCKET_PATCH_GEM_B1_IMAGE") - .unwrap_or_else(|_| "socket-patch-test-gem-b1:latest".to_string()) - } - - /// Returns `true` when the leg should skip (docker or the image missing). - /// Prints a skip notice — Rust tests have no native "skipped" outcome. - /// Build locally with - /// `docker build -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:latest .` - #[must_use] - fn skip_if_no_image(image: &str) -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", image]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `{image}` not present"); - return true; - } - false - } - - /// The whole flow runs INSIDE the container (no bind mounts, no network): - /// scaffold → setup refuses (version fallback path, no lock yet) → - /// `bundle install` still works and writes the 1.17 lock → setup refuses - /// again (lock path) → `setup --check` red-flags. Host-side assertions - /// read the markers + JSON the script echoes. - #[test] - fn bundler_1x_setup_refuses_and_installs_keep_working() { - let image = image(); - if skip_if_no_image(&image) { - return; - } - let script = r#" -set -eu -export SOCKET_NO_CONFIG=1 SOCKET_TELEMETRY_DISABLED=1 -mkdir -p /workspace/proj && cd /workspace/proj -printf '# no dependencies\n' > Gemfile -cp Gemfile /tmp/gemfile-pre - -# 1) No lock yet: the probe falls back to `bundle --version` (1.17.3). -set +e -socket-patch setup --yes --json --ecosystems gem > setup.json 2> setup.err -rc=$? -set -e -echo "SETUP-RC=$rc" -cat setup.json -if grep -q 'socket-patch:managed' Gemfile; then echo 'WIRED-BUT-MUST-NOT'; exit 1; fi -if [ -e .socket/bundler-plugin ]; then echo 'PLUGIN-DIR-BUT-MUST-NOT'; exit 1; fi -cmp -s Gemfile /tmp/gemfile-pre && echo 'GEMFILE-UNTOUCHED' - -# 2) The refused project still installs (writes the 1.17 lock). -bundle install > install.log 2>&1 || { echo 'INSTALL-BROKE'; cat install.log; exit 1; } -echo 'INSTALL-OK' -grep -q '1\.17\.3' Gemfile.lock && echo 'LOCK-1X' - -# 3) Lock present: the deterministic BUNDLED WITH path refuses too. -set +e -socket-patch setup --yes --json --ecosystems gem > setup2.json 2>&1 -rc2=$? -set -e -echo "SETUP2-RC=$rc2" - -# 4) check red-flags the unsupported state. -set +e -socket-patch setup --check --json --ecosystems gem > check.json 2>&1 -rc3=$? -set -e -echo "CHECK-RC=$rc3" -cat check.json -echo 'ALL-DONE' -"#; - let out = Command::new("docker") - .args([ - "run", - "--rm", - "--network", - "none", - &image, - "bash", - "-c", - script, - ]) - .output() - .expect("docker run"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - let ctx = format!("stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("ALL-DONE"), - "the in-container flow aborted early (a refused project whose \ - `bundle install` breaks is the campaign defect).\n{ctx}" - ); - assert!( - stdout.contains("SETUP-RC=1"), - "setup must refuse (exit 1) under bundler 1.17.3.\n{ctx}" - ); - // The refusal names the detected bundler and the floor (setup.json is - // echoed into stdout). - assert!( - stdout.contains("1.17.3") && stdout.contains("2.2"), - "the refusal must name bundler 1.17.3 and the >= 2.2 floor.\n{ctx}" - ); - assert!( - stdout.contains("GEMFILE-UNTOUCHED"), - "the Gemfile must be byte-untouched after the refusal.\n{ctx}" - ); - assert!( - stdout.contains("INSTALL-OK") && stdout.contains("LOCK-1X"), - "`bundle install` must keep working after the refusal.\n{ctx}" - ); - assert!( - stdout.contains("SETUP2-RC=1"), - "the lock-probe path must refuse as well.\n{ctx}" - ); - assert!( - stdout.contains("CHECK-RC=1") && stdout.contains("\"status\": \"error\""), - "`setup --check` must red-flag the unsupported bundler.\n{ctx}" - ); - } -} - -// ───────────────────────────────────────────────────────────────────────── -// Runtime guards for the GENERATED plugin, driven through a REAL `bundle -// install` (host bundler; validated against 4.0.15, and the same flows -// against bundler 2.7 in the gem Docker image during development). Each test -// wires a scratch project with the actual CLI binary (`setup --yes`), points -// SOCKET_PATCH_BIN at a fake apply whose exit code and invocation log we -// control, and asserts on bundler's real exit status + the on-disk state. -// -// Soft-skips (loudly, mirroring the docker_e2e_* convention) when no -// `bundle`/`ruby` toolchain is on PATH — the CI setup-matrix job and any dev -// machine with ruby run them for real. -// ───────────────────────────────────────────────────────────────────────── -#[cfg(unix)] -mod plugin_runtime { - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::process::Command; - - /// Manifest fixture: one committed gem patch record (hashes are dummies — - /// the fake apply never checks them; what matters is that the manifest - /// EXISTS so the plugin's applier engages). - const MANIFEST: &str = r#"{ - "patches": { - "pkg:gem/colorize@1.1.0": { - "uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "exportedAt": "2026-01-01T00:00:00Z", - "files": { "package/lib/colorize.rb": { "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" } }, - "vulnerabilities": {}, - "description": "plugin-runtime fixture", - "license": "MIT", - "tier": "free" - } - } -} -"#; - - /// Hand-pinned stamp locations (independent oracles, not copies of the - /// template constants): the project-scoped stamp the plugin must write, - /// and the legacy fixed-name file it must never write again. - const STAMP_REL: &str = ".socket/gem-plugin-stamp"; - const LEGACY_STAMP_NAME: &str = ".socket-patch-gem-stamp"; - - fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - fn have(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - - /// Strip every ambient var that could flip a verdict: the CLI's SOCKET_* - /// surface (a dev's SOCKET_PATCH_STRICT or SOCKET_DRY_RUN must not leak - /// into the child), and bundler/rubygems config that could retarget the - /// install (BUNDLE_GEMFILE, GEM_HOME, RUBYOPT). - fn scrub(cmd: &mut Command) { - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy().into_owned(); - let hit = (name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG") - || name.starts_with("BUNDLE_") - || name.starts_with("GEM_") - || name == "RUBYOPT"; - if hit { - cmd.env_remove(&name); - } - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - } - - fn run(mut cmd: Command) -> (i32, String, String) { - let out = cmd.output().expect("spawn child process"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// `bundle install` in `root` with the fake apply bin + extra env. - fn bundle_install(root: &Path, fake: &Path, extra: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = Command::new("bundle"); - cmd.arg("install").current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor/bundle"); - cmd.env("SOCKET_PATCH_BIN", fake); - for (k, v) in extra { - cmd.env(k, v); - } - run(cmd) - } - - /// A fake `socket-patch` that logs each invocation and exits `code`. - /// Returns (bin path, log path). - fn write_fake_apply(dir: &Path, code: i32) -> (PathBuf, PathBuf) { - let log = dir.join("apply.log"); - std::fs::write(&log, "").unwrap(); - let bin = dir.join("fake-socket-patch"); - std::fs::write( - &bin, - format!( - "#!/bin/sh\nprintf 'APPLY-CALLED %s\\n' \"$*\" >> '{}'\nexit {code}\n", - log.display() - ), - ) - .unwrap(); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - (bin, log) - } - - fn apply_calls(log: &Path) -> Vec { - std::fs::read_to_string(log) - .unwrap_or_default() - .lines() - .map(str::to_string) - .collect() - } - - /// Scaffold a setup-wired project the way a fresh clone sees it: a - /// Gemfile, a committed manifest, and the plugin generated by the REAL - /// binary. A zero-dependency Gemfile keeps the install offline — bundler - /// still registers the plugin and fires `after-install-all` (verified on - /// bundler 2.7 and 4.0.15), which is all these guards need. - fn scaffold(root: &Path) { - std::fs::write(root.join("Gemfile"), "# no dependencies\n").unwrap(); - std::fs::create_dir_all(root.join(".socket")).unwrap(); - std::fs::write(root.join(".socket/manifest.json"), MANIFEST).unwrap(); - - let mut cmd = Command::new(binary()); - cmd.args(["setup", "--yes", "--json"]).current_dir(root); - scrub(&mut cmd); - let (code, out, err) = run(cmd); - assert_eq!(code, 0, "setup --yes must wire the plugin.\n{out}\n{err}"); - assert!( - root.join(".socket/bundler-plugin/plugins.rb").exists(), - "setup must generate plugins.rb" - ); - } - - /// [P0 bootstrap deadlock] On a fresh clone of a setup-wired project the - /// FIRST `bundle install` evaluates plugins.rb at plugin REGISTRATION, - /// before any project gem lands; apply legitimately finds nothing and - /// exits 1. The old plugin raised Bundler::BundlerError there, so that - /// first install (and every retry) died with "Failed to install plugin" - /// — reproduced at exit 29 under bundler 4.0.15 / exit 1 under 2.7. The - /// generated plugin must instead warn (with the manual remediation) and - /// let the install succeed. - #[test] - fn first_bundle_install_survives_failing_apply() { - if !have("bundle") { - eprintln!("skip plugin_runtime: bundler not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - let (fake, log) = write_fake_apply(root, 1); - - let (code, out, err) = bundle_install(root, &fake, &[]); - assert_eq!( - code, 0, - "the FIRST bundle install of a setup-wired fresh checkout must \ - succeed even when apply fails (bootstrap deadlock).\n{out}\n{err}" - ); - // Anti-vacuity: the failure was real — the plugin DID shell apply. - let calls = apply_calls(&log); - assert!( - calls - .iter() - .any(|c| c.contains("apply --ecosystems gem --offline --silent")), - "the plugin must have invoked the (failing) gem-scoped apply:\n{calls:?}" - ); - // The warning names what failed and how to remediate. - assert!( - err.contains("socket-patch:"), - "a failing apply must be surfaced on stderr:\n{err}" - ); - assert!( - err.contains("socket-patch apply --ecosystems gem"), - "the warning must name the manual remediation command:\n{err}" - ); - assert!( - err.contains("SOCKET_PATCH_STRICT"), - "the warning must mention the strict escape hatch:\n{err}" - ); - - // A retry is not poisoned either (plugin registration completed). - let (code, out, err) = bundle_install(root, &fake, &[]); - assert_eq!( - code, 0, - "retried bundle install must also succeed.\n{out}\n{err}" - ); - } - - /// SOCKET_PATCH_STRICT=1 restores raise-on-failure for builds that must - /// not proceed with unpatched gems: the same failing-apply install must - /// break the build again. - #[test] - fn strict_mode_fails_bundle_install_on_apply_failure() { - if !have("bundle") { - eprintln!("skip plugin_runtime: bundler not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - let (fake, log) = write_fake_apply(root, 1); - - let (code, out, err) = bundle_install(root, &fake, &[("SOCKET_PATCH_STRICT", "1")]); - assert_ne!( - code, 0, - "strict mode must fail the build on a patch failure.\n{out}\n{err}" - ); - assert!( - !apply_calls(&log).is_empty(), - "the strict failure must come from a real apply invocation" - ); - assert!( - err.contains("socket-patch"), - "the strict failure must carry the socket-patch message:\n{err}" - ); - // The strict raise must tell the truth about the active mode: the - // tolerant trailer ("`bundle install` continues; set - // SOCKET_PATCH_STRICT=1 ...") is false on both counts while the - // install is failing and the var is already set. - assert!( - err.contains("because SOCKET_PATCH_STRICT is set"), - "the strict failure must say WHY the install is failing:\n{err}" - ); - assert!( - !err.contains("`bundle install` continues"), - "the strict failure must not claim the install continues:\n{err}" - ); - } - - /// [P0 regression: committed stale stamp] `.socket/` is a committed - /// directory, so a stamp that reaches version control (a blanket - /// `git add .socket`) arrives on every fresh clone BEFORE the first - /// `bundle install`. If the bootstrap gate keyed on the stamp's - /// existence, plugin REGISTRATION would shell the applier (targets - /// absent -> apply fails) and a strict-mode raise there resurrects the - /// exact deadlock this plugin exists to avoid — exit 29, "Failed to - /// install plugin", every retry identical (reproduced on bundler - /// 4.0.15). The gate must key on the patch targets existing on disk: - /// registration completes, strict enforcement waits for the - /// post-install hooks. - #[test] - fn committed_stale_stamp_does_not_deadlock_strict_fresh_clone() { - if !have("bundle") { - eprintln!("skip plugin_runtime: bundler not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - // The stale stamp a teammate committed, exactly as a fresh clone sees it. - std::fs::write(root.join(STAMP_REL), "a".repeat(64)).unwrap(); - let (fake, log) = write_fake_apply(root, 1); - - let (code, out, err) = bundle_install(root, &fake, &[("SOCKET_PATCH_STRICT", "1")]); - // Registration must complete — the strict failure may only come from - // the post-install hooks, never from plugin registration. - assert!( - !err.contains("Failed to install plugin"), - "a committed stale stamp must not fail plugin REGISTRATION.\n{out}\n{err}" - ); - assert!( - root.join(".bundle/plugin/index").is_file(), - "registration must be recorded despite the strict failure.\n{out}\n{err}" - ); - assert_ne!( - code, 0, - "strict mode still fails the install — from the hook.\n{out}\n{err}" - ); - assert!( - !apply_calls(&log).is_empty(), - "anti-vacuity: the forced post-install apply ran (and failed)" - ); - - // The deadlock is gone: with apply working, the SAME checkout (stale - // stamp still in place) converges on retry. - let (fake, _log) = write_fake_apply(root, 0); - let (code, out, err) = bundle_install(root, &fake, &[("SOCKET_PATCH_STRICT", "1")]); - assert_eq!( - code, 0, - "retry with a working apply must succeed — registration was never \ - poisoned.\n{out}\n{err}" - ); - } - - /// The bootstrap gate reads the LIVE gem tree, never the stamp. Both - /// directions matter: with no patch target on disk the gated triggers - /// must not shell out no matter what a (stale, possibly committed) stamp - /// says; with the target present they must re-apply even when the stamp - /// is missing — a `bundle pristine` run after deleting the stamp used to - /// leave the patches silently reverted until the next `bundle install` - /// (reproduced on bundler 4.0.15). - #[test] - fn bootstrap_gate_keys_on_target_presence_not_stamp() { - if !have("ruby") { - eprintln!("skip plugin_runtime: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - let (fake, log) = write_fake_apply(root, 0); - - // Drive the gated path exactly as the load-time / per-gem hooks do. - let drive = |label: &str| { - let mut cmd = Command::new("ruby"); - cmd.args([ - "-e", - "require \"bundler\"; load ARGV[0]; SocketPatch.apply!(bootstrap_gate: true)", - "--", - ]) - .arg(root.join(".socket/bundler-plugin/plugins.rb")) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor/bundle"); - cmd.env("SOCKET_PATCH_BIN", &fake); - let (code, out, err) = run(cmd); - assert_eq!(code, 0, "{label}: gated drive failed.\n{out}\n{err}"); - }; - - // Fresh clone: no target on disk, stale stamp committed. - std::fs::write(root.join(STAMP_REL), "b".repeat(64)).unwrap(); - drive("no target, stale stamp"); - assert_eq!( - apply_calls(&log).len(), - 0, - "no target on disk -> the gated trigger must not shell out, \ - whatever the stamp says" - ); - - // Target installed, stamp deleted: the pristine-heal case. - let mut cmd = Command::new("ruby"); - cmd.args(["-e", "require \"bundler\"; print Bundler.bundle_path"]) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor/bundle"); - let (code, bundle_path, err) = run(cmd); - assert_eq!(code, 0, "Bundler.bundle_path probe failed: {err}"); - let target = PathBuf::from(bundle_path.trim()).join("gems/colorize-1.1.0/lib/colorize.rb"); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::write(&target, "REVERTED BY PRISTINE\n").unwrap(); - std::fs::remove_file(root.join(STAMP_REL)).unwrap(); - - drive("target present, no stamp"); - assert_eq!( - apply_calls(&log).len(), - 1, - "with the target on disk and nothing validly stamped, the gated \ - trigger must re-apply — deleting the stamp defers nothing" - ); - drive("digest-gated no-op"); - assert_eq!( - apply_calls(&log).len(), - 1, - "the re-apply stamped the state; the next gated probe is a no-op" - ); - } - - /// [P2 stamp location] A successful apply stamps the PROJECT - /// (.socket/gem-plugin-stamp), not a fixed-name file under the bundle - /// path (machine-global when no path is configured — shared and clobbered - /// across every socket-patch project on the host). And the digest gate - /// holds: a second, fully-cached install runs exactly one more (forced - /// after-install-all) apply — the gated triggers stay quiet. - #[test] - fn successful_apply_stamps_project_scoped() { - if !have("bundle") { - eprintln!("skip plugin_runtime: bundler not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - let (fake, log) = write_fake_apply(root, 0); - - let (code, out, err) = bundle_install(root, &fake, &[]); - assert_eq!(code, 0, "install must succeed.\n{out}\n{err}"); - assert!( - !err.contains("socket-patch:"), - "a successful apply must not warn:\n{err}" - ); - - let stamp = root.join(STAMP_REL); - assert!( - stamp.is_file(), - "the digest stamp must land at the project-scoped {STAMP_REL}" - ); - let content = std::fs::read_to_string(&stamp).unwrap(); - let content = content.trim(); - assert!( - content.len() == 64 && content.bytes().all(|b| b.is_ascii_hexdigit()), - "the stamp must hold one sha256 hex digest, got: {content:?}" - ); - // No legacy fixed-name stamp anywhere under the bundle path. - let legacy_hits: Vec<_> = walk(&root.join("vendor")) - .into_iter() - .filter(|p| p.file_name().is_some_and(|n| n == LEGACY_STAMP_NAME)) - .collect(); - assert!( - legacy_hits.is_empty(), - "no legacy bundle-path stamp may be written: {legacy_hits:?}" - ); - - let after_first = apply_calls(&log).len(); - let (code, out, err) = bundle_install(root, &fake, &[]); - assert_eq!(code, 0, "cached install must succeed.\n{out}\n{err}"); - assert_eq!( - apply_calls(&log).len(), - after_first + 1, - "a fully-cached install runs exactly the one forced \ - after-install-all apply; the digest-gated triggers must not \ - shell out again" - ); - } - - /// [P2 remove leaves registration dangling] Bundler records the plugin - /// machine-locally at first install (`.bundle/plugin/index`: hook - /// subscriptions + plugin/load paths). `setup --remove` unwires the - /// Gemfile and deletes the generated plugin dir — if it leaves that - /// registration behind, EVERY later `bundle install` prints bundler's - /// 5-line "The following plugin paths don't exist ... Continuing without - /// installing plugin socket-patch" block with a misleading reinstall - /// suggestion (install still exits 0, so nothing ever heals it). - /// Reproduced against real bundler 2.7.2 and 4.0.18 in the 2026-08 e2e - /// campaign; this drives the same flow with the host bundler. - #[test] - fn setup_remove_clears_bundler_plugin_registration() { - if !have("bundle") { - eprintln!("skip plugin_runtime: bundler not on PATH"); - return; - } - let tmp = tempfile::tempdir().expect("create tempdir for the test project"); - let root = tmp.path(); - scaffold(root); - let (fake, _log) = write_fake_apply(root, 0); - - // First install: bundler registers the plugin machine-locally. - let (code, out, err) = bundle_install(root, &fake, &[]); - assert_eq!(code, 0, "wired install must succeed.\n{out}\n{err}"); - let index_path = root.join(".bundle/plugin/index"); - assert!( - std::fs::read_to_string(&index_path) - .unwrap_or_default() - .contains("socket-patch"), - "precondition: bundler must have registered the plugin at {}", - index_path.display() - ); - - // Unwire. - let mut cmd = Command::new(binary()); - cmd.args(["setup", "--remove", "--yes", "--json"]) - .current_dir(root); - scrub(&mut cmd); - let (code, out, err) = run(cmd); - assert_eq!(code, 0, "setup --remove must exit 0.\n{out}\n{err}"); - assert!( - out.contains("\"status\": \"success\""), - "setup --remove must report success:\n{out}" - ); - - // No socket-patch registration may survive under .bundle/plugin. - let residue = std::fs::read_to_string(&index_path).unwrap_or_default(); - assert!( - !residue.contains("socket-patch"), - ".bundle/plugin must hold no socket-patch entry after remove:\n{residue}" - ); - - // And the REAL oracle: the next bundle install is silent about the - // unwired plugin — no "plugin paths don't exist", no "Continuing - // without installing plugin", on either stream. - let (code, out, err) = bundle_install(root, &fake, &[]); - assert_eq!( - code, 0, - "post-remove install must still succeed.\n{out}\n{err}" - ); - let combined = format!("{out}\n{err}"); - assert!( - !combined.contains("plugin paths don't exist"), - "post-remove `bundle install` must not warn about the unwired \ - plugin's missing paths:\n{combined}" - ); - assert!( - !combined.contains("Continuing without installing plugin"), - "post-remove `bundle install` must not print bundler's \ - skipped-plugin block:\n{combined}" - ); - } - - /// [P1 digest honesty + migration] Drive the applier directly with plain - /// ruby (no bundler process, no network): the digest stamp must reflect - /// the ACTUAL on-disk gem-file state, so an out-of-band reversion - /// (`bundle pristine`, `gem pristine`, a manual edit) flips the digest - /// and the next trigger re-applies; and the legacy machine-global stamp - /// must be cleaned up, never read. - #[test] - fn digest_tracks_gem_file_content_and_legacy_stamp_is_removed() { - if !have("ruby") { - eprintln!("skip plugin_runtime: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - let (fake, log) = write_fake_apply(root, 0); - - // Where bundler would install this project's gems (BUNDLE_PATH set, - // so /vendor/bundle/ruby/). - let mut cmd = Command::new("ruby"); - cmd.args(["-e", "require \"bundler\"; print Bundler.bundle_path"]) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor/bundle"); - let (code, bundle_path, err) = run(cmd); - assert_eq!(code, 0, "Bundler.bundle_path probe failed: {err}"); - let bundle_path = PathBuf::from(bundle_path.trim()); - - // The installed file the manifest's patch record targets. - let target = bundle_path.join("gems/colorize-1.1.0/lib/colorize.rb"); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::write(&target, "UPSTREAM CONTENT\n").unwrap(); - // A stale stamp from the old plugin version in the shared location. - let legacy = bundle_path.join(LEGACY_STAMP_NAME); - std::fs::write(&legacy, "stale digest from another project\n").unwrap(); - - let drive = |label: &str| { - let mut cmd = Command::new("ruby"); - cmd.args([ - "-e", - "require \"bundler\"; load ARGV[0]; SocketPatch.apply!", - "--", - ]) - .arg(root.join(".socket/bundler-plugin/plugins.rb")) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor/bundle"); - cmd.env("SOCKET_PATCH_BIN", &fake); - let (code, out, err) = run(cmd); - assert_eq!( - code, 0, - "{label}: driving the applier failed.\n{out}\n{err}" - ); - }; - - drive("initial apply"); - assert_eq!( - apply_calls(&log).len(), - 1, - "first drive must shell apply (nothing stamped yet)" - ); - assert!(root.join(STAMP_REL).is_file(), "stamp written"); - assert!( - !legacy.exists(), - "the legacy bundle-path stamp must be deleted on the first run" - ); - - drive("stamped no-op"); - assert_eq!( - apply_calls(&log).len(), - 1, - "unchanged state must be digest-gated to a no-op" - ); - - // Out-of-band reversion: the committed inputs (manifest, blobs, lock) - // are untouched — only the installed gem file changed back. - std::fs::write(&target, "REVERTED BY PRISTINE\n").unwrap(); - drive("after reversion"); - assert_eq!( - apply_calls(&log).len(), - 2, - "reverting the installed gem file must flip the digest and \ - re-run apply — a manifest-only digest misses this" - ); - } - - /// [P2 Windows platform-gem glob] `Bundler.bundle_path` carries - /// backslash separators through verbatim (Windows spelling), and - /// `Dir.glob` treats `\` as an escape on EVERY platform — so the - /// platform-install wildcard (`/--*/`) built - /// from that base escape-eats the separator, matches nothing, and - /// platform installs (colorize-1.1.0-x64-mingw-ucrt) silently drop out - /// of the digest: a `bundle pristine` reversion of them leaves the stamp - /// matching and the re-apply skipped. Simulated on this host by feeding - /// bundler a backslash-bearing BUNDLE_PATH while the real tree lives at - /// the forward-slash spelling — exactly the two-spellings-one-directory - /// situation Windows creates (glob escape semantics are identical - /// everywhere). The plugin must glob a slash-normalized base; forward - /// slashes are valid separators on Windows. - #[test] - fn backslash_bundle_path_still_digests_platform_gem_files() { - if !have("ruby") { - eprintln!("skip plugin_runtime: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - scaffold(root); - let (fake, log) = write_fake_apply(root, 0); - - // Where bundler puts this project's gems under the backslash spelling. - let mut cmd = Command::new("ruby"); - cmd.args(["-e", "require \"bundler\"; print Bundler.bundle_path"]) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor\\bundle"); - let (code, bundle_path, err) = run(cmd); - assert_eq!(code, 0, "Bundler.bundle_path probe failed: {err}"); - let bundle_path = bundle_path.trim().to_string(); - assert!( - bundle_path.contains('\\'), - "precondition: bundler must carry the backslash spelling through \ - verbatim (the Windows behavior this test simulates), got: \ - {bundle_path:?}" - ); - - // On Windows both spellings denote the SAME directory; materialize - // the real tree at the slash spelling — the one the normalized glob - // must reach from the backslash-bearing base. Only a PLATFORM install - // exists: the plain `colorize-1.1.0` direct join never globs and is - // not at issue. - let target = PathBuf::from(bundle_path.replace('\\', "/")) - .join("gems/colorize-1.1.0-x64-mingw-ucrt/lib/colorize.rb"); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::write(&target, "PATCHED PLATFORM CONTENT\n").unwrap(); - - let drive = |label: &str| { - let mut cmd = Command::new("ruby"); - cmd.args([ - "-e", - "require \"bundler\"; load ARGV[0]; SocketPatch.apply!", - "--", - ]) - .arg(root.join(".socket/bundler-plugin/plugins.rb")) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor\\bundle"); - cmd.env("SOCKET_PATCH_BIN", &fake); - let (code, out, err) = run(cmd); - assert_eq!( - code, 0, - "{label}: driving the applier failed.\n{out}\n{err}" - ); - }; - - drive("initial apply"); - assert_eq!( - apply_calls(&log).len(), - 1, - "first drive must shell apply (nothing stamped yet)" - ); - assert!(root.join(STAMP_REL).is_file(), "stamp written"); - drive("stamped no-op"); - assert_eq!( - apply_calls(&log).len(), - 1, - "unchanged state must be digest-gated to a no-op" - ); - - // The pristine reversion the digest exists to catch — of the - // PLATFORM install this time. - std::fs::write(&target, "REVERTED BY PRISTINE\n").unwrap(); - drive("after platform-install reversion"); - assert_eq!( - apply_calls(&log).len(), - 2, - "reverting the platform gem install must flip the digest and \ - re-run apply — an escape-eaten glob omits platform installs \ - from the digest and skips this re-apply" - ); - - // And directly: the platform install is enumerated as a patch target. - let mut cmd = Command::new("ruby"); - cmd.args([ - "-e", - "require \"bundler\"; load ARGV[0]; puts SocketPatch.patch_target_files", - "--", - ]) - .arg(root.join(".socket/bundler-plugin/plugins.rb")) - .current_dir(root); - scrub(&mut cmd); - cmd.env("BUNDLE_PATH", "vendor\\bundle"); - cmd.env("SOCKET_PATCH_BIN", &fake); - let (code, out, err) = run(cmd); - assert_eq!(code, 0, "patch_target_files probe failed.\n{out}\n{err}"); - assert!( - out.contains("colorize-1.1.0-x64-mingw-ucrt"), - "patch_target_files must enumerate the platform install under a \ - backslash-bearing bundle path:\n{out}" - ); - } - - fn walk(dir: &Path) -> Vec { - let mut out = Vec::new(); - let Ok(entries) = std::fs::read_dir(dir) else { - return out; - }; - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - out.extend(walk(&p)); - } else { - out.push(p); - } - } - out - } - - /// MANIFEST-LESS vendored checkout (the depscan / `scan --mode vendored` - /// shape: a committed `.socket/vendor/gem//` artifact wired by a - /// Gemfile `path:`, NO `.socket/manifest.json`) with the setup plugin - /// wired, driven with the REAL binary as the plugin's apply under - /// `SOCKET_PATCH_STRICT=1`: the plugin's `apply` finds no manifest and - /// must exit 0, so a strict `bundle install` still succeeds (and the - /// real bundler locks the PATH section); the embedded `apply --vex` of - /// the installed checkout then attests the vendored patch from the lock - /// and the patch API. Offline, with no ledger, the patch is - /// `record_unavailable` — never attested from the `path:` alone. - #[test] - fn manifest_less_vendored_checkout_installs_strict_and_apply_vex_attests() { - use crate::vex_e2e_common::{ - assert_attested, assert_not_attested, patch_view, run_vex, Marker, PatchApi, VexRun, - VexVia, - }; - if !have("bundle") { - eprintln!("skip plugin_runtime: bundler not on PATH"); - return; - } - const UUID: &str = "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d"; - const PURL: &str = "pkg:gem/vexgem@1.0.0"; - const PATCHED: &[u8] = b"module Vexgem\n STATUS = \"PATCHED\"\nend\n"; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let rel = format!(".socket/vendor/gem/{UUID}/vexgem-1.0.0"); - let art = root.join(&rel); - std::fs::create_dir_all(art.join("lib")).unwrap(); - std::fs::write(art.join("lib/vexgem.rb"), PATCHED).unwrap(); - std::fs::write( - art.join("vexgem.gemspec"), - "Gem::Specification.new do |s|\n s.name = \"vexgem\"\n s.version = \"1.0.0\"\n \ - s.summary = \"vex fixture\"\n s.authors = [\"socket-patch e2e\"]\n \ - s.files = [\"lib/vexgem.rb\"]\nend\n", - ) - .unwrap(); - std::fs::write( - root.join("Gemfile"), - format!("gem \"vexgem\", \"1.0.0\", path: \"{rel}\"\n"), - ) - .unwrap(); - let mut cmd = Command::new(binary()); - cmd.args(["setup", "--yes", "--json"]).current_dir(root); - scrub(&mut cmd); - let (code, out, err) = run(cmd); - assert_eq!(code, 0, "setup --yes must wire the plugin.\n{out}\n{err}"); - assert!( - !root.join(".socket/manifest.json").exists(), - "setup wrote a manifest" - ); - - let (code, out, err) = bundle_install(root, &binary(), &[("SOCKET_PATCH_STRICT", "1")]); - assert_eq!( - code, 0, - "a STRICT install of a manifest-less vendored checkout must succeed: the \ - plugin's apply has nothing to apply.\n{out}\n{err}" - ); - let lock = std::fs::read_to_string(root.join("Gemfile.lock")).unwrap(); - assert!( - lock.contains(&format!( - "PATH\n remote: {rel}\n specs:\n vexgem (1.0.0)" - )), - "bundler must lock the vendored PATH source:\n{lock}" - ); - assert!( - !root.join(".socket/manifest.json").exists(), - "the plugin wrote a manifest" - ); - - let api = PatchApi::start(vec![( - UUID.into(), - patch_view( - UUID, - PURL, - &[("lib/vexgem.rb", &crate::vex_e2e_common::git_sha256(PATCHED))], - &[("GHSA-vexg-setu-0001", &["CVE-2026-9191"])], - ), - )]); - let base = VexRun { - product: Some("pkg:gem/app@1.0.0".into()), - ..VexRun::online(&api) - }; - let out = run_vex(&binary(), root, &base.clone().via(VexVia::Apply)); - assert_eq!(out.code, Some(0), "apply --vex: {out}\n{lock}"); - assert_eq!(out.envelope["status"], "noManifest", "{out}"); - assert_attested( - out.doc(), - PURL, - UUID, - Marker::Vendored, - &[("GHSA-vexg-setu-0001", &["CVE-2026-9191"])], - ); - let offline = VexRun { - offline: true, - ..base - }; - let before = api.request_count(); - let out = run_vex(&binary(), root, &offline); - assert_eq!(out.code, Some(1), "offline, no ledger: {out}"); - assert_not_attested(&out.envelope, PURL, "record_unavailable"); - assert_eq!(api.request_count(), before, "--offline made requests"); - } -} - -// ───────────────────────────────────────────────────────────────────────── -// Guards for the RubyGems CLI launcher (gem/socket-patch), driven with the -// host ruby. Ruby-gated with a loud skip, like `plugin_runtime` above. -// ───────────────────────────────────────────────────────────────────────── -#[cfg(unix)] -mod launcher_guard { - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::process::Command; - - fn have_ruby() -> bool { - Command::new("ruby") - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - - /// The launcher under test, resolved from the workspace checkout. - fn launcher_path() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../gem/socket-patch/lib/socket_patch/launcher.rb") - .canonicalize() - .expect("launcher.rb must exist in the workspace") - } - - /// Strip the ambient vars that could flip a verdict, mirroring - /// `plugin_runtime::scrub`: the CLI's SOCKET_* surface, and the - /// bundler/rubygems config a `bundle exec cargo test` run injects - /// (RUBYOPT=-rbundler/setup, BUNDLE_*, GEM_*) — which would activate a - /// foreign bundle inside the child ruby under test. - fn scrub(cmd: &mut Command) { - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy().into_owned(); - let hit = (name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG") - || name.starts_with("BUNDLE_") - || name.starts_with("GEM_") - || name == "RUBYOPT"; - if hit { - cmd.env_remove(&name); - } - } - } - - /// Run `script` (which `load`s the launcher via the SP_LAUNCHER env) with - /// a scratch HOME/cache so no real launcher cache is consulted. - fn run_ruby(script: &Path, cache: &Path, envs: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = Command::new("ruby"); - cmd.arg(script); - scrub(&mut cmd); - cmd.env("SP_LAUNCHER", launcher_path()); - cmd.env("XDG_CACHE_HOME", cache); - for (k, v) in envs { - cmd.env(k, v); - } - let out = cmd.output().expect("spawn ruby"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - fn write_executable(path: &Path, body: &str) { - std::fs::write(path, body).unwrap(); - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - - /// [P2a] The Windows arm must propagate the child's REAL exit code — the - /// old `system(...) ? $?.exitstatus : 1` collapsed every non-zero exit to - /// 1, erasing meaningful codes like `setup --check`'s needs-configuration - /// signal. Forced onto the Windows branch by stubbing `Gem.win_platform?`. - #[test] - fn windows_branch_propagates_child_exit_code() { - if !have_ruby() { - eprintln!("skip launcher_guard: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let child = tmp.path().join("exit7"); - write_executable(&child, "#!/bin/sh\nexit 7\n"); - let script = tmp.path().join("drive.rb"); - std::fs::write( - &script, - "require \"rubygems\"\n\ - def Gem.win_platform?; true; end\n\ - load ENV.fetch(\"SP_LAUNCHER\")\n\ - SocketPatch::Launcher.run([\"anything\"])\n", - ) - .unwrap(); - - let (code, out, err) = run_ruby( - &script, - &tmp.path().join("cache"), - &[("SOCKET_PATCH_BIN", child.to_str().unwrap())], - ); - assert_eq!( - code, 7, - "the child's exit 7 must survive the spawn+wait arm, not \ - collapse to 1.\nstdout:\n{out}\nstderr:\n{err}" - ); - } - - /// [nit] First-run failures outside LauncherError (DNS outages, TLS - /// errors, ...) must exit with a clean one-line message, not a raw ruby - /// backtrace. - #[test] - fn unexpected_download_errors_exit_cleanly() { - if !have_ruby() { - eprintln!("skip launcher_guard: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let script = tmp.path().join("drive.rb"); - std::fs::write( - &script, - "require \"net/http\"\n\ - def (Net::HTTP).start(*args, &block)\n\ - raise SocketError, \"simulated dns failure\"\n\ - end\n\ - load ENV.fetch(\"SP_LAUNCHER\")\n\ - SocketPatch::Launcher.run([\"--version\"])\n", - ) - .unwrap(); - - let (code, out, err) = run_ruby(&script, &tmp.path().join("cache"), &[]); - assert_eq!(code, 1, "a download failure exits 1.\n{out}\n{err}"); - assert!( - err.contains("socket-patch:") && err.contains("SocketError"), - "the failure must be reported as a clean launcher message:\n{err}" - ); - assert!( - !err.contains("launcher.rb:"), - "no raw backtrace frames may escape to the user:\n{err}" - ); - } - - /// [nit] PowerShell quoting: a path containing a single quote must be - /// escaped by doubling it, or the Expand-Archive fallback command breaks. - #[test] - fn powershell_quote_doubles_single_quotes() { - if !have_ruby() { - eprintln!("skip launcher_guard: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let script = tmp.path().join("drive.rb"); - std::fs::write( - &script, - "load ENV.fetch(\"SP_LAUNCHER\")\n\ - print SocketPatch::Launcher.powershell_quote(\"C:/it's a dir/x.zip\")\n", - ) - .unwrap(); - - let (code, out, err) = run_ruby(&script, &tmp.path().join("cache"), &[]); - assert_eq!(code, 0, "quoting helper must exist and run.\n{err}"); - assert_eq!( - out, "'C:/it''s a dir/x.zip'", - "single quotes must be doubled inside the single-quoted literal" - ); - // And the Expand-Archive fallback actually routes through it. - let launcher = std::fs::read_to_string(launcher_path()).unwrap(); - assert!( - launcher.contains("-LiteralPath #{powershell_quote(archive_path)}") - && launcher.contains("-DestinationPath #{powershell_quote(dir)}"), - "extract's PowerShell fallback must quote both paths via the helper" - ); - } - - /// [P2b] The binary-cache install must be atomic: staged in the - /// destination dir and renamed into place, leaving no temp litter — a - /// concurrent first run can then never exec a torn or not-yet-chmodded - /// binary. (The rename mechanism itself is pinned by inspection since a - /// mid-write race cannot be scheduled deterministically from a test.) - #[test] - fn install_executable_is_atomic_into_place() { - if !have_ruby() { - eprintln!("skip launcher_guard: ruby not on PATH"); - return; - } - let tmp = tempfile::tempdir().unwrap(); - let src = tmp.path().join("extracted-binary"); - std::fs::write(&src, "BINARY CONTENT\n").unwrap(); - let dest = tmp.path().join("cache/1.0.0/target/socket-patch"); - let script = tmp.path().join("drive.rb"); - std::fs::write( - &script, - "load ENV.fetch(\"SP_LAUNCHER\")\n\ - SocketPatch::Launcher.install_executable(ARGV[0], ARGV[1])\n", - ) - .unwrap(); - - let mut cmd = Command::new("ruby"); - cmd.arg(&script).arg(&src).arg(&dest); - scrub(&mut cmd); - cmd.env("SP_LAUNCHER", launcher_path()); - let out = cmd.output().expect("spawn ruby"); - assert!( - out.status.success(), - "install_executable must exist and succeed:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - std::fs::read_to_string(&dest).unwrap(), - "BINARY CONTENT\n", - "the cached binary must be byte-identical to the extracted one" - ); - let mode = std::fs::metadata(&dest).unwrap().permissions().mode(); - assert_ne!(mode & 0o111, 0, "the cached binary must be executable"); - let litter: Vec<_> = std::fs::read_dir(dest.parent().unwrap()) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|n| n != "socket-patch") - .collect(); - assert!( - litter.is_empty(), - "no staging temp files may be left behind: {litter:?}" - ); - let launcher = std::fs::read_to_string(launcher_path()).unwrap(); - assert!( - launcher.contains("File.rename(tmp, dest)"), - "the cache publish must go through a same-dir rename" - ); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_maven.rs b/crates/socket-patch-cli/tests/setup_matrix_maven.rs deleted file mode 100644 index 55105441..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_maven.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! setup-matrix: maven ecosystem (mvn). No native post-install hook and -//! `setup` is a no-op. The with-setup cases are an EXPECTED BASELINE GAP. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("maven", "mvn")` routes maven through the shared Docker -//! matrix harness, which *soft-skips and silently passes* whenever Docker -//! or the `maven` image is absent (the common case locally and in this -//! eval). maven is also NOT npm-family (see `is_npm_family` in the -//! harness), so the harness's check/remove behavioral round-trip is -//! skipped entirely for it; and because maven's `baseline_supported` is -//! false in matrix.json the only thing the matrix could ever assert is the -//! coarse `actual_applied == expect_applied` verdict — which, on a crashed -//! or never-run case, defaults to the same `false` that satisfies every -//! negative-control scenario. The net effect: the matrix call can never -//! turn red for a genuine maven `setup` regression. On its own it protects -//! nothing. -//! -//! To close that loophole WITHOUT touching the shared harness or the bash -//! driver, [`host_guard::maven_setup_is_a_clean_noop_host`] runs -//! unconditionally (no Docker, no network, no maven toolchain) and pins -//! maven `setup`'s *actual current contract*: a maven project's `pom.xml` -//! is NOT a manifest `setup` knows how to configure, so every `setup` -//! sub-command must (a) recognise the project as having no configurable -//! files (`status == "no_files"`, never `error`/`configured`/ -//! `needs_configuration`), (b) exit 0 with zero errors, and (c) leave the -//! `pom.xml` byte-for-byte untouched while creating no new files. A -//! positive-control run with a real `package.json` in a sibling dir proves -//! the `no_files` verdict is a discriminating decision and not a stuck -//! constant — so a regression that makes `setup` blind to *everything* -//! cannot hide behind maven's gap. It fails loudly if maven `setup` -//! ever starts crashing, erroring, misclassifying a pom.xml as -//! configurable, or mutating the project on disk. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_maven` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; -#[path = "vex_e2e_common/mod.rs"] -mod vex_e2e_common; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the maven negative -/// controls when Docker + the `maven` image are present. NOTE: this is the -/// path that silently no-ops on skip — it is NOT a regression guard. The -/// real teeth live in [`host_guard`] below. -#[test] -#[serial_test::serial] -// maven's aspirational setup-matrix cases are a BASELINE GAP (no install -// hook for setup to wire); this passes on CI only because the runners lack -// `mvn` (cases soft-skip) and fails on any host that has it. Ignore so maven -// can never block the blocking --all-features jobs; `host_guard` below still -// pins the real no-op contract. Run with `--features setup-e2e -- --ignored`. -#[ignore = "BASELINE GAP (maven): no install hook for setup to wire; not gating CI; run with --ignored"] -fn mvn() { - smc::run_pm("maven", "mvn"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for maven `setup`. -// -// maven has no post-install hook and no manifest `setup` configures, so the -// only honest contract to pin is the *negative* one: setup is a clean no-op -// on a maven project — it recognises there is nothing to configure, never -// errors, and never touches the project on disk. A positive control proves -// that verdict is discriminating, not a stuck `no_files` constant. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - use std::process::Command; - - /// A minimal but valid Maven `pom.xml`. `setup` must treat the directory - /// as having nothing to configure and leave this file byte-for-byte. - const POM_XML: &str = "\n\ -\n\ - 4.0.0\n\ - dev.socket\n\ - sm-maven-proj\n\ - 1.0.0\n\ - \n\ - \n\ - com.google.guava\n\ - guava\n\ - 32.1.2-jre\n\ - \n\ - \n\ -\n"; - - /// Faithful npm fixture for the positive control — proves `setup` - /// detection actually discriminates (so maven's `no_files` is a real - /// decision, not a stuck constant). - const PACKAGE_JSON: &str = - "{ \"name\": \"sm-proj\", \"version\": \"0.0.0\", \"private\": true, \"dependencies\": { \"minimist\": \"1.2.2\" } }\n"; - - /// Ambient decoys [`run`]'s prefix scrub must strip, planted by the test - /// itself so the scrub is exercised on every run, not only in hostile - /// shells. Three demonstrated failure classes on the old fixed-list scrub: - /// clap parses env-bound `GlobalArgs` values on EVERY invocation whether - /// or not the command uses the flag, so an invalid ambient `SOCKET_STRICT` - /// / `SOCKET_VENDOR_SOURCE` aborts the parse (exit 2) before `setup` even - /// runs; a (perfectly valid!) ambient `SOCKET_SETUP_EXCLUDE` stands in for - /// `setup --exclude`, which a real `setup` run PERSISTS — creating - /// `.socket/manifest.json` inside the maven fixture and failing - /// `assert_pristine`. (Safe to set process-wide: every other test in this - /// binary is `#[serial]` and spawns through this module's - /// prefix-scrubbing `run` / `run_vex` or `smc::host_driver_command`.) - const HOSTILE_DECOYS: &[(&str, &str)] = &[ - ("SOCKET_STRICT", "banana"), - ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), - ("SOCKET_SETUP_EXCLUDE", "decoy-member"), - ]; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots - /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / - /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see - /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: - /// nothing reaches authed endpoints and no ambient var can stand in for a - /// flag. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - 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); - } - } - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Parse the CLI's `--json` stdout into a single JSON object. Panics - /// (loudly) if stdout is not the single JSON object the command - /// promises — a non-JSON / multi-line dump means the command did not - /// run the path we think it did. - fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") - }) - } - - fn json_str_field(v: &serde_json::Value, key: &str, who: &str) -> String { - v.get(key) - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("{who}: JSON has no string `{key}` field:\n{v}")) - .to_string() - } - - /// The set of directory entries (names) present at `root`, sorted. - /// Used to prove `setup` created nothing. - fn dir_entries(root: &Path) -> Vec { - let mut names: Vec = std::fs::read_dir(root) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) - .collect(); - names.sort(); - names - } - - /// Assert maven `setup` was a clean no-op for the `who` stage: the - /// pom.xml is byte-for-byte unchanged and the directory still contains - /// ONLY the pom.xml (no package.json, no `.cargo/`, no scripts, nothing). - fn assert_pristine(root: &Path, who: &str) { - assert_eq!( - std::fs::read_to_string(root.join("pom.xml")).unwrap(), - POM_XML, - "{who}: setup must leave pom.xml byte-for-byte unchanged" - ); - assert_eq!( - dir_entries(root), - vec!["pom.xml".to_string()], - "{who}: setup must create no files in a maven project (dir must hold only pom.xml)" - ); - } - - /// Assert a `no_files` envelope: status is exactly `no_files`, no - /// manifests were touched, and (when present) every count field is zero. - /// Crucially rejects `error`, `configured`, `needs_configuration`, - /// `success`, etc. — anything other than the documented maven no-op. - fn assert_no_files_envelope(v: &serde_json::Value, who: &str) { - assert_eq!( - json_str_field(v, "status", who), - "no_files", - "{who}: maven pom.xml is not a configurable manifest — status must be `no_files`, \ - not error/configured/needs_configuration/success:\n{v}" - ); - let files = v - .get("files") - .and_then(|f| f.as_array()) - .unwrap_or_else(|| panic!("{who}: envelope has no `files` array:\n{v}")); - assert!( - files.is_empty(), - "{who}: no files may be reported for a maven project, got:\n{v}" - ); - // Count fields are optional in the `no_files` envelope, but any that - // ARE emitted must be zero — a non-zero count would mean setup thought - // it had work to do on a project it does not support. - for key in [ - "updated", - "alreadyConfigured", - "errors", - "configured", - "needsConfiguration", - ] { - if let Some(n) = v.get(key) { - assert_eq!( - n.as_i64(), - Some(0), - "{who}: `{key}` must be 0 in a maven no_files envelope, got {n}:\n{v}" - ); - } - } - } - - #[test] - #[serial_test::serial] - fn maven_setup_is_a_clean_noop_host() { - // Committed regression guard for the env scrub itself: with the old - // fixed-list scrub these leaked into the child — SOCKET_STRICT / - // SOCKET_VENDOR_SOURCE aborted every parse (exit 2) and - // SOCKET_SETUP_EXCLUDE made the real `setup` run write - // `.socket/manifest.json` into the fixture (assert_pristine RED). - let _decoys = crate::smc::DecoyGuard::set(HOSTILE_DECOYS); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("pom.xml"), POM_XML).unwrap(); - let root_s = root.to_str().unwrap(); - - // Precondition: the fixture is genuinely maven-only. If the temp dir - // somehow carried an npm/cargo/python manifest the no_files asserts - // below would be meaningless, so pin the starting state. - assert_eq!( - dir_entries(root), - vec!["pom.xml".to_string()], - "fixture must start as a maven-only project (pom.xml and nothing else)" - ); - - // ── setup --check: a maven project has nothing to configure ───────── - // Must exit 0 (not an error / needs-configuration) AND report - // no_files. A regression that crashes, errors, or misclassifies the - // pom.xml as a configurable manifest fails here. - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check on a maven project must exit 0 (no_files), not error/needs-config.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_envelope(&parse_json(&out, "check (maven)"), "check (maven)"); - assert_pristine(root, "after check"); - - // ── setup (no flag): still a no-op, zero updates, zero errors ─────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "setup on a maven project must exit 0 and do nothing.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_envelope(&parse_json(&out, "setup (maven)"), "setup (maven)"); - assert_pristine(root, "after setup"); - - // ── setup --remove: nothing was configured, so nothing to remove ──── - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!( - code, 0, - "setup --remove on a maven project must exit 0 and do nothing.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_envelope(&parse_json(&out, "remove (maven)"), "remove (maven)"); - assert_pristine(root, "after remove"); - - // ── positive control: prove `no_files` is a discriminating verdict ── - // The same binary, given a real package.json in a fresh dir, MUST - // reach a different, non-no_files conclusion (needs_configuration, - // exit 1). Without this, a regression that makes `setup` blind to - // everything — always emitting `no_files` — would sail through the - // maven asserts above. The contrast is the whole point. - let ctrl = tempfile::tempdir().unwrap(); - let ctrl_root = ctrl.path(); - std::fs::write(ctrl_root.join("package.json"), PACKAGE_JSON).unwrap(); - let (code, out, err) = run( - ctrl_root, - &[ - "setup", - "--check", - "--cwd", - ctrl_root.to_str().unwrap(), - "--json", - ], - ); - assert_eq!( - code, 1, - "positive control: setup --check on an npm project must exit 1 (needs_configuration), \ - proving the maven no_files verdict above is discriminating.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "control (npm)"); - assert_eq!( - json_str_field(&v, "status", "control (npm)"), - "needs_configuration", - "positive control: an npm project must report needs_configuration, not no_files — \ - otherwise `setup` is blind to all manifests and maven's no_files proves nothing.\nstderr:\n{err}" - ); - assert_eq!( - v.get("needsConfiguration").and_then(|n| n.as_i64()), - Some(1), - "positive control: exactly the package.json must count as needing configuration.\n{out}" - ); - } - - /// `setup` is a no-op for maven, and it must STAY one for a checkout - /// whose patches live in the pom: the hosted (`scan --mode hosted`) and - /// vendored (`vendor`) wirings survive `setup --check` / `setup` / - /// `setup --remove` byte-for-byte, no manifest appears, and the - /// manifest-less `vex` attests both before and after — `(redirected)` - /// from the fail-closed pom pin, `(vendored)` from the committed maven2 - /// tree (online record, no ledgers). - #[test] - #[serial_test::serial] - fn maven_setup_keeps_hosted_and_vendored_wiring_attestable_without_manifest() { - use crate::vex_e2e_common::*; - use sha1::{Digest as _, Sha1}; - use std::io::Write as _; - - const HOSTED_UUID: &str = "77777777-7777-7777-7777-777777777777"; - const HOSTED_PURL: &str = "pkg:maven/org.slf4j/slf4j-api@1.7.36"; - const VENDOR_UUID: &str = "7b7b7b7b-2222-4222-8222-7b7b7b7b7b7b"; - const VENDOR_PURL: &str = "pkg:maven/org.apache.commons/commons-text@1.10.0"; - const MEMBER: &str = "META-INF/NOTICE.txt"; - let vulns: [(&str, &[&str]); 1] = [("GHSA-setup-mvn-0001", &["CVE-2026-7300"])]; - - // Hosted: the committed rewriter golden (pom + `.mvn/`). - let hosted = tempfile::tempdir().unwrap(); - let golden = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../socket-patch-core/tests/fixtures/redirect/maven/pom/basic/expected"); - let pom = std::fs::read(golden.join("pom.xml")).unwrap(); - std::fs::write(hosted.path().join("pom.xml"), &pom).unwrap(); - for rel in [".mvn/maven.config", ".mvn/checksums/checksums.sha256"] { - let dst = hosted.path().join(rel); - std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); - std::fs::copy(golden.join(rel), dst).unwrap(); - } - - // Vendored: `vendor_maven`'s repository + the committed jar/sidecar. - let vendored = tempfile::tempdir().unwrap(); - let patched = b"upstream NOTICE\nSOCKET-PATCHED\n"; - let mut jar = std::io::Cursor::new(Vec::new()); - { - let mut w = zip::ZipWriter::new(&mut jar); - w.start_file(MEMBER, zip::write::SimpleFileOptions::default()) - .unwrap(); - w.write_all(patched).unwrap(); - w.finish().unwrap(); - } - let jar = jar.into_inner(); - let leaf = vendored.path().join(format!( - ".socket/vendor/maven/{VENDOR_UUID}/org/apache/commons/commons-text/1.10.0" - )); - std::fs::create_dir_all(&leaf).unwrap(); - std::fs::write(leaf.join("commons-text-1.10.0.jar"), &jar).unwrap(); - std::fs::write( - leaf.join("commons-text-1.10.0.jar.sha1"), - hex::encode(Sha1::digest(&jar)), - ) - .unwrap(); - std::fs::write(leaf.join("commons-text-1.10.0.pom"), "").unwrap(); - std::fs::write( - vendored.path().join("pom.xml"), - format!( - "\n \ - 4.0.0\n dev.socket\n \ - sm-maven-proj\n 1.0.0\n \ - \n \n \ - org.apache.commons\n \ - commons-text\n 1.10.0\n \ - \n \n \n \n \ - socket-patch-vendor-{VENDOR_UUID}\n \ - file://${{project.basedir}}/.socket/vendor/maven/{VENDOR_UUID}\n \ - \n fail\n \n \ - \n \n\n" - ), - ) - .unwrap(); - - let api = PatchApi::start(vec![ - ( - HOSTED_UUID.to_string(), - patch_view( - HOSTED_UUID, - HOSTED_PURL, - &[("slf4j-api-1.7.36.jar", &git_sha256(b"patched jar"))], - &vulns, - ), - ), - ( - VENDOR_UUID.to_string(), - patch_view( - VENDOR_UUID, - VENDOR_PURL, - &[(MEMBER, &git_sha256(patched))], - &vulns, - ), - ), - ]); - let m2 = tempfile::tempdir().unwrap(); - let cases = [ - (hosted.path(), HOSTED_PURL, HOSTED_UUID, Marker::Redirected), - (vendored.path(), VENDOR_PURL, VENDOR_UUID, Marker::Vendored), - ]; - let attest = |stage: &str| { - for (root, purl, uuid, marker) in cases { - let out = run_vex( - &binary(), - root, - &VexRun { - product: Some("pkg:maven/dev.socket/sm-maven-proj@1.0.0".to_string()), - ..VexRun::online(&api) - } - .env("MAVEN_REPO_LOCAL", m2.path().as_os_str()), - ); - assert_eq!(out.code, Some(0), "{stage} {purl}: {out}"); - assert_attested(out.doc(), purl, uuid, marker, &vulns); - std::fs::remove_file(&out.output).unwrap(); - } - }; - attest("before setup"); - for (root, _, _, _) in cases { - let snapshot = std::fs::read(root.join("pom.xml")).unwrap(); - let root_s = root.to_str().unwrap(); - for args in [ - &["setup", "--check", "--cwd", root_s, "--json"][..], - &["setup", "--cwd", root_s, "--yes", "--json"][..], - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"][..], - ] { - let (code, out, err) = run(root, args); - assert_eq!(code, 0, "{args:?}\nstdout:\n{out}\nstderr:\n{err}"); - assert_no_files_envelope(&parse_json(&out, "setup (wired)"), "setup (wired)"); - assert_eq!( - std::fs::read(root.join("pom.xml")).unwrap(), - snapshot, - "{args:?} must leave the wired pom.xml byte-for-byte" - ); - assert!( - !root.join(".socket/manifest.json").exists(), - "{args:?} must not create a manifest" - ); - } - } - attest("after setup"); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs b/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs deleted file mode 100644 index e19a8aa6..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! setup-matrix: polyglot all-ecosystem monorepo. -//! -//! A single repo containing an npm workspace alongside -//! python/rust/go/php/ruby/nuget/deno manifests. Confirms `socket-patch -//! setup` works in this mixed environment — it must configure the npm -//! hooks and NOT choke on the foreign manifests; a root `npm install` -//! then applies the patch to the npm slice. Runs in the npm image (the -//! only one with the npm toolchain); the foreign manifests are present -//! to test setup's robustness, not installed. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_monorepo` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -use std::path::{Path, PathBuf}; - -/// The behavioral driver: scaffold the polyglot monorepo, run -/// `setup`/install/remove inside the npm image (or host), and assert each -/// matrix case meets its aspirational expectation plus the npm-family -/// check/remove round-trip. Soft-skips when docker/the image is absent. -#[test] -fn monorepo() { - smc::run_monorepo(); -} - -// --------------------------------------------------------------------------- -// Static guards for the monorepo's DISTINCTIVE invariants. -// -// `run_monorepo()` reuses the generic harness, which treats `layout==monorepo` -// like any npm case and (a) soft-skips entirely when docker/the image is -// unavailable and (b) never inspects the polyglot fixture or the matrix spec. -// That makes the headline guarantee of THIS suite — "setup works in a mixed -// polyglot repo and does NOT choke on the foreign manifests" — completely -// unverified by the behavioral path whenever docker is missing, and even when -// present it would happily pass if the fixture were silently reduced to a plain -// npm project. These guards run with NO docker dependency and fail loudly if -// the polyglot scaffold, the matrix scenarios (incl. the negative controls), or -// the monorepo target wiring are ever hollowed out — i.e. they keep the -// behavioral test honestly *polyglot* rather than an npm test in disguise. -// --------------------------------------------------------------------------- - -/// Workspace root = two levels up from this crate's manifest dir. -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .expect("workspace root") - .to_path_buf() -} - -fn read(rel: &str) -> String { - let p = workspace_root().join(rel); - std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) -} - -/// Extract the body of a `name() { ... }` bash function from the driver, -/// matched brace-for-brace so a refactor that moves/renames it is caught. -fn bash_fn_body<'a>(script: &'a str, name: &str) -> &'a str { - let header = format!("{name}() {{"); - let start = script - .find(&header) - .unwrap_or_else(|| panic!("run-case.sh: function `{name}` not found")); - let after = start + header.len(); - let rest = &script[after..]; - let mut depth = 1usize; - for (i, c) in rest.char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return &rest[..i]; - } - } - _ => {} - } - } - panic!("run-case.sh: unbalanced braces in `{name}`"); -} - -/// The whole point of the monorepo case is exercising `setup` against a repo -/// that ALSO carries non-npm manifests. If the scaffold ever drops them, the -/// behavioral test silently becomes a plain npm test while still passing — so -/// pin that every foreign ecosystem manifest is created, plus the npm slice -/// `setup` is meant to patch. -#[test] -fn monorepo_scaffold_is_genuinely_polyglot() { - let script = read("tests/setup_matrix/run-case.sh"); - let body = bash_fn_body(&script, "scaffold_monorepo"); - - // The npm workspace slice — the surface `setup` actually patches. - assert!( - body.contains("package.json") && body.contains("workspaces"), - "scaffold_monorepo no longer creates the npm workspace root — the patched \ - slice would not exist:\n{body}" - ); - - // One representative manifest per FOREIGN ecosystem named in the suite's - // contract (python, rust, go, php, ruby, deno, nuget). `setup` must tolerate - // each of these sitting next to the npm project; dropping any one quietly - // narrows what "does not choke on foreign manifests" actually tests. - let foreign: &[(&str, &str)] = &[ - ("python", "pyproject.toml"), - ("rust", "Cargo.toml"), - ("go", "go.mod"), - ("php", "composer.json"), - ("ruby", "Gemfile"), - ("deno", "deno.json"), - ("nuget", ".csproj"), - ]; - let missing: Vec<&str> = foreign - .iter() - .filter(|(_, manifest)| !body.contains(manifest)) - .map(|(eco, _)| *eco) - .collect(); - assert!( - missing.is_empty(), - "scaffold_monorepo is no longer polyglot — missing foreign manifest(s) for: {missing:?}. \ - The monorepo suite would degrade to a plain npm test and stop proving setup tolerates \ - foreign manifests.\n{body}" - ); - - // Foreign manifests must be REAL (non-npm) ecosystems, not more npm. Require - // at least the distinctive non-JSON manifests so the fixture can't be faked - // with a pile of package.json files. - for distinctive in ["Cargo.toml", "go.mod", "Gemfile"] { - assert!( - body.contains(distinctive), - "scaffold_monorepo dropped the `{distinctive}` manifest" - ); - } -} - -/// The harness only runs the check/remove round-trip + LEAK detection when -/// `is_npm_family()` is true, which for the monorepo hinges on -/// `layout == "monorepo"`. Pin that the wiring still routes monorepo through -/// that branch (npm image, baseline_supported) so the case can't silently fall -/// into the untested "foreign ecosystem, no round-trip" bucket. -#[test] -fn monorepo_target_routes_through_npm_round_trip() { - let spec: serde_json::Value = - serde_json::from_str(&read("tests/setup_matrix/matrix.json")).expect("parse matrix.json"); - - let targets = spec["monorepo_targets"] - .as_array() - .expect("monorepo_targets array"); - assert_eq!( - targets.len(), - 1, - "expected exactly one monorepo target; got {}", - targets.len() - ); - let t = &targets[0]; - assert_eq!( - t["ecosystem"], "monorepo", - "monorepo target ecosystem changed" - ); - assert_eq!(t["pm"], "mono", "monorepo target pm changed"); - assert_eq!( - t["image"], "npm", - "monorepo must run in the npm image (only toolchain that can install it)" - ); - assert_eq!( - t["baseline_supported"], true, - "monorepo baseline_supported flipped to false — the npm slice IS supported today, so a \ - non-applying install must classify as a REGRESSION, not a tolerated BASELINE GAP" - ); - // The patched slice must be the npm package (minimist), proving the npm - // slice — not a foreign one — is what the round-trip exercises. - assert_eq!( - t["purl"], "pkg:npm/minimist@1.2.2", - "monorepo target purl changed — the patched slice is no longer the npm dependency" - ); - assert!( - t["manifest_key"] - .as_str() - .unwrap_or("") - .contains("index.js"), - "monorepo manifest_key no longer points at the npm package file" - ); - assert_eq!( - t["apply_ecosystems"], "npm", - "monorepo apply_ecosystems changed — should patch only the npm slice" - ); -} - -/// The matrix's negative controls are what keep a "patch always applies" bug -/// honest: a no-setup ablation (hook absent ⇒ must NOT apply) and a -/// patch-missing ablation (hook present but no committed patchset ⇒ must NOT -/// apply). Pin that all three monorepo scenarios — the positive plus both -/// controls — are present with the expected `run_setup`/`expect_applied` -/// polarity, so dropping a control can't quietly remove the guard. -#[test] -fn monorepo_scenarios_keep_their_negative_controls() { - let spec: serde_json::Value = - serde_json::from_str(&read("tests/setup_matrix/matrix.json")).expect("parse matrix.json"); - - let scenarios = spec["monorepo_scenarios"] - .as_array() - .expect("monorepo_scenarios array"); - - // id -> (run_setup, expect_applied) - let find = |id: &str| -> (bool, bool) { - let s = scenarios - .iter() - .find(|s| s["id"] == id) - .unwrap_or_else(|| panic!("monorepo scenario `{id}` missing from matrix.json")); - ( - s["run_setup"] - .as_bool() - .unwrap_or_else(|| panic!("`{id}`.run_setup not a bool")), - s["expect_applied"] - .as_bool() - .unwrap_or_else(|| panic!("`{id}`.expect_applied not a bool")), - ) - }; - - // Positive: setup runs, primary patchset, must apply. - assert_eq!( - find("monorepo_with_setup"), - (true, true), - "positive monorepo scenario must run setup AND expect the patch applied" - ); - // Negative control #1: no setup ⇒ no hook ⇒ must NOT apply. - assert_eq!( - find("monorepo_no_setup"), - (false, false), - "no-setup ablation must NOT run setup and must expect NOT applied (proves the hook, not \ - install alone, is what applies the patch)" - ); - // Negative control #2: setup runs but no committed patchset ⇒ must NOT apply. - assert_eq!( - find("monorepo_patch_missing"), - (true, false), - "patch-missing ablation must run setup yet expect NOT applied (proves the committed \ - patchset, not setup/install alone, is what changes the code)" - ); - - // Guard against a fourth scenario being added that quietly expects-applied - // without a matching control; at minimum the two negative controls must - // outnumber-or-equal the positives so the suite can't become all-positive. - let positives = scenarios - .iter() - .filter(|s| s["expect_applied"].as_bool().unwrap_or(false)) - .count(); - let negatives = scenarios.len() - positives; - assert!( - negatives >= positives && negatives >= 2, - "monorepo scenarios lost their negative controls (positives={positives}, \ - negatives={negatives}); a 'patch always applies' regression could pass" - ); -} - -/// The headline guarantee — `setup` must NOT choke on the foreign manifests — -/// is the driver's `setup_exit` field. `setup --yes` aggregates errors across -/// ALL manifest kinds it edits (npm + python + gem + composer; see -/// `run_setup` in commands/setup.rs) and exits 1 on `partial_failure`, so in -/// the polyglot monorepo it can land the npm hook (⇒ `check` passes, the -/// patch applies, every other round-trip probe looks healthy) and STILL choke -/// on a foreign slice. The round-trip validator must flag that; if it only -/// watches the check/install/remove exits, the exact regression this suite -/// exists to catch passes green. Hermetic: feeds the validator a synthetic -/// driver result, no docker. -#[test] -fn round_trip_flags_setup_choke_even_when_hook_lands() { - let case = smc::load_section( - "monorepo_targets", - "monorepo_scenarios", - "monorepo", - "monorepo", - "mono", - ) - .into_iter() - .next() - .expect("at least one monorepo case"); - - // A driver result where every probe EXCEPT setup_exit is healthy — - // exactly what a hook-landed-then-choked setup produces. - let result = |setup_exit: i64| smc::RunResult { - actual_applied: true, - raw: String::new(), - parsed: Some(serde_json::json!({ - "actual_applied": true, - "applied_before_setup": false, - "applied_after_remove": false, - "primary_marker_present": true, - "setup_exit": setup_exit, - "install_exit": 0, - "check_before_setup_exit": 2, - "check_after_setup_exit": 0, - "remove_exit": 0, - "check_after_remove_exit": 2, - })), - }; - - // Sanity: a fully healthy round trip must not be flagged. - assert_eq!( - smc::round_trip_failure(&case, &result(0)), - None, - "healthy synthetic round-trip result must pass" - ); - - let failure = smc::round_trip_failure(&case, &result(1)).expect( - "round_trip_failure must flag setup_exit=1: a `setup` that chokes on a foreign \ - manifest after landing the npm hook currently passes the whole suite", - ); - assert!( - failure.contains("setup exit"), - "failure message should name the setup exit problem, got: {failure}" - ); -} - -/// Defensive cross-check on the harness routing: `layout == "monorepo"` is the -/// ONLY thing that makes a non-npm-family `pm` (here `mono`) take the -/// round-trip + LEAK-detection path. If the driver's `is_npm_family` gate ever -/// stops honoring the monorepo layout, the behavioral guarantees silently -/// vanish. Pin the driver still gates on the monorepo layout. -#[test] -fn driver_round_trip_still_gated_on_monorepo_layout() { - let script = read("tests/setup_matrix/run-case.sh"); - let body = bash_fn_body(&script, "is_npm_family"); - assert!( - body.contains("SM_LAYOUT") && body.contains("monorepo"), - "run-case.sh is_npm_family no longer treats the monorepo layout as round-trip-eligible — \ - the monorepo would skip the check/remove + LEAK assertions:\n{body}" - ); -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_npm.rs b/crates/socket-patch-cli/tests/setup_matrix_npm.rs deleted file mode 100644 index 66d72679..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_npm.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! setup-matrix: npm ecosystem (npm / yarn / pnpm / bun / vlt). -//! -//! `socket-patch setup` supports these package managers (it writes a -//! package.json postinstall hook), so the -//! `baseline_with_setup` / `alt_content_patchset` cases are expected to -//! PASS here. See `setup_matrix_common/mod.rs` for the harness and -//! `tests/setup_matrix/matrix.json` for the case list. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_npm` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -#[test] -#[serial_test::serial] -fn npm() { - smc::run_pm("npm", "npm"); -} - -#[test] -#[serial_test::serial] -fn yarn() { - smc::run_pm("npm", "yarn"); -} - -#[test] -#[serial_test::serial] -fn pnpm() { - smc::run_pm("npm", "pnpm"); -} - -#[test] -#[serial_test::serial] -fn bun() { - smc::run_pm("npm", "bun"); -} - -#[test] -#[serial_test::serial] -fn vlt() { - smc::run_pm("npm", "vlt"); -} - -// ── Nested-workspace layouts ────────────────────────────────────────── -// A root + several members (incl. a deeply-nested one and a member with -// no dependency on the patched package). Exercises `setup`'s workspace -// handling (npm/yarn write the hook to every member; pnpm and vlt only to -// the root) plus the cross-workspace apply on the root install. These should -// PASS — they're real regression guards, not gap documentation. - -#[test] -#[serial_test::serial] -fn npm_workspace() { - smc::run_workspace_pm("npm", "npm"); -} - -#[test] -#[serial_test::serial] -fn pnpm_workspace() { - smc::run_workspace_pm("npm", "pnpm"); -} - -#[test] -#[serial_test::serial] -fn yarn_workspace() { - smc::run_workspace_pm("npm", "yarn"); -} - -#[test] -#[serial_test::serial] -fn vlt_workspace() { - smc::run_workspace_pm("npm", "vlt"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for npm `setup`. -// -// IMPORTANT — why this file needs an assertion of its own: -// every `smc::run_pm` / `smc::run_workspace_pm` call above routes through the -// shared Docker matrix harness, which *soft-skips and silently passes* whenever -// Docker or the `npm` image is absent (the common case locally and in this -// eval). So for npm, the ecosystem family whose `setup` hook the matrix round-trips, the matrix -// calls can be entirely green having exercised NOTHING — a broken -// package.json-hook writer would never turn this file red. -// -// To close that loophole WITHOUT touching the shared harness, the module below -// adds a self-contained, host-only (no Docker, no network, no real npm -// toolchain) exercise of the actual `socket-patch` binary against a real -// package.json. It runs unconditionally and fails loudly if npm -// `setup` / `setup --check` / `setup --remove` regress. State is verified with -// an *independent* JSON read + raw substring probes (NOT the production -// `is_setup_configured` / `update_package_json` detectors), so the oracle can -// disagree with a broken writer. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - use std::process::Command; - - /// The apply command `setup` is supposed to inject into the npm lifecycle - /// scripts. Hardcoded HERE (not imported from production) so a regression - /// that drops/garbles the command is caught by an independent oracle. The - /// detector accepts several variants; we pin the canonical npm one the - /// writer emits for a lockfile-less project. - const NPM_APPLY_CMD: &str = "@socketsecurity/socket-patch apply"; - const NPM_ECOSYSTEM_FLAG: &str = "--ecosystems npm"; - /// A pre-existing, user-authored postinstall step `setup` must PRESERVE - /// (prepend the patch command before it, never clobber it). - const USER_POSTINSTALL: &str = "echo user-build-step"; - - /// Ambient decoys [`run`]'s prefix scrub must strip, planted by the test - /// itself so the scrub is exercised on every run, not only in hostile - /// shells. Two demonstrated failure classes on the old fixed-list scrub - /// (same as the maven twin): clap parses env-bound `GlobalArgs` values on - /// EVERY invocation whether or not the command uses the flag, so an - /// invalid ambient `SOCKET_STRICT` / `SOCKET_VENDOR_SOURCE` aborts the - /// parse (exit 2) before `setup` even runs — turning the whole roundtrip - /// red; and a (perfectly valid!) ambient `SOCKET_SETUP_EXCLUDE` stands in - /// for `setup --exclude`, silently altering the run under test. (Safe to - /// set process-wide: every other test in this binary routes its children - /// through `smc::host_driver_command`'s own `SOCKET_*` prefix scrub, and - /// the harness's only ambient `SOCKET_*` read is `SOCKET_PATCH_TEST_HOST`, - /// which the decoys don't touch.) - const HOSTILE_DECOYS: &[(&str, &str)] = &[ - ("SOCKET_STRICT", "banana"), - ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), - ("SOCKET_SETUP_EXCLUDE", "decoy-member"), - ]; - - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots - /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / - /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see - /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: - /// no ambient var can stand in for a flag or abort the parse. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - 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); - } - } - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Independent oracle: parse package.json with serde_json (a plain JSON - /// read, NOT the production setup detector) and return a named lifecycle - /// script, if present and a string. - fn lifecycle_script(root: &Path, key: &str) -> Option { - let text = std::fs::read_to_string(root.join("package.json")).unwrap(); - let val: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| { - panic!("package.json is not valid JSON after CLI ran: {e}\n{text}") - }); - val.get("scripts") - .and_then(|s| s.get(key)) - .and_then(|v| v.as_str()) - .map(str::to_string) - } - - fn stage_project(root: &Path) { - // A package.json with a pre-existing postinstall step. No lockfile, so - // the npm-family detector resolves to plain npm. No Cargo.toml / - // pyproject, so only the npm branch of `setup` fires. - std::fs::write( - root.join("package.json"), - format!( - r#"{{ - "name": "sm-npm-host-guard", - "version": "1.0.0", - "private": true, - "scripts": {{ - "postinstall": "{USER_POSTINSTALL}" - }}, - "dependencies": {{}} -}} -"# - ), - ) - .unwrap(); - } - - /// setup → check → remove → check, asserting REAL on-disk package.json - /// state at every stage. This is the assertion the soft-skipping Docker - /// matrix can never make. - #[test] - #[serial_test::serial] - fn npm_setup_roundtrip_host() { - // Committed regression guard for the env scrub itself: with the old - // fixed-list scrub these leaked into the child — SOCKET_STRICT / - // SOCKET_VENDOR_SOURCE aborted every parse (exit 2, so the very first - // `--check` assertion went red) and SOCKET_SETUP_EXCLUDE stood in for - // `setup --exclude` on the real run. - let _decoys = crate::smc::DecoyGuard::set(HOSTILE_DECOYS); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - stage_project(root); - let root_s = root.to_str().unwrap(); - - // ── pristine precondition ────────────────────────────────────────── - // Pin the BEFORE state so post-setup assertions prove `setup` CREATED - // the hook, not that a leftover fixture already contained it. - let pristine = std::fs::read_to_string(root.join("package.json")).unwrap(); - assert!( - !pristine.contains(NPM_APPLY_CMD), - "fixture must start WITHOUT the socket-patch hook:\n{pristine}" - ); - assert_eq!( - lifecycle_script(root, "postinstall").as_deref(), - Some(USER_POSTINSTALL), - "fixture must start with only the user's postinstall step" - ); - - // ── check (before setup): unconfigured → must report non-zero ────── - // Proves `--check` reads real state instead of hardcoding success. - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) on an unconfigured project.\nstdout:\n{out}\nstderr:\n{err}" - ); - - // ── setup ────────────────────────────────────────────────────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes"]); - assert_eq!( - code, 0, - "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - - // The postinstall hook must now carry the apply command AND the npm - // ecosystem filter, run FIRST, and PRESERVE the user's original step. - let post = lifecycle_script(root, "postinstall") - .unwrap_or_else(|| panic!("postinstall script missing after setup")); - assert!( - post.contains(NPM_APPLY_CMD) && post.contains(NPM_ECOSYSTEM_FLAG), - "postinstall must contain the npm apply command after setup, got: {post:?}" - ); - assert!( - post.contains(USER_POSTINSTALL), - "setup must PRESERVE the user's existing postinstall step, got: {post:?}" - ); - assert!( - post.trim_start().starts_with("npx ") - && post.find(NPM_APPLY_CMD) < post.find(USER_POSTINSTALL), - "the patch apply command must be prepended to run BEFORE the user's step, got: {post:?}" - ); - // setup also wires the `dependencies` lifecycle script (created fresh, - // since the fixture had none). - let deps = lifecycle_script(root, "dependencies") - .unwrap_or_else(|| panic!("dependencies script missing after setup")); - assert!( - deps.contains(NPM_APPLY_CMD) && deps.contains(NPM_ECOSYSTEM_FLAG), - "the `dependencies` lifecycle script must also be configured, got: {deps:?}" - ); - - // ── check (configured): must report zero ─────────────────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!( - code, 0, - "setup --check must PASS (exit 0) after setup.\nstdout:\n{out}\nstderr:\n{err}" - ); - - // ── remove ────────────────────────────────────────────────────────── - let (code, out, err) = run(root, &["setup", "--remove", "--cwd", root_s, "--yes"]); - assert_eq!( - code, 0, - "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - - // The apply command must be gone everywhere, and the user's original - // postinstall step restored intact (not left mangled by the removal). - let after = std::fs::read_to_string(root.join("package.json")).unwrap(); - assert!( - !after.contains(NPM_APPLY_CMD), - "the socket-patch apply command must be removed from package.json:\n{after}" - ); - assert_eq!( - lifecycle_script(root, "postinstall").as_deref(), - Some(USER_POSTINSTALL), - "remove must restore the user's original postinstall step verbatim:\n{after}" - ); - - // ── check (after remove): back to needs-configuration ─────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) again after remove.\nstdout:\n{out}\nstderr:\n{err}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs deleted file mode 100644 index a7fc11dc..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! setup-matrix: nuget ecosystem (dotnet). No native post-install hook and -//! `setup` is a no-op. The with-setup cases are an EXPECTED BASELINE GAP. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! `smc::run_pm("nuget", "dotnet")` routes nuget through the shared Docker -//! matrix harness, which *soft-skips and silently passes* whenever Docker -//! or the `nuget` image is absent (the common case locally and in this -//! eval). nuget is also NOT npm-family (see `is_npm_family` in the harness -//! and `run-case.sh`), so the harness's check/remove behavioral -//! round-trip is skipped entirely for it; and because nuget's -//! `baseline_supported` is false in matrix.json the only thing the matrix -//! could ever assert is the coarse `actual_applied == expect_applied` -//! verdict — which, on a crashed or never-run case, defaults to the same -//! `false` that satisfies every negative-control scenario. The net -//! effect: the matrix call can never turn red for a genuine nuget `setup` -//! regression. On its own it protects nothing. -//! -//! To close that loophole WITHOUT touching the shared harness or the bash -//! driver, [`host_guard::nuget_setup_roundtrip_host`] runs unconditionally -//! (no Docker, no network, no dotnet toolchain) and pins nuget `setup`'s -//! *actual current contract*: a dotnet project carries only a `.csproj` — -//! a manifest `setup` does NOT support — so every `setup` subcommand must -//! report `no_files` (exit 0 for setup/remove; exit 0 for `--check`, since -//! "nothing to configure" is success not failure) and must leave the -//! `.csproj` byte-for-byte untouched. It reads on-disk state with an -//! *independent* probe (a hand-pinned constant, not a copy of any writer -//! output) so the oracle can disagree with a broken implementation. It -//! fails loudly if nuget `setup` ever starts mutating a `.csproj`, crashes -//! on a dotnet project, mis-classifies the `.csproj` as a configurable -//! manifest, or returns the wrong exit code / status. -//! -//! If `setup` ever GROWS real dotnet support, this guard's expectations -//! become wrong-by-design and must be upgraded to the deno-style positive -//! round-trip (check fails → setup configures → check passes → remove). -//! That is the intended signal: the test going red here means the baseline -//! gap closed, not that something broke. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_nuget` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; -#[path = "vex_e2e_common/mod.rs"] -mod vex_e2e_common; - -/// Documentation/negative-control pass through the shared Docker matrix. -/// Kept for parity with the other ecosystems and to run the nuget negative -/// controls when Docker + the `nuget` image are present. NOTE: this is the -/// path that silently no-ops on skip — it is NOT a regression guard. The -/// real teeth live in [`host_guard`] below. -#[test] -#[serial_test::serial] -// nuget's aspirational setup-matrix cases are a BASELINE GAP (no install -// hook for setup to wire); this passes on CI only because the runners lack -// `dotnet` (cases soft-skip) and fails on any host that has it. Ignore so -// nuget can never block the blocking --all-features jobs; `host_guard` below -// still pins the real no-op contract. Run with `--features setup-e2e -- --ignored`. -#[ignore = "BASELINE GAP (nuget): no install hook for setup to wire; not gating CI; run with --ignored"] -fn dotnet() { - smc::run_pm("nuget", "dotnet"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for nuget `setup`. -// -// A dotnet project carries only a `.csproj` (no package.json / Python / -// Cargo manifest), which `setup` does not support. The guard pins that -// no-op contract precisely so a regression (`.csproj` mutation, crash, -// mis-detection, wrong exit code) turns this suite red even with no Docker. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - use std::process::Command; - - /// Name of the project file written into the fixture. - const CSPROJ_NAME: &str = "app.csproj"; - - /// A faithful dotnet project fixture, mirroring the polyglot monorepo's - /// `nuget-app/app.csproj` in `tests/setup_matrix/run-case.sh` and the - /// nuget target's package/version in matrix.json - /// (`Newtonsoft.Json` @ `13.0.3`). - const CSPROJ: &str = "\n \ - \n \ - \n \ - \n\n"; - - /// Ambient decoys [`run`]'s prefix scrub must strip, planted by the test - /// itself so the scrub is exercised on every run, not only in hostile - /// shells. Three demonstrated failure classes on the old fixed-list scrub - /// (same trio as `setup_matrix_maven`): clap parses env-bound - /// `GlobalArgs` values on EVERY invocation whether or not the command - /// uses the flag, so an invalid ambient `SOCKET_STRICT` / - /// `SOCKET_VENDOR_SOURCE` aborts the parse (exit 2) before `setup` even - /// runs; and a (perfectly valid!) ambient `SOCKET_SETUP_EXCLUDE` stands - /// in for `setup --exclude`, which a real `setup` run PERSISTS — - /// creating `.socket/manifest.json` inside the dotnet fixture and - /// failing the final only-the-csproj assertion. (Safe to set - /// process-wide: the only other test in this binary is the `#[ignore]`d - /// matrix pass, which routes through `smc::host_driver_command`'s own - /// `SOCKET_*` prefix scrub.) - const HOSTILE_DECOYS: &[(&str, &str)] = &[ - ("SOCKET_STRICT", "banana"), - ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), - ("SOCKET_SETUP_EXCLUDE", "decoy-member"), - ]; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots - /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / - /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see - /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: - /// nothing reaches authed endpoints and no ambient var can stand in for a - /// flag. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - 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); - } - } - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Parse the CLI's `--json` stdout into a single JSON object. Panics - /// (loudly) if stdout is not the JSON object the command promises — a - /// non-JSON / non-object dump means the command did not run the path we - /// think it did. - fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - let v: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("{who}: stdout was not valid JSON ({e}):\n{stdout}")); - assert!( - v.is_object(), - "{who}: stdout JSON must be a single object, got:\n{stdout}" - ); - v - } - - fn json_str(v: &serde_json::Value, key: &str, who: &str) -> String { - v.get(key) - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("{who}: JSON has no string `{key}` field:\n{v}")) - .to_string() - } - - /// The `.csproj` must be byte-for-byte what we wrote — `setup` (in any - /// mode) operates on package.json / Python / Gemfile / composer.json manifests and must - /// NEVER touch a dotnet project file. - fn assert_csproj_pristine(root: &Path, who: &str) { - assert_eq!( - std::fs::read_to_string(root.join(CSPROJ_NAME)).unwrap(), - CSPROJ, - "{who}: {CSPROJ_NAME} must be left byte-for-byte unchanged by setup" - ); - } - - /// `setup`'s contract on a manifest it does not support is `no_files` - /// with a clean exit (0) and zero side effects. This single helper pins - /// every subcommand to that contract: a `no_files` status, exit 0, the - /// `files` list empty, and the `.csproj` untouched. - fn assert_no_files(root: &Path, args: &[&str], who: &str) -> serde_json::Value { - let (code, out, err) = run(root, args); - assert_eq!( - code, 0, - "{who}: must exit 0 on an unsupported (.csproj-only) project.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, who); - assert_eq!( - json_str(&v, "status", who), - "no_files", - "{who}: a dotnet project must report status=no_files (.csproj is not a configurable manifest).\nstderr:\n{err}" - ); - let files = v - .get("files") - .and_then(|f| f.as_array()) - .unwrap_or_else(|| panic!("{who}: JSON has no `files` array:\n{v}")); - assert!( - files.is_empty(), - "{who}: no_files result must carry an EMPTY files list (the .csproj must not be picked up as a manifest):\n{v}" - ); - assert_csproj_pristine(root, who); - v - } - - /// Manifest-less VEX over a setup-only project: `setup` wires only the - /// agent-mode install hook (or nothing) — never a hosted/vendored - /// lockfile reference — so there is nothing to attest (exit 2, - /// `manifest_not_found`), no document, no patch-API request, and nothing - /// written into the project (the document path is outside it). - fn assert_manifestless_vex_has_nothing(root: &Path, product: &str, who: &str) { - use crate::vex_e2e_common::{run_vex, PatchApi, VexRun}; - let out_dir = tempfile::tempdir().unwrap(); - let api = PatchApi::empty(); - for no_verify in [false, true] { - let mut r = VexRun::online(&api); - r.output = Some(out_dir.path().join("out.vex.json")); - r.product = Some(product.to_string()); - r.no_verify = no_verify; - let out = run_vex(&binary(), root, &r); - assert_eq!(out.code, Some(2), "{who} (no_verify={no_verify}): {out}"); - assert_eq!( - out.envelope["error"]["code"], "manifest_not_found", - "{who}: {out}" - ); - assert!( - out.doc.is_none(), - "{who}: no document may be written: {out}" - ); - } - api.assert_no_requests(); - } - - /// setup / setup --check / setup --remove against a real dotnet project, - /// asserting REAL on-disk + JSON state at every stage. This is the - /// assertion the Docker matrix can never make for nuget. - #[test] - #[serial_test::serial] - fn nuget_setup_roundtrip_host() { - // Committed regression guard for the env scrub itself: with the old - // fixed-list scrub these leaked into the child — SOCKET_STRICT / - // SOCKET_VENDOR_SOURCE aborted every parse (exit 2) and - // SOCKET_SETUP_EXCLUDE made the real `setup` run write - // `.socket/manifest.json` into the fixture (final entries check RED). - let _decoys = crate::smc::DecoyGuard::set(HOSTILE_DECOYS); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join(CSPROJ_NAME), CSPROJ).unwrap(); - let root_s = root.to_str().unwrap(); - - // ── pristine precondition ────────────────────────────────────────── - // Pin the BEFORE state so the assertions prove the *binary* left the - // .csproj alone, not that the fixture happened to match afterwards. - assert_csproj_pristine(root, "fixture"); - assert!( - !root.join("package.json").exists(), - "fixture must not contain a package.json (would change the path under test)" - ); - - // ── check (before): no supported manifest → no_files, exit 0 ──────── - // `--check` returning exit 1 here would be wrong (there is nothing to - // configure); returning `needs_configuration`/`configured` would mean - // the .csproj was mis-detected as an npm/python/cargo manifest. - assert_no_files( - root, - &["setup", "--check", "--cwd", root_s, "--json"], - "check (pristine)", - ); - - // ── setup: must be a true no-op (no .csproj mutation, nothing wired) ─ - let v = assert_no_files( - root, - &["setup", "--cwd", root_s, "--yes", "--json"], - "setup", - ); - assert_eq!( - v.get("updated").and_then(|n| n.as_i64()), - Some(0), - "setup on a dotnet project must update zero manifests:\n{v}" - ); - assert_eq!( - v.get("errors").and_then(|n| n.as_i64()), - Some(0), - "setup on a dotnet project must report zero errors:\n{v}" - ); - assert_eq!( - v.get("alreadyConfigured").and_then(|n| n.as_i64()), - Some(0), - "setup on a dotnet project must configure nothing (alreadyConfigured=0):\n{v}" - ); - // Defensively confirm setup created no stray hook artifacts. - assert!( - !root.join("package.json").exists(), - "setup must NOT synthesize a package.json for a dotnet project" - ); - - // ── check (after setup): still nothing to configure → no_files ────── - // Proves `setup` did not silently configure something a later check - // would then report as `configured` (which would flip exit to 0 for a - // different, wrong reason). - assert_no_files( - root, - &["setup", "--check", "--cwd", root_s, "--json"], - "check (after setup)", - ); - - // ── remove: also a no-op on an unsupported project ────────────────── - assert_no_files( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - "remove", - ); - - // ── manifest-less VEX: nothing setup did is attestable ───────────── - assert_manifestless_vex_has_nothing(root, "pkg:nuget/app@1.0.0", "vex (after remove)"); - - // ── final: directory still holds exactly the one file we created ──── - // A stray sidecar/hook artifact left behind by any stage would betray - // a non-no-op that the per-stage `files: []` check could miss. - let entries: Vec = std::fs::read_dir(root) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) - .collect(); - assert_eq!( - entries, - vec![CSPROJ_NAME.to_string()], - "setup round-trip must leave ONLY the original {CSPROJ_NAME}; stray entries: {entries:?}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs deleted file mode 100644 index 4bc19021..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs +++ /dev/null @@ -1,535 +0,0 @@ -//! setup-matrix: pypi ecosystem (pip / uv / poetry / pdm / hatch). -//! -//! Python installers have no native post-install hook, so `socket-patch -//! setup` instead commits a `socket-patch-hook` dependency whose wheel ships -//! a startup `.pth` that re-applies patches after install -//! (package-manager-agnostic). pip, uv and hatch are wired + verified in -//! Docker: their `baseline_with_setup` / `alt_content_patchset` cases APPLY -//! (the harness builds the hook wheel and the driver installs it + fires an -//! interpreter). poetry / pdm are resolver-based — their `add`/`install`/`run` -//! re-resolve the whole manifest (now incl. the committed `socket-patch-hook`) -//! against a package index, which the hermetic test can't provide, so they -//! remain BASELINE GAPs (the mechanism is PM-agnostic and proven by the -//! others). Nested-workspace layouts are also still gaps. The negative-control -//! / empty / wrong-target cases must NOT apply for any of them. -//! -//! IMPORTANT — why this file carries a real assertion of its own: -//! every `smc::run_pm("pypi", …)` below routes through the shared Docker -//! matrix harness, which *soft-skips and silently passes* whenever Docker -//! or the `pypi` image is absent (the common case locally and in this -//! eval). On a skip the harness `return`s before running a single case, so -//! none of the `pip`/`uv`/… tests can ever turn red for a genuine pypi -//! `setup` regression. And even when Docker IS present, pypi is NOT -//! npm-family (see `is_npm_family` in the harness), so the harness's -//! behavioral check/remove round-trip is skipped for it entirely — the -//! only thing it asserts is the coarse `actual_applied == expect_applied` -//! verdict, whose missing-result fallback is the same `false` that -//! satisfies every negative-control scenario. On its own this file -//! protects nothing. -//! -//! To close that loophole WITHOUT touching the shared harness or the bash -//! driver, [`host_guard::pypi_setup_roundtrip_host`] runs unconditionally -//! (no Docker, no network, no Python toolchain — pip's `requirements.txt` -//! manifest needs no lockfile refresh, so the path is fully hermetic) and -//! exercises the REAL `socket-patch` binary against a real pip project: -//! `setup --check` (fails) → `setup` (adds `socket-patch[hook]`) → -//! `--check` (passes) → idempotent re-`setup` → `--remove` → `--check` -//! (fails again). It verifies on-disk `requirements.txt` bytes against a -//! hand-pinned golden (NOT a copy of any writer output) so the oracle can -//! disagree with a broken implementation, and pins the JSON envelope -//! (`status`, counts, `pythonPackageManager`, per-file `pth` entry) at -//! every stage. It fails loudly if pypi `setup` ever stops wiring the hook -//! dependency, mutates the wrong line, mis-reports its status/exit code, -//! or fails to round-trip cleanly back to the original manifest. -//! -//! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_pypi` -#![cfg(feature = "setup-e2e")] - -#[path = "setup_matrix_common/mod.rs"] -mod smc; - -#[test] -#[serial_test::serial] -fn pip() { - smc::run_pm("pypi", "pip"); -} - -#[test] -#[serial_test::serial] -fn uv() { - smc::run_pm("pypi", "uv"); -} - -#[test] -#[serial_test::serial] -fn poetry() { - smc::run_pm("pypi", "poetry"); -} - -#[test] -#[serial_test::serial] -fn pdm() { - smc::run_pm("pypi", "pdm"); -} - -#[test] -#[serial_test::serial] -fn hatch() { - smc::run_pm("pypi", "hatch"); -} - -// ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for pypi `setup`. -// -// A pip project carries a `requirements.txt`, which `setup` DOES support: -// it commits the `socket-patch[hook]` dependency (the `.pth` post-install -// carrier). Unlike the no-op `no_files` ecosystems (go, maven, nuget, -// cargo), pypi has a -// positive contract, so this guard asserts the full configure round-trip -// rather than a no-op. It runs with no Docker, no network, and (for pip, -// whose `lock_commands()` is `None`) no external toolchain. -// ───────────────────────────────────────────────────────────────────────── -mod host_guard { - use std::path::Path; - use std::process::Command; - - /// Initial pip manifest. A single ordinary requirement so the assertions - /// can prove `setup` appended the hook line WITHOUT disturbing the - /// user's existing entries (order + content preserved). - const REQ_INITIAL: &str = "requests==2.31.0\n"; - - /// The exact bytes `setup` must produce for pip's `requirements.txt`: - /// the original line, untouched, followed by the canonical - /// `socket-patch[hook]` requirement on its own line. This golden is - /// hand-derived from the documented contract (append `socket-patch[hook]`), - /// NOT copied from a run of the writer — so it can disagree with a broken - /// implementation that reorders, rewrites, or mangles the manifest. - const REQ_WITH_HOOK: &str = "requests==2.31.0\nsocket-patch[hook]\n"; - - /// Ambient decoys [`run`]'s prefix scrub must strip, planted by - /// [`pypi_setup_roundtrip_host`] itself so the scrub is exercised on every - /// run, not only in hostile shells. Three demonstrated failure classes on - /// the old fixed-list scrub: clap parses env-bound `GlobalArgs` values on - /// EVERY invocation whether or not the command uses the flag, so an - /// invalid ambient `SOCKET_STRICT` / `SOCKET_VENDOR_SOURCE` aborts the - /// parse (exit 2) before `setup` even runs; and a (perfectly valid!) - /// ambient `SOCKET_SETUP_EXCLUDE` stands in for `setup --exclude`, which - /// a real `setup` run PERSISTS into `.socket/manifest.json` inside the - /// fixture. (Safe to set process-wide: every other test in this binary - /// routes through either this module's [`run`] or - /// `smc::host_driver_command`, both of which prefix-scrub `SOCKET_*`.) - const HOSTILE_DECOYS: &[(&str, &str)] = &[ - ("SOCKET_STRICT", "banana"), - ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), - ("SOCKET_SETUP_EXCLUDE", "decoy-member"), - ]; - - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots - /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / - /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see - /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: - /// nothing reaches authed endpoints and no ambient var can stand in for a - /// flag. - fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); - cmd.args(args).current_dir(cwd); - 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); - } - } - // This guard's contract is "no network" (module docs): `setup` fires a - // usage-telemetry POST when telemetry is enabled, and the scrub above - // would strip a developer's own opt-out. Force it off for the child — - // no assertion here concerns telemetry. - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); - let out = cmd.output().expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Parse the CLI's `--json` stdout into a single JSON object. Panics - /// (loudly) if stdout is not the single JSON object the command - /// promises — a non-JSON / multi-line dump means the command did not - /// run the path we think it did. - fn parse_json(stdout: &str, who: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!("{who}: stdout was not a single JSON object ({e}):\n{stdout}") - }) - } - - fn json_str(v: &serde_json::Value, key: &str, who: &str) -> String { - v.get(key) - .and_then(|s| s.as_str()) - .unwrap_or_else(|| panic!("{who}: JSON has no string `{key}` field:\n{v}")) - .to_string() - } - - fn json_i64(v: &serde_json::Value, key: &str, who: &str) -> i64 { - v.get(key) - .and_then(|n| n.as_i64()) - .unwrap_or_else(|| panic!("{who}: JSON has no integer `{key}` field:\n{v}")) - } - - /// Read `requirements.txt` and assert it is byte-for-byte `expected`. The - /// independent on-disk oracle: it never calls production parsing code, so - /// a writer that produces a "looks-configured" but wrong manifest fails. - fn assert_requirements(root: &Path, expected: &str, who: &str) { - let got = std::fs::read_to_string(root.join("requirements.txt")) - .unwrap_or_else(|e| panic!("{who}: requirements.txt unreadable: {e}")); - assert_eq!(got, expected, "{who}: requirements.txt bytes mismatch"); - } - - /// Find the single `files[]` entry whose `kind == "pth"` (the Python - /// manifest). Fails if absent — a setup/check that reports no `pth` entry - /// never touched the Python manifest the test is about. - fn pth_entry(v: &serde_json::Value, who: &str) -> serde_json::Value { - v.get("files") - .and_then(|f| f.as_array()) - .unwrap_or_else(|| panic!("{who}: JSON has no `files` array:\n{v}")) - .iter() - .find(|e| e.get("kind").and_then(|k| k.as_str()) == Some("pth")) - .unwrap_or_else(|| panic!("{who}: no files[] entry with kind=\"pth\":\n{v}")) - .clone() - } - - /// Independent textual probe: is the exact `socket-patch[hook]` - /// requirement present as its own line (comment-stripped)? Deliberately - /// does NOT use `deps_contain_hook` (the production detector) so the - /// oracle can disagree with a broken writer. - fn has_hook_line(content: &str) -> bool { - content.lines().any(|l| { - let spec = l.split('#').next().unwrap_or("").trim(); - spec == "socket-patch[hook]" - }) - } - - /// setup --check → setup → --check → re-setup → --remove → --check against - /// a real pip project, asserting REAL on-disk + JSON state at every stage. - /// This is the assertion the Docker matrix can never make for pypi. - #[test] - #[serial_test::serial] - fn pypi_setup_roundtrip_host() { - // Committed regression guard for the env scrub itself: with the old - // fixed-list scrub these leaked into the child — SOCKET_STRICT / - // SOCKET_VENDOR_SOURCE aborted every parse (exit 2) and - // SOCKET_SETUP_EXCLUDE made the real `setup` run write - // `.socket/manifest.json` into the fixture. - let _decoys = crate::smc::DecoyGuard::set(HOSTILE_DECOYS); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("requirements.txt"), REQ_INITIAL).unwrap(); - let root_s = root.to_str().unwrap(); - - // ── pristine precondition ────────────────────────────────────────── - // Pin the BEFORE state so the post-setup assertions prove `setup` - // *added* the hook line, not that a leftover fixture already had it. - assert_requirements(root, REQ_INITIAL, "fixture"); - assert!( - !has_hook_line(REQ_INITIAL), - "fixture must start WITHOUT the hook dependency" - ); - assert!( - !root.join("package.json").exists(), - "fixture must not contain a package.json (would change the path under test)" - ); - - // ── check (before setup): unconfigured → exit 1, needs_configuration ─ - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) on a pristine pip project.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (pristine)"); - assert_eq!( - json_str(&v, "status", "check (pristine)"), - "needs_configuration", - "pristine pip project must report needs_configuration:\n{v}" - ); - assert_eq!( - json_str( - &pth_entry(&v, "check (pristine)"), - "status", - "check (pristine) pth" - ), - "needs_configuration", - "the requirements.txt pth entry must read needs_configuration before setup:\n{v}" - ); - // --check must NEVER write — manifest still pristine. - assert_requirements(root, REQ_INITIAL, "after check (pristine)"); - - // ── setup: must append the hook dep and report success ────────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "setup"); - assert_eq!( - json_str(&v, "status", "setup"), - "success", - "setup on a pip project must report status=success:\n{v}" - ); - assert_eq!( - json_i64(&v, "updated", "setup"), - 1, - "setup must update exactly one manifest (requirements.txt):\n{v}" - ); - assert_eq!( - json_i64(&v, "errors", "setup"), - 0, - "setup must report zero errors:\n{v}" - ); - assert_eq!( - json_str(&v, "pythonPackageManager", "setup"), - "pip", - "a requirements.txt-only project must be detected as pip:\n{v}" - ); - let e = pth_entry(&v, "setup"); - assert_eq!( - json_str(&e, "status", "setup pth"), - "updated", - "the requirements.txt pth entry must report updated:\n{v}" - ); - assert!( - json_str(&e, "path", "setup pth").ends_with("requirements.txt"), - "the pth entry must point at requirements.txt:\n{v}" - ); - // The decisive on-disk check: exact golden bytes (line preserved + hook - // appended), verified WITHOUT the production parser. - assert_requirements(root, REQ_WITH_HOOK, "after setup"); - assert!( - !root.join("package.json").exists(), - "setup must NOT synthesize a package.json for a pip project" - ); - - // ── check (after setup): configured → exit 0 ──────────────────────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check must PASS (exit 0) after setup.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (configured)"); - assert_eq!( - json_str(&v, "status", "check (configured)"), - "configured", - "after setup the project must report configured:\n{v}" - ); - assert_eq!( - json_str( - &pth_entry(&v, "check (configured)"), - "status", - "check (configured) pth" - ), - "configured", - "the requirements.txt pth entry must read configured after setup:\n{v}" - ); - - // ── idempotent re-setup: no further change ────────────────────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "re-setup must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "re-setup"); - assert_eq!( - json_str(&v, "status", "re-setup"), - "already_configured", - "a second setup must be a no-op (already_configured), not re-append:\n{v}" - ); - assert_eq!( - json_i64(&v, "updated", "re-setup"), - 0, - "re-setup must update zero manifests:\n{v}" - ); - // No duplicate hook line written. - assert_requirements(root, REQ_WITH_HOOK, "after re-setup"); - - // ── remove: strip the hook dep, restore the original manifest ─────── - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!( - code, 0, - "setup --remove must succeed.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "remove"); - assert_eq!( - json_str(&v, "status", "remove"), - "success", - "remove must report status=success:\n{v}" - ); - assert_eq!( - json_i64(&v, "removed", "remove"), - 1, - "remove must strip exactly one hook dependency:\n{v}" - ); - assert_eq!( - json_str(&pth_entry(&v, "remove"), "status", "remove pth"), - "removed", - "the requirements.txt pth entry must report removed:\n{v}" - ); - // Manifest must be byte-for-byte back to the original (no orphaned - // blank line, no mangled user requirement). - assert_requirements(root, REQ_INITIAL, "after remove"); - - // ── check (after remove): back to needs-configuration → exit 1 ────── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) again after remove.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (after remove)"); - assert_eq!( - json_str(&v, "status", "check (after remove)"), - "needs_configuration", - "after remove the project must report needs_configuration again:\n{v}" - ); - } - - /// Regression: a commented-out hook line is NOT a configured project. - /// - /// pip never installs a `# socket-patch[hook]` comment, and plain `setup` - /// (whose `requirements_add` strips comments before probing) would still - /// append the hook — but the `--check` probe read the raw file and saw the - /// marker inside the comment, reporting `configured` (exit 0) for a - /// project with no hook at all. Check and setup must agree on the same - /// bytes. - #[test] - #[serial_test::serial] - fn pypi_check_ignores_commented_out_hook_host() { - const REQ_COMMENTED: &str = "requests==2.31.0\n# socket-patch[hook]\n"; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let root_s = root.to_str().unwrap(); - std::fs::write(root.join("requirements.txt"), REQ_COMMENTED).unwrap(); - assert!( - !has_hook_line(REQ_COMMENTED), - "fixture: the commented-out line must not count as a hook line" - ); - - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1): a commented-out hook dep is not \ - configured.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "check (commented-out)"); - assert_eq!( - json_str(&v, "status", "check (commented-out)"), - "needs_configuration", - "a commented-out hook line must report needs_configuration:\n{v}" - ); - assert_eq!( - json_str( - &pth_entry(&v, "check (commented-out)"), - "status", - "check (commented-out) pth" - ), - "needs_configuration", - "the requirements.txt pth entry must read needs_configuration:\n{v}" - ); - // --check must NEVER write. - assert_requirements(root, REQ_COMMENTED, "after check (commented-out)"); - } - - /// Regression: classic-Poetry projects. - /// - /// `setup` writes the hook into a Poetry manifest as the *structural* - /// `socket-patch = { version = "*", extras = ["hook"] }` — which has NO - /// literal `socket-patch[hook]` substring. A `setup --check` that probes - /// the manifest *textually* would therefore report a freshly-and-correctly - /// configured Poetry project as `needs_configuration` (exit 1), breaking - /// the setup→check round-trip. This guard pins the structural detection by - /// running the real binary against a hand-authored Poetry manifest in each - /// state. Fully hermetic: `--check` neither writes nor refreshes a lockfile. - #[test] - #[serial_test::serial] - fn poetry_check_recognizes_structural_hook_host() { - // ── configured: the exact structural form `setup` emits ───────────── - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let root_s = root.to_str().unwrap(); - std::fs::write( - root.join("pyproject.toml"), - "[tool.poetry]\nname = \"x\"\nversion = \"0.1.0\"\n\n\ - [tool.poetry.dependencies]\npython = \"^3.9\"\n\ - socket-patch = {version = \"*\", extras = [\"hook\"]}\n", - ) - .unwrap(); - - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check must PASS (exit 0) for a Poetry project carrying the \ - structural hook extra.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "poetry check (configured)"); - assert_eq!( - json_str(&v, "status", "poetry check (configured)"), - "configured", - "structurally-configured Poetry project must report configured:\n{v}" - ); - assert_eq!( - json_str( - &pth_entry(&v, "poetry check (configured)"), - "status", - "poetry check (configured) pth" - ), - "configured", - "the pyproject pth entry must read configured:\n{v}" - ); - - // ── unconfigured: a plain socket-patch dep (no hook) is NOT enough ── - let tmp2 = tempfile::tempdir().unwrap(); - let root2 = tmp2.path(); - let root2_s = root2.to_str().unwrap(); - std::fs::write( - root2.join("pyproject.toml"), - "[tool.poetry]\nname = \"x\"\nversion = \"0.1.0\"\n\n\ - [tool.poetry.dependencies]\npython = \"^3.9\"\nsocket-patch = \"^3.3.0\"\n", - ) - .unwrap(); - let (code, out, err) = run(root2, &["setup", "--check", "--cwd", root2_s, "--json"]); - assert_eq!( - code, 1, - "setup --check must FAIL (exit 1) for a Poetry project whose \ - socket-patch dep carries no hook extra.\nstdout:\n{out}\nstderr:\n{err}" - ); - let v = parse_json(&out, "poetry check (unconfigured)"); - assert_eq!( - json_str(&v, "status", "poetry check (unconfigured)"), - "needs_configuration", - "a hook-less Poetry project must report needs_configuration:\n{v}" - ); - } -} - -// ── Nested-workspace layouts (EXPECTED BASELINE GAP) ────────────────── -// uv workspace (root + members, one shared .venv) and a pip -// nested-requirements monorepo. Python has no post-install hook, so -// these don't apply today — but the install itself must succeed. - -#[test] -#[serial_test::serial] -fn pip_workspace() { - smc::run_workspace_pm("pypi", "pip"); -} - -#[test] -#[serial_test::serial] -fn uv_workspace() { - smc::run_workspace_pm("pypi", "uv"); -} diff --git a/crates/socket-patch-cli/tests/setup_matrix_static.rs b/crates/socket-patch-cli/tests/setup_matrix_static.rs deleted file mode 100644 index 60c424e8..00000000 --- a/crates/socket-patch-cli/tests/setup_matrix_static.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Static setup-matrix wiring guards that need neither docker nor the -//! `setup-e2e` feature, so the default test run keeps them honest. - -use std::path::{Path, PathBuf}; - -fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|p| p.parent()) - .expect("workspace root") - .to_path_buf() -} - -fn read(rel: &str) -> String { - let p = workspace_root().join(rel); - std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) -} - -/// The body of a `name() { ... }` bash function, matched brace for brace. -fn bash_fn_body<'a>(script: &'a str, name: &str) -> &'a str { - let header = format!("{name}() {{"); - let start = script - .find(&header) - .unwrap_or_else(|| panic!("run-case.sh: function `{name}` not found")); - let rest = &script[start + header.len()..]; - let mut depth = 1usize; - for (i, c) in rest.char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return &rest[..i]; - } - } - _ => {} - } - } - panic!("run-case.sh: unbalanced braces in `{name}`"); -} - -/// vlt is an npm-family row of the matrix: it runs in the npm image, gets -/// npm's `npx` hook, takes the round-trip path, and its scaffolds carry the -/// vlt.json marker (with registry config) that `setup` detects vlt by. -/// The docker legs soft-skip, so this pins the wiring they would exercise. -#[test] -fn vlt_targets_route_through_the_npm_round_trip() { - let spec: serde_json::Value = - serde_json::from_str(&read("tests/setup_matrix/matrix.json")).expect("parse matrix.json"); - for section in ["targets", "workspace_targets"] { - let vlt: Vec<&serde_json::Value> = spec[section] - .as_array() - .unwrap_or_else(|| panic!("{section} array")) - .iter() - .filter(|t| t["pm"] == "vlt") - .collect(); - assert_eq!(vlt.len(), 1, "{section}: one vlt target"); - let t = vlt[0]; - assert_eq!(t["ecosystem"], "npm", "{section}"); - assert_eq!(t["image"], "npm", "{section}"); - assert_eq!(t["hook_family"], "npm", "{section}: vlt uses the npx hook"); - assert_eq!(t["baseline_supported"], true, "{section}"); - } - - let script = read("tests/setup_matrix/run-case.sh"); - assert!( - bash_fn_body(&script, "is_npm_family").contains("vlt"), - "vlt must take the check/remove round trip" - ); - for scaffold in ["scaffold_project", "scaffold_workspace"] { - let body = bash_fn_body(&script, scaffold); - let arm = body - .split("\n vlt)") - .nth(1) - .unwrap_or_else(|| panic!("{scaffold} has no vlt arm")); - let arm = &arm[..arm.find(";;").expect("vlt arm ends")]; - assert!( - arm.contains("vlt_json"), - "{scaffold}: vlt.json marker:\n{arm}" - ); - } - assert!( - bash_fn_body(&script, "scaffold_workspace").contains("\"workspaces\""), - "the vlt workspace is declared in vlt.json" - ); - let vlt_json = bash_fn_body(&script, "vlt_json"); - assert!( - vlt_json.contains("\"registries\"") && vlt_json.contains("\"registry\""), - "vlt >= 1.0.0-rc.33 installs need a registry config:\n{vlt_json}" - ); -} diff --git a/crates/socket-patch-cli/tests/setup_pth_invariants.rs b/crates/socket-patch-cli/tests/setup_pth_invariants.rs deleted file mode 100644 index a2fdeb25..00000000 --- a/crates/socket-patch-cli/tests/setup_pth_invariants.rs +++ /dev/null @@ -1,624 +0,0 @@ -//! Integration tests for `setup`'s Python `.pth`-hook branch. Like the npm -//! `setup_invariants`, these operate entirely on disk (manifest detection + -//! editing + audit record) and need no network. - -use std::collections::BTreeSet; -use std::path::Path; - -#[path = "common/mod.rs"] -mod common; - -/// Run `setup --json --yes [extra]` in `cwd` through the shared hermetic -/// runner. The binary binds a wide `SOCKET_*` env surface (SOCKET_DRY_RUN, -/// SOCKET_ECOSYSTEMS, SOCKET_CWD, SOCKET_SETUP_EXCLUDE, ...); an ambient -/// value silently flips what every test here exercises (SOCKET_DRY_RUN=true -/// turns each real run into a dry run, SOCKET_ECOSYSTEMS=npm hides the -/// Python branch entirely), so `common::run_with_env`'s seed-then-scrub is -/// load-bearing, not hygiene. -fn run_setup(cwd: &Path, extra: &[&str]) -> (i32, serde_json::Value) { - let mut args = vec!["setup", "--json", "--yes"]; - args.extend_from_slice(extra); - let (code, stdout, _stderr) = - common::run_with_env(cwd, &args, &[("SOCKET_TELEMETRY_DISABLED", "1")]); - let v = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (code, v) -} - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(path, content).expect("write file"); -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read file") -} - -/// The set of directory-entry names directly under `dir` (non-recursive). -fn dir_entries(dir: &Path) -> BTreeSet { - std::fs::read_dir(dir) - .expect("read_dir") - .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) - .collect() -} - -/// Every regular-file path under `dir`, relative to `dir` (recursive). Proves -/// `setup` writes nothing outside the repo (property 5) and snapshots a -/// "clone" (property 6). -fn files_under(dir: &Path) -> BTreeSet { - fn walk(base: &Path, dir: &Path, out: &mut BTreeSet) { - if let Ok(rd) = std::fs::read_dir(dir) { - for e in rd.flatten() { - let p = e.path(); - if p.is_dir() { - walk(base, &p, out); - } else { - out.insert(p.strip_prefix(base).unwrap().to_string_lossy().to_string()); - } - } - } - } - let mut out = BTreeSet::new(); - walk(dir, dir, &mut out); - out -} - -/// Copy every file under `src` into `dst`. Simulates a fresh checkout of the -/// committed tree on another host. -fn copy_tree(src: &Path, dst: &Path) { - for rel in files_under(src) { - let to = dst.join(&rel); - if let Some(parent) = to.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::copy(src.join(&rel), &to).expect("copy file"); - } -} - -/// Return the single `files[]` entry whose `kind == kind`, panicking if there -/// is not exactly one. Stops a regression from hiding a wrong/extra entry -/// behind a positional `files[0]`. -fn file_entry<'a>(v: &'a serde_json::Value, kind: &str) -> &'a serde_json::Value { - let arr = v["files"] - .as_array() - .unwrap_or_else(|| panic!("files must be an array: {v}")); - let matches: Vec<&serde_json::Value> = arr.iter().filter(|f| f["kind"] == kind).collect(); - assert_eq!( - matches.len(), - 1, - "expected exactly one `{kind}` file entry, got {}: {v}", - matches.len() - ); - matches[0] -} - -/// Extract the literal text inside the first top-level `dependencies = [ ... ]` -/// array in a pyproject.toml, so we can assert membership *within the array* -/// rather than merely "the string appears somewhere in the file". Deliberately -/// independent of the production toml_edit code path. -fn dependencies_array_body(toml: &str) -> String { - let start = toml - .find("dependencies = [") - .unwrap_or_else(|| panic!("no `dependencies = [` in:\n{toml}")); - // Scan from just inside the opening `[` (depth 1) and find the matching - // close, accounting for nested brackets like the `[hook]` extra in - // `socket-patch[hook]` — a naive `.find(']')` would stop there. - let after = &toml[start + "dependencies = [".len()..]; - let mut depth = 1usize; - let mut end = None; - for (i, c) in after.char_indices() { - match c { - '[' => depth += 1, - ']' => { - depth -= 1; - if depth == 0 { - end = Some(i); - break; - } - } - _ => {} - } - } - let end = end.unwrap_or_else(|| panic!("unterminated dependencies array in:\n{toml}")); - after[..end].to_string() -} - -#[test] -fn pip_requirements_gets_hook_dep() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("requirements.txt"), "requests==2.31.0\n"); - - let (code, v) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0, "setup should succeed; payload={v}"); - assert_eq!(v["status"], "success"); - assert_eq!(v["updated"], 1); - assert_eq!( - v["alreadyConfigured"], 0, - "fresh file is not already-configured" - ); - assert_eq!(v["errors"], 0); - assert_eq!(v["pythonPackageManager"], "pip"); - - let entry = file_entry(&v, "pth"); - assert_eq!(entry["status"], "updated"); - assert!( - entry["path"] - .as_str() - .unwrap() - .ends_with("requirements.txt"), - "pth entry must point at requirements.txt: {entry}" - ); - assert!(entry["error"].is_null(), "no error expected: {entry}"); - - // Exact on-disk result: the hook dep is appended on its own trailing line, - // the existing pinned dep is preserved verbatim, nothing else is rewritten. - let req = read(&tmp.path().join("requirements.txt")); - assert_eq!( - req, "requests==2.31.0\nsocket-patch[hook]\n", - "requirements.txt must gain exactly the hook line; got:\n{req}" - ); - - // The committed dependency is the source of truth — no separate marker file - // and no other files conjured into the project dir. - assert_eq!( - dir_entries(tmp.path()), - BTreeSet::from(["requirements.txt".to_string()]), - "setup must touch only requirements.txt" - ); -} - -#[test] -fn uv_pyproject_array_edited_and_format_preserved() { - let tmp = tempfile::tempdir().unwrap(); - let original = "[project]\nname = \"x\"\nversion = \"0.0.0\"\ndependencies = [\n \"requests\",\n]\n\n[tool.uv]\n"; - write(&tmp.path().join("pyproject.toml"), original); - write(&tmp.path().join("uv.lock"), ""); // detected as uv - - let (code, v) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0, "payload={v}"); - assert_eq!(v["status"], "success", "payload={v}"); - assert_eq!(v["updated"], 1); - assert_eq!(v["errors"], 0); - assert_eq!(v["pythonPackageManager"], "uv"); - - let entry = file_entry(&v, "pth"); - assert_eq!(entry["status"], "updated"); - assert!(entry["path"].as_str().unwrap().ends_with("pyproject.toml")); - - let py = read(&tmp.path().join("pyproject.toml")); - - // The hook dep must land *inside* the PEP 621 dependencies array, alongside - // the pre-existing `requests` — not appended as a stray top-level line. - let body = dependencies_array_body(&py); - assert!( - body.contains("socket-patch[hook]"), - "hook dep must be inside the dependencies array; array body:\n{body}\nfull:\n{py}" - ); - assert!( - body.contains("\"requests\""), - "existing dep must remain in the array; array body:\n{body}" - ); - // Exactly one occurrence in the whole file (no duplication / stray copy). - assert_eq!( - py.matches("socket-patch[hook]").count(), - 1, - "hook dep must appear exactly once; got:\n{py}" - ); - - // Format / unrelated content preserved: the [tool.uv] table survives, the - // user's 4-space array indentation is kept, and the file is still parseable - // by the same edit path (idempotent re-run reports already-configured, which - // proves the array is well-formed enough to be re-detected). - assert!( - py.contains("[tool.uv]"), - "unrelated tables preserved:\n{py}" - ); - assert!(py.contains("name = \"x\""), "scalar keys preserved:\n{py}"); - assert!( - py.contains(" \"requests\""), - "original 4-space array indentation must be preserved:\n{py}" - ); - - let (code2, v2) = run_setup(tmp.path(), &[]); - assert_eq!(code2, 0); - assert_eq!( - v2["status"], "already_configured", - "re-run must detect the array entry it just wrote: {v2}" - ); -} - -#[test] -fn idempotent_second_run_reports_already_configured() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("requirements.txt"), "requests\n"); - - let (code1, v1) = run_setup(tmp.path(), &[]); - assert_eq!(code1, 0, "first run must succeed: {v1}"); - assert_eq!(v1["status"], "success", "first run must configure: {v1}"); - assert_eq!( - v1["updated"], 1, - "first run updates exactly one manifest: {v1}" - ); - - let (code, v) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0); - assert_eq!(v["status"], "already_configured"); - assert_eq!(v["updated"], 0, "second run must not re-edit: {v}"); - assert_eq!( - v["alreadyConfigured"], 1, - "second run sees it configured: {v}" - ); - let req = read(&tmp.path().join("requirements.txt")); - assert_eq!( - req.matches("socket-patch[hook]").count(), - 1, - "must not duplicate the hook dependency" - ); -} - -#[test] -fn pep503_equivalent_hook_spellings_are_already_configured() { - // pip installs the hook from `socket_patch[hook]` exactly as from the - // canonical spelling (PEP 503: `-`/`_`/`.` are interchangeable in names) - // and from a combined-extras spec like `socket-patch[cli,hook]` (PEP 508). - // Setup must recognize both as configured: appending a second spelling of - // the same requirement is non-idempotent, `--check` would fail a CI gate - // on a correctly configured repo, and `--remove` would report nothing to - // remove while the hook stays wired. - for spec in ["socket_patch[hook]\n", "socket-patch[cli,hook]>=1.0\n"] { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("requirements.txt"), spec); - - let (code, v) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0, "spec {spec:?}: payload={v}"); - assert_eq!( - v["status"], "already_configured", - "spec {spec:?} already declares the hook; setup must not re-add it: {v}" - ); - assert_eq!( - read(&tmp.path().join("requirements.txt")), - spec, - "spec {spec:?}: requirements.txt must be untouched" - ); - - let (code, v) = run_setup(tmp.path(), &["--check"]); - assert_eq!( - code, 0, - "spec {spec:?}: --check must pass on a configured project: {v}" - ); - assert_eq!(v["status"], "configured", "spec {spec:?}: {v}"); - } -} - -#[test] -fn dry_run_does_not_modify_or_create_files() { - let tmp = tempfile::tempdir().unwrap(); - let original = "requests\n"; - write(&tmp.path().join("requirements.txt"), original); - let before = dir_entries(tmp.path()); - - let (code, v) = run_setup(tmp.path(), &["--dry-run"]); - assert_eq!(code, 0); - assert_eq!(v["status"], "dry_run"); - assert_eq!(v["dryRun"], true); - assert_eq!(v["wouldUpdate"], 1); - assert_eq!(v["errors"], 0); - - // No write: byte-identical content AND no new files created anywhere in the - // project dir (the failure mode the test name warns about). - assert_eq!(read(&tmp.path().join("requirements.txt")), original); - assert_eq!( - dir_entries(tmp.path()), - before, - "dry-run must not create or remove any files" - ); -} - -#[test] -fn remove_reverses_dep() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("requirements.txt"), "requests\n"); - // Configure first. - let (_, v) = run_setup(tmp.path(), &[]); - assert_eq!(v["status"], "success"); - assert_eq!( - read(&tmp.path().join("requirements.txt")), - "requests\nsocket-patch[hook]\n", - "precondition: setup added the hook line" - ); - - let (code, v) = run_setup(tmp.path(), &["--remove"]); - assert_eq!(code, 0, "payload={v}"); - assert_eq!(v["status"], "success", "remove must report success: {v}"); - assert_eq!(v["removed"], 1, "exactly one manifest reverted: {v}"); - assert_eq!(v["errors"], 0); - let entry = file_entry(&v, "pth"); - assert_eq!(entry["status"], "removed"); - - // Exact restoration to the pre-setup content — not merely "hook absent". - let req = read(&tmp.path().join("requirements.txt")); - assert_eq!( - req, "requests\n", - "remove must restore the original file byte-for-byte; got:\n{req}" - ); -} - -#[test] -fn polyglot_configures_both_npm_and_python() { - let tmp = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("package.json"), - "{ \"name\": \"x\", \"version\": \"0.0.0\" }\n", - ); - write( - &tmp.path().join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.0.0\"\ndependencies = []\n", - ); - - let (code, v) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0, "payload={v}"); - assert_eq!(v["status"], "success", "payload={v}"); - assert_eq!(v["updated"], 2); - assert_eq!( - v["alreadyConfigured"], 0, - "both manifests start unconfigured: {v}" - ); - assert_eq!(v["errors"], 0); - - let files = v["files"].as_array().unwrap(); - // Exactly the two expected kinds, each updated. - let pj = file_entry(&v, "package_json"); - assert_eq!(pj["status"], "updated"); - let pth = file_entry(&v, "pth"); - assert_eq!(pth["status"], "updated"); - assert_eq!(files.len(), 2, "no spurious extra file entries: {v}"); - - // The npm side injects the postinstall hook into package.json. - let pkg = read(&tmp.path().join("package.json")); - assert!( - pkg.contains("socket-patch"), - "package.json must gain the hook:\n{pkg}" - ); - assert!( - pkg.contains("postinstall"), - "npm hook is a postinstall script:\n{pkg}" - ); - - // The python side adds the dep inside the dependencies array. - let py = read(&tmp.path().join("pyproject.toml")); - assert!( - dependencies_array_body(&py).contains("socket-patch[hook]"), - "hook dep must be inside the pyproject dependencies array:\n{py}" - ); -} - -#[test] -fn pure_python_with_no_manifest_files_is_no_op() { - // `setup.py`-only project (no pyproject/requirements): pip path would - // create requirements.txt. But an EMPTY dir with neither markers nor - // package.json must report no_files. - let tmp = tempfile::tempdir().unwrap(); - let (code, v) = run_setup(tmp.path(), &[]); - assert_eq!(code, 0); - assert_eq!(v["status"], "no_files"); - assert_eq!(v["updated"], 0, "no_files must touch nothing: {v}"); - assert_eq!(v["errors"], 0); - assert!( - v["files"].as_array().map(|a| a.is_empty()).unwrap_or(false), - "no_files must report an empty files list: {v}" - ); - - // Crucially: setup must NOT conjure a requirements.txt (or any file) into an - // empty, non-python directory. - assert!( - dir_entries(tmp.path()).is_empty(), - "no files may be created on a no_files run; found: {:?}", - dir_entries(tmp.path()) - ); -} - -// --------------------------------------------------------------------------- -// Property 5 — the Python branch writes only inside the repo. The `.pth` wheel -// is installed later by the user's package manager into site-packages; `setup` -// itself only edits the committed requirements.txt / pyproject.toml and must -// never write to `$HOME` or global site-packages. -// (CLI_CONTRACT.md → "Setup command contract", property 5.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_python_writes_only_inside_repo() { - let proj = tempfile::tempdir().unwrap(); - let home = tempfile::tempdir().unwrap(); - write(&proj.path().join("requirements.txt"), "requests\n"); - assert!( - files_under(home.path()).is_empty(), - "sentinel HOME must start empty" - ); - - let (code, _stdout, stderr) = common::run_with_env( - proj.path(), - &["setup", "--json", "--yes"], - &[ - ("HOME", home.path().to_str().unwrap()), - ("SOCKET_TELEMETRY_DISABLED", "1"), - ], - ); - assert_eq!(code, 0, "setup should succeed; stderr=\n{stderr}"); - - assert!( - files_under(home.path()).is_empty(), - "Python setup must not write outside --cwd; HOME gained: {:?}", - files_under(home.path()) - ); - // Only the committed manifest was touched — no site-packages, no .pth, no - // marker file beside it. - assert_eq!( - files_under(proj.path()), - BTreeSet::from(["requirements.txt".to_string()]), - "setup must touch only the in-repo requirements.txt" - ); - assert_eq!( - read(&proj.path().join("requirements.txt")), - "requests\nsocket-patch[hook]\n", - "the in-repo manifest must have gained exactly the hook line" - ); -} - -// --------------------------------------------------------------------------- -// Property 6 — Python setup state is clone-portable: the committed dependency -// line is the whole story, so `--check` passes on a copied tree. -// (CLI_CONTRACT.md → "Setup command contract", property 6.) -// --------------------------------------------------------------------------- - -#[test] -fn setup_python_state_is_clone_portable() { - let a = tempfile::tempdir().unwrap(); - write(&a.path().join("requirements.txt"), "requests\n"); - let (c, v) = run_setup(a.path(), &[]); - assert_eq!(c, 0, "initial setup must succeed: {v}"); - assert_eq!(v["status"], "success"); - - let b = tempfile::tempdir().unwrap(); - copy_tree(a.path(), b.path()); - - let before = read(&b.path().join("requirements.txt")); - let (code, v) = run_setup(b.path(), &["--check"]); - assert_eq!(code, 0, "clone must already be configured: {v}"); - assert_eq!(v["status"], "configured"); - assert_eq!( - read(&b.path().join("requirements.txt")), - before, - "--check must not modify the clone" - ); -} - -// --------------------------------------------------------------------------- -// Property 7 — the post-edit lockfile refresh must not rewrite the user's -// pinned dependency set. Poetry 1.x's bare `poetry lock` re-resolves EVERY -// dependency to the newest compatible version (the pin-preserving spelling is -// `lock --no-update`); Poetry 2.x makes pin-preserving the default and -// REMOVES `--no-update`, so setup must try the 1.x spelling first and fall -// back to the bare form when the flag is unknown. Same shape for PDM -// (`--update-reuse`). A fake package manager on PATH records the argv setup -// actually invokes. -// --------------------------------------------------------------------------- - -/// Lay a fake `name` executable into `bin_dir` that appends its argv to `log` -/// and exits 0 — unless the argv contains `reject_arg`, in which case it prints -/// an unknown-option error and exits 1 without logging (a tool that does not -/// know the flag, e.g. Poetry 2.x and `--no-update`). -#[cfg(unix)] -fn write_pm_shim(bin_dir: &Path, name: &str, log: &Path, reject_arg: Option<&str>) { - use std::os::unix::fs::PermissionsExt; - std::fs::create_dir_all(bin_dir).expect("create shim dir"); - let reject = match reject_arg { - Some(flag) => format!( - "case \"$*\" in *{flag}*) echo 'The \"{flag}\" option does not exist.' >&2; exit 1;; esac\n" - ), - None => String::new(), - }; - let body = format!( - "#!/bin/sh\n{reject}printf '%s\\n' \"$*\" >> '{}'\nexit 0\n", - log.display() - ); - let p = bin_dir.join(name); - std::fs::write(&p, body).expect("write shim"); - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod shim"); -} - -/// `run_setup` with the shim dir prepended to PATH so the spawned lockfile -/// refresh resolves to the fake package manager. -#[cfg(unix)] -fn run_setup_with_shims(cwd: &Path, bin_dir: &Path) -> (i32, serde_json::Value) { - let path_env = format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - let (code, stdout, _stderr) = common::run_with_env( - cwd, - &["setup", "--json", "--yes"], - &[("SOCKET_TELEMETRY_DISABLED", "1"), ("PATH", &path_env)], - ); - let v = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (code, v) -} - -const POETRY_PYPROJECT: &str = "[tool.poetry]\nname = \"x\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = []\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n"; - -#[cfg(unix)] -#[test] -fn poetry_lock_refresh_asks_for_pin_preserving_lock_first() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("pyproject.toml"), POETRY_PYPROJECT); - write(&tmp.path().join("poetry.lock"), "# stub lock\n"); - let log = tmp.path().join("poetry-argv.log"); - // Poetry 1.x: knows both `lock` and `lock --no-update`. - write_pm_shim(&tmp.path().join("bin"), "poetry", &log, None); - - let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); - assert_eq!(code, 0, "setup must succeed: {v}"); - let argvs = read(&log); - let first = argvs.lines().next().unwrap_or_default(); - assert_eq!( - first, "lock --no-update", - "on a tool that accepts it, the FIRST lock invocation must be the \ - pin-preserving `poetry lock --no-update` — the bare `poetry lock` \ - re-resolves the user's entire pinned set on Poetry 1.x; got argv log:\n{argvs}" - ); - assert!( - v.get("warnings").is_none(), - "successful pin-preserving refresh must not warn: {v}" - ); -} - -#[cfg(unix)] -#[test] -fn poetry_2x_without_no_update_falls_back_to_bare_lock() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("pyproject.toml"), POETRY_PYPROJECT); - write(&tmp.path().join("poetry.lock"), "# stub lock\n"); - let log = tmp.path().join("poetry-argv.log"); - // Poetry 2.x: `--no-update` was removed (pin-preserving became the - // default), so that spelling exits non-zero. - write_pm_shim(&tmp.path().join("bin"), "poetry", &log, Some("--no-update")); - - let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); - assert_eq!(code, 0, "setup must succeed: {v}"); - let argvs = read(&log); - assert_eq!( - argvs.lines().last().unwrap_or_default(), - "lock", - "when `--no-update` is unknown the bare `poetry lock` must still run \ - (it is pin-preserving on 2.x); got argv log:\n{argvs}" - ); - assert!( - v.get("warnings").is_none(), - "a successful fallback is not a failure — no lockfile warning: {v}" - ); -} - -#[cfg(unix)] -#[test] -fn pdm_lock_refresh_asks_for_pin_preserving_lock_first() { - let tmp = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\ndependencies = [\"requests\"]\n\n[tool.pdm]\n", - ); - write(&tmp.path().join("pdm.lock"), "# stub lock\n"); - let log = tmp.path().join("pdm-argv.log"); - write_pm_shim(&tmp.path().join("bin"), "pdm", &log, None); - - let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); - assert_eq!(code, 0, "setup must succeed: {v}"); - let argvs = read(&log); - assert_eq!( - argvs.lines().next().unwrap_or_default(), - "lock --update-reuse", - "the first PDM lock invocation must reuse the user's pins; got argv log:\n{argvs}" - ); -} diff --git a/crates/socket-patch-cli/tests/setup_terminal_output.rs b/crates/socket-patch-cli/tests/setup_terminal_output.rs deleted file mode 100644 index a1c31ad2..00000000 --- a/crates/socket-patch-cli/tests/setup_terminal_output.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Terminal-output contract for `setup` / `setup --check` / `setup --remove`: -//! the advice a human gets, which stream it lands on, and the preview -//! layout. Host-only fixtures (no toolchains), hermetic runner. - -#[path = "common/mod.rs"] -mod common; - -use std::collections::HashMap; -use std::path::Path; - -use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, -}; - -const WIRED_PACKAGE_JSON: &str = "{\"name\":\"root\",\"version\":\"1.0.0\",\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}"; - -fn write(path: &Path, content: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, content).unwrap(); -} - -fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - common::run_with_env(cwd, args, &[("SOCKET_TELEMETRY_DISABLED", "1")]) -} - -/// Hooks wired, but the installed minimist file matches neither hash. -fn drifted_patch_fixture(cwd: &Path) { - write(&cwd.join("package.json"), WIRED_PACKAGE_JSON); - let pkg = cwd.join("node_modules/minimist"); - write( - &pkg.join("package.json"), - r#"{"name":"minimist","version":"1.2.5"}"#, - ); - write(&pkg.join("index.js"), "locally edited\n"); - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "a".repeat(64), - after_hash: "b".repeat(64), - }, - ); - let mut vulns = HashMap::new(); - vulns.insert( - "GHSA-xvch-5gv4-984h".to_string(), - VulnerabilityInfo { - cves: vec!["CVE-2021-44906".to_string()], - summary: "s".to_string(), - severity: "critical".to_string(), - description: "d".to_string(), - }, - ); - let mut m = PatchManifest::new(); - m.patches.insert( - "pkg:npm/minimist@1.2.5".to_string(), - PatchRecord { - uuid: "11111111-1111-4111-8111-111111111111".to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files, - vulnerabilities: vulns, - description: "p".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - write( - &cwd.join(".socket/manifest.json"), - &serde_json::to_string_pretty(&m).unwrap(), - ); -} - -#[test] -fn check_drifted_patch_points_at_apply_not_setup() { - let tmp = tempfile::tempdir().unwrap(); - drifted_patch_fixture(tmp.path()); - - let (code, stdout, stderr) = run(tmp.path(), &["setup", "--check"]); - assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stdout.contains(" ✓ package.json (configured)"), - "stdout=\n{stdout}" - ); - assert!( - stdout.contains(" ✗ pkg:npm/minimist@1.2.5: patch not applied on disk (hash_mismatch)"), - "stdout=\n{stdout}" - ); - assert!( - stdout.trim_end().ends_with( - "1 patch is not applied on disk. Run `socket-patch apply` to re-apply the patches." - ), - "stdout=\n{stdout}" - ); - assert!( - !stdout.contains("socket-patch setup` to"), - "setup cannot fix drift; stdout=\n{stdout}" - ); - assert!(!stdout.contains("(s)"), "stdout=\n{stdout}"); - // The progress line is a transient status line: nothing on a - // non-terminal stderr, never on stdout. - assert!(!stderr.contains("Searching for"), "stderr=\n{stderr}"); - assert!(!stdout.contains("Searching for"), "stdout=\n{stdout}"); -} - -#[test] -fn check_invalid_json_keeps_parser_detail_and_advice() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("package.json"), "{"); - let (code, stdout, _) = run(tmp.path(), &["setup", "--check"]); - assert_eq!(code, 1, "stdout=\n{stdout}"); - assert!( - stdout.contains(" ! package.json: Invalid package.json: EOF while parsing"), - "stdout=\n{stdout}" - ); - assert!( - stdout - .trim_end() - .ends_with("1 error. Fix the errors above, then re-run `socket-patch setup --check`."), - "stdout=\n{stdout}" - ); -} - -#[test] -fn setup_preview_errors_name_the_file() { - let tmp = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "workspaces": ["packages/*"] }"#, - ); - write(&tmp.path().join("packages/bad/package.json"), "{"); - let (code, stdout, _) = run(tmp.path(), &["setup", "--dry-run"]); - assert_eq!(code, 1, "stdout=\n{stdout}"); - let norm = stdout.replace('\\', "/"); - assert!( - norm.contains("\nErrors:\n ! packages/bad/package.json: Invalid package.json: "), - "stdout=\n{stdout}" - ); - assert!( - !stdout.contains("\n\n\n"), - "no double blank lines: {stdout:?}" - ); - - let (_, _, stderr) = run(tmp.path(), &["setup", "--dry-run", "--silent"]); - let norm = stderr.replace('\\', "/"); - assert!( - norm.contains("Error: packages/bad/package.json: Invalid package.json: "), - "stderr=\n{stderr}" - ); -} - -#[test] -fn unsupported_ecosystem_filter_says_so() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("package.json"), WIRED_PACKAGE_JSON); - for args in [ - &["setup", "-e", "cargo"][..], - &["setup", "--check", "-e", "cargo"], - ] { - let (code, stdout, _) = run(tmp.path(), args); - assert_eq!(code, 0, "{args:?}: stdout=\n{stdout}"); - assert_eq!( - stdout.trim_end(), - "Setup has no install hook for: cargo (supported: npm, pypi, gem, composer)", - "{args:?}" - ); - } - let empty = tempfile::tempdir().unwrap(); - let (_, stdout, _) = run(empty.path(), &["setup", "-e", "npm"]); - assert_eq!(stdout.trim_end(), "No package.json project found"); -} - -#[test] -fn unmatched_exclude_warns() { - let tmp = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("package.json"), - r#"{ "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }"#, - ); - write( - &tmp.path().join("packages/b/package.json"), - r#"{ "name": "b", "version": "1.0.0" }"#, - ); - let (code, _, stderr) = run( - tmp.path(), - &["setup", "--dry-run", "--exclude", "packages/b, nope"], - ); - assert_eq!(code, 0, "stderr=\n{stderr}"); - assert!( - stderr.contains("Warning: --exclude \"nope\" matched no workspace member"), - "stderr=\n{stderr}" - ); - assert!( - !stderr.contains("\"packages/b\" matched"), - "stderr=\n{stderr}" - ); - - let (_, _, stderr) = run( - tmp.path(), - &["setup", "--dry-run", "--silent", "--exclude", "nope"], - ); - assert!(stderr.is_empty(), "--silent mutes warnings: {stderr}"); -} - -#[test] -fn already_configured_run_prints_one_verdict() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("package.json"), WIRED_PACKAGE_JSON); - let (code, stdout, stderr) = run(tmp.path(), &["setup", "--yes"]); - assert_eq!(code, 0); - assert_eq!( - stdout.trim(), - "All install hooks are already configured with socket-patch!" - ); - // The progress line is transient: nothing on a non-terminal stderr. - assert_eq!(stderr, ""); -} - -#[test] -fn remove_dry_run_layout() { - let tmp = tempfile::tempdir().unwrap(); - write(&tmp.path().join("package.json"), WIRED_PACKAGE_JSON); - let (code, stdout, _) = run(tmp.path(), &["setup", "--remove", "--dry-run"]); - assert_eq!(code, 0, "stdout=\n{stdout}"); - assert!( - !stdout.contains("\n\n\n"), - "no double blank lines: {stdout:?}" - ); - assert!( - stdout.ends_with( - " -> dependencies: (removed)\n\nSummary (dry run):\n 1 item would have \ - socket-patch removed\n" - ), - "{stdout:?}" - ); -} - -#[test] -fn vlt_preview_shows_the_npx_hook_and_the_advisory_on_stderr() { - let tmp = tempfile::tempdir().unwrap(); - write( - &tmp.path().join("package.json"), - "{\n \"name\": \"root\"\n}\n", - ); - write( - &tmp.path().join("vlt-lock.json"), - "{\"lockfileVersion\":0,\"nodes\":{},\"edges\":{}}\n", - ); - let empty_path = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = common::run_with_env( - tmp.path(), - &["setup", "--dry-run"], - &[ - ("SOCKET_TELEMETRY_DISABLED", "1"), - ("PATH", empty_path.path().to_str().unwrap()), - ], - ); - assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stdout.contains( - " + package.json\n -> postinstall: \"npx @socketsecurity/socket-patch apply \ - --silent --ecosystems npm\"\n" - ), - "{stdout:?}" - ); - assert_eq!( - stderr, - "Warning (vlt_root_scripts_not_run): vlt before 1.0.0-rc.13 does not run the root \ - postinstall hook; upgrade vlt or run `socket-patch apply` after `vlt ci` \ - (vlt-lock.json has lockfileVersion 0, so this project may be installed by such a \ - vlt)\n" - ); -} diff --git a/crates/socket-patch-cli/tests/vex_terminal_output.rs b/crates/socket-patch-cli/tests/vex_terminal_output.rs index 5e772f91..f9a6a020 100644 --- a/crates/socket-patch-cli/tests/vex_terminal_output.rs +++ b/crates/socket-patch-cli/tests/vex_terminal_output.rs @@ -10,7 +10,7 @@ use std::process::{Command, Output}; use serde_json::Value; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; const STALE_OPENVEX_DOC: &str = r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#; @@ -56,10 +56,8 @@ fn record(uuid: &str, ghsa: &str) -> PatchRecord { } } -/// A manifest with `purls` (each with a distinct GHSA). `manual` declares -/// npm so the property-7 setup filter keeps the patches; `None` leaves the -/// ecosystem un-set-up. -fn write_manifest(cwd: &Path, purls: &[&str], manual: bool) { +/// A manifest with `purls` (each with a distinct GHSA). +fn write_manifest(cwd: &Path, purls: &[&str]) { let mut m = PatchManifest::new(); for (i, purl) in purls.iter().enumerate() { m.patches.insert( @@ -70,12 +68,6 @@ fn write_manifest(cwd: &Path, purls: &[&str], manual: bool) { ), ); } - if manual { - m.setup = Some(SetupConfig { - exclude: Vec::new(), - manual: vec!["npm".to_string()], - }); - } let dir = cwd.join(".socket"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( @@ -112,7 +104,7 @@ fn stdout(o: &Output) -> String { fn dry_run_with_output_writes_nothing_and_says_so() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("d.json"); let o = vex( @@ -150,7 +142,7 @@ fn dry_run_failure_keeps_previous_document() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); // Verify mode with nothing installed: nothing attests. - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("keep.json"); std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); @@ -164,7 +156,7 @@ fn dry_run_failure_keeps_previous_document() { fn output_dash_means_stdout() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let o = vex(cwd, &["--no-verify", "-O", "-"]); assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); @@ -184,7 +176,7 @@ fn output_dash_means_stdout() { fn written_document_ends_with_newline_and_summary_is_singular() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("out.json"); let o = vex(cwd, &["--no-verify", "-O", out_path.to_str().unwrap()]); @@ -208,7 +200,7 @@ fn written_document_ends_with_newline_and_summary_is_singular() { fn failed_run_reports_stale_document_removal() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("keep.json"); std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); @@ -254,7 +246,7 @@ fn omissions_are_sorted_and_listed_once() { "pkg:npm/alpha@1.0.0", "pkg:npm/mid@1.0.0", ]; - write_manifest(cwd, &purls, true); + write_manifest(cwd, &purls); let o = vex(cwd, &[]); assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); @@ -308,34 +300,26 @@ fn omissions_are_sorted_and_listed_once() { } #[test] -fn all_setup_drops_skip_the_generic_note() { +fn trusted_patch_attests_without_setup_config() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - // No `manual`, no hook: property 7 drops the (trusted) patch. - write_manifest(cwd, &["pkg:npm/a@1.0.0"], false); + // No manifest `setup` section and no install hook: the trusted patch + // still attests, with no omission warning. + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let o = vex(cwd, &["--no-verify"]); - assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); let err = stderr(&o); - assert!(!err.contains("Note:"), "{err}"); - let lines: Vec<&str> = err.lines().collect(); - assert_eq!(lines.len(), 2, "{err}"); - assert!(lines[0].starts_with("Warning: omitting pkg:npm/a@1.0.0 from VEX: applied, but")); - assert!(lines[0].ends_with("(ecosystem_not_setup)"), "{err}"); - assert!( - lines[1].starts_with( - "Error: 1 applied patch with vulnerability metadata was omitted from VEX because \ - its ecosystem is not set up" - ), - "{err}" - ); + assert_eq!(o.status.code(), Some(0), "{err}"); + assert!(!err.contains("omitting"), "{err}"); + let doc: Value = serde_json::from_slice(&o.stdout).expect("OpenVEX doc on stdout"); + assert_eq!(doc["statements"].as_array().unwrap().len(), 1, "{doc}"); } #[test] fn org_that_looks_like_a_file_warns() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); let o = vex(cwd, &["--no-verify", "-o", "out.json"]); assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); @@ -401,7 +385,7 @@ fn corrupt_manifest_error_names_the_file() { fn product_undetected_names_the_unusable_manifest() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + write_manifest(cwd, &["pkg:npm/a@1.0.0"]); std::fs::write(cwd.join("package.json"), r#"{"name":"app"}"#).unwrap(); let o = cli() .args(["vex", "--no-verify", "--cwd", cwd.to_str().unwrap()]) diff --git a/crates/socket-patch-cli/tests/vlt-leg-manifest.json b/crates/socket-patch-cli/tests/vlt-leg-manifest.json index 2248dd1e..6b46a002 100644 --- a/crates/socket-patch-cli/tests/vlt-leg-manifest.json +++ b/crates/socket-patch-cli/tests/vlt-leg-manifest.json @@ -122,14 +122,6 @@ "persistence_survives", "persistence_reverted_by_reinstall", "reruns_and_vex" - ], - "setup": [ - "hook_fires_per_reify", - "root_scripts_advisory", - "workspace_root_only", - "twice_no_duplicate", - "hook_failure", - "hook_abort_leaves_no_staging" ] }, "e2e_hosted_production": { @@ -257,14 +249,6 @@ "persistence_reverted_by_reinstall", "reruns_and_vex" ], - "setup": [ - "hook_fires_per_reify", - "root_scripts_advisory", - "workspace_root_only", - "twice_no_duplicate", - "hook_failure", - "hook_abort_leaves_no_staging" - ], "production": [ "hosted_install_proof", "vendored_install_proof" @@ -891,55 +875,6 @@ }, "conditions": [], "reason": "non-hermetic-registry" - }, - { - "boundary": "root `pre*`/`post*` scripts run without an `install` script", - "suite": "setup", - "legs": [ - "hook_fires_per_reify", - "workspace_root_only", - "hook_failure" - ], - "except": [], - "versions": { - "op": "<", - "a": "1.0.0-rc.13" - }, - "conditions": [], - "reason": "root-postinstall-not-run" - }, - { - "boundary": "a failing hook's abort on Windows (rollback EBUSY fix from 1.0.5)", - "suite": "setup", - "legs": [ - "hook_abort_leaves_no_staging" - ], - "except": [], - "versions": { - "op": "all" - }, - "conditions": [ - { - "key": "os", - "op": "!=", - "value": "windows" - } - ], - "reason": "windows-only" - }, - { - "boundary": "as above", - "suite": "setup", - "legs": [ - "hook_abort_leaves_no_staging" - ], - "except": [], - "versions": { - "op": "<", - "a": "1.0.5" - }, - "conditions": [], - "reason": "pre-ebusy-fix" } ], "supported": [ diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index 5a6fefeb..0eccb5b4 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -70,20 +70,18 @@ mod tests { /// 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: binary locks have a native byte reader, and the -/// `pnpm-lock.yml` spelling is accepted only by setup detection. This table -/// encodes each divergence once, visibly, instead of homogenizing them. +/// `scan::hosted`) and the crawler layout probe (`crawlers::pkg_managers`). +/// Those sites accept INTENTIONALLY divergent subsets (binary locks have a +/// native byte reader; `.yarnrc.yml`/`vlt.json` are redirect inputs only). +/// This table 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. +/// `vendor::npm_flavor`'s wiring-family list and `scan::hosted`'s +/// REDIRECT_CANDIDATE_FILES npm subset. 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 { @@ -92,9 +90,6 @@ pub mod npm_family { 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] = &[ @@ -102,73 +97,49 @@ pub mod npm_family { 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, }, // Binary Bun locks are read and rewritten natively. FileRow { name: "bun.lockb", vendor_probe: true, redirect_candidate: true, - detects_pnpm: false, }, FileRow { name: "vlt-lock.json", vendor_probe: true, redirect_candidate: true, - detects_pnpm: false, }, // vlt's config: a read-only redirect input, never wired. FileRow { name: "vlt.json", vendor_probe: false, redirect_candidate: true, - detects_pnpm: false, }, // deno.lock is deliberately absent: deno is its own ecosystem // (JSR-crawled); no npm-family vendor/redirect/detection path treats @@ -217,8 +188,4 @@ pub mod npm_family { pub const VLT_STORE_DIR: &str = "node_modules/.vlt"; /// Workspace globs of vlt <= 0.0.0-12 (`{"packages": ...}`). pub const VLT_LEGACY_WORKSPACES: &str = "vlt-workspaces.json"; - /// Any one in the cwd makes setup treat the project as vlt's - /// (`VLT_STORE_DIR` only as a directory). - pub const VLT_SETUP_MARKERS: [&str; 4] = - [VLT_LOCK, VLT_CONFIG, VLT_HIDDEN_LOCK_REL, VLT_STORE_DIR]; } diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 348c6377..146e53a9 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -65,7 +65,7 @@ fn parse_package_json_identity(content: &str) -> Option<(String, String)> { // (Windows-authored packages ship them), but serde_json rejects it — // a BOM'd install would be invisible to scan and unpatchable. let pkg: PackageJsonPartial = - serde_json::from_str(crate::package_json::detect::strip_bom(content)).ok()?; + serde_json::from_str(crate::utils::serde::strip_bom(content)).ok()?; let name = pkg.name?; let version = pkg.version?; if name.is_empty() || version.is_empty() { @@ -760,7 +760,7 @@ impl NpmCrawler { /// later restore) all of them: patching only one leaves a live, /// vulnerable copy while reporting success (a silent partial). The /// per-PURL `Vec` is ordered root-copy-first (breadth-first), so callers - /// that only need one representative (`vendor`, `vex`, `setup`) can take + /// that only need one representative (`vendor`, `vex`) can take /// the first and get the root copy. /// /// pnpm's and vlt's store peer-variant copies are deliberately NOT diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 383018e3..8c2a1744 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -211,13 +211,9 @@ impl RubyCrawler { /// /// 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 - /// `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 - /// silently find zero gems. + /// (`Bundler::SharedHelpers.default_gemfile`). Both count: gating on + /// `Gemfile` alone left a `gems.rb` project with a non-deployment + /// `bundle install` undiscoverable, so `apply` silently found 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() { @@ -447,9 +443,9 @@ impl RubyCrawler { /// The `BUNDLE_PATH` recorded in bundler's app config file — the value /// `bundle config set --local path ` writes. The file lives at /// `$BUNDLE_APP_CONFIG/config`, else `/.bundle/config`, resolved by - /// the shared [`crate::setup::gem::bundler_app_config_dir`] rule. + /// the shared [`bundler_app_config_dir`] rule. async fn app_config_bundle_path(cwd: &Path, app_config_env: Option<&OsStr>) -> Option { - let config = crate::setup::gem::bundler_app_config_dir(cwd, app_config_env).join("config"); + let config = bundler_app_config_dir(cwd, app_config_env).join("config"); // The config lives inside the (untrusted) project tree: a planted // FIFO would make a plain `read_to_string` open block forever // waiting for a writer, wedging scan (crawl_all) and apply/get @@ -948,11 +944,29 @@ fn expand_tilde(value: &Path, home: Option<&Path>) -> PathBuf { value.to_path_buf() } +/// Bundler's app-config dir for `root`, following `Bundler.app_config_path` +/// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against +/// the project root, NOT the process cwd), else `/.bundle` — e.g. the +/// official ruby Docker images export `BUNDLE_APP_CONFIG=/usr/local/bundle`. +fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> PathBuf { + match env_value { + Some(v) if !v.is_empty() => { + let p = PathBuf::from(v); + if p.is_absolute() { + p + } else { + root.join(p) + } + } + _ => root.join(".bundle"), + } +} + /// Resolve a trusted (ENV-sourced) `BUNDLE_PATH` value against the project /// root. Bundler `File.expand_path`s the value: a leading `~` expands to /// the user's home, and a relative path resolves against the directory of /// the Gemfile (`Bundler.root`), not the process cwd — the same rule -/// [`crate::setup::gem::bundler_app_config_dir`] follows for +/// [`bundler_app_config_dir`] follows for /// `BUNDLE_APP_CONFIG`. `.`/`..` segments are folded lexically so the same /// physical root spelled two ways dedups to one probe; a value that pops /// above its own root keeps its unnormalized spelling (it is only ever @@ -980,8 +994,7 @@ fn resolve_bundle_path(root: &Path, value: &Path, home: Option<&Path>) -> PathBu /// writes outside the project. Policy: after `~` expansion and lexical /// `.`/`..` normalization, the root must stay contained in the project /// root — the same containment posture as the composer crawler's -/// `install-path` guard and the gem plugin-index cleanup in -/// `setup/gem/mod.rs`. Out-of-tree bundle paths stay reachable via the +/// `install-path` guard. Out-of-tree bundle paths stay reachable via the /// trusted env `BUNDLE_PATH`. fn resolve_config_bundle_path( project_root: &Path, diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f9..52166a53 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -3,19 +3,10 @@ pub mod constants; pub mod crawlers; pub mod hash; pub mod manifest; -pub mod package_json; pub mod patch; -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 5.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/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index 9dd8f04e..7fd487e0 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -36,20 +36,18 @@ pub struct PatchRecord { pub tier: String, } -/// Persisted `setup` configuration (CLI_CONTRACT property 9). Lives under the -/// manifest's `setup` key so a fresh clone's `setup` / `setup --check` honors it -/// without re-passing flags. +/// Legacy `setup` configuration, written by the `setup` command that v5 +/// removed. Still parsed and kept on rewrite (so a v4 manifest round-trips +/// byte-stably) but read by nothing. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct SetupConfig { /// Workspace-member paths (relative to the repo root, forward-slashed) that - /// `setup` must NOT configure — and `setup --check` must not flag as - /// needing configuration. + /// the removed `setup` skipped. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub exclude: Vec, - /// Ecosystems (by `Ecosystem::cli_name`, e.g. `"pypi"`) the user runs - /// `socket-patch apply` for by hand, so their patches are still attested in - /// VEX even though no auto-install hook is wired (CLI_CONTRACT property 7). + /// Ecosystems (by `Ecosystem::cli_name`, e.g. `"pypi"`) the pre-v5 `vex` + /// attested with no install hook wired. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub manual: Vec, } @@ -78,7 +76,7 @@ pub struct PatchManifest { /// Maps package PURL (e.g., "pkg:npm/lodash@4.17.21") -> patch record. #[serde(serialize_with = "serialize_sorted")] pub patches: HashMap, - /// Optional persisted `setup` state (e.g. excluded workspace members). + /// Optional legacy `setup` state (see [`SetupConfig`]). /// Absent on manifests that predate / don't use it (serde default), and /// omitted from the serialized form when empty so existing manifests are /// byte-stable. diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs deleted file mode 100644 index 782d01e6..00000000 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ /dev/null @@ -1,1266 +0,0 @@ -use crate::vendor::common::JsonLayout; - -/// Package manager type for selecting the correct command prefix. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum PackageManager { - Npm, - Pnpm, - Vlt, -} - -/// Get the socket-patch apply command for the given package manager. -/// vlt gets npm's `npx` hook: npx ships with every vlt install (vlt is -/// distributed through npm), while `vlx`'s argument parsing changed at -/// 1.0.0-rc.28 in a way that breaks either spelling on one side. -fn socket_patch_command(pm: PackageManager) -> &'static str { - match pm { - PackageManager::Npm | PackageManager::Vlt => { - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - PackageManager::Pnpm => { - "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - } -} - -/// Legacy command patterns to detect existing configurations. -const LEGACY_PATCH_PATTERNS: &[&str] = &[ - "socket-patch apply", - "npx @socketsecurity/socket-patch apply", - "socket patch apply", -]; - -/// Check if a script string contains any known socket-patch apply pattern. -fn script_is_configured(script: &str) -> bool { - LEGACY_PATCH_PATTERNS - .iter() - .any(|pattern| script.contains(pattern)) -} - -/// Status of setup script configuration (both postinstall and dependencies). -#[derive(Debug, Clone)] -pub struct ScriptSetupStatus { - pub postinstall_configured: bool, - pub postinstall_script: String, - pub dependencies_configured: bool, - pub dependencies_script: String, - pub needs_update: bool, -} - -/// Read `scripts.` as a string, treating absent or non-string as empty. -fn read_script(package_json: &serde_json::Value, key: &str) -> String { - package_json - .get("scripts") - .and_then(|s| s.get(key)) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() -} - -/// Check if package.json scripts are properly configured for socket-patch. -/// Checks both the postinstall and dependencies lifecycle scripts. -fn is_setup_configured(package_json: &serde_json::Value) -> ScriptSetupStatus { - let postinstall_script = read_script(package_json, "postinstall"); - let postinstall_configured = script_is_configured(&postinstall_script); - - let dependencies_script = read_script(package_json, "dependencies"); - let dependencies_configured = script_is_configured(&dependencies_script); - - ScriptSetupStatus { - postinstall_configured, - postinstall_script, - dependencies_configured, - dependencies_script, - needs_update: !postinstall_configured || !dependencies_configured, - } -} - -/// Strip a leading UTF-8 BOM. npm and Node tolerate (and strip) a BOM in -/// package.json, and cargo accepts one in Cargo.toml — files saved by Windows -/// editors commonly carry one — but serde_json (and vex's TOML line scanner) -/// reject it, so every parse of user-supplied manifest content must go through -/// this first or toolchain-valid manifests error out. Also used by -/// `vex::product`. -pub(crate) fn strip_bom(content: &str) -> &str { - content.strip_prefix('\u{feff}').unwrap_or(content) -} - -/// Check if a package.json content string is properly configured. -pub fn is_setup_configured_str(content: &str) -> ScriptSetupStatus { - match serde_json::from_str::(strip_bom(content)) { - Ok(val) => is_setup_configured(&val), - Err(_) => ScriptSetupStatus { - postinstall_configured: false, - postinstall_script: String::new(), - dependencies_configured: false, - dependencies_script: String::new(), - needs_update: true, - }, - } -} - -/// Generate an updated script that includes the socket-patch apply command. -/// If already configured, returns unchanged. Otherwise prepends the command. -fn generate_updated_script(current_script: &str, pm: PackageManager) -> String { - let command = socket_patch_command(pm); - let trimmed = current_script.trim(); - - // If empty, just add the socket-patch command. - if trimmed.is_empty() { - return command.to_string(); - } - - // If any socket-patch variant is already present, return unchanged. - if script_is_configured(trimmed) { - return trimmed.to_string(); - } - - // Prepend socket-patch command so it runs first. - format!("{command} && {trimmed}") -} - -/// Update a package.json Value with socket-patch in both postinstall and -/// dependencies scripts. -/// Returns (modified, new_postinstall, new_dependencies). -fn update_package_json_object( - package_json: &mut serde_json::Value, - pm: PackageManager, -) -> (bool, String, String) { - let status = is_setup_configured(package_json); - - if !status.needs_update { - return (false, status.postinstall_script, status.dependencies_script); - } - - // We can only attach scripts to an object root. Anything else (array, - // string, number, bool, null) cannot hold a "scripts" key, so indexing it - // below would panic. Bail out as a no-op instead. - if !package_json.is_object() { - return (false, status.postinstall_script, status.dependencies_script); - } - - // Ensure `scripts` exists *and* is an object. A present-but-non-object - // `scripts` (e.g. a string or array) would otherwise panic when indexed. - if !package_json - .get("scripts") - .map(serde_json::Value::is_object) - .unwrap_or(false) - { - package_json["scripts"] = serde_json::json!({}); - } - - let mut modified = false; - - let new_postinstall = if !status.postinstall_configured { - modified = true; - let s = generate_updated_script(&status.postinstall_script, pm); - package_json["scripts"]["postinstall"] = serde_json::Value::String(s.clone()); - s - } else { - status.postinstall_script - }; - - let new_dependencies = if !status.dependencies_configured { - modified = true; - let s = generate_updated_script(&status.dependencies_script, pm); - package_json["scripts"]["dependencies"] = serde_json::Value::String(s.clone()); - s - } else { - status.dependencies_script - }; - - (modified, new_postinstall, new_dependencies) -} - -/// Strip every socket-patch segment out of a single lifecycle script. -/// -/// Commands are chained with `&&` (that is exactly how -/// [`generate_updated_script`] prepends the patch command), so splitting on -/// 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. 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. -/// - `(true, Some(rest))` — patch segment(s) removed, other commands survive. -/// - `(true, None)` — the script was *only* socket-patch; the key should be -/// deleted entirely. -fn remove_socket_patch_from_script(script: &str) -> (bool, Option) { - let trimmed = script.trim(); - if trimmed.is_empty() { - return (false, None); - } - - 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 - // empty segments, so keying `changed` off `kept.len() != segments.len()` - // would falsely report a removal for a patch-free script that merely - // contained a stray empty segment (e.g. a double `" && "` separator), - // violating this function's documented `(false, ..)`/`(true, ..)` contract. - let had_patch = segments.iter().any(|s| script_is_configured(s.trim())); - - 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("&&").trim().to_string())) - } -} - -/// Status of a remove operation on a single package.json object. -#[derive(Debug, Clone)] -pub(crate) struct ScriptRemoveStatus { - pub modified: bool, - pub old_postinstall: String, - pub new_postinstall: Option, - pub old_dependencies: String, - pub new_dependencies: Option, -} - -/// Remove socket-patch from both lifecycle scripts in a package.json object. -/// -/// Full revert: an emptied `postinstall`/`dependencies` key is deleted, and if -/// `scripts` ends up empty the whole `scripts` key is dropped too — undoing -/// exactly what [`update_package_json_object`] added. Returns a -/// [`ScriptRemoveStatus`] describing what changed. -fn remove_package_json_object(package_json: &mut serde_json::Value) -> ScriptRemoveStatus { - let old_postinstall = read_script(package_json, "postinstall"); - let old_dependencies = read_script(package_json, "dependencies"); - - // `remove_socket_patch_from_script` reports `changed` only when a - // socket-patch segment was actually present and removed. - let (pi_changed, new_postinstall) = remove_socket_patch_from_script(&old_postinstall); - let (dep_changed, new_dependencies) = remove_socket_patch_from_script(&old_dependencies); - let modified = pi_changed || dep_changed; - - if !modified { - return ScriptRemoveStatus { - modified: false, - new_postinstall: Some(old_postinstall.clone()), - old_postinstall, - new_dependencies: Some(old_dependencies.clone()), - old_dependencies, - }; - } - - // We can only mutate scripts on an object root with an object `scripts`. - // Anything else has nothing to remove and is handled by the no-op path - // above (its scripts read as empty). - if let Some(scripts) = package_json - .get_mut("scripts") - .and_then(|s| s.as_object_mut()) - { - if pi_changed { - match &new_postinstall { - Some(s) => { - scripts.insert( - "postinstall".to_string(), - serde_json::Value::String(s.clone()), - ); - } - None => { - scripts.remove("postinstall"); - } - } - } - if dep_changed { - match &new_dependencies { - Some(s) => { - scripts.insert( - "dependencies".to_string(), - serde_json::Value::String(s.clone()), - ); - } - None => { - scripts.remove("dependencies"); - } - } - } - - // If `scripts` is now empty, drop the key entirely for a clean revert. - if scripts.is_empty() { - if let Some(obj) = package_json.as_object_mut() { - obj.remove("scripts"); - } - } - } - - ScriptRemoveStatus { - modified, - old_postinstall, - new_postinstall, - old_dependencies, - new_dependencies, - } -} - -/// Re-serialize a package.json in the layout the file already uses. -/// -/// serde's `to_string_pretty` is hard-wired to 2 spaces and bare `\n`, so a -/// 4-space or tab-indented manifest came back reformatted top to bottom, and -/// a Windows one (yarn berry's persistManifest pretty-prints with `os.EOL`) -/// flipped every CRLF to LF and lost its BOM — turning a two-key edit into a -/// whole-file diff that `setup --remove` could never undo byte-exactly. The -/// vendor backends render through [`JsonLayout`] (BOM, indent, line ending, -/// trailing-newline shape); reuse it so `setup` touches only the lines it -/// means to. -fn serialize_preserving_indent(value: &serde_json::Value, original: &str) -> String { - match JsonLayout::of(original).render(value) { - // Always valid UTF-8: the original was a &str and 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( - content: &str, -) -> Result<(bool, String, ScriptRemoveStatus), String> { - let mut package_json: serde_json::Value = serde_json::from_str(strip_bom(content)) - .map_err(|e| format!("Invalid package.json: {e}"))?; - - if !package_json.is_object() { - return Err("Invalid package.json: root is not a JSON object".to_string()); - } - - // Refuse to touch a malformed (present but non-object) `scripts` value. - if let Some(scripts) = package_json.get("scripts") { - if !scripts.is_null() && !scripts.is_object() { - return Err("Invalid package.json: \"scripts\" is not a JSON object".to_string()); - } - } - - let status = remove_package_json_object(&mut package_json); - - if !status.modified { - return Ok((false, content.to_string(), status)); - } - - let new_content = serialize_preserving_indent(&package_json, content); - Ok((true, new_content, status)) -} - -/// Parse package.json content and update it with socket-patch scripts. -/// Returns (modified, new_content, old_postinstall, new_postinstall, -/// old_dependencies, new_dependencies). -pub(crate) fn update_package_json_content( - content: &str, - pm: PackageManager, -) -> Result<(bool, String, String, String, String, String), String> { - let mut package_json: serde_json::Value = serde_json::from_str(strip_bom(content)) - .map_err(|e| format!("Invalid package.json: {e}"))?; - - // A package.json must be a JSON object; otherwise there is nowhere to add - // lifecycle scripts. - if !package_json.is_object() { - return Err("Invalid package.json: root is not a JSON object".to_string()); - } - - // Refuse to clobber a malformed (present but non-object) `scripts` value. - // `null` is treated as absent and replaced with a fresh object downstream. - if let Some(scripts) = package_json.get("scripts") { - if !scripts.is_null() && !scripts.is_object() { - 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); - - if !status.needs_update { - return Ok(( - false, - content.to_string(), - status.postinstall_script.clone(), - status.postinstall_script, - status.dependencies_script.clone(), - status.dependencies_script, - )); - } - - let old_postinstall = status.postinstall_script.clone(); - let old_dependencies = status.dependencies_script.clone(); - - let (_, new_postinstall, new_dependencies) = update_package_json_object(&mut package_json, pm); - let new_content = serialize_preserving_indent(&package_json, content); - - Ok(( - true, - new_content, - old_postinstall, - new_postinstall, - old_dependencies, - new_dependencies, - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── is_setup_configured ───────────────────────────────────────── - - #[test] - fn test_not_configured() { - let pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { - "build": "tsc" - } - }); - let status = is_setup_configured(&pkg); - assert!(!status.postinstall_configured); - assert!(!status.dependencies_configured); - assert!(status.needs_update); - } - - #[test] - fn test_postinstall_configured_dependencies_not() { - let pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - }); - let status = is_setup_configured(&pkg); - assert!(status.postinstall_configured); - assert!(!status.dependencies_configured); - assert!(status.needs_update); - } - - #[test] - fn test_both_configured() { - let pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm", - "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - }); - let status = is_setup_configured(&pkg); - assert!(status.postinstall_configured); - assert!(status.dependencies_configured); - assert!(!status.needs_update); - } - - #[test] - fn test_legacy_socket_patch_apply_recognized() { - let pkg: serde_json::Value = serde_json::json!({ - "scripts": { - "postinstall": "socket patch apply --silent --ecosystems npm", - "dependencies": "socket-patch apply" - } - }); - let status = is_setup_configured(&pkg); - assert!(status.postinstall_configured); - assert!(status.dependencies_configured); - assert!(!status.needs_update); - } - - #[test] - fn test_no_scripts() { - let pkg: serde_json::Value = serde_json::json!({"name": "test"}); - let status = is_setup_configured(&pkg); - assert!(!status.postinstall_configured); - assert!(status.postinstall_script.is_empty()); - assert!(!status.dependencies_configured); - assert!(status.dependencies_script.is_empty()); - } - - #[test] - fn test_no_postinstall() { - let pkg: serde_json::Value = serde_json::json!({ - "scripts": {"build": "tsc"} - }); - let status = is_setup_configured(&pkg); - assert!(!status.postinstall_configured); - assert!(status.postinstall_script.is_empty()); - } - - // ── is_setup_configured_str ───────────────────────────────────── - - #[test] - fn test_configured_str_invalid_json() { - let status = is_setup_configured_str("not json"); - assert!(!status.postinstall_configured); - assert!(status.needs_update); - } - - #[test] - fn test_configured_str_utf8_bom() { - // npm strips a leading BOM when reading package.json; a BOM'd, - // configured manifest must read as configured, not as unparseable - // (which would mis-report it as needing setup). - let content = "\u{feff}{\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}"; - let status = is_setup_configured_str(content); - assert!(status.postinstall_configured); - assert!(status.dependencies_configured); - assert!(!status.needs_update); - } - - #[test] - fn test_configured_str_legacy_npx_pattern() { - let content = - r#"{"scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent"}}"#; - let status = is_setup_configured_str(content); - assert!(status.postinstall_configured); - } - - #[test] - fn test_configured_str_socket_dash_patch() { - let content = - r#"{"scripts":{"postinstall":"socket-patch apply --silent --ecosystems npm"}}"#; - let status = is_setup_configured_str(content); - assert!(status.postinstall_configured); - } - - #[test] - fn test_configured_str_pnpm_dlx_pattern() { - let content = r#"{"scripts":{"postinstall":"pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#; - let status = is_setup_configured_str(content); - // "pnpm dlx @socketsecurity/socket-patch apply" contains "socket-patch apply" - assert!(status.postinstall_configured); - } - - // ── generate_updated_script ───────────────────────────────────── - - #[test] - fn test_generate_empty_npm() { - assert_eq!( - generate_updated_script("", PackageManager::Npm), - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - ); - } - - #[test] - fn test_generate_empty_pnpm() { - assert_eq!( - generate_updated_script("", PackageManager::Pnpm), - "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm" - ); - } - - #[test] - fn test_generate_empty_vlt() { - assert_eq!( - generate_updated_script("", PackageManager::Vlt), - generate_updated_script("", PackageManager::Npm), - ); - assert_eq!( - generate_updated_script("echo done", PackageManager::Vlt), - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done" - ); - } - - #[test] - fn test_generate_prepend_npm() { - assert_eq!( - generate_updated_script("echo done", PackageManager::Npm), - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done" - ); - } - - #[test] - fn test_generate_prepend_pnpm() { - assert_eq!( - generate_updated_script("echo done", PackageManager::Pnpm), - "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done" - ); - } - - #[test] - fn test_generate_already_configured() { - let current = "socket-patch apply && echo done"; - assert_eq!( - generate_updated_script(current, PackageManager::Npm), - current - ); - } - - #[test] - fn test_generate_whitespace_only() { - let result = generate_updated_script(" \t ", PackageManager::Npm); - assert_eq!( - result, - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - ); - } - - // ── update_package_json_object ────────────────────────────────── - - #[test] - fn test_update_object_creates_scripts() { - let mut pkg: serde_json::Value = serde_json::json!({"name": "test"}); - let (modified, new_postinstall, new_dependencies) = - update_package_json_object(&mut pkg, PackageManager::Npm); - assert!(modified); - assert!(new_postinstall.contains("npx @socketsecurity/socket-patch apply")); - assert!(new_dependencies.contains("npx @socketsecurity/socket-patch apply")); - assert!(pkg.get("scripts").is_some()); - assert!(pkg["scripts"]["postinstall"].is_string()); - assert!(pkg["scripts"]["dependencies"].is_string()); - } - - #[test] - fn test_update_object_creates_scripts_pnpm() { - let mut pkg: serde_json::Value = serde_json::json!({"name": "test"}); - let (modified, new_postinstall, new_dependencies) = - update_package_json_object(&mut pkg, PackageManager::Pnpm); - assert!(modified); - assert!(new_postinstall.contains("pnpm dlx @socketsecurity/socket-patch apply")); - assert!(new_dependencies.contains("pnpm dlx @socketsecurity/socket-patch apply")); - } - - #[test] - fn test_update_object_noop_when_both_configured() { - let mut pkg: serde_json::Value = serde_json::json!({ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm", - "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - }); - let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm); - assert!(!modified); - } - - #[test] - fn test_update_object_adds_dependencies_when_postinstall_exists() { - let mut pkg: serde_json::Value = serde_json::json!({ - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - }); - let (modified, _, new_dependencies) = - update_package_json_object(&mut pkg, PackageManager::Npm); - assert!(modified); - assert!(new_dependencies.contains("npx @socketsecurity/socket-patch apply")); - // postinstall should remain unchanged - assert_eq!( - pkg["scripts"]["postinstall"].as_str().unwrap(), - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - ); - } - - // ── update_package_json_content ───────────────────────────────── - - #[test] - fn test_update_content_roundtrip_no_scripts() { - let content = r#"{"name": "test"}"#; - let (modified, new_content, old_pi, new_pi, old_dep, new_dep) = - update_package_json_content(content, PackageManager::Npm).unwrap(); - assert!(modified); - assert!(old_pi.is_empty()); - assert!(new_pi.contains("npx @socketsecurity/socket-patch apply")); - assert!(old_dep.is_empty()); - assert!(new_dep.contains("npx @socketsecurity/socket-patch apply")); - 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_update_content_already_configured() { - let content = r#"{"scripts":{"postinstall":"socket patch apply --silent --ecosystems npm","dependencies":"socket patch apply --silent --ecosystems npm"}}"#; - let (modified, _, _, _, _, _) = - update_package_json_content(content, PackageManager::Npm).unwrap(); - assert!(!modified); - } - - #[test] - fn test_update_content_invalid_json() { - let result = update_package_json_content("not json", PackageManager::Npm); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid package.json")); - } - - /// Mirror of test_update_object_adds_dependencies_when_postinstall_exists: - /// `dependencies` is already configured but `postinstall` is not. The - /// already-configured script must come back byte-for-byte unchanged (the - /// keep-arm of `update_package_json_object`), with only `postinstall` - /// added — and no second patch command prepended to `dependencies`. - #[test] - fn test_update_content_dependencies_preconfigured_adds_postinstall() { - let configured = "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"; - let content = format!(r#"{{"scripts":{{"dependencies":"{configured}"}}}}"#); - let (modified, new_content, old_pi, new_pi, old_dep, new_dep) = - update_package_json_content(&content, PackageManager::Npm).unwrap(); - assert!(modified); - assert!(old_pi.is_empty()); - assert!(new_pi.contains("npx @socketsecurity/socket-patch apply")); - // The pre-configured dependencies script is untouched. - assert_eq!(old_dep, configured); - assert_eq!(new_dep, configured); - let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); - assert_eq!( - parsed["scripts"]["dependencies"].as_str().unwrap(), - configured - ); - assert_eq!( - parsed["scripts"]["dependencies"] - .as_str() - .unwrap() - .matches("socket-patch apply") - .count(), - 1, - "must not double-prepend the patch command:\n{new_content}" - ); - assert!(parsed["scripts"]["postinstall"] - .as_str() - .unwrap() - .contains("npx @socketsecurity/socket-patch apply")); - } - - #[test] - fn test_update_object_scripts_is_string_does_not_panic() { - // A present-but-non-object `scripts` must not panic when indexed - // (`cannot access key "postinstall" in JSON string`). - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": "build" - }); - let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm); - // Root is an object but `scripts` is malformed; the object-level helper - // replaces it rather than panicking. - assert!(modified); - assert!(pkg["scripts"]["postinstall"].is_string()); - assert!(pkg["scripts"]["dependencies"].is_string()); - } - - #[test] - fn test_update_object_scripts_is_array_does_not_panic() { - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": ["build"] - }); - let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm); - assert!(modified); - assert!(pkg["scripts"].is_object()); - } - - #[test] - fn test_update_object_scripts_is_null() { - // `null` scripts is treated as absent and replaced with an object. - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": null - }); - let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm); - assert!(modified); - assert!(pkg["scripts"]["postinstall"].is_string()); - } - - #[test] - fn test_update_object_non_object_root_is_noop() { - // A non-object root must not panic on `["scripts"] = ...`. - let mut arr: serde_json::Value = serde_json::json!([1, 2, 3]); - let (modified, _, _) = update_package_json_object(&mut arr, PackageManager::Npm); - assert!(!modified); - assert_eq!(arr, serde_json::json!([1, 2, 3])); - } - - #[test] - fn test_update_content_non_object_root_errors() { - // Regression: valid JSON that is not an object must error, not panic. - for content in ["[1,2,3]", "42", "\"hello\"", "true", "null"] { - let result = update_package_json_content(content, PackageManager::Npm); - assert!(result.is_err(), "expected error for content {content:?}"); - assert!(result.unwrap_err().contains("root is not a JSON object")); - } - } - - #[test] - fn test_update_content_non_object_scripts_errors() { - // Regression: a present-but-non-object `scripts` must error rather than - // silently clobbering the user's value or panicking. - let content = r#"{"name":"test","scripts":"build"}"#; - let result = update_package_json_content(content, PackageManager::Npm); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("\"scripts\" is not a JSON object")); - } - - #[test] - fn test_update_content_null_scripts_creates_object() { - // `null` scripts is benign: treated as absent and populated. - let content = r#"{"name":"test","scripts":null}"#; - let (modified, new_content, _, new_pi, _, new_dep) = - update_package_json_content(content, PackageManager::Npm).unwrap(); - assert!(modified); - assert!(new_pi.contains("npx @socketsecurity/socket-patch apply")); - assert!(new_dep.contains("npx @socketsecurity/socket-patch apply")); - let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); - assert!(parsed["scripts"]["postinstall"].is_string()); - assert!(parsed["scripts"]["dependencies"].is_string()); - } - - // ── remove_socket_patch_from_script ───────────────────────────── - - #[test] - fn test_remove_script_only_socket_patch_deletes_key() { - let (changed, new) = remove_socket_patch_from_script( - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm", - ); - assert!(changed); - assert_eq!(new, None); - } - - #[test] - fn test_remove_script_strips_prefix_keeps_rest() { - let (changed, new) = remove_socket_patch_from_script( - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done", - ); - assert!(changed); - assert_eq!(new.as_deref(), Some("echo done")); - } - - #[test] - fn test_remove_script_no_socket_patch_unchanged() { - let (changed, new) = remove_socket_patch_from_script("echo done && tsc"); - assert!(!changed); - assert_eq!(new.as_deref(), Some("echo done && tsc")); - } - - #[test] - fn test_remove_script_legacy_pattern() { - let (changed, new) = remove_socket_patch_from_script("socket-patch apply && echo done"); - assert!(changed); - assert_eq!(new.as_deref(), Some("echo done")); - } - - #[test] - fn test_remove_script_empty() { - let (changed, new) = remove_socket_patch_from_script(""); - assert!(!changed); - assert_eq!(new, None); - } - - #[test] - fn test_remove_script_empty_segment_no_patch_is_unchanged() { - // Regression: a patch-free script with a stray empty segment (double - // `" && "`) must report `changed == false` — `(true, ..)` means a - // socket-patch segment was removed, which did not happen, so - // `changed` must not be keyed off `kept.len() != segments.len()`. - let (changed, new) = remove_socket_patch_from_script("echo a && && echo b"); - assert!( - !changed, - "no socket-patch present, must not report a removal" - ); - assert_eq!(new.as_deref(), Some("echo a && && echo b")); - } - - #[test] - fn test_remove_script_patch_in_middle_keeps_siblings() { - let (changed, new) = - remove_socket_patch_from_script("echo a && socket-patch apply && echo b"); - assert!(changed); - assert_eq!(new.as_deref(), Some("echo a && echo b")); - } - - #[test] - fn test_remove_script_multiple_patch_segments() { - // Defensive: more than one socket-patch invocation, all removed. - let (changed, new) = remove_socket_patch_from_script( - "socket-patch apply && build && npx @socketsecurity/socket-patch apply", - ); - assert!(changed); - 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")); - } - - /// A `&&` inside a QUOTED argument of a surviving user command also - /// splits at the operator scan, and a canonical `" && "` rejoin would - /// rewrite the user's bytes — `grep "a&&b"` becoming `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 - // contains the "socket-patch apply" pattern). - let (changed, new) = remove_socket_patch_from_script( - "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo hi", - ); - assert!(changed); - assert_eq!(new.as_deref(), Some("echo hi")); - } - - // ── remove_package_json_object ────────────────────────────────── - - #[test] - fn test_remove_object_deletes_lifecycle_keys() { - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm", - "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm" - } - }); - let status = remove_package_json_object(&mut pkg); - assert!(status.modified); - // Both keys were only socket-patch, so they (and the now-empty - // `scripts` object) are removed entirely. - assert!(pkg.get("scripts").is_none()); - } - - #[test] - fn test_remove_object_keeps_sibling_scripts() { - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { - "build": "tsc", - "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo hi" - } - }); - let status = remove_package_json_object(&mut pkg); - assert!(status.modified); - assert_eq!(pkg["scripts"]["build"], "tsc"); - assert_eq!(pkg["scripts"]["postinstall"], "echo hi"); - } - - #[test] - fn test_remove_object_noop_when_empty_segment_no_patch() { - // Regression: a patch-free script whose only oddity is a stray empty - // segment must be a no-op — neither reported modified nor rewritten. - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { "postinstall": "echo a && && echo b" } - }); - let status = remove_package_json_object(&mut pkg); - assert!(!status.modified); - // The original (untouched) value must be preserved, empty segment and all. - assert_eq!(pkg["scripts"]["postinstall"], "echo a && && echo b"); - } - - #[test] - fn test_remove_object_noop_when_not_configured() { - let mut pkg: serde_json::Value = serde_json::json!({ - "name": "test", - "scripts": { "build": "tsc" } - }); - let status = remove_package_json_object(&mut pkg); - assert!(!status.modified); - assert_eq!(pkg["scripts"]["build"], "tsc"); - } - - // ── remove_package_json_content ───────────────────────────────── - - #[test] - fn test_remove_content_roundtrip_with_update() { - // update then remove must return to a no-socket-patch state. - let original = r#"{"name":"x","scripts":{"build":"tsc"}}"#; - let (_, updated, ..) = update_package_json_content(original, PackageManager::Npm).unwrap(); - assert!(updated.contains("socket-patch")); - - let (modified, removed, _) = remove_package_json_content(&updated).unwrap(); - assert!(modified); - assert!(!removed.contains("socket-patch")); - let parsed: serde_json::Value = serde_json::from_str(&removed).unwrap(); - assert_eq!(parsed["scripts"]["build"], "tsc"); - assert!(parsed["scripts"].get("postinstall").is_none()); - assert!(parsed["scripts"].get("dependencies").is_none()); - } - - #[test] - fn test_remove_content_idempotent() { - let configured = - r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply"}}"#; - let (modified1, removed, _) = remove_package_json_content(configured).unwrap(); - assert!(modified1); - let (modified2, _, _) = remove_package_json_content(&removed).unwrap(); - assert!(!modified2); - } - - #[test] - fn test_remove_content_roundtrip_pnpm() { - // update (pnpm) then remove must fully revert to a no-socket-patch state. - let original = r#"{"name":"x","scripts":{"build":"tsc"}}"#; - let (_, updated, ..) = update_package_json_content(original, PackageManager::Pnpm).unwrap(); - assert!(updated.contains("pnpm dlx @socketsecurity/socket-patch apply")); - - let (modified, removed, _) = remove_package_json_content(&updated).unwrap(); - assert!(modified); - assert!(!removed.contains("socket-patch")); - let parsed: serde_json::Value = serde_json::from_str(&removed).unwrap(); - assert_eq!(parsed["scripts"]["build"], "tsc"); - assert!(parsed["scripts"].get("postinstall").is_none()); - assert!(parsed["scripts"].get("dependencies").is_none()); - } - - #[test] - fn test_remove_content_invalid_json_errors() { - assert!(remove_package_json_content("not json").is_err()); - } - - #[test] - fn test_remove_content_non_object_root_errors() { - // Twin of test_update_content_non_object_root_errors: valid JSON whose - // root is not an object must error, not panic or no-op. - for content in ["[1,2,3]", "42", "\"hello\"", "true", "null"] { - let result = remove_package_json_content(content); - assert!(result.is_err(), "expected error for content {content:?}"); - assert!(result.unwrap_err().contains("root is not a JSON object")); - } - } - - /// Partial removal from the `dependencies` script (the Some-arm of the - /// dep_changed branch): the patch is present ONLY in `dependencies`, - /// chained with a user command that must survive. Also pins the - /// pi_changed=false && dep_changed=true status shape: `new_postinstall` - /// is `None` here because the postinstall script was already empty/absent - /// (not because a postinstall key was deleted). - #[test] - fn test_remove_content_dependencies_only_keeps_user_command() { - let content = r#"{"name":"x","scripts":{"dependencies":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm && npm run prep"}}"#; - let (modified, new_content, status) = remove_package_json_content(content).unwrap(); - assert!(modified); - assert_eq!(status.new_dependencies.as_deref(), Some("npm run prep")); - assert_eq!( - status.old_dependencies, - "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && npm run prep" - ); - assert!(status.old_postinstall.is_empty()); - assert!( - status.new_postinstall.is_none(), - "absent postinstall reads as empty and unchanged reports None" - ); - let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); - assert_eq!( - parsed["scripts"]["dependencies"], "npm run prep", - "the user's command must survive:\n{new_content}" - ); - assert!(parsed["scripts"].get("postinstall").is_none()); - assert!(!new_content.contains("socket-patch")); - } - - #[test] - fn test_remove_content_non_object_scripts_errors() { - let result = remove_package_json_content(r#"{"name":"x","scripts":"build"}"#); - 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"}"#; - let (modified, new_content, _, new_pi, _, new_dep) = - update_package_json_content(content, PackageManager::Pnpm).unwrap(); - assert!(modified); - assert!(new_pi.contains("pnpm dlx @socketsecurity/socket-patch apply")); - assert!(new_dep.contains("pnpm dlx @socketsecurity/socket-patch apply")); - let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap(); - assert!(parsed["scripts"]["postinstall"] - .as_str() - .unwrap() - .contains("pnpm dlx")); - assert!(parsed["scripts"]["dependencies"] - .as_str() - .unwrap() - .contains("pnpm dlx")); - } - - #[test] - fn test_update_content_vlt_round_trips_through_remove() { - let content = "{\n \"name\": \"test\"\n}\n"; - let (modified, updated, _, new_pi, _, new_dep) = - update_package_json_content(content, PackageManager::Vlt).unwrap(); - assert!(modified); - let npx = "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"; - assert_eq!(new_pi, npx); - assert_eq!(new_dep, npx); - assert!(!is_setup_configured_str(&updated).needs_update); - let (removed, restored, _) = remove_package_json_content(&updated).unwrap(); - assert!(removed); - assert_eq!(restored, content); - } -} diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs deleted file mode 100644 index 7912e153..00000000 --- a/crates/socket-patch-core/src/package_json/find.rs +++ /dev/null @@ -1,2093 +0,0 @@ -use std::path::{Path, PathBuf}; -use tokio::fs; - -use super::detect::{strip_bom, PackageManager}; -use crate::constants::npm_family; -use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries, read_regular_to_string}; -use crate::vendor::vlt_lock_text::{sniff_lock, LockSniff}; - -/// Detect the package manager based on lockfiles in the project root. -/// vlt's markers ([`VLT_SETUP_MARKERS`](npm_family::VLT_SETUP_MARKERS)) win -/// over pnpm's, and only the start directory is consulted: an ancestor -/// `vlt.json` does not make a nested package a vlt project. The accepted -/// pnpm marker spellings (including the `pnpm-lock.yml` variant no other -/// subsystem accepts) live in the shared [`npm_family`] table. -pub async fn detect_package_manager(start_path: &Path) -> PackageManager { - for name in npm_family::VLT_SETUP_MARKERS { - let path = start_path.join(name); - let present = if name == npm_family::VLT_STORE_DIR { - is_dir(&path).await - } else { - fs::metadata(&path).await.is_ok() - }; - if present { - return PackageManager::Vlt; - } - } - for name in npm_family::names_with(|r| r.detects_pnpm) { - if fs::metadata(start_path.join(name)).await.is_ok() { - return PackageManager::Pnpm; - } - } - PackageManager::Npm -} - -/// A `vlt-lock.json` written by a vlt that may predate 1.0.0-rc.13, the -/// first release that runs a root `postinstall` without an `install` -/// script. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LegacyVltLock { - /// No `lockfileVersion` key (vlt <= 0.0.0-18). - NoVersion, - /// `lockfileVersion: 0` (vlt < 1.0.0-rc.15; rc.13 and rc.14 included). - VersionZero, -} - -/// The start directory's `vlt-lock.json`, when its `lockfileVersion` is -/// absent or `0`. Any other lock, an unreadable one or none yields `None`. -pub async fn legacy_vlt_lock(start_path: &Path) -> Option { - let text = read_regular_to_string(&start_path.join(npm_family::VLT_LOCK)) - .await - .ok()?; - match sniff_lock(&text) { - LockSniff::Readable(lock) => match lock.version { - None => Some(LegacyVltLock::NoVersion), - Some(0) => Some(LegacyVltLock::VersionZero), - Some(_) => None, - }, - _ => None, - } -} - -/// Workspace configuration type. -#[derive(Debug, Clone)] -pub enum WorkspaceType { - Npm, - Pnpm, - /// vlt.json `workspaces` (or vlt <= 0.0.0-12's `vlt-workspaces.json`). - Vlt, - None, -} - -/// Workspace configuration. -#[derive(Debug, Clone)] -struct WorkspaceConfig { - ws_type: WorkspaceType, - patterns: Vec, -} - -/// Location of a discovered package.json file. -#[derive(Debug, Clone)] -pub struct PackageJsonLocation { - pub path: PathBuf, - pub is_root: bool, - pub is_workspace: bool, -} - -/// Result of finding package.json files. -#[derive(Debug)] -pub struct PackageJsonFindResult { - pub files: Vec, - pub workspace_type: WorkspaceType, -} - -/// Find all package.json files, respecting workspace configurations. -pub async fn find_package_json_files(start_path: &Path) -> PackageJsonFindResult { - let mut results = Vec::new(); - let root_package_json = start_path.join("package.json"); - - let mut root_exists = false; - let mut workspace_config = WorkspaceConfig { - ws_type: WorkspaceType::None, - patterns: Vec::new(), - }; - - if fs::metadata(&root_package_json).await.is_ok() { - root_exists = true; - workspace_config = detect_workspaces(&root_package_json).await; - results.push(PackageJsonLocation { - path: root_package_json.clone(), - is_root: true, - is_workspace: false, - }); - } - - match workspace_config.ws_type { - WorkspaceType::None => { - // No workspace config: pick up nested manifests with a bounded - // walk (the root entry is already in `results`). - if root_exists { - let mut nested = Vec::new(); - search_recursive(start_path, 0, 5, &mut nested).await; - results.extend(nested.into_iter().filter(|p| *p != root_package_json).map( - |path| PackageJsonLocation { - path, - is_root: false, - is_workspace: false, - }, - )); - } - } - _ => { - // Members are collected into their own vec so a `!`-negation - // pattern can only remove members, never the root entry. - let mut members = Vec::new(); - collect_workspace_members(start_path, &workspace_config, 0, &mut members).await; - results.extend(members); - } - } - - // Workspace patterns can overlap (e.g. "packages/*" and "packages/a", or a - // glob plus an exact path), which would otherwise yield the same - // package.json more than once. De-duplicate by path, preserving discovery - // order so the root entry stays first. - let mut seen = std::collections::HashSet::new(); - results.retain(|loc| seen.insert(loc.path.clone())); - - PackageJsonFindResult { - files: results, - workspace_type: workspace_config.ws_type, - } -} - -/// Detect workspace configuration from package.json. -async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { - let default = WorkspaceConfig { - ws_type: WorkspaceType::None, - patterns: Vec::new(), - }; - - // Check vlt's workspace config (vlt.json / vlt-workspaces.json) and then - // pnpm-workspace.yaml first — pnpm projects may also have "workspaces" in - // package.json for compatibility, but these files are the definitive - // signal. They live next to package.json and do not depend on it being - // present or even valid JSON, so they must be checked *before* parsing - // package.json — otherwise a malformed (e.g. - // JSONC, or simply broken) root manifest would wrongly demote a real pnpm - // workspace to "no workspace". - let dir = package_json_path.parent().unwrap_or(Path::new(".")); - if let Some(patterns) = vlt_workspace_patterns(dir).await { - return WorkspaceConfig { - ws_type: WorkspaceType::Vlt, - patterns, - }; - } - let pnpm_workspace = dir.join("pnpm-workspace.yaml"); - // Every manifest/config read here lives inside the (untrusted) project - // tree — and the workspace walk reads the package.json of *every* - // glob-discovered member — so reads go through the FIFO-safe - // `read_regular_to_string` (non-blocking open, regular-file check): a - // planted FIFO fails fast instead of wedging `setup`'s discovery. - if let Ok(yaml_content) = read_regular_to_string(&pnpm_workspace).await { - let patterns = parse_pnpm_workspace_patterns(&yaml_content); - return WorkspaceConfig { - ws_type: WorkspaceType::Pnpm, - patterns, - }; - } - - let content = match read_regular_to_string(package_json_path).await { - Ok(c) => c, - Err(_) => return default, - }; - - let pkg: serde_json::Value = match serde_json::from_str(strip_bom(&content)) { - Ok(v) => v, - Err(_) => return default, - }; - - // Check for npm/yarn workspaces - if let Some(workspaces) = pkg.get("workspaces") { - let patterns = if let Some(arr) = workspaces.as_array() { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - } else if let Some(obj) = workspaces.as_object() { - obj.get("packages") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default() - } else { - Vec::new() - }; - - return WorkspaceConfig { - ws_type: WorkspaceType::Npm, - patterns, - }; - } - - default -} - -/// vlt's workspace globs for `dir`: vlt.json `workspaces` (vlt >= 0.0.0-13) -/// or, without one, the legacy `vlt-workspaces.json` (vlt <= 0.0.0-12; the -/// two eras never read each other's file). vlt never reads package.json -/// `workspaces`. `None` when neither file declares workspaces. -async fn vlt_workspace_patterns(dir: &Path) -> Option> { - if let Some(workspaces) = read_json_config(&dir.join(npm_family::VLT_CONFIG)) - .await - .and_then(|mut config| config.get_mut("workspaces").map(serde_json::Value::take)) - .filter(|workspaces| !workspaces.is_null()) - { - return Some(parse_vlt_workspaces(&workspaces)); - } - read_json_config(&dir.join(npm_family::VLT_LEGACY_WORKSPACES)) - .await - .filter(|legacy| !legacy.is_null()) - .map(|legacy| parse_vlt_workspaces(&legacy)) -} - -/// A JSON config file inside the project (FIFO-safe read, BOM stripped), or -/// `None` when it is missing, unreadable or not JSON. -async fn read_json_config(path: &Path) -> Option { - let text = read_regular_to_string(path).await.ok()?; - serde_json::from_str(strip_bom(&text)).ok() -} - -/// vlt's workspace declaration: a glob, a list of globs, or an object of -/// named groups whose values are either. Non-string entries are ignored. -fn parse_vlt_workspaces(value: &serde_json::Value) -> Vec { - fn globs(value: &serde_json::Value, out: &mut Vec) { - match value { - serde_json::Value::String(glob) => out.push(glob.clone()), - serde_json::Value::Array(items) => { - out.extend(items.iter().filter_map(|v| v.as_str().map(String::from))) - } - _ => {} - } - } - let mut patterns = Vec::new(); - match value { - serde_json::Value::Object(groups) => { - for group in groups.values() { - globs(group, &mut patterns); - } - } - other => globs(other, &mut patterns), - } - patterns -} - -/// Simple parser for pnpm-workspace.yaml packages field. -fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec { - let mut patterns = Vec::new(); - let mut in_packages = false; - - // A BOM is not Unicode whitespace, so `trim` would leave it glued to a - // first-line `packages:` header and the whole section would be missed. - // Lines are collected up front so a flow sequence can hand the scanner - // its continuation lines — a `[` need not close on the line it opens on. - let lines: Vec<&str> = strip_bom(yaml_content).lines().collect(); - for (idx, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - - // The header may carry an inline comment (`packages: # globs`); a `#` - // opens a comment only when preceded by whitespace. - let after_key = trimmed.strip_prefix("packages:"); - let is_packages_header = match after_key { - Some("") => true, - Some(rest) => { - rest.starts_with(|c: char| c.is_whitespace()) && rest.trim_start().starts_with('#') - } - None => false, - }; - if is_packages_header { - in_packages = true; - continue; - } - - // The value may instead be a YAML flow sequence on the header line - // (`packages: ['a/*', "b/*"]`) — pnpm's real YAML parser accepts it - // exactly like the block list. The sequence need not close on that - // line; the scanner consumes continuation lines up to the unquoted - // closing `]`. - if let Some(rest) = after_key { - if rest.starts_with(|c: char| c.is_whitespace()) && rest.trim_start().starts_with('[') { - return parse_yaml_flow_sequence(rest.trim_start(), &lines[idx + 1..]); - } - } - - if in_packages { - // The flow sequence may equally sit on its own line below the - // header (`packages:` then ` ['a/*']`); the next-section check - // below would otherwise break on the `[` and silently drop every - // pattern. Only the section's first item line can open one — - // after a real block item, a `[` line means the section ended. - if patterns.is_empty() && trimmed.starts_with('[') { - return parse_yaml_flow_sequence(trimmed, &lines[idx + 1..]); - } - - if !trimmed.is_empty() && !trimmed.starts_with('-') && !trimmed.starts_with('#') { - break; - } - - if let Some(rest) = trimmed.strip_prefix('-') { - let item = parse_yaml_list_value(rest); - if !item.is_empty() { - patterns.push(item); - } - } - } - } - - patterns -} - -/// Parse a YAML flow sequence (`['a', "b", c]`) into its scalar items. -/// `first` starts at the `[`; when the sequence does not close on that line, -/// scanning continues through `continuation` — pnpm's real parser accepts -/// the multi-line spelling (`packages: [` with items on following lines) — -/// until the unquoted closing `]`. Anything after the `]` (e.g. an inline -/// comment) is ignored, and an unquoted whitespace-preceded `#` opens a -/// comment running to end of line, so a `,` or `]` inside one neither splits -/// nor closes the sequence. A `,` inside a quoted scalar is part of the -/// value — a brace pattern carries one — not an item separator. -fn parse_yaml_flow_sequence(first: &str, continuation: &[&str]) -> Vec { - let mut items = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - // Skip the opening `[` (one ASCII byte). - let lines = std::iter::once(&first[1..]).chain(continuation.iter().copied()); - 'sequence: for line in lines { - // Start-of-line counts as whitespace: a leading `#` opens a comment. - let mut prev_is_whitespace = true; - 'chars: for c in line.chars() { - match quote { - Some(q) => { - current.push(c); - if c == q { - quote = None; - } - } - None => match c { - '\'' | '"' => { - quote = Some(c); - current.push(c); - } - ',' => items.push(std::mem::take(&mut current)), - ']' => break 'sequence, - // Comment through end of line. - '#' if prev_is_whitespace => break 'chars, - _ => current.push(c), - }, - } - prev_is_whitespace = c.is_whitespace(); - } - // A line break separates tokens like any other whitespace; it is - // never part of a scalar. - current.push(' '); - } - items.push(current); - - items - .iter() - .map(|item| parse_yaml_list_value(item)) - .filter(|item| !item.is_empty()) - .collect() -} - -/// Extract the scalar value of a YAML list item, handling surrounding quotes -/// and trailing inline comments (`# ...`). -fn parse_yaml_list_value(raw: &str) -> String { - let s = raw.trim(); - - // Quoted scalar: take the content between the first matching pair of - // quotes. Anything after the closing quote (e.g. an inline comment) is - // ignored, and a `#` inside the quotes stays part of the value. - for q in ['\'', '"'] { - if let Some(rest) = s.strip_prefix(q) { - if let Some(end) = rest.find(q) { - return rest[..end].to_string(); - } - } - } - - // A list item that is *only* a comment (`- # foo`) has no scalar value. - // The inline-comment scan below starts at index 1 (a `#` is a comment only - // when preceded by whitespace), so a leading `#` would otherwise survive as - // a bogus `"# foo"` pattern. Skip it here. - if s.starts_with('#') { - return String::new(); - } - - // Unquoted scalar: a `#` preceded by whitespace begins an inline comment. - let bytes = s.as_bytes(); - let comment_start = - (1..bytes.len()).find(|&i| bytes[i] == b'#' && bytes[i - 1].is_ascii_whitespace()); - let value = match comment_start { - Some(idx) => &s[..idx], - None => s, - }; - value.trim().to_string() -} - -/// Bounded-depth recursion limit for nested workspaces — deep enough for any -/// real monorepo, a hard stop against a pattern that loops back on itself. -const MAX_WORKSPACE_DEPTH: usize = 10; - -/// Collect workspace members matching the config's patterns, recursing into -/// any member that is **itself** a workspace root (property 9's -/// nested-workspace rule). A member's own `workspaces` patterns are resolved -/// relative to that member's directory. -async fn collect_workspace_members( - root_path: &Path, - config: &WorkspaceConfig, - depth: usize, - results: &mut Vec, -) { - if depth > MAX_WORKSPACE_DEPTH { - return; - } - for pattern in &config.patterns { - // npm (`@npmcli/map-workspaces`), yarn, and pnpm all support - // `!`-prefixed exclusion patterns, processed in order: a negation - // removes whatever earlier patterns matched. Resolve the negated - // pattern with the same matcher and drop those members. - if let Some(negated) = pattern.strip_prefix('!') { - let excluded = find_packages_matching_pattern(root_path, negated).await; - results.retain(|loc| !excluded.contains(&loc.path)); - continue; - } - let packages = find_packages_matching_pattern(root_path, pattern).await; - for p in packages { - let member_dir = p.parent().map(Path::to_path_buf); - results.push(PackageJsonLocation { - path: p, - is_root: false, - is_workspace: true, - }); - // If this member declares its own workspaces, configure ITS members - // too (one repo-root `setup` covers the whole nested tree). The - // final de-dup in `find_package_json_files` collapses any overlap. - if let Some(dir) = member_dir { - let member_pkg = dir.join("package.json"); - let member_config = detect_workspaces(&member_pkg).await; - if !matches!(member_config.ws_type, WorkspaceType::None) { - Box::pin(collect_workspace_members( - &dir, - &member_config, - depth + 1, - results, - )) - .await; - } - } - } - } -} - -/// Find packages matching a workspace pattern. -async fn find_packages_matching_pattern(root_path: &Path, pattern: &str) -> Vec { - let mut results = Vec::new(); - - // A trailing `*`/`**` segment is a glob; everything before the final `/` - // is a (possibly empty, possibly multi-segment) directory prefix. Split on - // the *last* `/` so bare globs (`*`, `**`) and deeper prefixes (`a/b/*`) - // are handled, not just the two-segment `prefix/*` form. - let (prefix, last) = pattern.rsplit_once('/').unwrap_or(("", pattern)); - - match last { - "*" | "**" => { - let search_path = if prefix.is_empty() { - root_path.to_path_buf() - } else { - root_path.join(prefix) - }; - if last == "*" { - search_one_level(&search_path, &mut results).await; - } else { - // Globstar matches zero segments too — npm/pnpm glob - // `/**/package.json`, which matches the prefix dir's - // own `package.json` — so the prefix directory itself is a - // candidate member, not just its descendants. (For a bare - // `**` this re-finds the root manifest; the caller's de-dup - // keeps the root entry.) - let own_pkg = search_path.join("package.json"); - if fs::metadata(&own_pkg).await.is_ok() { - results.push(own_pkg); - } - search_recursive(&search_path, 0, usize::MAX, &mut results).await; - } - } - _ => { - let pkg_json = root_path.join(pattern).join("package.json"); - if fs::metadata(&pkg_json).await.is_ok() { - results.push(pkg_json); - } - } - } - - results -} - -/// Directories that are never workspace members and must be skipped while -/// walking the tree (hidden dirs plus dependency/output directories). -fn is_ignored_dir(name: &str) -> bool { - name.starts_with('.') || name == "node_modules" || name == "dist" || name == "build" -} - -/// Search one level deep for package.json files. -async fn search_one_level(dir: &Path, results: &mut Vec) { - for entry in list_dir_entries(dir).await { - let path = entry.path(); - // A single-level `dir/*` glob follows a symlinked direct member, the - // way npm/pnpm resolve a workspace member - // that is itself a symlink. `entry.file_type()` reports the *link's* - // own type — `is_dir() == false` — so it would silently drop such a - // member; stat the path instead so the link is followed. (The - // recursive searcher below deliberately does NOT follow symlinks, - // to avoid loops/escapes — there a symlink's `is_dir() == false` is the - // desired skip.) - if !is_dir(&path).await { - continue; - } - // A `dir/*` pattern must not pick up node_modules/hidden/output dirs as - // workspace members, matching the recursive searchers below. - if is_ignored_dir(&entry.file_name().to_string_lossy()) { - continue; - } - let pkg_json = path.join("package.json"); - if fs::metadata(&pkg_json).await.is_ok() { - results.push(pkg_json); - } - } -} - -/// Search recursively for package.json files, descending at most `max_depth` -/// directory levels below `dir` (pass `usize::MAX` for an unbounded walk). -/// Symlinks are deliberately not followed — see `search_one_level`. -async fn search_recursive(dir: &Path, depth: usize, max_depth: usize, results: &mut Vec) { - if depth > max_depth { - return; - } - - for entry in list_dir_entries(dir).await { - let Some(ft) = entry_file_type(&entry).await else { - continue; - }; - if !ft.is_dir() { - continue; - } - - // Skip hidden directories, node_modules, dist, build - if is_ignored_dir(&entry.file_name().to_string_lossy()) { - continue; - } - - let full_path = entry.path(); - let pkg_json = full_path.join("package.json"); - if fs::metadata(&pkg_json).await.is_ok() { - results.push(pkg_json); - } - - Box::pin(search_recursive(&full_path, depth + 1, max_depth, results)).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Group 1: parse_pnpm_workspace_patterns ─────────────────────── - - #[test] - fn test_parse_pnpm_basic() { - let yaml = "packages:\n - packages/*"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); - } - - #[test] - fn test_parse_pnpm_multiple_patterns() { - let yaml = "packages:\n - packages/*\n - apps/*\n - tools/*"; - assert_eq!( - parse_pnpm_workspace_patterns(yaml), - vec!["packages/*", "apps/*", "tools/*"] - ); - } - - #[test] - fn test_parse_pnpm_quoted_patterns() { - let yaml = "packages:\n - 'packages/*'\n - \"apps/*\""; - assert_eq!( - parse_pnpm_workspace_patterns(yaml), - vec!["packages/*", "apps/*"] - ); - } - - #[test] - fn test_parse_pnpm_comments_interspersed() { - let yaml = "packages:\n # workspace packages\n - packages/*\n # apps\n - apps/*"; - assert_eq!( - parse_pnpm_workspace_patterns(yaml), - vec!["packages/*", "apps/*"] - ); - } - - #[test] - fn test_parse_pnpm_empty_content() { - assert!(parse_pnpm_workspace_patterns("").is_empty()); - } - - #[test] - fn test_parse_pnpm_no_packages_key() { - let yaml = "name: my-project\nversion: 1.0.0"; - assert!(parse_pnpm_workspace_patterns(yaml).is_empty()); - } - - #[test] - fn test_parse_pnpm_stops_at_next_section() { - let yaml = "packages:\n - packages/*\ncatalog:\n lodash: 4.17.21"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); - } - - #[test] - fn test_parse_pnpm_indented_key() { - // The header is matched on the trimmed line, so leading spaces still match - let yaml = " packages:\n - packages/*"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); - } - - #[test] - fn test_parse_pnpm_dash_only_line() { - let yaml = "packages:\n -\n - packages/*"; - // A bare "-" with no value should be skipped (empty after trim) - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); - } - - #[test] - fn test_parse_pnpm_glob_star_star() { - let yaml = "packages:\n - packages/**"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/**"]); - } - - #[test] - fn test_parse_pnpm_bom_first_line() { - // A UTF-8 BOM (Windows editors commonly write one) is NOT Unicode - // whitespace, so `trim` leaves it in place and a `packages:` header on - // the first line never matches — every pattern is silently lost, and - // because pnpm-workspace.yaml still marks the project as a pnpm - // workspace, no fallback walk runs: zero members discovered. - let yaml = "\u{feff}packages:\n - packages/*"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); - } - - #[test] - 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] - 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/**"] - ); - } - - #[test] - fn test_parse_pnpm_flow_sequence_multiline() { - // A flow sequence need not close on the line it opens on: `packages: [` - // with items on the following lines is valid YAML, accepted by pnpm's - // real parser. The single-line scanner saw only the bare `[`, returned - // zero patterns, and — pnpm-workspace.yaml still marking the project - // as a pnpm workspace — every member was silently skipped. - assert_eq!( - parse_pnpm_workspace_patterns("packages: [\n 'packages/*',\n \"apps/*\"\n]\n"), - vec!["packages/*", "apps/*"] - ); - // Comments inside the sequence run to end of line; a `,` or `]` in - // one must not split or close the sequence. A quoted `,` stays part - // of its value across the line-by-line scan. - assert_eq!( - parse_pnpm_workspace_patterns( - "packages: [ # globs, right]\n '{apps,libs}/*', # brace, glob\n tools/*\n]" - ), - vec!["{apps,libs}/*", "tools/*"] - ); - } - - #[test] - fn test_parse_pnpm_flow_sequence_on_line_after_header() { - // The flow sequence may equally sit indented on its own line below the - // header (`packages:` then ` ['a']`); the next-section check must not - // break on the `[` line and drop every pattern. - assert_eq!( - parse_pnpm_workspace_patterns("packages:\n ['packages/*', apps/*]"), - vec!["packages/*", "apps/*"] - ); - // ...preceded by comment/blank lines, and itself spanning lines. - assert_eq!( - parse_pnpm_workspace_patterns("packages:\n # globs\n\n [\n 'packages/*'\n ]"), - vec!["packages/*"] - ); - // After a real block item a `[` line is NOT a flow sequence — the - // section simply ended (same next-section break as any other line). - assert_eq!( - parse_pnpm_workspace_patterns("packages:\n - real/*\n['other']"), - vec!["real/*"] - ); - } - - #[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 - )); - } - - #[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] - async fn test_detect_workspaces_npm_array() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"workspaces": ["packages/*"]}"#) - .await - .unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::Npm)); - assert_eq!(config.patterns, vec!["packages/*"]); - } - - #[tokio::test] - async fn test_detect_workspaces_npm_object() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write( - &pkg, - r#"{"workspaces": {"packages": ["packages/*", "apps/*"]}}"#, - ) - .await - .unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::Npm)); - assert_eq!(config.patterns, vec!["packages/*", "apps/*"]); - } - - #[tokio::test] - async fn test_detect_workspaces_pnpm() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap(); - let pnpm = dir.path().join("pnpm-workspace.yaml"); - fs::write(&pnpm, "packages:\n - packages/*").await.unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::Pnpm)); - assert_eq!(config.patterns, vec!["packages/*"]); - } - - #[tokio::test] - async fn test_detect_workspaces_pnpm_with_workspaces_field() { - // When both pnpm-workspace.yaml AND "workspaces" in package.json - // exist, pnpm should take priority - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name": "root", "workspaces": ["packages/*"]}"#) - .await - .unwrap(); - let pnpm = dir.path().join("pnpm-workspace.yaml"); - fs::write(&pnpm, "packages:\n - workspaces/*") - .await - .unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::Pnpm)); - // Should use pnpm-workspace.yaml patterns, not package.json workspaces - assert_eq!(config.patterns, vec!["workspaces/*"]); - } - - #[tokio::test] - async fn test_detect_workspaces_pnpm_with_malformed_package_json() { - // Regression: pnpm-workspace.yaml is the definitive signal and must be - // honored even when the root package.json is not valid JSON. - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - // JSONC-style comment — valid for some tooling, invalid for serde_json. - fs::write(&pkg, "{\n // a comment\n \"name\": \"root\"\n}") - .await - .unwrap(); - let pnpm = dir.path().join("pnpm-workspace.yaml"); - fs::write(&pnpm, "packages:\n - packages/*").await.unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::Pnpm)); - assert_eq!(config.patterns, vec!["packages/*"]); - } - - #[tokio::test] - async fn test_detect_workspaces_npm_with_bom() { - // npm strips a leading UTF-8 BOM before parsing package.json, so a - // BOM'd manifest is npm-valid; its workspaces must not be silently - // dropped (which would demote the project to "no workspace"). - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, "\u{feff}{\"workspaces\": [\"packages/*\"]}") - .await - .unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::Npm)); - assert_eq!(config.patterns, vec!["packages/*"]); - } - - #[tokio::test] - async fn test_find_bom_root_workspace_negation_honored() { - // End-to-end symptom of the BOM gap: with a BOM'd root manifest the - // workspace config silently degraded to None, so members were found - // only by the fallback walk — mislabeled as non-workspace and with - // `!`-negations ignored, letting setup edit an excluded package. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - "\u{feff}{\"workspaces\": [\"packages/*\", \"!packages/private\"]}", - ) - .await - .unwrap(); - for member in ["a", "private"] { - let m = dir.path().join("packages").join(member); - fs::create_dir_all(&m).await.unwrap(); - fs::write(m.join("package.json"), r#"{"name":"m"}"#) - .await - .unwrap(); - } - let result = find_package_json_files(dir.path()).await; - assert!(matches!(result.workspace_type, WorkspaceType::Npm)); - let members: Vec<_> = result.files.iter().filter(|f| f.is_workspace).collect(); - assert_eq!( - members.len(), - 1, - "negated member must stay excluded under a BOM'd root: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - assert!(members[0].path.ends_with("packages/a/package.json")); - } - - #[tokio::test] - async fn test_detect_workspaces_none() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name": "root"}"#).await.unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::None)); - assert!(config.patterns.is_empty()); - } - - #[tokio::test] - async fn test_detect_workspaces_non_array_workspaces_field() { - // "workspaces" present but neither array nor object (a bare string, - // null, ...) hits the else-arm: WorkspaceType::Npm with zero - // patterns. Characterizes the current contract — note this also - // suppresses the no-workspace fallback walk (pinned end-to-end - // below); if the team decides such values should degrade to - // WorkspaceType::None (npm semantics), update detect_workspaces and - // flip these assertions together. - for spelling in [r#"{"workspaces": "packages/*"}"#, r#"{"workspaces": null}"#] { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, spelling).await.unwrap(); - let config = detect_workspaces(&pkg).await; - assert!( - matches!(config.ws_type, WorkspaceType::Npm), - "non-array workspaces must still read as npm: {spelling}" - ); - assert!( - config.patterns.is_empty(), - "no patterns can be extracted from: {spelling}" - ); - } - } - - #[tokio::test] - async fn test_find_non_array_workspaces_suppresses_fallback_walk() { - // End-to-end consequence of the else-arm above: a non-array - // "workspaces" value marks the project as an (empty) npm workspace, - // so the no-workspace fallback walk never runs and a nested manifest - // is NOT discovered — only the root comes back. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": "packages/*"}"#, - ) - .await - .unwrap(); - let sub = dir.path().join("packages").join("a"); - fs::create_dir_all(&sub).await.unwrap(); - fs::write(sub.join("package.json"), r#"{"name":"a"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - assert!(matches!(result.workspace_type, WorkspaceType::Npm)); - assert_eq!( - result.files.len(), - 1, - "only the root must be found: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - assert!(result.files[0].is_root); - } - - #[tokio::test] - async fn test_detect_workspaces_invalid_json() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, "not valid json!!!").await.unwrap(); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::None)); - } - - #[tokio::test] - async fn test_detect_workspaces_file_not_found() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("nonexistent.json"); - let config = detect_workspaces(&pkg).await; - assert!(matches!(config.ws_type, WorkspaceType::None)); - } - - #[tokio::test] - async fn test_find_no_root_package_json() { - let dir = tempfile::tempdir().unwrap(); - let result = find_package_json_files(dir.path()).await; - assert!(result.files.is_empty()); - } - - #[tokio::test] - async fn test_find_root_only() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - assert_eq!(result.files.len(), 1); - assert!(result.files[0].is_root); - } - - #[tokio::test] - async fn test_find_npm_workspaces() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*"]}"#, - ) - .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::Npm)); - // root + workspace member - assert_eq!(result.files.len(), 2); - assert!(result.files[0].is_root); - assert!(result.files[1].is_workspace); - } - - #[tokio::test] - async fn test_find_pnpm_workspaces() { - 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:\n - packages/*", - ) - .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)); - // find_package_json_files still returns all files; - // filtering for pnpm is done by the caller (setup command) - assert_eq!(result.files.len(), 2); - assert!(result.files[0].is_root); - assert!(result.files[1].is_workspace); - } - - #[tokio::test] - 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_pnpm_multiline_flow_sequence_members_discovered() { - // The multi-line spelling of the same gap: `packages: [` with items on - // the following lines parsed to zero patterns (the scanner saw only - // the bare `[`), 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: [\n 'packages/*'\n]\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")), - "multi-line 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(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - let nm = dir.path().join("node_modules").join("lodash"); - fs::create_dir_all(&nm).await.unwrap(); - fs::write(nm.join("package.json"), r#"{"name":"lodash"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - // Only root, node_modules should be skipped - assert_eq!(result.files.len(), 1); - assert!(result.files[0].is_root); - } - - #[tokio::test] - async fn test_find_nested_depth_limit() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - // Create deeply nested package.json at depth 7 (> limit of 5) - let mut deep = dir.path().to_path_buf(); - for i in 0..7 { - deep = deep.join(format!("level{}", i)); - } - fs::create_dir_all(&deep).await.unwrap(); - fs::write(deep.join("package.json"), r#"{"name":"deep"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - // Only root (the deep one exceeds depth limit) - assert_eq!(result.files.len(), 1); - } - - #[tokio::test] - async fn test_find_nested_manifest_without_workspaces() { - // No workspace config at all: the bounded fallback walk picks up a - // plain nested manifest and labels it {is_root: false, - // is_workspace: false} — the location contract `setup` relies on to - // tell fallback-walk hits apart from real workspace members. - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - let sub = dir.path().join("sub"); - fs::create_dir_all(&sub).await.unwrap(); - fs::write(sub.join("package.json"), r#"{"name":"sub"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - assert!(matches!(result.workspace_type, WorkspaceType::None)); - assert_eq!( - result.files.len(), - 2, - "root + nested manifest: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - assert!(result.files[0].is_root); - let nested = &result.files[1]; - assert!(nested.path.ends_with("sub/package.json")); - assert!(!nested.is_root, "fallback-walk hit is not the root"); - assert!( - !nested.is_workspace, - "fallback-walk hit is not a workspace member" - ); - } - - #[tokio::test] - async fn test_find_nested_depth_boundary_included() { - // The fallback walk's guard is `depth > 5`, and a call at depth d - // discovers manifests one directory level below it — so a manifest - // exactly 6 levels down is the LAST included tier. The sibling test - // above pins exclusion at 7 levels; this pins the boundary from the - // inside so a future off-by-one can't silently shrink the walk. - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - let mut deep = dir.path().to_path_buf(); - for i in 0..6 { - deep = deep.join(format!("level{}", i)); - } - fs::create_dir_all(&deep).await.unwrap(); - fs::write(deep.join("package.json"), r#"{"name":"deep"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - assert_eq!( - result.files.len(), - 2, - "manifest 6 levels down must still be found: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - let nested = &result.files[1]; - assert!(nested - .path - .ends_with("level0/level1/level2/level3/level4/level5/package.json")); - assert!(!nested.is_root && !nested.is_workspace); - } - - #[tokio::test] - async fn test_find_workspace_double_glob() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["apps/**"]}"#, - ) - .await - .unwrap(); - let nested = dir.path().join("apps").join("web").join("client"); - fs::create_dir_all(&nested).await.unwrap(); - fs::write(nested.join("package.json"), r#"{"name":"client"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - // root + recursively found workspace member - assert!(result.files.len() >= 2); - } - - #[tokio::test] - async fn test_find_recurses_into_nested_workspace() { - // Property 9: a workspace member that is itself a workspace root has ITS - // members discovered too. root → packages/inner → sub/leaf. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"name":"root","workspaces":["packages/*"]}"#, - ) - .await - .unwrap(); - let inner = dir.path().join("packages").join("inner"); - fs::create_dir_all(&inner).await.unwrap(); - fs::write( - inner.join("package.json"), - r#"{"name":"inner","workspaces":["sub/*"]}"#, - ) - .await - .unwrap(); - let leaf = inner.join("sub").join("leaf"); - fs::create_dir_all(&leaf).await.unwrap(); - fs::write(leaf.join("package.json"), r#"{"name":"leaf"}"#) - .await - .unwrap(); - - let result = find_package_json_files(dir.path()).await; - let paths: Vec = result - .files - .iter() - .map(|f| f.path.to_string_lossy().into_owned()) - .collect(); - // `Path::ends_with` matches whole path components and treats `/` in the - // pattern as a separator on every platform (Windows accepts both `/` - // and `\`), so this is correct regardless of the OS path separator — - // unlike a byte-wise `str::ends_with` on a forward-slash literal, which - // fails on Windows' `\`-separated paths. - assert!( - result - .files - .iter() - .any(|f| f.path.ends_with("packages/inner/package.json")), - "first-level member must be found: {paths:?}" - ); - assert!( - result - .files - .iter() - .any(|f| f.path.ends_with("packages/inner/sub/leaf/package.json")), - "nested-workspace leaf must be found via recursion: {paths:?}" - ); - // root + inner + leaf, no duplicates. - assert_eq!( - result.files.len(), - 3, - "exactly root + inner + leaf: {paths:?}" - ); - } - - #[tokio::test] - async fn test_find_workspace_exact_path() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/core"]}"#, - ) - .await - .unwrap(); - let core = dir.path().join("packages").join("core"); - fs::create_dir_all(&core).await.unwrap(); - fs::write(core.join("package.json"), r#"{"name":"core"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - assert_eq!(result.files.len(), 2); - } - - #[test] - fn test_parse_pnpm_inline_comment_stripped() { - // A `# ...` inline comment after a pattern must not become part of it. - let yaml = "packages:\n - packages/* # workspace packages\n - apps/*\t# trailing tab"; - assert_eq!( - parse_pnpm_workspace_patterns(yaml), - vec!["packages/*", "apps/*"] - ); - } - - #[test] - fn test_parse_pnpm_comment_only_list_item_skipped() { - // A `- # comment` item is a YAML null (the value is just a comment) and - // must NOT become a literal `"# comment"` workspace pattern (the - // inline-comment scan must consider index 0). - let yaml = "packages:\n - # only a comment\n - real/*"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["real/*"]); - } - - #[test] - fn test_parse_pnpm_quoted_value_keeps_hash() { - // A `#` inside quotes is part of the value, not a comment. - let yaml = "packages:\n - 'packages/#weird' # but this is a comment"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/#weird"]); - } - - #[test] - fn test_parse_pnpm_unterminated_quote_kept_verbatim() { - // Malformed YAML: a quoted scalar with no closing quote. The quote - // scan in `parse_yaml_list_value` finds no matching pair and falls - // through to the unquoted-scalar path, so the item is neither - // dropped nor panicking — the raw text survives, leading quote - // included. Characterizes the fallthrough; if malformed quotes - // should instead be rejected, change parse_yaml_list_value and - // assert emptiness here. - assert_eq!( - parse_pnpm_workspace_patterns("packages:\n - 'packages/*"), - vec!["'packages/*"] - ); - assert_eq!( - parse_pnpm_workspace_patterns("packages:\n - \"packages/*"), - vec!["\"packages/*"] - ); - } - - #[tokio::test] - async fn test_find_overlapping_patterns_no_duplicates() { - // "packages/*" and the exact "packages/a" both match the same member; - // the result must contain it only once. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*", "packages/a"]}"#, - ) - .await - .unwrap(); - let a = dir.path().join("packages").join("a"); - fs::create_dir_all(&a).await.unwrap(); - fs::write(a.join("package.json"), r#"{"name":"a"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - // root + exactly one workspace member (no duplicate for packages/a) - assert_eq!(result.files.len(), 2); - assert!(result.files[0].is_root); - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - assert_eq!(workspace_count, 1); - } - - #[tokio::test] - async fn test_find_star_pattern_skips_node_modules() { - // A `packages/*` glob must not treat node_modules (or hidden/output - // dirs) as a workspace member, even if they contain a package.json. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*"]}"#, - ) - .await - .unwrap(); - let real = dir.path().join("packages").join("real"); - fs::create_dir_all(&real).await.unwrap(); - fs::write(real.join("package.json"), r#"{"name":"real"}"#) - .await - .unwrap(); - for ignored in ["node_modules", ".cache", "dist", "build"] { - let d = dir.path().join("packages").join(ignored); - fs::create_dir_all(&d).await.unwrap(); - fs::write(d.join("package.json"), r#"{"name":"x"}"#) - .await - .unwrap(); - } - let result = find_package_json_files(dir.path()).await; - // root + only the "real" member - assert_eq!(result.files.len(), 2); - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - assert_eq!(workspace_count, 1); - } - - #[tokio::test] - async fn test_find_workspace_bare_star() { - // A bare `*` glob means "every immediate subdirectory" and must be - // expanded, not treated as a literal directory named `*`. - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"workspaces": ["*"]}"#) - .await - .unwrap(); - for member in ["a", "b"] { - let m = dir.path().join(member); - fs::create_dir_all(&m).await.unwrap(); - fs::write(m.join("package.json"), r#"{"name":"m"}"#) - .await - .unwrap(); - } - // node_modules must still be ignored even for a root-level `*`. - let nm = dir.path().join("node_modules").join("dep"); - fs::create_dir_all(&nm).await.unwrap(); - fs::write(nm.join("package.json"), r#"{"name":"dep"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - // root + members a and b (node_modules excluded) - assert_eq!(workspace_count, 2); - assert!(result.files[0].is_root); - } - - #[tokio::test] - async fn test_find_workspace_bare_double_glob() { - // A bare `**` glob recurses from the root. - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"workspaces": ["**"]}"#) - .await - .unwrap(); - let nested = dir.path().join("a").join("b"); - fs::create_dir_all(&nested).await.unwrap(); - fs::write(nested.join("package.json"), r#"{"name":"b"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - assert!(workspace_count >= 1); - } - - #[tokio::test] - async fn test_find_workspace_deep_prefix_glob() { - // A glob with a multi-segment prefix (`group/sub/*`) must expand the - // directory under that prefix, not be treated as a literal path. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["group/sub/*"]}"#, - ) - .await - .unwrap(); - let member = dir.path().join("group").join("sub").join("pkg"); - fs::create_dir_all(&member).await.unwrap(); - fs::write(member.join("package.json"), r#"{"name":"pkg"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - assert_eq!(workspace_count, 1); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_find_star_glob_follows_symlinked_member() { - // A single-level `packages/*` glob must follow a workspace member that - // is itself a symlink (npm/pnpm resolve such members). - // `entry.file_type()` reports the link as a non-directory, so gating on - // it would drop the member and `setup` would never patch the package. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*"]}"#, - ) - .await - .unwrap(); - // The real member lives outside `packages/`; `packages/a` links to it. - let real = dir.path().join("real"); - fs::create_dir_all(&real).await.unwrap(); - fs::write(real.join("package.json"), r#"{"name":"a"}"#) - .await - .unwrap(); - fs::create_dir_all(dir.path().join("packages")) - .await - .unwrap(); - std::os::unix::fs::symlink(&real, dir.path().join("packages").join("a")).unwrap(); - - let result = find_package_json_files(dir.path()).await; - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - assert_eq!( - workspace_count, - 1, - "symlinked workspace member must be discovered: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_find_double_glob_does_not_follow_symlinks() { - // The asymmetric counterpart: a recursive `apps/**` glob must NOT follow - // symlinks — a loop back to an ancestor would recurse forever and an - // escaping link would let `setup` edit an out-of-tree manifest. Only the - // real on-disk member is discovered. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["apps/**"]}"#, - ) - .await - .unwrap(); - let real = dir.path().join("apps").join("web"); - fs::create_dir_all(&real).await.unwrap(); - fs::write(real.join("package.json"), r#"{"name":"web"}"#) - .await - .unwrap(); - // A loop symlink back to the repo root and an escape symlink to an - // out-of-tree package — neither must be traversed. - std::os::unix::fs::symlink(dir.path(), dir.path().join("apps").join("loop")).unwrap(); - let outside = tempfile::tempdir().unwrap(); - fs::write(outside.path().join("package.json"), r#"{"name":"escape"}"#) - .await - .unwrap(); - std::os::unix::fs::symlink(outside.path(), dir.path().join("apps").join("escape")).unwrap(); - - let result = find_package_json_files(dir.path()).await; - let workspace_count = result.files.iter().filter(|f| f.is_workspace).count(); - assert_eq!( - workspace_count, - 1, - "only the real member must be found; symlinks not followed: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - } - - /// mkfifo(2) directly, not the /usr/bin/mkfifo binary: spawning a child - /// flakes under heavy parallel load (fork/exec starvation) and the - /// syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - /// A FIFO planted as `pnpm-workspace.yaml` must not wedge discovery. The - /// workspace probe used a plain `tokio::fs::read_to_string`, whose - /// `open(2)` on a FIFO waits for a writer that never comes — so one - /// special file in the project root wedged `setup` indefinitely, with no - /// error and no timeout. Same class as the `open_regular_file` guards in - /// the npm/composer/python/ruby crawlers. An unreadable - /// pnpm-workspace.yaml falls through to the package.json `workspaces` - /// field, like a directory of that name already does. - #[cfg(unix)] - #[tokio::test] - async fn pnpm_workspace_fifo_does_not_wedge_discovery() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*"]}"#, - ) - .await - .unwrap(); - let fifo = dir.path().join("pnpm-workspace.yaml"); - mkfifo(&fifo); - let a = dir.path().join("packages").join("a"); - fs::create_dir_all(&a).await.unwrap(); - fs::write(a.join("package.json"), r#"{"name":"a"}"#) - .await - .unwrap(); - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a writer to release - // it so the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout(deadline, find_package_json_files(dir.path())).await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("discovery must complete promptly with a FIFO pnpm-workspace.yaml"); - }; - assert!(matches!(result.workspace_type, WorkspaceType::Npm)); - assert!( - result - .files - .iter() - .any(|f| f.is_workspace && f.path.ends_with("packages/a/package.json")), - "member must still be found via the package.json fallback: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - } - - /// The member-manifest twin: every glob-discovered workspace member has - /// its package.json read (nested-workspace probe), so a FIFO planted as - /// any member's package.json wedged discovery the same way. - #[cfg(unix)] - #[tokio::test] - async fn member_package_json_fifo_does_not_wedge_discovery() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*"]}"#, - ) - .await - .unwrap(); - let a = dir.path().join("packages").join("a"); - fs::create_dir_all(&a).await.unwrap(); - let fifo = a.join("package.json"); - mkfifo(&fifo); - let b = dir.path().join("packages").join("b"); - fs::create_dir_all(&b).await.unwrap(); - fs::write(b.join("package.json"), r#"{"name":"b"}"#) - .await - .unwrap(); - - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout(deadline, find_package_json_files(dir.path())).await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("discovery must complete promptly with a FIFO member package.json"); - }; - assert!( - result - .files - .iter() - .any(|f| f.is_workspace && f.path.ends_with("packages/b/package.json")), - "the real member must still be found: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - } - - #[tokio::test] - async fn test_find_workspace_negation_excludes_member() { - // npm (`@npmcli/map-workspaces`), yarn, and pnpm all support - // `!`-prefixed exclusion patterns: a member matched by an earlier - // pattern and then negated is NOT a workspace member. `!pattern` must - // not be treated as a literal directory named `!packages`, or `setup` - // edits a package.json the user explicitly excluded. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*", "!packages/private"]}"#, - ) - .await - .unwrap(); - for member in ["a", "private"] { - let m = dir.path().join("packages").join(member); - fs::create_dir_all(&m).await.unwrap(); - fs::write(m.join("package.json"), r#"{"name":"m"}"#) - .await - .unwrap(); - } - let result = find_package_json_files(dir.path()).await; - let members: Vec<_> = result.files.iter().filter(|f| f.is_workspace).collect(); - assert_eq!( - members.len(), - 1, - "negated member must be excluded: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - assert!(members[0].path.ends_with("packages/a/package.json")); - } - - #[tokio::test] - async fn test_find_workspace_glob_negation_excludes_subtree() { - // A negation can itself be a glob (pnpm's docs show `!**/test/**`); - // `!legacy/**` must remove every member an earlier positive pattern - // picked up under legacy/. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["**", "!legacy/**"]}"#, - ) - .await - .unwrap(); - let app = dir.path().join("app"); - fs::create_dir_all(&app).await.unwrap(); - fs::write(app.join("package.json"), r#"{"name":"app"}"#) - .await - .unwrap(); - let old = dir.path().join("legacy").join("old"); - fs::create_dir_all(&old).await.unwrap(); - fs::write(old.join("package.json"), r#"{"name":"old"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - let members: Vec<_> = result.files.iter().filter(|f| f.is_workspace).collect(); - assert_eq!( - members.len(), - 1, - "legacy subtree must be excluded: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - assert!(members[0].path.ends_with("app/package.json")); - } - - #[tokio::test] - async fn test_find_double_glob_matches_prefix_dir_itself() { - // Globstar matches zero segments: npm/pnpm resolve members by globbing - // `apps/**/package.json`, which matches `apps/package.json` itself. A - // package living at the pattern's prefix directory is a workspace - // member too, not just its descendants. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["apps/**"]}"#, - ) - .await - .unwrap(); - let apps = dir.path().join("apps"); - fs::create_dir_all(&apps).await.unwrap(); - fs::write(apps.join("package.json"), r#"{"name":"apps"}"#) - .await - .unwrap(); - let web = apps.join("web"); - fs::create_dir_all(&web).await.unwrap(); - fs::write(web.join("package.json"), r#"{"name":"web"}"#) - .await - .unwrap(); - let result = find_package_json_files(dir.path()).await; - assert!( - result - .files - .iter() - .any(|f| f.is_workspace && f.path.ends_with("apps/package.json")), - "prefix dir's own package.json must be a member: {:?}", - result.files.iter().map(|f| &f.path).collect::>() - ); - assert!( - result - .files - .iter() - .any(|f| f.is_workspace && f.path.ends_with("apps/web/package.json")), - "descendant member must still be found" - ); - } - - #[test] - fn test_parse_pnpm_packages_key_inline_comment() { - // The section header itself may carry an inline comment - // (`packages: # workspace globs`); the exact-equality compare missed - // it and silently dropped the whole section. - let yaml = "packages: # workspace globs\n - packages/*"; - assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); - } - - // ── detect_package_manager ────────────────────────────────────── - - #[tokio::test] - async fn test_detect_npm_by_default() { - let dir = tempfile::tempdir().unwrap(); - let pm = detect_package_manager(dir.path()).await; - assert_eq!(pm, PackageManager::Npm); - } - - #[tokio::test] - async fn test_detect_pnpm_lock_yaml() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9.0\n") - .await - .unwrap(); - let pm = detect_package_manager(dir.path()).await; - assert_eq!(pm, PackageManager::Pnpm); - } - - #[tokio::test] - async fn test_detect_pnpm_workspace_yaml() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("pnpm-workspace.yaml"), - "packages:\n - packages/*", - ) - .await - .unwrap(); - let pm = detect_package_manager(dir.path()).await; - assert_eq!(pm, PackageManager::Pnpm); - } - - // ── vlt ───────────────────────────────────────────────────────── - - #[tokio::test] - async fn test_detect_vlt_from_each_setup_marker() { - for marker in npm_family::VLT_SETUP_MARKERS { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(marker); - if marker == npm_family::VLT_STORE_DIR { - fs::create_dir_all(&path).await.unwrap(); - } else { - fs::create_dir_all(path.parent().unwrap()).await.unwrap(); - fs::write(&path, "{}").await.unwrap(); - } - assert_eq!( - detect_package_manager(dir.path()).await, - PackageManager::Vlt, - "{marker}" - ); - } - } - - #[tokio::test] - async fn test_detect_vlt_store_marker_must_be_a_directory() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join("node_modules")) - .await - .unwrap(); - fs::write(dir.path().join(npm_family::VLT_STORE_DIR), "") - .await - .unwrap(); - assert_eq!( - detect_package_manager(dir.path()).await, - PackageManager::Npm - ); - } - - #[tokio::test] - async fn test_detect_vlt_wins_over_pnpm_markers() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9.0\n") - .await - .unwrap(); - fs::write(dir.path().join("pnpm-workspace.yaml"), "packages:\n") - .await - .unwrap(); - fs::write(dir.path().join("vlt-lock.json"), "{}") - .await - .unwrap(); - assert_eq!( - detect_package_manager(dir.path()).await, - PackageManager::Vlt - ); - } - - #[tokio::test] - async fn test_detect_vlt_ignores_an_ancestor_vlt_json() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("vlt.json"), "{}").await.unwrap(); - let nested = dir.path().join("packages").join("a"); - fs::create_dir_all(&nested).await.unwrap(); - assert_eq!(detect_package_manager(&nested).await, PackageManager::Npm); - } - - async fn vlt_workspace_root(vlt_json: &str) -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - fs::write(dir.path().join("vlt.json"), vlt_json) - .await - .unwrap(); - dir - } - - #[tokio::test] - async fn test_detect_workspaces_vlt_shapes() { - for (vlt_json, want) in [ - (r#"{"workspaces":"packages/*"}"#, vec!["packages/*"]), - ( - r#"{"workspaces":["packages/*","apps/*",7]}"#, - vec!["packages/*", "apps/*"], - ), - ( - r#"{"workspaces":{"libs":"libs/*","apps":["apps/*","tools/x"],"bad":1}}"#, - vec!["libs/*", "apps/*", "tools/x"], - ), - ( - "\u{feff}{\"workspaces\":[\"packages/*\"]}", - vec!["packages/*"], - ), - (r#"{"workspaces":[]}"#, vec![]), - ] { - let dir = vlt_workspace_root(vlt_json).await; - let config = detect_workspaces(&dir.path().join("package.json")).await; - assert!( - matches!(config.ws_type, WorkspaceType::Vlt), - "{vlt_json}: {config:?}" - ); - assert_eq!(config.patterns, want, "{vlt_json}"); - } - } - - #[tokio::test] - async fn test_detect_workspaces_vlt_json_without_workspaces_falls_through() { - for vlt_json in [ - r#"{"config":{"registry":"https://registry.npmjs.org/"}}"#, - r#"{"workspaces":null}"#, - "not json", - ] { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces":["packages/*"]}"#, - ) - .await - .unwrap(); - fs::write(dir.path().join("vlt.json"), vlt_json) - .await - .unwrap(); - let config = detect_workspaces(&dir.path().join("package.json")).await; - assert!( - matches!(config.ws_type, WorkspaceType::Npm), - "{vlt_json}: {config:?}" - ); - } - } - - #[tokio::test] - async fn test_detect_workspaces_vlt_wins_over_pnpm_and_bad_package_json() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{ // jsonc\n}") - .await - .unwrap(); - fs::write( - dir.path().join("pnpm-workspace.yaml"), - "packages:\n - pnpm/*", - ) - .await - .unwrap(); - fs::write(dir.path().join("vlt.json"), r#"{"workspaces":"vlt/*"}"#) - .await - .unwrap(); - let config = detect_workspaces(&dir.path().join("package.json")).await; - assert!(matches!(config.ws_type, WorkspaceType::Vlt), "{config:?}"); - assert_eq!(config.patterns, vec!["vlt/*"]); - } - - #[tokio::test] - async fn test_detect_workspaces_legacy_vlt_workspaces_json() { - for (legacy, want) in [ - (r#"{"packages":"packages/*"}"#, vec!["packages/*"]), - ( - r#"{"packages":["packages/*","apps/*"]}"#, - vec!["packages/*", "apps/*"], - ), - ] { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#) - .await - .unwrap(); - fs::write(dir.path().join("vlt-workspaces.json"), legacy) - .await - .unwrap(); - let config = detect_workspaces(&dir.path().join("package.json")).await; - assert!(matches!(config.ws_type, WorkspaceType::Vlt), "{legacy}"); - assert_eq!(config.patterns, want, "{legacy}"); - } - - let dir = vlt_workspace_root(r#"{"workspaces":"current/*"}"#).await; - fs::write( - dir.path().join("vlt-workspaces.json"), - r#"{"packages":"legacy/*"}"#, - ) - .await - .unwrap(); - let config = detect_workspaces(&dir.path().join("package.json")).await; - assert_eq!(config.patterns, vec!["current/*"]); - } - - #[tokio::test] - async fn test_find_vlt_workspaces_discovers_members_with_the_shared_matcher() { - let dir = - vlt_workspace_root(r#"{"workspaces":{"a":"packages/*","b":["!packages/skip"]}}"#).await; - for member in ["packages/a", "packages/b", "packages/skip"] { - let path = dir.path().join(member); - fs::create_dir_all(&path).await.unwrap(); - fs::write(path.join("package.json"), "{}").await.unwrap(); - } - let result = find_package_json_files(dir.path()).await; - assert!(matches!(result.workspace_type, WorkspaceType::Vlt)); - let mut members: Vec = result - .files - .iter() - .filter(|f| f.is_workspace) - .map(|f| { - f.path - .parent() - .unwrap() - .strip_prefix(dir.path()) - .unwrap() - .to_string_lossy() - .replace('\\', "/") - }) - .collect(); - members.sort(); - assert_eq!(members, vec!["packages/a", "packages/b"]); - assert!(result.files[0].is_root); - } - - #[cfg(unix)] - #[tokio::test] - async fn vlt_json_fifo_does_not_wedge_discovery() { - for name in ["vlt.json", "vlt-workspaces.json"] { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"workspaces": ["packages/*"]}"#, - ) - .await - .unwrap(); - let fifo = dir.path().join(name); - mkfifo(&fifo); - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = - tokio::time::timeout(deadline, find_package_json_files(dir.path())).await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("discovery must complete promptly with a FIFO {name}"); - }; - assert!( - matches!(result.workspace_type, WorkspaceType::Npm), - "{name}" - ); - } - } - - #[tokio::test] - async fn test_legacy_vlt_lock_reads_only_version_zero_or_absent() { - for (lock, want) in [ - (None, None), - ( - Some(r#"{"nodes":{},"edges":{}}"#), - Some(LegacyVltLock::NoVersion), - ), - ( - Some(r#"{"lockfileVersion":0,"nodes":{}}"#), - Some(LegacyVltLock::VersionZero), - ), - (Some(r#"{"lockfileVersion":1,"nodes":{}}"#), None), - (Some(r#"{"lockfileVersion":"0"}"#), None), - (Some("\u{feff}{\"lockfileVersion\":0}"), None), - (Some("[]"), None), - (Some("not json"), None), - ] { - let dir = tempfile::tempdir().unwrap(); - if let Some(lock) = lock { - fs::write(dir.path().join("vlt-lock.json"), lock) - .await - .unwrap(); - } - assert_eq!(legacy_vlt_lock(dir.path()).await, want, "{lock:?}"); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn test_legacy_vlt_lock_fifo_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("vlt-lock.json"); - mkfifo(&fifo); - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout(deadline, legacy_vlt_lock(dir.path())).await else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("the lock sniff must not wedge on a FIFO vlt-lock.json"); - }; - assert_eq!(result, None); - } -} diff --git a/crates/socket-patch-core/src/package_json/mod.rs b/crates/socket-patch-core/src/package_json/mod.rs deleted file mode 100644 index 7e1546ac..00000000 --- a/crates/socket-patch-core/src/package_json/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod detect; -pub mod find; -pub mod update; diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs deleted file mode 100644 index 4eea2515..00000000 --- a/crates/socket-patch-core/src/package_json/update.rs +++ /dev/null @@ -1,903 +0,0 @@ -use std::path::Path; - -use super::detect::{remove_package_json_content, update_package_json_content, PackageManager}; -use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; - -/// Result of updating a single package.json. -#[derive(Debug, Clone)] -pub struct UpdateResult { - pub path: String, - pub status: UpdateStatus, - /// Previous `postinstall` script (empty if absent). - pub old_script: String, - /// New `postinstall` script. - pub new_script: String, - /// Previous `dependencies` script (empty if absent). - pub old_dependencies_script: String, - /// New `dependencies` script (equal to the old one when unchanged). - pub new_dependencies_script: String, - pub error: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum UpdateStatus { - Updated, - AlreadyConfigured, - Error, -} - -/// Update a single package.json file with socket-patch lifecycle scripts. -pub async fn update_package_json( - package_json_path: &Path, - dry_run: bool, - pm: PackageManager, -) -> UpdateResult { - let path_str = package_json_path.display().to_string(); - - // Guarded read: a FIFO planted as package.json would make a plain - // `read_to_string` open block forever waiting for a writer — discovery - // lists any path whose metadata stats, so it reaches here unopened. - let content = match read_regular_to_string(package_json_path).await { - Ok(c) => c, - Err(e) => { - return UpdateResult { - path: path_str, - status: UpdateStatus::Error, - old_script: String::new(), - new_script: String::new(), - old_dependencies_script: String::new(), - new_dependencies_script: String::new(), - error: Some(e.to_string()), - }; - } - }; - - match update_package_json_content(&content, pm) { - Ok((modified, new_content, old_pi, new_pi, old_deps, new_deps)) => { - if modified && !dry_run { - if let Err(e) = - atomic_write_bytes_preserving_mode(package_json_path, new_content.as_bytes()) - .await - { - return UpdateResult { - path: path_str, - status: UpdateStatus::Error, - old_script: old_pi, - new_script: new_pi, - old_dependencies_script: old_deps, - new_dependencies_script: new_deps, - error: Some(e.to_string()), - }; - } - } - - UpdateResult { - path: path_str, - status: if modified { - UpdateStatus::Updated - } else { - UpdateStatus::AlreadyConfigured - }, - old_script: old_pi, - new_script: new_pi, - old_dependencies_script: old_deps, - new_dependencies_script: new_deps, - error: None, - } - } - Err(e) => UpdateResult { - path: path_str, - status: UpdateStatus::Error, - old_script: String::new(), - new_script: String::new(), - old_dependencies_script: String::new(), - new_dependencies_script: String::new(), - error: Some(e), - }, - } -} - -/// Result of removing socket-patch from a single package.json. -#[derive(Debug, Clone)] -pub struct RemoveResult { - pub path: String, - pub status: RemoveStatus, - /// Previous `postinstall` script (empty if absent). - pub old_script: String, - /// New `postinstall` value: `None` means the key was deleted. - pub new_script: Option, - pub old_dependencies_script: String, - pub new_dependencies_script: Option, - pub error: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum RemoveStatus { - /// socket-patch was present and has been (or would be) removed. - Removed, - /// Nothing to remove — the file is not configured for socket-patch. - NotConfigured, - Error, -} - -/// Remove socket-patch lifecycle scripts from a single package.json file. -/// -/// Mirrors [`update_package_json`] but in reverse. Needs no [`PackageManager`]: -/// it strips any known socket-patch pattern regardless of how it was written. -pub async fn remove_package_json(package_json_path: &Path, dry_run: bool) -> RemoveResult { - let path_str = package_json_path.display().to_string(); - - // Guarded read — see the matching note in `update_package_json`. - let content = match read_regular_to_string(package_json_path).await { - Ok(c) => c, - Err(e) => { - return RemoveResult { - path: path_str, - status: RemoveStatus::Error, - old_script: String::new(), - new_script: None, - old_dependencies_script: String::new(), - new_dependencies_script: None, - error: Some(e.to_string()), - }; - } - }; - - match remove_package_json_content(&content) { - Ok((modified, new_content, status)) => { - if modified && !dry_run { - if let Err(e) = - atomic_write_bytes_preserving_mode(package_json_path, new_content.as_bytes()) - .await - { - return RemoveResult { - path: path_str, - status: RemoveStatus::Error, - old_script: status.old_postinstall, - new_script: status.new_postinstall, - old_dependencies_script: status.old_dependencies, - new_dependencies_script: status.new_dependencies, - error: Some(e.to_string()), - }; - } - } - - RemoveResult { - path: path_str, - status: if modified { - RemoveStatus::Removed - } else { - RemoveStatus::NotConfigured - }, - old_script: status.old_postinstall, - new_script: status.new_postinstall, - old_dependencies_script: status.old_dependencies, - new_dependencies_script: status.new_dependencies, - error: None, - } - } - Err(e) => RemoveResult { - path: path_str, - status: RemoveStatus::Error, - old_script: String::new(), - new_script: None, - old_dependencies_script: String::new(), - new_dependencies_script: None, - error: Some(e), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::fs; - - #[tokio::test] - async fn test_update_file_not_found() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("nonexistent.json"); - let result = update_package_json(&missing, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Error); - assert!(result.error.is_some()); - } - - #[tokio::test] - async fn test_update_already_configured() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write( - &pkg, - r#"{"name":"test","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm","dependencies":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#, - ) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::AlreadyConfigured); - } - - #[tokio::test] - async fn test_update_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - let original = r#"{"name":"test","scripts":{"build":"tsc"}}"#; - fs::write(&pkg, original).await.unwrap(); - let result = update_package_json(&pkg, true, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - // File should remain unchanged - let content = fs::read_to_string(&pkg).await.unwrap(); - assert_eq!(content, original); - } - - #[tokio::test] - async fn test_update_writes_file() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"test","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - let content = fs::read_to_string(&pkg).await.unwrap(); - assert!(content.contains("npx @socketsecurity/socket-patch apply")); - assert!(content.contains("postinstall")); - assert!(content.contains("dependencies")); - } - - #[tokio::test] - async fn test_update_invalid_json() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, "not json!!!").await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Error); - assert!(result.error.is_some()); - } - - #[tokio::test] - async fn test_update_no_scripts_key() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - let content = fs::read_to_string(&pkg).await.unwrap(); - assert!(content.contains("postinstall")); - assert!(content.contains("dependencies")); - assert!(content.contains("npx @socketsecurity/socket-patch apply")); - } - - #[tokio::test] - async fn test_update_pnpm() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Pnpm).await; - assert_eq!(result.status, UpdateStatus::Updated); - let content = fs::read_to_string(&pkg).await.unwrap(); - assert!(content.contains("pnpm dlx @socketsecurity/socket-patch apply")); - } - - #[tokio::test] - async fn test_update_vlt() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Vlt).await; - assert_eq!(result.status, UpdateStatus::Updated); - let npx = "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"; - assert_eq!(result.new_script, npx); - assert_eq!(result.new_dependencies_script, npx); - let content: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&pkg).await.unwrap()).unwrap(); - assert_eq!(content["scripts"]["postinstall"], npx); - assert_eq!(content["scripts"]["dependencies"], npx); - } - - #[tokio::test] - async fn test_update_adds_dependencies_when_postinstall_exists() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write( - &pkg, - r#"{"name":"test","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#, - ) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - let content = fs::read_to_string(&pkg).await.unwrap(); - assert!(content.contains("dependencies")); - } - - /// Writing back the user's package.json must not reorder their existing - /// keys. Without `serde_json/preserve_order` the value map is sorted - /// alphabetically, so a file like `{"version":..,"name":..}` would be - /// rewritten as `{"name":..,"version":..}` — a destructive, noisy diff - /// over something the tool only meant to append two scripts to. - #[tokio::test] - async fn test_update_preserves_top_level_key_order() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - // Deliberately non-alphabetical key order. - fs::write( - &pkg, - r#"{"version":"1.0.0","name":"x","private":true,"scripts":{"build":"tsc"}}"#, - ) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - - let content = fs::read_to_string(&pkg).await.unwrap(); - let pos_version = content.find("\"version\"").unwrap(); - let pos_name = content.find("\"name\"").unwrap(); - let pos_private = content.find("\"private\"").unwrap(); - let pos_scripts = content.find("\"scripts\"").unwrap(); - assert!( - pos_version < pos_name && pos_name < pos_private && pos_private < pos_scripts, - "original top-level key order must be preserved, got:\n{content}" - ); - } - - /// The pre-existing `build` script (and its position) must survive an - /// update that only appends the lifecycle scripts. - #[tokio::test] - async fn test_update_preserves_existing_scripts() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write( - &pkg, - r#"{"name":"x","scripts":{"build":"tsc","test":"jest"}}"#, - ) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - - let parsed: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&pkg).await.unwrap()).unwrap(); - assert_eq!(parsed["scripts"]["build"], "tsc"); - assert_eq!(parsed["scripts"]["test"], "jest"); - assert!(parsed["scripts"]["postinstall"].is_string()); - assert!(parsed["scripts"]["dependencies"].is_string()); - } - - /// Running setup twice must be idempotent: the second run reports - /// `AlreadyConfigured` and leaves the file byte-for-byte unchanged (no - /// duplicated `socket-patch apply` commands). - #[tokio::test] - async fn test_update_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - - let r1 = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(r1.status, UpdateStatus::Updated); - let after_first = fs::read_to_string(&pkg).await.unwrap(); - - let r2 = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(r2.status, UpdateStatus::AlreadyConfigured); - let after_second = fs::read_to_string(&pkg).await.unwrap(); - - assert_eq!(after_first, after_second); - assert_eq!(after_first.matches("socket-patch apply").count(), 2); - } - - /// Valid JSON whose root is not an object cannot hold lifecycle scripts; - /// it must surface an error rather than panicking or silently succeeding. - #[tokio::test] - async fn test_update_non_object_root_errors() { - let dir = tempfile::tempdir().unwrap(); - for (i, body) in ["[1,2,3]", "42", "\"hi\"", "true", "null"] - .iter() - .enumerate() - { - let pkg = dir.path().join(format!("pkg{i}.json")); - fs::write(&pkg, body).await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Error, "body={body}"); - assert!(result.error.is_some(), "body={body}"); - } - } - - /// A present-but-non-object `scripts` is malformed; refuse to clobber it. - #[tokio::test] - async fn test_update_non_object_scripts_errors_and_leaves_file() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - let original = r#"{"name":"x","scripts":"build"}"#; - fs::write(&pkg, original).await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Error); - // File must be left untouched. - assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original); - } - - /// npm and Node tolerate (and strip) a UTF-8 BOM in package.json — files - /// saved by Windows editors commonly carry one. serde_json does not, so - /// without stripping it a perfectly npm-valid manifest errors out with - /// "Invalid package.json" instead of being configured. - #[tokio::test] - async fn test_update_tolerates_utf8_bom() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write( - &pkg, - "\u{feff}{\"name\":\"x\",\"scripts\":{\"build\":\"tsc\"}}", - ) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!( - result.status, - UpdateStatus::Updated, - "BOM'd package.json is valid for npm and must be updatable, got error: {:?}", - result.error - ); - let content = fs::read_to_string(&pkg).await.unwrap(); - // The BOM survives the rewrite (the editor that added it keeps it). - let body = content - .strip_prefix('\u{feff}') - .expect("setup must keep the manifest's BOM"); - let parsed: serde_json::Value = serde_json::from_str(body).unwrap(); - assert!(parsed["scripts"]["postinstall"].is_string()); - assert!(parsed["scripts"]["dependencies"].is_string()); - assert_eq!(parsed["scripts"]["build"], "tsc"); - } - - /// A BOM'd file that is already fully configured must report - /// `AlreadyConfigured` (and stay untouched), not `Error`. - #[tokio::test] - async fn test_update_bom_already_configured() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - let original = "\u{feff}{\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}"; - fs::write(&pkg, original).await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::AlreadyConfigured); - assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original); - } - - /// An empty file is invalid JSON and must error without writing. - #[tokio::test] - async fn test_update_empty_file_errors() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, "").await.unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Error); - assert!(result.error.is_some()); - } - - /// Dry-run on a file that needs updating reports `Updated` but must not - /// touch the bytes on disk — the consumer relies on this for its preview. - #[tokio::test] - async fn test_update_dry_run_reports_updated_without_writing_scripts() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - let original = r#"{"name":"x","scripts":{"postinstall":"echo hi"}}"#; - fs::write(&pkg, original).await.unwrap(); - let result = update_package_json(&pkg, true, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - // old_script reflects the existing script; new_script the prepended one. - assert_eq!(result.old_script, "echo hi"); - assert!(result.new_script.contains("socket-patch apply")); - assert!(result.new_script.contains("echo hi")); - assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original); - } - - /// After a successful (non-dry-run) write the staged temp file must be - /// renamed into place, never left behind. A leaked `.socket-stage-*` - /// sibling would signal the atomic write didn't complete its rename. - async fn count_stage_litter(dir: &Path) -> usize { - let mut rd = fs::read_dir(dir).await.unwrap(); - let mut n = 0; - while let Some(entry) = rd.next_entry().await.unwrap() { - if entry - .file_name() - .to_string_lossy() - .starts_with(".socket-stage-") - { - n += 1; - } - } - n - } - - #[tokio::test] - async fn test_update_atomic_write_leaves_no_stage_litter() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated); - // The write must have gone through stage+rename and cleaned up. - assert_eq!(count_stage_litter(dir.path()).await, 0); - // And produced valid, fully-written JSON (not a truncated stage). - let content = fs::read_to_string(&pkg).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert!(parsed["scripts"]["postinstall"].is_string()); - assert!(parsed["scripts"]["dependencies"].is_string()); - } - - #[tokio::test] - async fn test_remove_atomic_write_leaves_no_stage_litter() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - update_package_json(&pkg, false, PackageManager::Npm).await; - - let result = remove_package_json(&pkg, false).await; - assert_eq!(result.status, RemoveStatus::Removed); - assert_eq!(count_stage_litter(dir.path()).await, 0); - let content = fs::read_to_string(&pkg).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(parsed["scripts"]["build"], "tsc"); - assert!(!content.contains("socket-patch")); - } - - /// The stage+rename write swaps in a fresh inode, so unless the writer - /// re-applies the destination's permission bits, an edit resets the - /// user's package.json mode to umask defaults (typically 0644): a 0600 - /// user-private manifest silently becomes world-readable, and a 0664 - /// group-writable one locks the group out. npm's own write-file-atomic - /// preserves mode on package.json edits; so must we. (The 0744 file - /// makes this red under any umask — a 0666-based create can never - /// produce an exec bit.) - #[cfg(unix)] - #[tokio::test] - async fn test_update_preserves_file_mode() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - for mode in [0o600u32, 0o744] { - let pkg = dir.path().join(format!("pkg-{mode:o}.json")); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(mode)).unwrap(); - - let result = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(result.status, UpdateStatus::Updated, "mode {mode:o}"); - let got = std::fs::metadata(&pkg).unwrap().permissions().mode() & 0o777; - assert_eq!( - got, mode, - "update must preserve the package.json mode, got {got:o} for {mode:o}" - ); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_preserves_file_mode() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - for mode in [0o600u32, 0o744] { - let pkg = dir.path().join(format!("pkg-{mode:o}.json")); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - update_package_json(&pkg, false, PackageManager::Npm).await; - std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(mode)).unwrap(); - - let result = remove_package_json(&pkg, false).await; - assert_eq!(result.status, RemoveStatus::Removed, "mode {mode:o}"); - let got = std::fs::metadata(&pkg).unwrap().permissions().mode() & 0o777; - assert_eq!( - got, mode, - "remove must preserve the package.json mode, got {got:o} for {mode:o}" - ); - } - } - - /// A dry-run must never create a stage file either — it does no I/O at all. - #[tokio::test] - async fn test_update_dry_run_leaves_no_stage_litter() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - update_package_json(&pkg, true, PackageManager::Npm).await; - assert_eq!(count_stage_litter(dir.path()).await, 0); - } - - // ── remove_package_json ───────────────────────────────────────── - - #[tokio::test] - async fn test_remove_file_not_found() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("nonexistent.json"); - let result = remove_package_json(&missing, false).await; - assert_eq!(result.status, RemoveStatus::Error); - assert!(result.error.is_some()); - } - - #[tokio::test] - async fn test_remove_not_configured() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - let result = remove_package_json(&pkg, false).await; - assert_eq!(result.status, RemoveStatus::NotConfigured); - } - - #[tokio::test] - async fn test_remove_writes_and_strips_socket_patch() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - // Configure first, then remove. - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - update_package_json(&pkg, false, PackageManager::Npm).await; - - let result = remove_package_json(&pkg, false).await; - assert_eq!(result.status, RemoveStatus::Removed); - let content = fs::read_to_string(&pkg).await.unwrap(); - assert!(!content.contains("socket-patch")); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(parsed["scripts"]["build"], "tsc"); - } - - #[tokio::test] - async fn test_remove_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - let original = - r#"{"name":"x","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply"}}"#; - fs::write(&pkg, original).await.unwrap(); - let result = remove_package_json(&pkg, true).await; - assert_eq!(result.status, RemoveStatus::Removed); - // File must be byte-identical after a dry-run. - assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original); - } - - #[tokio::test] - async fn test_remove_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - update_package_json(&pkg, false, PackageManager::Npm).await; - - let r1 = remove_package_json(&pkg, false).await; - assert_eq!(r1.status, RemoveStatus::Removed); - let r2 = remove_package_json(&pkg, false).await; - assert_eq!(r2.status, RemoveStatus::NotConfigured); - } - - /// Remove must tolerate a UTF-8 BOM the same way npm does: a BOM'd, - /// configured package.json must be cleanly reverted, not rejected as - /// invalid JSON. - #[tokio::test] - async fn test_remove_tolerates_utf8_bom() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write( - &pkg, - "\u{feff}{\"name\":\"x\",\"scripts\":{\"build\":\"tsc\",\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}", - ) - .await - .unwrap(); - let result = remove_package_json(&pkg, false).await; - assert_eq!( - result.status, - RemoveStatus::Removed, - "BOM'd package.json is valid for npm and must be removable, got error: {:?}", - result.error - ); - let content = fs::read_to_string(&pkg).await.unwrap(); - assert!(!content.contains("socket-patch")); - let body = content - .strip_prefix('\u{feff}') - .expect("setup --remove must keep the manifest's BOM"); - let parsed: serde_json::Value = serde_json::from_str(body).unwrap(); - assert_eq!(parsed["scripts"]["build"], "tsc"); - } - - /// A Windows yarn-berry manifest (persistManifest pretty-prints with - /// `os.EOL`, so CRLF; an editor may add a BOM; some tools drop the final - /// newline) must keep its layout through `setup`, and `setup --remove` - /// must land byte-identical on the pre-setup file. serde's serializer - /// emits bare `\n` and no BOM, which would flip every line to LF — a - /// whole-file diff yarn then keeps (it follows the majority ending). - #[tokio::test] - async fn test_setup_then_remove_round_trips_crlf_bom_and_final_newline_shape() { - let lf = "{\n \"name\": \"x\",\n \"version\": \"1.0.0\",\n \"scripts\": {\n \"build\": \"tsc\"\n },\n \"packageManager\": \"yarn@4.12.0\"\n}"; - let crlf = lf.replace('\n', "\r\n"); - let cases = [ - ("lf", format!("{lf}\n")), - ("lf-no-final-newline", lf.to_string()), - ("crlf", format!("{crlf}\r\n")), - ("bom-crlf", format!("\u{feff}{crlf}\r\n")), - ("crlf-no-final-newline", crlf.clone()), - ("bom-lf", format!("\u{feff}{lf}\n")), - ("crlf-two-final-newlines", format!("{crlf}\r\n\r\n")), - ]; - for (label, original) in cases { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, &original).await.unwrap(); - - let up = update_package_json(&pkg, false, PackageManager::Npm).await; - assert_eq!(up.status, UpdateStatus::Updated, "{label}: {:?}", up.error); - let wired = fs::read_to_string(&pkg).await.unwrap(); - assert_eq!( - wired.starts_with('\u{feff}'), - original.starts_with('\u{feff}'), - "{label}: BOM presence must survive setup:\n{wired:?}" - ); - if original.contains("\r\n") { - assert!( - !wired.replace("\r\n", "").contains('\n'), - "{label}: setup left a bare LF in a CRLF manifest:\n{wired:?}" - ); - } else { - assert!( - !wired.contains('\r'), - "{label}: setup added CR to an LF manifest" - ); - } - let trailer = |t: &str| t.len() - t.trim_end_matches(['\r', '\n']).len(); - assert_eq!( - trailer(&wired), - trailer(&original), - "{label}: trailing-newline shape must survive setup:\n{wired:?}" - ); - let parsed: serde_json::Value = - serde_json::from_str(wired.trim_start_matches('\u{feff}')).unwrap(); - assert!(parsed["scripts"]["postinstall"].is_string(), "{label}"); - - let down = remove_package_json(&pkg, false).await; - assert_eq!( - down.status, - RemoveStatus::Removed, - "{label}: {:?}", - down.error - ); - assert_eq!( - fs::read_to_string(&pkg).await.unwrap(), - original, - "{label}: setup --remove must restore the pre-setup bytes" - ); - } - } - - /// mkfifo(2) directly rather than shelling out to the `mkfifo` binary — - /// same helper as the find.rs FIFO tests: fork/exec flakes under heavy - /// parallel load and the syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - /// A FIFO planted as `package.json` must not wedge setup. Discovery lists - /// any `package.json` whose metadata stats (a FIFO does), then setup calls - /// `update_package_json` on it; a plain `read_to_string` open(2) on a FIFO - /// waits for a writer that never comes, wedging `socket-patch setup` - /// indefinitely with no error and no timeout. Same class as the - /// `open_regular_file` guards in find.rs and the npm/composer/python/ruby - /// crawlers. - #[cfg(unix)] - #[tokio::test] - async fn test_update_fifo_package_json_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("package.json"); - mkfifo(&fifo); - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a writer to release - // it so the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout( - deadline, - update_package_json(&fifo, false, PackageManager::Npm), - ) - .await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("update must complete promptly with a FIFO package.json"); - }; - assert_eq!(result.status, UpdateStatus::Error); - assert!(result.error.is_some()); - } - - /// The removal twin: `setup --remove` reads every discovered package.json - /// through `remove_package_json`, so a FIFO wedged it the same way. - #[cfg(unix)] - #[tokio::test] - async fn test_remove_fifo_package_json_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("package.json"); - mkfifo(&fifo); - - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout(deadline, remove_package_json(&fifo, false)).await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("remove must complete promptly with a FIFO package.json"); - }; - assert_eq!(result.status, RemoveStatus::Error); - assert!(result.error.is_some()); - } - - #[tokio::test] - async fn test_remove_invalid_json_errors_and_leaves_file() { - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, "not json!!!").await.unwrap(); - let result = remove_package_json(&pkg, false).await; - assert_eq!(result.status, RemoveStatus::Error); - assert_eq!(fs::read_to_string(&pkg).await.unwrap(), "not json!!!"); - } - - /// When the stripped package.json cannot be written back (the atomic - /// writer's stage file cannot be created in a read-only parent), remove - /// must report `RemoveStatus::Error` carrying the parsed script fields — - /// not the empty-string invalid-JSON arm — and leave the configured file - /// byte-identical on disk. (0o555 on the parent makes the stage-file - /// `create_new` fail EACCES; like the repo's other read-only-dir tests - /// this assumes a non-root test run.) - #[cfg(unix)] - #[tokio::test] - async fn test_remove_write_failure_reports_error_and_leaves_file() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - let pkg = dir.path().join("package.json"); - fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) - .await - .unwrap(); - update_package_json(&pkg, false, PackageManager::Npm).await; - let before = fs::read_to_string(&pkg).await.unwrap(); - - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); - - let result = remove_package_json(&pkg, false).await; - - // Restore before any assertion can unwind, so the tempdir cleans up. - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); - - assert_eq!(result.status, RemoveStatus::Error); - assert!(result.error.is_some(), "write failure must be surfaced"); - // The error branch must carry the parsed status fields (proving it is - // the write-failure arm, not the invalid-JSON arm whose fields are - // empty): update added postinstall/dependencies purely for - // socket-patch, so the strip deletes both keys. - assert!( - result.old_script.contains("socket-patch apply"), - "old_script must reflect the configured postinstall, got {:?}", - result.old_script - ); - assert!(result.new_script.is_none()); - assert!( - result - .old_dependencies_script - .contains("socket-patch apply"), - "old_dependencies_script must reflect the configured script, got {:?}", - result.old_dependencies_script - ); - assert!(result.new_dependencies_script.is_none()); - // The write never landed: file byte-identical, no stage litter. - assert_eq!(fs::read_to_string(&pkg).await.unwrap(), before); - assert_eq!(count_stage_litter(dir.path()).await, 0); - } -} diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index 14788d3e..699ed494 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -170,7 +170,7 @@ pub(crate) fn normalize_file_path(file_name: &str) -> &str { /// that stays inside the package directory when joined to it. /// /// SECURITY: manifest file keys come from a committed `.socket/manifest.json`, -/// which the auto-running install hook applies without explicit user action. An +/// which a CI `apply` step applies without explicit user action. An /// unvalidated key like `../../home/u/.bashrc` or `/etc/cron.d/x` would let a /// poisoned manifest write OUTSIDE site-packages (arbitrary-file write → code /// execution) via `pkg_path.join(key)` — `Path::join` discards the base on an @@ -1083,7 +1083,7 @@ async fn resolve_from_diff( /// Strategy 3 (on-disk half) — read `blobs_path/` fail-closed. /// /// SECURITY: `hash` comes from a committed `.socket/manifest.json` that the -/// install hook applies without user action, so it is validated as a blob +/// CI `apply` step applies without user action, so it is validated as a blob /// hash before it is joined (no traversal, no absolute path), and the /// directory ENTRY must be a regular file ([`read_blob_entry`]): a symlink /// planted at `blobs/` must not carry the read out of the blobs diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index 9d73089a..ba709c6b 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -9,11 +9,3 @@ pub(crate) mod path_safety; pub mod redirect; pub mod rollback; pub mod sidecars; - -// 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 in the next major. -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/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 9f03dbaa..5f1cfb5a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6014,7 +6014,7 @@ fn rewrite_gem( // `default_gemfile` tries gems.rb first — verified on bundler 4.0.15, // which warns "Multiple gemfiles (gems.rb and Gemfile) detected ... // bundler is ignoring them in favor of gems.rb and gems.locked"; same - // order as `setup::gem::discover_bundler_project`). DIVERGING spellings + // order as the ruby crawler's manifest probe). DIVERGING spellings // are ambiguous — the redirect would land in the file bundler reads while // tooling pinned to the other keeps resolving upstream — so fail closed // on the whole gem set. Divergence is judged on the redirect-footprint diff --git a/crates/socket-patch-core/src/setup/composer/mod.rs b/crates/socket-patch-core/src/setup/composer/mod.rs deleted file mode 100644 index 1783f8dd..00000000 --- a/crates/socket-patch-core/src/setup/composer/mod.rs +++ /dev/null @@ -1,935 +0,0 @@ -//! Composer (PHP) `setup` backend: wire the socket-patch re-apply hook into a -//! project's `composer.json` `scripts`. -//! -//! Composer has a native post-install hook — the `post-install-cmd` and -//! `post-update-cmd` script events fire after `composer install` / -//! `composer update` finish populating `vendor/`. `setup` appends -//! `socket-patch apply --offline --silent --ecosystems composer` to both so the -//! committed `.socket/` patches are re-applied on every install/update (the -//! socket-patch CLI must be on `PATH`, the same requirement as the gem Bundler -//! plugin). -//! -//! `composer.json` is JSON, so — like the npm `package_json` backend — edits go -//! through `serde_json` (with the workspace's `preserve_order` feature, so the -//! user's key order survives) and are written back in the file's own -//! formatting (see [`serialize_like_input`]). The contract mirrors the other -//! backends: idempotent, `dry_run`-aware, `Updated`/`AlreadyConfigured`/ -//! `Error`, and a `--remove` that strips exactly what `setup` added. - -use std::path::{Path, PathBuf}; - -use serde_json::{Map, Value}; -use tokio::fs; - -use crate::vendor::common::{detect_indent, serialize_json}; - -/// The command `setup` appends to each composer script event. The socket-patch -/// CLI is invoked from `PATH` (composer has no `npx`-style fetch), offline (the -/// patches are committed under `.socket/`) and silent (so it doesn't clutter -/// composer's own output). -const APPLY_COMMAND: &str = "socket-patch apply --offline --silent --ecosystems composer"; - -/// Composer script events `setup` wires: post-install-cmd fires after -/// `composer install`, post-update-cmd after `composer update`. Covering both -/// re-applies patches whenever the installed set could have changed. -const HOOK_EVENTS: &[&str] = &["post-install-cmd", "post-update-cmd"]; - -/// Loose marker for "this script line is ours" — used by `--check` detection so -/// a slightly different flag set still reads as configured. -const HOOK_MARKER: &str = "socket-patch apply"; - -/// Outcome of one setup edit. Mirrors `setup::gem::GemSetupStatus`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ComposerSetupStatus { - Updated, - AlreadyConfigured, - Error, -} - -#[derive(Debug)] -pub struct ComposerEditResult { - /// Envelope `files[].kind` — always `composer`. - pub kind: &'static str, - pub path: String, - pub status: ComposerSetupStatus, - pub error: Option, -} - -/// Find the composer project rooted at `cwd`: the path to a `composer.json` -/// directly in `cwd`. cwd-only, like the pypi backend (gem discovery walks up -/// to the nearest Gemfile/gems.rb). -pub async fn discover_composer_project(cwd: &Path) -> Option { - let composer_json = cwd.join("composer.json"); - fs::metadata(&composer_json) - .await - .is_ok() - .then_some(composer_json) -} - -/// Static check: does this `composer.json` already wire our re-apply hook into -/// any of the covered script events? Pure parse + scan — what a repo auditor -/// reads. A user's own unrelated script does not match. -pub fn is_hook_present(content: &str) -> bool { - let doc: Value = match serde_json::from_str(content) { - Ok(v) => v, - Err(_) => return false, - }; - let scripts = match doc.get("scripts").and_then(Value::as_object) { - Some(s) => s, - None => return false, - }; - HOOK_EVENTS - .iter() - .any(|event| event_contains_marker(scripts.get(*event))) -} - -/// Whether a script-event value (string | array-of-strings) holds a command -/// carrying our marker. -fn event_contains_marker(value: Option<&Value>) -> bool { - match value { - Some(Value::String(s)) => s.contains(HOOK_MARKER), - Some(Value::Array(arr)) => arr - .iter() - .any(|v| v.as_str().is_some_and(|s| s.contains(HOOK_MARKER))), - _ => false, - } -} - -// ── pure transforms ────────────────────────────────────────────────────────── - -/// Parse `composer.json` for editing, rejecting malformed input: the root must -/// be a JSON object, and a present `scripts` must be an object (or `null`) — -/// add refuses to clobber it, remove refuses to silently swallow it as a -/// "nothing to remove" no-op. -fn parse_checked(content: &str) -> Result { - let doc: Value = - serde_json::from_str(content).map_err(|e| format!("Invalid composer.json: {e}"))?; - if !doc.is_object() { - return Err("Invalid composer.json: root is not a JSON object".to_string()); - } - if let Some(scripts) = doc.get("scripts") { - if !scripts.is_null() && !scripts.is_object() { - return Err("Invalid composer.json: \"scripts\" is not a JSON object".to_string()); - } - } - Ok(doc) -} - -/// Re-serialize the edited document in the formatting the file already used. -/// -/// Composer writes `composer.json` through PHP's `JSON_PRETTY_PRINT`, which -/// indents with 4 spaces, while serde's `to_string_pretty` is hard-wired to 2 — -/// so re-serializing turned a two-key edit into a whole-file diff and left -/// `--remove` unable to restore the original bytes. `detect_indent` / -/// `serialize_json` are the same helpers the vendor backends use when they -/// rewrite composer.json and the lockfiles. A file saved without a trailing -/// newline keeps that too, since `serialize_json` always appends one. -fn serialize_like_input(doc: &Value, original: &str) -> String { - let indent = detect_indent(original); - let mut text = match serialize_json(doc, &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(doc).unwrap_or_default() + "\n", - }; - if !original.ends_with('\n') { - text.pop(); - } - text -} - -/// Append [`APPLY_COMMAND`] to both hook events, normalising each to an array. -/// `None` if already present in every event (idempotent no-op). -fn composer_add(content: &str) -> Result, String> { - let mut doc = parse_checked(content)?; - let root = doc - .as_object_mut() - .expect("parse_checked guarantees an object root"); - - // Get-or-create the `scripts` object (replacing a `null`). - if !root.get("scripts").map(Value::is_object).unwrap_or(false) { - root.insert("scripts".to_string(), Value::Object(Map::new())); - } - let scripts = root - .get_mut("scripts") - .expect("the guard above inserts `scripts` when absent") - .as_object_mut() - .expect("the guard above replaces a non-object `scripts`"); - - let mut changed = false; - for event in HOOK_EVENTS { - changed |= add_command_to_event(scripts, event)?; - } - if !changed { - // We created an empty `scripts` object above only if it was absent; - // drop it again so a no-op truly changes nothing. - if root - .get("scripts") - .and_then(Value::as_object) - .is_some_and(Map::is_empty) - { - root.remove("scripts"); - } - return Ok(None); - } - Ok(Some(serialize_like_input(&doc, content))) -} - -/// Strip [`APPLY_COMMAND`] from both hook events, pruning emptied events and an -/// emptied `scripts` object. `None` if our command is absent everywhere. -fn composer_remove(content: &str) -> Result, String> { - let mut doc = parse_checked(content)?; - let root = doc - .as_object_mut() - .expect("parse_checked guarantees an object root"); - // An absent (or `null`) `scripts` is a legitimate no-op: nothing of ours. - let scripts = match root.get_mut("scripts").and_then(Value::as_object_mut) { - Some(s) => s, - None => return Ok(None), - }; - - let mut changed = false; - for event in HOOK_EVENTS { - changed |= remove_command_from_event(scripts, event); - } - if !changed { - return Ok(None); - } - if scripts.is_empty() { - // shift_remove: with preserve_order, plain `remove` is swap_remove and - // would teleport the last root key into this slot. - root.shift_remove("scripts"); - } - Ok(Some(serialize_like_input(&doc, content))) -} - -/// Add [`APPLY_COMMAND`] to one event, normalising string → array. Returns -/// whether the event changed; errors on a non-string/array event value it -/// refuses to clobber. Any command already carrying [`HOOK_MARKER`] -/// counts as present — the same predicate as [`is_hook_present`] / `--check`, -/// so a user-customized flag set is left alone rather than duplicated. -fn add_command_to_event(scripts: &mut Map, event: &str) -> Result { - if event_contains_marker(scripts.get(event)) { - return Ok(false); - } - let cmd = Value::String(APPLY_COMMAND.to_string()); - match scripts.get_mut(event) { - None => { - scripts.insert(event.to_string(), Value::Array(vec![cmd])); - Ok(true) - } - Some(Value::String(s)) => { - let existing = Value::String(s.clone()); - scripts.insert(event.to_string(), Value::Array(vec![existing, cmd])); - Ok(true) - } - Some(Value::Array(arr)) => { - arr.push(cmd); - Ok(true) - } - // A non-string/array script value is user data we won't clobber — and - // can't wire into, so treating it as "no change" would surface as - // AlreadyConfigured while `--check` says not configured. Refuse loudly, - // like the non-object-`scripts` guard. - Some(_) => Err(format!( - "Invalid composer.json: \"{event}\" script is not a string or array" - )), - } -} - -/// Remove [`APPLY_COMMAND`] from one event, pruning an emptied event key. -/// Returns whether the event changed. -fn remove_command_from_event(scripts: &mut Map, event: &str) -> bool { - // shift_remove throughout: with preserve_order, plain `remove` is - // swap_remove and would shuffle the user's other scripts. - match scripts.get_mut(event) { - Some(Value::String(s)) if s == APPLY_COMMAND => { - scripts.shift_remove(event); - true - } - Some(Value::Array(arr)) => { - let before = arr.len(); - arr.retain(|v| v.as_str() != Some(APPLY_COMMAND)); - if arr.len() == before { - return false; - } - if arr.is_empty() { - scripts.shift_remove(event); - } - true - } - _ => false, - } -} - -// ── async wrappers ─────────────────────────────────────────────────────────── - -/// Wire the project: append our command to the composer script events. -pub async fn add_hook(composer_json: &Path, dry_run: bool) -> ComposerEditResult { - edit(composer_json, dry_run, composer_add).await -} - -/// Unwire the project: strip our command, pruning emptied keys. -pub async fn remove_hook(composer_json: &Path, dry_run: bool) -> ComposerEditResult { - edit(composer_json, dry_run, composer_remove).await -} - -async fn edit( - composer_json: &Path, - dry_run: bool, - transform: impl FnOnce(&str) -> Result, String>, -) -> ComposerEditResult { - let result = async { - // Guarded read: a FIFO planted as `composer.json` would make a plain - // `read_to_string` open block forever waiting for a writer — - // discovery accepts any path whose metadata stats, so it reaches - // here unopened. The shared reader rejects non-regular files. - let content = match crate::utils::fs::read_regular_to_string(composer_json).await { - Ok(c) => c, - // A missing composer.json on remove is a no-op, not an error. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(e) => return Err(e.to_string()), - }; - match transform(&content)? { - None => Ok(false), - Some(new) => { - if !dry_run { - // Atomic (stage+fsync+rename), so the user's committed - // composer.json is never left torn by a crash mid-write, - // and mode-preserving, because the rename swaps in a fresh - // inode that would otherwise take umask defaults — the - // same writer every sibling manifest editor uses. - crate::utils::fs::atomic_write_bytes_preserving_mode( - composer_json, - new.as_bytes(), - ) - .await - .map_err(|e| e.to_string())?; - } - Ok(true) - } - } - } - .await; - let (status, error) = match result { - Ok(true) => (ComposerSetupStatus::Updated, None), - Ok(false) => (ComposerSetupStatus::AlreadyConfigured, None), - Err(e) => (ComposerSetupStatus::Error, Some(e)), - }; - ComposerEditResult { - kind: "composer", - path: composer_json.display().to_string(), - status, - error, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const BASIC: &str = - "{\n \"name\": \"acme/app\",\n \"require\": {\n \"php\": \">=8.1\"\n }\n}\n"; - - fn parse(s: &str) -> Value { - serde_json::from_str(s).unwrap() - } - - #[test] - fn test_add_wires_both_events_and_is_idempotent() { - let out = composer_add(BASIC).unwrap().unwrap(); - let doc = parse(&out); - for event in HOOK_EVENTS { - let arr = doc["scripts"][event].as_array().unwrap(); - assert!( - arr.iter().any(|v| v == APPLY_COMMAND), - "{event} must carry our command" - ); - } - assert!(is_hook_present(&out)); - // Idempotent: second add is a no-op. - assert!(composer_add(&out).unwrap().is_none()); - } - - #[test] - fn test_add_preserves_key_order_and_require() { - let out = composer_add(BASIC).unwrap().unwrap(); - // `name` and `require` must precede the appended `scripts`. - let pos_name = out.find("\"name\"").unwrap(); - let pos_require = out.find("\"require\"").unwrap(); - let pos_scripts = out.find("\"scripts\"").unwrap(); - assert!( - pos_name < pos_require && pos_require < pos_scripts, - "key order preserved:\n{out}" - ); - assert_eq!(parse(&out)["require"]["php"], ">=8.1"); - } - - #[test] - fn test_add_preserves_user_script_as_array_member() { - let with_user = "{\n \"scripts\": {\n \"post-install-cmd\": \"@php artisan\"\n }\n}\n"; - let out = composer_add(with_user).unwrap().unwrap(); - let arr = parse(&out)["scripts"]["post-install-cmd"] - .as_array() - .unwrap() - .clone(); - assert!(arr.iter().any(|v| v == "@php artisan"), "user command kept"); - assert!(arr.iter().any(|v| v == APPLY_COMMAND), "ours appended"); - // post-update-cmd is freshly created. - assert!(parse(&out)["scripts"]["post-update-cmd"] - .as_array() - .unwrap() - .iter() - .any(|v| v == APPLY_COMMAND)); - } - - #[test] - fn test_remove_restores_user_only_state() { - let with_user = "{\n \"scripts\": {\n \"post-install-cmd\": \"@php artisan\"\n }\n}\n"; - let added = composer_add(with_user).unwrap().unwrap(); - let removed = composer_remove(&added).unwrap().unwrap(); - let doc = parse(&removed); - // Our command is gone everywhere. - assert!(!is_hook_present(&removed)); - // The user's command survives (still present in post-install-cmd). - let pi = doc["scripts"]["post-install-cmd"].as_array().unwrap(); - assert!(pi.iter().any(|v| v == "@php artisan")); - // The event we created solely for our command is pruned. - assert!(doc["scripts"].get("post-update-cmd").is_none()); - } - - #[test] - fn test_remove_prunes_scripts_object_when_only_ours() { - let added = composer_add(BASIC).unwrap().unwrap(); - let removed = composer_remove(&added).unwrap().unwrap(); - // We created `scripts` solely for our two events; removing both prunes it. - assert!( - parse(&removed).get("scripts").is_none(), - "emptied scripts pruned:\n{removed}" - ); - assert!(!is_hook_present(&removed)); - } - - #[test] - fn test_remove_absent_is_noop() { - assert!(composer_remove(BASIC).unwrap().is_none()); - } - - #[test] - fn test_round_trip_restores_basic_byte_for_byte() { - // A composer.json already in 2-space `to_string_pretty` form round-trips - // byte-for-byte: add then remove yields the input exactly. - let added = composer_add(BASIC).unwrap().unwrap(); - let removed = composer_remove(&added).unwrap().unwrap(); - assert_eq!(removed, BASIC, "add→remove restores the original bytes"); - } - - /// Composer writes `composer.json` with PHP's `JSON_PRETTY_PRINT`, i.e. - /// 4-space indent — the shape virtually every real-world manifest has. - const COMPOSER_AUTHORED: &str = - "{\n \"name\": \"acme/app\",\n \"require\": {\n \"php\": \">=8.1\"\n }\n}\n"; - - #[test] - fn test_add_preserves_composer_four_space_indent() { - // Regression: re-serializing at serde's fixed 2-space indent reformatted - // a composer-authored manifest top to bottom, turning a one-key edit - // into a whole-file diff. - let out = composer_add(COMPOSER_AUTHORED).unwrap().unwrap(); - assert!( - out.contains("\n \"name\": \"acme/app\","), - "depth-1 indent not preserved:\n{out}" - ); - assert!( - out.contains("\n \"php\": \">=8.1\""), - "depth-2 indent not preserved:\n{out}" - ); - assert!( - out.contains("\n \"scripts\": {"), - "our own added key must use the file's indent:\n{out}" - ); - assert!(is_hook_present(&out)); - } - - #[test] - fn test_round_trip_restores_composer_authored_manifest() { - // The whole point of preserving the indent: `--remove` must give the - // user back the exact bytes composer wrote, not a reformatted file. - let added = composer_add(COMPOSER_AUTHORED).unwrap().unwrap(); - let removed = composer_remove(&added).unwrap().unwrap(); - assert_eq!( - removed, COMPOSER_AUTHORED, - "add→remove must restore composer's own formatting" - ); - } - - #[test] - fn test_round_trip_preserves_tab_indent() { - let inp = - "{\n\t\"name\": \"acme/app\",\n\t\"require\": {\n\t\t\"php\": \">=8.1\"\n\t}\n}\n"; - let added = composer_add(inp).unwrap().unwrap(); - assert!( - added.contains("\n\t\"scripts\": {"), - "tab indent not preserved:\n{added}" - ); - assert_eq!(composer_remove(&added).unwrap().unwrap(), inp); - } - - #[test] - fn test_round_trip_preserves_absent_trailing_newline() { - // `serialize_json` always appends a trailing newline, so a manifest - // saved without one would gain a phantom last-line diff that `--remove` - // could never take back. - let inp = COMPOSER_AUTHORED.trim_end_matches('\n'); - let added = composer_add(inp).unwrap().unwrap(); - assert!( - !added.ends_with('\n'), - "must not add a trailing newline the file did not have:\n{added:?}" - ); - assert_eq!(composer_remove(&added).unwrap().unwrap(), inp); - } - - #[test] - fn test_user_string_event_already_ours_is_noop() { - // An event whose string value is exactly our command counts as present. - let already = format!( - "{{\n \"scripts\": {{\n \"post-install-cmd\": \"{APPLY_COMMAND}\",\n \"post-update-cmd\": \"{APPLY_COMMAND}\"\n }}\n}}\n" - ); - assert!(is_hook_present(&already)); - assert!( - composer_add(&already).unwrap().is_none(), - "exact-string command is idempotent" - ); - } - - #[test] - fn test_invalid_json_is_error() { - assert!(composer_add("not json!!!").is_err()); - } - - #[test] - fn test_non_object_scripts_is_error() { - assert!(composer_add("{\"scripts\": \"oops\"}").is_err()); - } - - #[test] - fn test_is_hook_present_false_without_scripts() { - assert!(!is_hook_present(BASIC)); - assert!(!is_hook_present("{}")); - } - - #[test] - fn test_is_hook_present_false_on_malformed_json() { - // An unparseable composer.json reads as "not configured" — the - // `--check` probe is a pure scan that must never crash or wedge on - // malformed input. - assert!(!is_hook_present("{ not json")); - assert!(!is_hook_present("")); - // The asymmetry is intentional and must stay so: on the very same - // bytes `setup` (composer_add) errors loudly instead of quietly - // reporting AlreadyConfigured — so `--check` says needs-configuration - // and `setup` refuses with an error, never a silent success. - assert!(composer_add("{ not json").is_err()); - assert!(composer_add("").is_err()); - } - - #[tokio::test] - async fn test_async_add_remove_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let cj = dir.path().join("composer.json"); - fs::write(&cj, BASIC).await.unwrap(); - let found = discover_composer_project(dir.path()).await.unwrap(); - - let added = add_hook(&found, false).await; - assert_eq!(added.status, ComposerSetupStatus::Updated); - assert!(is_hook_present(&fs::read_to_string(&cj).await.unwrap())); - - // Idempotent. - assert_eq!( - add_hook(&found, false).await.status, - ComposerSetupStatus::AlreadyConfigured - ); - - let removed = remove_hook(&found, false).await; - assert_eq!(removed.status, ComposerSetupStatus::Updated); - assert_eq!( - fs::read_to_string(&cj).await.unwrap(), - BASIC, - "byte-for-byte restore" - ); - } - - #[tokio::test] - async fn test_async_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let cj = dir.path().join("composer.json"); - fs::write(&cj, BASIC).await.unwrap(); - let found = discover_composer_project(dir.path()).await.unwrap(); - let res = add_hook(&found, true).await; - assert_eq!(res.status, ComposerSetupStatus::Updated); - assert_eq!( - fs::read_to_string(&cj).await.unwrap(), - BASIC, - "dry-run must not write" - ); - } - - #[tokio::test] - async fn test_discover_none_without_composer_json() { - let dir = tempfile::tempdir().unwrap(); - assert!(discover_composer_project(dir.path()).await.is_none()); - } - - #[test] - fn test_remove_round_trip_with_other_user_scripts() { - // add then remove restores a composer.json that already had unrelated - // scripts, byte-for-byte (our two events are added and then pruned). - let inp = "{\n \"name\": \"x\",\n \"scripts\": {\n \"test\": \"phpunit\"\n }\n}\n"; - let added = composer_add(inp).unwrap().unwrap(); - let removed = composer_remove(&added).unwrap().unwrap(); - assert_eq!(removed, inp, "round-trip with user scripts"); - } - - #[test] - fn test_remove_non_object_root_is_error() { - // Regression: composer_remove must reject a malformed (non-object) root - // with an error, not silently report "nothing to remove" — matching - // composer_add and the npm `remove_package_json_content` contract. - let err = composer_remove("[1, 2, 3]").unwrap_err(); - assert!(err.contains("root is not a JSON object"), "got: {err}"); - assert!(composer_remove("\"just a string\"").is_err()); - assert!(composer_remove("42").is_err()); - } - - #[test] - fn test_remove_non_object_scripts_is_error() { - // Regression: a present-but-non-object `scripts` is malformed. `setup` - // (composer_add) errors on it; `setup --remove` must too, rather than - // silently swallowing it as a no-op success. - let err = composer_remove("{\"scripts\": \"oops\"}").unwrap_err(); - assert!( - err.contains("\"scripts\" is not a JSON object"), - "got: {err}" - ); - assert!(composer_remove("{\"scripts\": 7}").is_err()); - assert!(composer_remove("{\"scripts\": [\"a\"]}").is_err()); - // add and remove agree on what counts as malformed. - assert!(composer_add("{\"scripts\": \"oops\"}").is_err()); - } - - #[test] - fn test_remove_absent_or_null_scripts_is_noop_not_error() { - // A genuinely absent or null `scripts` has nothing of ours: no-op, not - // an error (the malformed-input guard must not over-trigger). - assert!(composer_remove("{\"name\": \"x\"}").unwrap().is_none()); - assert!(composer_remove("{\"scripts\": null}").unwrap().is_none()); - } - - #[test] - fn test_exhaustive_invariants() { - let event_values = [ - None, - Some(format!("\"{APPLY_COMMAND}\"")), - Some("\"@php artisan\"".to_string()), - Some(format!("[\"{APPLY_COMMAND}\"]")), - Some("[\"@php artisan\"]".to_string()), - Some(format!("[\"@php artisan\",\"{APPLY_COMMAND}\"]")), - Some("[]".to_string()), - ]; - for a in &event_values { - for b in &event_values { - let mut parts = vec![]; - if let Some(v) = a { - parts.push(format!("\"post-install-cmd\":{v}")); - } - if let Some(v) = b { - parts.push(format!("\"post-update-cmd\":{v}")); - } - let json = format!("{{\"scripts\":{{{}}}}}", parts.join(",")); - - // add is idempotent - let after_add = match composer_add(&json).unwrap() { - Some(out) => { - assert!( - is_hook_present(&out), - "add changed but not present:\n{json}\n{out}" - ); - assert!( - composer_add(&out).unwrap().is_none(), - "add NOT idempotent:\n{json}\n{out}" - ); - out - } - None => json.clone(), - }; - - // after a full add, both events must carry our command - if composer_add(&json).unwrap().is_some() { - assert!(is_hook_present(&after_add)); - } - - // remove undoes add, and remove is idempotent - if let Some(rem) = composer_remove(&after_add).unwrap() { - assert!( - composer_remove(&rem).unwrap().is_none(), - "remove NOT idempotent:\n{after_add}\n{rem}" - ); - } - } - } - } - - #[test] - fn test_add_noops_on_flag_variant_hook() { - // Regression: `setup --check` (is_hook_present) treats any - // `socket-patch apply` variant as configured, and `setup` must agree — - // appending the stock command next to a user-customized flag set would - // run the hook twice on every install. Mirrors the npm backend's - // `script_is_configured` contract (loose marker on both sides). - let customized = "{\"scripts\":{\ - \"post-install-cmd\":[\"socket-patch apply --offline --ecosystems composer\"],\ - \"post-update-cmd\":\"socket-patch apply --offline --ecosystems composer\"}}"; - assert!(is_hook_present(customized), "variant reads as configured"); - assert!( - composer_add(customized).unwrap().is_none(), - "add must not duplicate a hook --check already reports as configured" - ); - } - - // ── atomic-write contract (no truncation / no stage litter) ────── - // - // The edit must go through stage+fsync+rename, never a bare truncating - // write, so a crash can't leave the user's committed composer.json empty. - - #[cfg(unix)] - #[tokio::test] - async fn test_add_replaces_readonly_manifest_atomically() { - use std::os::unix::fs::PermissionsExt; - // Oracle for the truncating-write bug: rename needs only directory - // write permission, while a bare `fs::write` must open the target - // itself for writing — so a read-only composer.json distinguishes the - // two (EACCES under truncate, clean replace under stage+rename, same - // as the npm/pypi/cargo/go manifest writers). - 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(0o444)).unwrap(); - - let found = discover_composer_project(dir.path()).await.unwrap(); - let res = add_hook(&found, false).await; - assert_eq!( - res.status, - ComposerSetupStatus::Updated, - "err: {:?}", - res.error - ); - assert!(is_hook_present(&fs::read_to_string(&cj).await.unwrap())); - } - - #[cfg(unix)] - #[tokio::test] - 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(); - let cj = dir.path().join("composer.json"); - fs::write(&cj, BASIC).await.unwrap(); - let found = discover_composer_project(dir.path()).await.unwrap(); - - assert_eq!( - add_hook(&found, false).await.status, - ComposerSetupStatus::Updated - ); - assert_eq!( - remove_hook(&found, false).await.status, - ComposerSetupStatus::Updated - ); - assert_eq!(fs::read_to_string(&cj).await.unwrap(), BASIC); - - // No half-written `.socket-stage-*` sibling left behind. - let mut rd = fs::read_dir(dir.path()).await.unwrap(); - while let Some(entry) = rd.next_entry().await.unwrap() { - let name = entry.file_name().to_string_lossy().into_owned(); - assert!(!name.starts_with(".socket-stage-"), "stage litter: {name}"); - } - } - - #[test] - fn test_remove_event_prune_preserves_sibling_script_order() { - // Regression: with preserve_order, serde_json's `Map::remove` is - // swap_remove — pruning an emptied event key teleported the *last* - // script into its slot, shuffling the user's own scripts. Scenario: - // setup wired the events first, the user appended scripts later. - let inp = format!( - "{{\"scripts\":{{\"post-install-cmd\":[\"{APPLY_COMMAND}\"],\"post-update-cmd\":[\"{APPLY_COMMAND}\"],\"test\":\"phpunit\",\"lint\":\"phpcs\"}}}}" - ); - let removed = composer_remove(&inp).unwrap().unwrap(); - assert!(!is_hook_present(&removed)); - let pos_test = removed.find("\"test\"").unwrap(); - let pos_lint = removed.find("\"lint\"").unwrap(); - assert!( - pos_test < pos_lint, - "sibling script order must survive event pruning:\n{removed}" - ); - } - - #[test] - fn test_remove_scripts_prune_preserves_root_key_order() { - // Regression: same swap_remove hazard at the root — pruning an emptied - // `scripts` object teleported the last root key into its slot. - let inp = format!( - "{{\"name\":\"acme/app\",\"scripts\":{{\"post-install-cmd\":[\"{APPLY_COMMAND}\"]}},\"require\":{{\"php\":\">=8.1\"}},\"autoload\":{{}}}}" - ); - let removed = composer_remove(&inp).unwrap().unwrap(); - assert!(parse(&removed).get("scripts").is_none(), "scripts pruned"); - let pos_name = removed.find("\"name\"").unwrap(); - let pos_require = removed.find("\"require\"").unwrap(); - let pos_autoload = removed.find("\"autoload\"").unwrap(); - assert!( - pos_name < pos_require && pos_require < pos_autoload, - "root key order must survive scripts pruning:\n{removed}" - ); - } - - #[test] - fn test_add_malformed_event_value_is_error_not_silent_success() { - // Regression: an event value that is neither string nor array can't be - // wired (we won't clobber it), but reporting "no change" surfaced as - // AlreadyConfigured — exit-0 success — while `--check` - // (is_hook_present) says not configured on the very same file. Refusal - // must be a loud error, matching the non-object-`scripts` guard. - let malformed = "{\"scripts\":{\"post-install-cmd\":42,\"post-update-cmd\":{\"a\":\"b\"}}}"; - assert!(!is_hook_present(malformed), "nothing configured here"); - let err = composer_add(malformed).unwrap_err(); - assert!( - err.contains("not a string or array"), - "refusal must be an error, got: {err}" - ); - // remove stays a no-op: a non-string/array value can't hold our - // command, so there is honestly nothing to strip. - assert!(composer_remove(malformed).unwrap().is_none()); - } - - /// Plant a FIFO with a direct `mkfifo(2)` syscall — same helper as the - /// find.rs / package_json FIFO tests: fork/exec flakes under heavy - /// parallel load and the syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - /// A FIFO planted as `composer.json` must not wedge setup. Discovery - /// accepts any path whose metadata stats (a FIFO does), then setup calls - /// `add_hook` on it; a plain `read_to_string` open(2) on a FIFO waits for - /// a writer that never comes, wedging `socket-patch setup` indefinitely - /// with no error and no timeout. Same class as the `open_regular_file` - /// guards in package_json/update.rs, find.rs and the crawlers. - #[cfg(unix)] - #[tokio::test] - async fn test_add_fifo_composer_json_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("composer.json"); - mkfifo(&fifo); - // Discovery lists it (metadata-only gate), so the production path - // reaches the edit's read unopened. - assert!(discover_composer_project(dir.path()).await.is_some()); - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a writer to release - // it so the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout(deadline, add_hook(&fifo, false)).await else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("add_hook must complete promptly with a FIFO composer.json"); - }; - assert_eq!(result.status, ComposerSetupStatus::Error); - assert!(result.error.is_some()); - } - - /// The removal twin: `setup --remove` reads the discovered composer.json - /// through `remove_hook`, so a FIFO wedged it the same way. - #[cfg(unix)] - #[tokio::test] - async fn test_remove_fifo_composer_json_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("composer.json"); - mkfifo(&fifo); - - let deadline = std::time::Duration::from_secs(5); - let Ok(result) = tokio::time::timeout(deadline, remove_hook(&fifo, false)).await else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("remove_hook must complete promptly with a FIFO composer.json"); - }; - assert_eq!(result.status, ComposerSetupStatus::Error); - assert!(result.error.is_some()); - } - - #[test] - fn test_add_then_check_consistency() { - // For every input where add reports a change, is_hook_present must be true. - let inputs = [ - BASIC, - "{\"scripts\":{\"post-install-cmd\":\"@php artisan\"}}", - "{\"scripts\":{\"post-install-cmd\":[\"a\",\"b\"]}}", - "{\"scripts\":{}}", - "{\"scripts\":null}", - "{}", - ]; - for inp in inputs { - if let Some(out) = composer_add(inp).unwrap() { - assert!( - is_hook_present(&out), - "add changed but check false for {inp}\n{out}" - ); - // second add is a no-op - assert!( - composer_add(&out).unwrap().is_none(), - "not idempotent for {inp}" - ); - // remove undoes - let rem = composer_remove(&out).unwrap().unwrap(); - assert!(!is_hook_present(&rem), "remove left hook for {inp}\n{rem}"); - } - } - } -} diff --git a/crates/socket-patch-core/src/setup/gem/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs deleted file mode 100644 index e592dc7b..00000000 --- a/crates/socket-patch-core/src/setup/gem/mod.rs +++ /dev/null @@ -1,2157 +0,0 @@ -//! Gem (Bundler) `setup` support: wire a Ruby project for automatic patching. -//! -//! Bundler loads a declared **plugin** whenever one of its subscribed hook -//! events fires — on every `bundle install`, fresh AND fully cached (verified -//! against bundler 2.7 and 4.0). So setup delivers the gate as a generated, -//! git-committed Bundler plugin plus a `plugin` directive in the Gemfile: -//! -//! * `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}` — a generated -//! plugin whose `plugins.rb` re-runs `socket-patch apply --ecosystems gem` -//! on every `bundle install` (digest-gated load-time + per-gem -//! `after-install` triggers, forced `after-install-all` re-apply). A patch -//! failure warns with a remediation and lets the install continue — -//! bundler evaluates `plugins.rb` at plugin REGISTRATION, before any -//! project gem is installed, so raising there would deadlock a fresh -//! clone on its own first `bundle install` (plugin registration fails and -//! every retry fails identically). `SOCKET_PATCH_STRICT=1` restores -//! raise-on-failure (`Bundler::BundlerError`); -//! * a managed block appended to the `Gemfile` that references the plugin via -//! `plugin "socket-patch", path: File.expand_path(".socket/bundler-plugin", -//! __dir__)`. The source must be `path:` — Bundler fetches `git:` plugin -//! sources with `git clone`, and the generated dir is a plain directory -//! (committing it to the parent repo does not give it a `.git`), so `git:` -//! fails every `bundle install`. The directory still must be committed so -//! clones and CI have the plugin on disk; -//! * a `.socket/.gitignore` entry for the plugin's digest stamp -//! (`gem-plugin-stamp`) — everything else under `.socket/` is meant to be -//! committed, and an untracked stamp in every wired repo's `git status` -//! invites a `git add .socket` that ships one machine's digest to every -//! clone. -//! -//! The actual gem patching is done by `apply` (unchanged); this module only -//! manages the setup wiring. Phase 2 (follow-up) replaces the in-tree plugin -//! with a published `socket-patch-bundler` gem. - -mod update; -mod version; - -use std::path::{Path, PathBuf}; - -use tokio::fs; - -// Guarded read for every raw read in this module tree: a FIFO planted at any -// path setup reads (`plugins.rb`, the gemspec, `.socket/.gitignore`, -// bundler's plugin index, `Gemfile.lock`) fails fast with `InvalidInput` -// instead of wedging `setup`/`--check`/`--remove` forever in an `open(2)` -// that waits for a writer. -use crate::utils::fs::read_regular_to_string; - -pub use update::{ - add_plugin_directive, add_plugin_directive_with, is_plugin_directive_present, - remove_plugin_directive, GemEditResult, GemSetupStatus, -}; -pub use version::{probe_bundler, unsupported_bundler_message, BundlerProbe, MIN_BUNDLER}; - -/// The in-tree plugin directory, relative to the project root. -const PLUGIN_DIR: &str = ".socket/bundler-plugin"; -/// First line of every generated plugin file — the ownership signal for removal -/// (we never delete a file that lacks it). -const GENERATED_MARKER: &str = "# Code generated by `socket-patch setup`. DO NOT EDIT."; -/// The generated plugin's digest stamp, relative to the project root -/// (machine-local state; the plugin owns its content, setup owns its lifecycle). -const STAMP_REL: &str = ".socket/gem-plugin-stamp"; -/// The `.socket/.gitignore` line that keeps the stamp out of version control. -/// Everything else under `.socket/` is meant to be committed, so without this -/// line every install drops an untracked stamp into `git status` — and a stamp -/// committed by a blanket `git add .socket` ships one machine's digest to -/// every clone. -const STAMP_IGNORE_LINE: &str = "/gem-plugin-stamp"; - -/// The generated `plugins.rb` body (the three-trigger idempotent applier). -const PLUGINS_RB: &str = include_str!("templates/plugins.rb.tmpl"); -/// The generated plugin gemspec. -const GEMSPEC: &str = include_str!("templates/gemspec.tmpl"); - -/// A discovered Bundler project. -#[derive(Debug, Clone)] -pub struct BundlerProject { - /// Directory containing the Gemfile (the project root). The plugin dir and - /// `.socket/manifest.json` live here. - pub root: PathBuf, - /// The Bundler manifest to edit (`Gemfile` or `gems.rb`). - pub gemfile: PathBuf, -} - -/// Find the Bundler project that `cwd` belongs to by walking up to the nearest -/// 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 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 ["gems.rb", "Gemfile"] { - let candidate = dir.join(name); - // Regular files only, like bundler's own gate - // (`SharedHelpers.find_file` accepts a candidate with - // `File.file?`): a directory or FIFO named `Gemfile` is skipped - // and the walk continues to the manifest `bundle` actually - // loads. Bare `metadata` would select it — handing the Gemfile - // editor a directory (setup errors in a project where `bundle - // install` works fine) or a FIFO (whose plain `open(2)` blocks - // forever waiting for a writer). - if fs::metadata(&candidate).await.is_ok_and(|m| m.is_file()) { - return Some(BundlerProject { - root: dir, - gemfile: candidate, - }); - } - } - dir = if matches!( - dir.components().next_back(), - Some(std::path::Component::ParentDir) - ) { - // `Path::parent` is lexical: it strips a trailing `..`, stepping - // the walk back DOWN into the directory the `..` just escaped - // (parent of `a/b/..` is `a/b`) and probing descendants outside - // the real ancestry. Resolve the position against the filesystem — - // the same way the kernel resolved this iteration's metadata - // probes — and continue from its real parent. - fs::canonicalize(&dir).await.ok()?.parent()?.to_path_buf() - } else { - match dir.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(), - // A relative `cwd` (e.g. the CLI's default `--cwd .`) exhausts its - // lexical components here (`Path::parent` of `.` is `Some("")`, - // then `None`) without ever reaching the real parent directories. - // Re-root the walk on the process cwd so the true ancestry is - // still probed — `bundle` resolves the Gemfile from the invocation - // dir's real ancestors, wherever it is run from. - _ if dir.is_relative() => std::env::current_dir() - .ok()? - .join(&dir) - .parent()? - .to_path_buf(), - _ => return None, - } - }; - } -} - -/// Absolute path to the generated plugin directory for a project root. -pub fn plugin_dir(root: &Path) -> PathBuf { - root.join(PLUGIN_DIR) -} - -fn plugins_rb_path(root: &Path) -> PathBuf { - plugin_dir(root).join("plugins.rb") -} - -fn gemspec_path(root: &Path) -> PathBuf { - plugin_dir(root).join("socket-patch.gemspec") -} - -fn stamp_path(root: &Path) -> PathBuf { - root.join(STAMP_REL) -} - -fn stamp_gitignore_path(root: &Path) -> PathBuf { - root.join(".socket").join(".gitignore") -} - -/// Whether `.socket/.gitignore` is missing the stamp entry (so `setup` still -/// has a write to make). Shared by [`add_plugin_files`] and -/// [`plugin_files_present`] to keep `setup` and `--check` in agreement. -async fn stamp_ignore_missing(root: &Path) -> bool { - match read_regular_to_string(&stamp_gitignore_path(root)).await { - Ok(c) => !c.lines().any(|l| l.trim() == STAMP_IGNORE_LINE), - Err(_) => true, - } -} - -/// Append the stamp entry to `.socket/.gitignore`, preserving any existing -/// (user or other-tool) lines. Creates the file when absent. -async fn add_stamp_gitignore(root: &Path) -> Result<(), String> { - let path = stamp_gitignore_path(root); - let existing = match read_regular_to_string(&path).await { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - // An existing-but-unreadable file (non-UTF-8 bytes — gitignore - // entries are byte strings — a FIFO, EACCES) must fail the setup: - // treating it as empty and rewriting would destroy every user line. - Err(e) => return Err(format!("read {}: {e}", path.display())), - }; - let mut body = existing; - if !body.is_empty() && !body.ends_with('\n') { - body.push('\n'); - } - body.push_str(STAMP_IGNORE_LINE); - body.push('\n'); - write_file(&path, &body).await -} - -/// Best-effort cleanup of the stamp artifacts on `--remove`: delete the stamp -/// itself and strip our line from `.socket/.gitignore` (deleting the file when -/// nothing else is left, sparing any other lines). Best-effort by design — a -/// leftover stamp is inert machine-local state, not worth failing an -/// otherwise-successful unwire over. -async fn remove_stamp_artifacts(root: &Path) { - let _ = fs::remove_file(stamp_path(root)).await; - let path = stamp_gitignore_path(root); - let Ok(content) = read_regular_to_string(&path).await else { - return; - }; - let kept: Vec<&str> = content - .lines() - .filter(|l| l.trim() != STAMP_IGNORE_LINE) - .collect(); - if kept.len() == content.lines().count() { - return; - } - if kept.iter().all(|l| l.trim().is_empty()) { - let _ = fs::remove_file(&path).await; - } else { - let _ = fs::write(&path, format!("{}\n", kept.join("\n"))).await; - } -} - -// ───────────────────────────────────────────────────────────────────────── -// Bundler's machine-local plugin registration (`.bundle/plugin/index`) -// ───────────────────────────────────────────────────────────────────────── - -/// The plugin name our Gemfile directive registers — the key/value bundler -/// records in its machine-local plugin index at first install. -const PLUGIN_NAME: &str = "socket-patch"; - -/// Outcome of clearing bundler's machine-local plugin registration on -/// `setup --remove`. -#[derive(Debug)] -pub enum GemRegistrationCleanup { - /// socket-patch entries were found and removed (or would be, on dry-run). - Cleaned { - /// The registration index that held (or holds, on dry-run) them. - index: PathBuf, - }, - /// No socket-patch registration exists — nothing to clean. - NotRegistered, - /// A socket-patch registration is (probably) present but could not be - /// cleared safely; the human remedy must be surfaced. - Residue { - /// The registration index carrying the leftover entries. - index: PathBuf, - /// Why the surgical cleanup refused/failed. - reason: String, - }, -} - -/// Bundler's app-config dir for `root`, following `Bundler.app_config_path` -/// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against -/// the project root, NOT the process cwd), else `/.bundle`. The -/// machine-local plugin registration lives at `/plugin/index`, so -/// getting this resolution wrong means `--remove` cleans (or misses) the -/// wrong directory — e.g. under the official ruby Docker images, which export -/// `BUNDLE_APP_CONFIG=/usr/local/bundle`. -/// -/// `pub(crate)`: the ruby crawler resolves the same dir to honor the app -/// config's `BUNDLE_PATH:` entry when discovering bundler install roots. -pub(crate) fn bundler_app_config_dir(root: &Path, env_value: Option<&std::ffi::OsStr>) -> PathBuf { - match env_value { - Some(v) if !v.is_empty() => { - let p = PathBuf::from(v); - if p.is_absolute() { - p - } else { - root.join(p) - } - } - _ => root.join(".bundle"), - } -} - -/// One `key:` entry of a section in bundler's plugin index, carrying the raw -/// lines it spans so kept entries are rebuilt byte-verbatim. -struct IndexEntry { - /// The entry key (unquoted): a plugin name, or a hook event name. - key: String, - key_line: String, - /// Inline scalar value (`plugin_paths`-style `key: "value"`), unquoted. - value: Option, - /// `- "item"` lines under the key, with their unquoted values. - items: Vec<(String, String)>, -} - -/// One top-level section (`commands` / `hooks` / `load_paths` / -/// `plugin_paths` / `sources`) of the index. -struct IndexSection { - name: String, - header_line: String, - entries: Vec, -} - -/// Strip a matching pair of quotes, mirroring bundler's `YAMLSerializer` -/// loader (its regexes accept an optional `'`/`"` wrapper around values). -fn unquote(s: &str) -> &str { - let s = s.trim(); - let b = s.as_bytes(); - if s.len() >= 2 - && ((b[0] == b'"' && b[s.len() - 1] == b'"') || (b[0] == b'\'' && b[s.len() - 1] == b'\'')) - { - &s[1..s.len() - 1] - } else { - s - } -} - -/// Parse bundler's plugin index into sections/entries, refusing anything -/// outside the exact dialect bundler's `YAMLSerializer` writes (`---`, then -/// two-level `key:` maps with 2-space indents and `- "item"` array lines). -/// The refusal matters more than the acceptance: a hand-edited or -/// future-format index must fall through to the "residue remains" remedy -/// path, never be rewritten on a guess and corrupted. -fn parse_plugin_index(content: &str) -> Result, String> { - let mut sections: Vec = Vec::new(); - for (n, line) in content.lines().enumerate() { - let lineno = n + 1; - if n == 0 && line.trim_end() == "---" { - continue; - } - if let Some(body) = line.strip_prefix(" ") { - // Second level: an array item or an entry key. - if body.starts_with(' ') || body.starts_with('\t') { - return Err(format!("line {lineno}: unexpected indentation")); - } - let section = sections - .last_mut() - .ok_or_else(|| format!("line {lineno}: entry before any section"))?; - if let Some(item) = body.strip_prefix("- ") { - let entry = section - .entries - .last_mut() - .ok_or_else(|| format!("line {lineno}: array item before any key"))?; - entry - .items - .push((line.to_string(), unquote(item).to_string())); - } else if let Some((key, value)) = body.split_once(": ") { - section.entries.push(IndexEntry { - key: unquote(key).to_string(), - key_line: line.to_string(), - value: Some(unquote(value).to_string()), - items: Vec::new(), - }); - } else if let Some(key) = body.strip_suffix(':') { - section.entries.push(IndexEntry { - key: unquote(key).to_string(), - key_line: line.to_string(), - value: None, - items: Vec::new(), - }); - } else { - return Err(format!("line {lineno}: unrecognized entry line")); - } - } else if let Some(name) = line.strip_suffix(':') { - if name.is_empty() || name.contains(' ') || line.starts_with(' ') { - return Err(format!("line {lineno}: unrecognized section line")); - } - sections.push(IndexSection { - name: name.to_string(), - header_line: line.to_string(), - entries: Vec::new(), - }); - } else { - return Err(format!("line {lineno}: unrecognized line")); - } - } - Ok(sections) -} - -/// The result of surgically removing socket-patch from a parsed index. -struct StrippedIndex { - /// The index content with every socket-patch entry removed; kept lines - /// are byte-verbatim. - content: String, - /// Whether any OTHER plugin's registration remains (the index must then - /// survive; an all-empty index is deleted instead). - plugins_remain: bool, - /// The dir bundler recorded as the plugin's install location - /// (`plugin_paths`), if present. - installed_dir: Option, -} - -/// Pure transform: drop every socket-patch entry from the index — its -/// `plugin_paths`/`load_paths` keys, its `hooks` subscriptions (removing an -/// event key left with no subscribers), and any `commands`/`sources` mapping -/// to it — while preserving every other plugin's lines byte-verbatim. -/// `Ok(None)` when nothing of ours is registered; `Err` when the file is not -/// in bundler's dialect (the caller must warn, never write). -fn strip_plugin_registration(content: &str) -> Result, String> { - let mut sections = parse_plugin_index(content)?; - let mut changed = false; - let mut installed_dir = None; - for section in &mut sections { - match section.name.as_str() { - "plugin_paths" | "load_paths" => { - let is_plugin_paths = section.name == "plugin_paths"; - section.entries.retain(|e| { - if e.key != PLUGIN_NAME { - return true; - } - if is_plugin_paths { - if let Some(v) = &e.value { - installed_dir = Some(PathBuf::from(v)); - } - } - changed = true; - false - }); - } - "hooks" => { - section.entries.retain_mut(|e| { - let before = e.items.len(); - e.items.retain(|(_, v)| v != PLUGIN_NAME); - let lost = e.items.len() != before; - changed |= lost; - // Drop an event key we just emptied — bundler never - // writes a subscriber-less event, so leaving one behind - // is not round-trippable. Entries empty on arrival are - // not ours to judge and stay. - !(lost && e.items.is_empty() && e.value.is_none()) - }); - } - "commands" | "sources" => { - section.entries.retain(|e| { - if e.value.as_deref() == Some(PLUGIN_NAME) { - changed = true; - false - } else { - true - } - }); - } - // Unknown section (a future bundler's addition): keep verbatim. - _ => {} - } - } - if !changed { - return Ok(None); - } - let plugins_remain = sections.iter().any(|s| !s.entries.is_empty()); - let mut out = String::from("---\n"); - for section in §ions { - out.push_str(§ion.header_line); - out.push('\n'); - for entry in §ion.entries { - out.push_str(&entry.key_line); - out.push('\n'); - for (line, _) in &entry.items { - out.push_str(line); - out.push('\n'); - } - } - } - Ok(Some(StrippedIndex { - content: out, - plugins_remain, - installed_dir, - })) -} - -/// Clear bundler's machine-local plugin registration for socket-patch on -/// `setup --remove` — the `.bundle/plugin/index` entries (hook subscriptions -/// and plugin/load paths) bundler wrote when the wired plugin first installed. -/// `remove_plugin_files` deletes the plugin *source*; without this step the -/// registration dangles and every later `bundle install` prints bundler's -/// "The following plugin paths don't exist ... Continuing without installing -/// plugin socket-patch" block (with a misleading reinstall suggestion) -/// forever. Surgical: only socket-patch entries leave the index; another -/// plugin's registration survives byte-verbatim, and an index in an -/// unexpected format is never rewritten on a guess — that reports -/// [`GemRegistrationCleanup::Residue`] so the caller surfaces the -/// `bundler plugin uninstall socket-patch` remedy instead. -pub async fn remove_plugin_registration(root: &Path, dry_run: bool) -> GemRegistrationCleanup { - let env = std::env::var_os("BUNDLE_APP_CONFIG"); - remove_plugin_registration_at(root, env.as_deref(), dry_run).await -} - -/// [`remove_plugin_registration`] with the `BUNDLE_APP_CONFIG` resolution -/// input made explicit (tests inject it; the public entry reads the process -/// env, exactly like bundler itself). -async fn remove_plugin_registration_at( - root: &Path, - app_config_env: Option<&std::ffi::OsStr>, - dry_run: bool, -) -> GemRegistrationCleanup { - let plugin_root = bundler_app_config_dir(root, app_config_env).join("plugin"); - let index = plugin_root.join("index"); - let content = match read_regular_to_string(&index).await { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return GemRegistrationCleanup::NotRegistered; - } - Err(e) => { - return GemRegistrationCleanup::Residue { - index, - reason: format!("could not read it: {e}"), - }; - } - }; - if !content.contains(PLUGIN_NAME) { - return GemRegistrationCleanup::NotRegistered; - } - let stripped = match strip_plugin_registration(&content) { - Ok(Some(s)) => s, - // Mentioned only incidentally (e.g. inside another plugin's path): - // nothing registered under our name. - Ok(None) => return GemRegistrationCleanup::NotRegistered, - Err(reason) => { - return GemRegistrationCleanup::Residue { - index, - reason: format!("unexpected index format ({reason})"), - }; - } - }; - if dry_run { - return GemRegistrationCleanup::Cleaned { index }; - } - // The dir bundler recorded as the plugin's install location. For our - // `path:`-sourced wiring that is the project's `.socket/bundler-plugin` - // (already deleted by `remove_plugin_files`); a `bundler plugin install`ed - // copy lives under the plugin root itself. Delete it only inside that - // root — never a recorded path elsewhere on the machine. The recorded - // path is attacker-authored input (the index can be committed), and - // `starts_with` compares components lexically, so a `..` traversal like - // `/../../victim` would pass the prefix check while pointing - // anywhere on the machine: reject any `..` component outright (bundler - // never records traversal paths, so nothing legitimate is lost). - if let Some(dir) = &stripped.installed_dir { - let traversal_free = dir - .components() - .all(|c| !matches!(c, std::path::Component::ParentDir)); - if traversal_free && dir.starts_with(&plugin_root) && dir != &plugin_root { - let _ = fs::remove_dir_all(dir).await; - } - } - let write_result = if stripped.plugins_remain { - // Another plugin is still registered: rewrite the index without our - // entries (staged + renamed — a torn index would break EVERY plugin). - crate::utils::fs::atomic_write_bytes_preserving_mode(&index, stripped.content.as_bytes()) - .await - .map_err(|e| format!("could not rewrite it: {e}")) - } else { - // Nothing registered anymore: delete the index outright (bundler - // treats a missing index as empty and regenerates it on demand). - match fs::remove_file(&index).await { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(format!("could not delete it: {e}")), - } - }; - if let Err(reason) = write_result { - return GemRegistrationCleanup::Residue { index, reason }; - } - if !stripped.plugins_remain { - // Prune the now-empty machine-local plugin dir (and an app-config dir - // that held nothing else). `remove_dir` refuses non-empty dirs, so a - // `.bundle/config` or another plugin's `gems/` cache keeps its parent. - let _ = fs::remove_dir(&plugin_root).await; - if let Some(parent) = plugin_root.parent() { - let _ = fs::remove_dir(parent).await; - } - } - GemRegistrationCleanup::Cleaned { index } -} - -/// 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 { - !needs_write(&plugins_rb_path(root), PLUGINS_RB).await - && !needs_write(&gemspec_path(root), GEMSPEC).await - && !stamp_ignore_missing(root).await -} - -/// True if the file is absent or its content differs from `desired`. -async fn needs_write(path: &Path, desired: &str) -> bool { - match read_regular_to_string(path).await { - Ok(c) => c != desired, - Err(_) => true, - } -} - -async fn write_file(path: &Path, body: &str) -> Result<(), String> { - if let Some(p) = path.parent() { - fs::create_dir_all(p) - .await - .map_err(|e| format!("create {}: {e}", p.display()))?; - } - // Stage + rename (mode-preserving): never opens the destination, so a - // FIFO squatting on a generated path is replaced, not opened (a plain - // `fs::write` would block in `open(2)` waiting for a FIFO reader that - // never comes), and a crash mid-write cannot leave a torn half-template - // in the committed `.socket/` dir. - crate::utils::fs::atomic_write_bytes_preserving_mode(path, body.as_bytes()) - .await - .map_err(|e| format!("write {}: {e}", path.display())) -} - -/// Generate `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}` and -/// make sure `.socket/.gitignore` keeps the plugin's digest stamp untracked. -/// Idempotent: `AlreadyConfigured` when everything already matches. `kind = -/// "gem_plugin"`. -async fn add_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { - let dir = plugin_dir(root); - let result = async { - let rb_changed = needs_write(&plugins_rb_path(root), PLUGINS_RB).await; - let spec_changed = needs_write(&gemspec_path(root), GEMSPEC).await; - let ignore_missing = stamp_ignore_missing(root).await; - if !rb_changed && !spec_changed && !ignore_missing { - return Ok(false); - } - if !dry_run { - if rb_changed { - write_file(&plugins_rb_path(root), PLUGINS_RB).await?; - } - if spec_changed { - write_file(&gemspec_path(root), GEMSPEC).await?; - } - if ignore_missing { - add_stamp_gitignore(root).await?; - } - } - Ok(true) - } - .await; - GemEditResult::from_result("gem_plugin", dir.display().to_string(), result) -} - -/// Whether the file at `path` carries our [`GENERATED_MARKER`] as its first -/// line — the per-file ownership test for removal. -async fn is_generated(path: &Path) -> bool { - match read_regular_to_string(path).await { - Ok(content) => content.starts_with(GENERATED_MARKER), - Err(_) => false, - } -} - -/// Delete a generated file, tolerating it already being gone but surfacing -/// any other failure (a swallowed error here would report a false "removed"). -async fn remove_generated(path: &Path) -> Result<(), String> { - match fs::remove_file(path).await { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(format!("remove {}: {e}", path.display())), - } -} - -/// Remove the generated plugin files — each only when it carries our -/// [`GENERATED_MARKER`], so a user-authored file at either path is never -/// deleted (and an orphaned generated file is still cleaned up) — plus the -/// plugin's digest stamp and its `.socket/.gitignore` entry (the plugin that -/// maintained them is being unwired; leaving the stamp behind would orphan it -/// in the otherwise-committed `.socket/`). Idempotent: `AlreadyConfigured` -/// when nothing of ours is there. -async fn remove_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { - let dir = plugin_dir(root); - let result = async { - let rb_ours = is_generated(&plugins_rb_path(root)).await; - let spec_ours = is_generated(&gemspec_path(root)).await; - if !rb_ours && !spec_ours { - return Ok(false); - } - if !dry_run { - if rb_ours { - remove_generated(&plugins_rb_path(root)).await?; - } - if spec_ours { - remove_generated(&gemspec_path(root)).await?; - } - remove_stamp_artifacts(root).await; - // Prune the now-empty plugin dir, then `.socket/` itself when - // nothing else — manifest, blobs, vendor tree, a user - // `.gitignore` — is left in it: `remove_dir` refuses a non-empty - // dir, and every writer recreates `.socket/` on demand. Never - // `remove_dir_all`. - let _ = fs::remove_dir(&dir).await; - let _ = fs::remove_dir(root.join(".socket")).await; - } - Ok(true) - } - .await; - GemEditResult::from_result("gem_plugin", dir.display().to_string(), result) -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn write(path: &Path, body: &str) { - if let Some(p) = path.parent() { - fs::create_dir_all(p).await.unwrap(); - } - fs::write(path, body).await.unwrap(); - } - - #[tokio::test] - async fn test_discover_finds_gemfile() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("Gemfile"), "source 'https://rubygems.org'\n").await; - let proj = discover_bundler_project(root).await.unwrap(); - assert_eq!(proj.root, root); - assert_eq!(proj.gemfile, root.join("Gemfile")); - } - - #[tokio::test] - async fn test_discover_finds_gems_rb() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("gems.rb"), "source 'https://rubygems.org'\n").await; - let proj = discover_bundler_project(root).await.unwrap(); - assert_eq!(proj.gemfile, root.join("gems.rb")); - } - - #[tokio::test] - async fn test_discover_none_for_lock_only() { - let dir = tempfile::tempdir().unwrap(); - write(&dir.path().join("Gemfile.lock"), "GEM\n").await; - assert!(discover_bundler_project(dir.path()).await.is_none()); - } - - #[tokio::test] - async fn test_discover_none_without_gemfile() { - let dir = tempfile::tempdir().unwrap(); - assert!(discover_bundler_project(dir.path()).await.is_none()); - } - - #[tokio::test] - async fn test_discover_walks_up_from_subdirectory() { - // A Gemfile at the project root, `setup` invoked from a nested subdir — - // `bundle` resolves the Gemfile by walking up, so discovery must too, - // and the project root must be the Gemfile's dir (where `.socket/` and - // the plugin dir live), NOT the invocation cwd. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("Gemfile"), "source 'https://rubygems.org'\n").await; - let nested = root.join("lib").join("widgets"); - fs::create_dir_all(&nested).await.unwrap(); - - let proj = discover_bundler_project(&nested) - .await - .expect("Bundler project must be found from a subdirectory"); - assert_eq!(proj.root, root, "root is the Gemfile's dir, not the cwd"); - assert_eq!(proj.gemfile, root.join("Gemfile")); - // The plugin dir resolves under the real root, not the subdir. - assert_eq!(plugin_dir(&proj.root), root.join(PLUGIN_DIR)); - } - - #[tokio::test] - async fn test_discover_walks_up_finds_gems_rb() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("gems.rb"), "source 'https://rubygems.org'\n").await; - let nested = root.join("app"); - fs::create_dir_all(&nested).await.unwrap(); - let proj = discover_bundler_project(&nested).await.unwrap(); - assert_eq!(proj.root, root); - assert_eq!(proj.gemfile, root.join("gems.rb")); - } - - #[tokio::test] - 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("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] - async fn test_discover_returns_nearest_ancestor_gemfile() { - // Two Gemfiles in the ancestry: the NEAREST one (the inner project) - // must win, never a far-up parent. - let dir = tempfile::tempdir().unwrap(); - let outer = dir.path(); - write(&outer.join("Gemfile"), "outer\n").await; - let inner = outer.join("subproject"); - fs::create_dir_all(&inner).await.unwrap(); - write(&inner.join("Gemfile"), "inner\n").await; - let nested = inner.join("lib"); - fs::create_dir_all(&nested).await.unwrap(); - - let proj = discover_bundler_project(&nested).await.unwrap(); - assert_eq!(proj.root, inner, "nearest ancestor Gemfile wins"); - assert_eq!(fs::read_to_string(&proj.gemfile).await.unwrap(), "inner\n"); - } - - #[tokio::test] - async fn test_discover_dot_dot_cwd_walks_real_ancestry_not_back_down() { - // `--cwd`/SOCKET_CWD may carry `..` components (`--cwd ..`, `$PWD/..`). - // The metadata probes resolve them against the real filesystem, but - // `Path::parent` strips components lexically — the parent of - // `up/mid/b/..` is `up/mid/b`, the very directory the `..` just - // escaped. The walk must continue UP the real ancestry - // (`up/mid` → `up`), never back down into a descendant that `bundle` - // itself would not consult. - let dir = tempfile::tempdir().unwrap(); - // Canonicalize so the expected root compares path-exactly (macOS - // tempdirs live behind the /var → /private/var symlink). - let base = std::fs::canonicalize(dir.path()).unwrap(); - let up = base.join("up"); - let b = up.join("mid").join("b"); - fs::create_dir_all(&b).await.unwrap(); - write(&up.join("Gemfile"), "real ancestor\n").await; - write(&b.join("Gemfile"), "descendant, not in the ancestry\n").await; - - let proj = discover_bundler_project(&b.join("..")).await.unwrap(); - assert_eq!( - proj.root, up, - "walk from up/mid (= up/mid/b/..) must reach the ancestor `up`, \ - not fall back down into up/mid/b" - ); - assert_eq!( - fs::read_to_string(&proj.gemfile).await.unwrap(), - "real ancestor\n" - ); - } - - #[test] - fn test_templates_are_well_formed() { - // The plugin must carry the ownership marker and all three triggers. - assert!(PLUGINS_RB.starts_with(GENERATED_MARKER)); - assert!(PLUGINS_RB.contains("def apply!")); - // Load-time trigger + the per-gem after-install hook (the only event - // bundler fires during `bundle pristine`) + after-install-all hook. - assert!(PLUGINS_RB.contains("SocketPatch.apply!")); - assert!(PLUGINS_RB.contains("Bundler::Plugin.add_hook(\"after-install\")")); - assert!(PLUGINS_RB.contains("Bundler::Plugin.add_hook(\"after-install-all\")")); - // The applier shells the gem-scoped offline apply. - assert!(PLUGINS_RB.contains("\"apply\"")); - assert!(PLUGINS_RB.contains("\"--ecosystems\", \"gem\", \"--offline\"")); - // Digest folds in Gemfile.lock + the manifest + the on-disk state of - // the patch target files (so an out-of-band reversion is detected). - assert!(PLUGINS_RB.contains("Gemfile.lock")); - assert!(PLUGINS_RB.contains("manifest.json")); - assert!(PLUGINS_RB.contains("def patch_target_files")); - // The platform-gem wildcard must glob a forward-slash base: Dir.glob - // treats `\` as an escape on every platform, so a Windows bundle path - // (backslash separators) would otherwise never match platform installs - // and they would silently drop out of the digest. - assert!(PLUGINS_RB.contains(r#"glob_gems_dir = gems_dir.tr("\\", "/")"#)); - // The gemspec names the plugin the Gemfile directive references. - assert!(GEMSPEC.starts_with(GENERATED_MARKER)); - assert!(GEMSPEC.contains("\"socket-patch\"")); - assert!(GEMSPEC.contains("plugins.rb")); - // The flat plugin dir has no lib/; without this override Bundler - // refuses to load the plugin ("plugin paths don't exist: .../lib") - // and silently continues without it. - assert!(GEMSPEC.contains("s.require_paths = [\".\"]")); - } - - #[test] - fn test_plugin_template_failure_policy_and_stamp_location() { - // Failure policy: tolerant by default — a patch failure WARNS (with - // the manual-apply remediation) and lets `bundle install` continue. - // Bundler evaluates plugins.rb at plugin REGISTRATION, before any - // project gem is installed; a raise there deadlocks a fresh clone on - // its own first `bundle install` (plugin registration fails, every - // retry fails identically — reproduced against bundler 2.7 and 4.0). - assert!( - PLUGINS_RB.contains("def report_failure"), - "the applier must route failures through the tolerant reporter" - ); - assert!( - !PLUGINS_RB.contains("def fail!"), - "the unconditional raise helper must be gone — it is what \ - deadlocked bootstrap installs" - ); - assert!( - PLUGINS_RB.contains("warn(message)"), - "tolerant mode must surface the failure as a stderr warning" - ); - assert!( - PLUGINS_RB.contains("socket-patch apply --ecosystems gem"), - "the warning must name the manual remediation command" - ); - // Strict escape hatch: SOCKET_PATCH_STRICT=1 restores raise-on-failure. - assert!(PLUGINS_RB.contains("SOCKET_PATCH_STRICT")); - assert!( - PLUGINS_RB.contains("BundlerError"), - "strict mode must still raise Bundler::BundlerError" - ); - // The strict raise must carry a strict trailer, not the tolerant - // "`bundle install` continues" text (which is false mid-raise). - assert!(PLUGINS_RB.contains("def failure_trailer")); - // Stamp location: project-scoped under .socket/, NOT a fixed-name file - // in Bundler.bundle_path (machine-global with no bundle path - // configured, shared and clobbered across every project on the host). - assert!(PLUGINS_RB.contains("STAMP_NAME = \"gem-plugin-stamp\"")); - assert!(PLUGINS_RB.contains("File.join(socket_dir, STAMP_NAME)")); - // The legacy global stamp is cleaned up, never read. - assert!(PLUGINS_RB.contains("LEGACY_STAMP_NAME = \".socket-patch-gem-stamp\"")); - assert!(PLUGINS_RB.contains("def remove_legacy_stamp")); - // The stamp must be excluded from its own digest inputs, or every - // write would invalidate the digest it records. - assert!(PLUGINS_RB.contains("p != stamp_path")); - // The bootstrap gate keys on the patch targets existing on disk, NEVER - // on the stamp: `.socket/` is a committed directory, so a stale stamp - // that reaches version control would otherwise pass the gate at plugin - // REGISTRATION on a fresh clone and resurrect the strict-mode - // bootstrap deadlock this plugin exists to avoid. - assert!(PLUGINS_RB.contains("bootstrap_gate && patch_target_files.none?")); - assert!( - !PLUGINS_RB.contains("require_stamp"), - "the stamp-existence gate must be gone — a committed stamp made it \ - a remote-controlled registration deadlock" - ); - - // The published-gem twin must carry the same applier contract. - let published = include_str!("../../../../../gem/socket-patch-bundler/plugins.rb"); - for needle in [ - "def report_failure", - "def failure_trailer", - "SOCKET_PATCH_STRICT", - "STAMP_NAME = \"gem-plugin-stamp\"", - "def remove_legacy_stamp", - "def patch_target_files", - r#"glob_gems_dir = gems_dir.tr("\\", "/")"#, - "bootstrap_gate && patch_target_files.none?", - "Bundler::Plugin.add_hook(\"after-install\")", - "Bundler::Plugin.add_hook(\"after-install-all\")", - ] { - assert!( - published.contains(needle), - "published plugins.rb drifted from the template: missing {needle:?}" - ); - } - assert!(!published.contains("def fail!")); - assert!(!published.contains("require_stamp")); - } - - #[tokio::test] - async fn test_add_then_remove_plugin_files_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let r = add_plugin_files(root, false).await; - assert_eq!(r.status, GemSetupStatus::Updated); - assert!(plugin_files_present(root).await); - assert_eq!( - fs::read_to_string(plugins_rb_path(root)).await.unwrap(), - PLUGINS_RB - ); - // The stamp is gitignored: everything else under .socket/ is committed, - // so without this line every install litters `git status` and a blanket - // `git add .socket` commits one machine's stamp to every clone. - assert_eq!( - fs::read_to_string(stamp_gitignore_path(root)) - .await - .unwrap(), - "/gem-plugin-stamp\n" - ); - // Idempotent. - assert_eq!( - add_plugin_files(root, false).await.status, - GemSetupStatus::AlreadyConfigured - ); - // Remove — including the stamp a previous apply left behind, which - // would otherwise be orphaned in the committed .socket/ dir. - fs::write(stamp_path(root), "f".repeat(64)).await.unwrap(); - let rr = remove_plugin_files(root, false).await; - assert_eq!(rr.status, GemSetupStatus::Updated); - assert!(!plugin_files_present(root).await); - assert!(!plugin_dir(root).exists(), "empty plugin dir pruned"); - assert!(!stamp_path(root).exists(), "stamp removed with the plugin"); - assert!( - !stamp_gitignore_path(root).exists(), - "the .gitignore we created (nothing but our line) is removed too" - ); - assert!( - !root.join(".socket").exists(), - "an emptied .socket/ is pruned: --remove restores the pre-setup tree" - ); - // Remove again → already gone. - assert_eq!( - remove_plugin_files(root, false).await.status, - GemSetupStatus::AlreadyConfigured - ); - } - - #[tokio::test] - async fn test_add_plugin_files_dry_run_writes_nothing() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let r = add_plugin_files(root, true).await; - assert_eq!( - r.status, - GemSetupStatus::Updated, - "dry-run reports the change" - ); - assert!(!plugin_files_present(root).await, "dry-run wrote nothing"); - } - - #[tokio::test] - async fn test_remove_spares_user_authored_dir() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - // A user file at the generated path WITHOUT our marker. - write(&plugins_rb_path(root), "# my own plugin\n").await; - let r = remove_plugin_files(root, false).await; - assert_eq!(r.status, GemSetupStatus::AlreadyConfigured); - assert!( - plugins_rb_path(root).exists(), - "user file must be left alone" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_surfaces_removal_failure_as_error() { - // Deleting a file requires write permission on its directory. With the - // plugin dir read-only, the removes fail — that failure must surface as - // `Error`, not be swallowed into a false "removed" success while the - // files are in fact still there. - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - add_plugin_files(root, false).await; - fs::set_permissions(&plugin_dir(root), std::fs::Permissions::from_mode(0o555)) - .await - .unwrap(); - - let r = remove_plugin_files(root, false).await; - - // Restore so the tempdir can be cleaned up regardless of the outcome. - fs::set_permissions(&plugin_dir(root), std::fs::Permissions::from_mode(0o755)) - .await - .unwrap(); - - assert!( - plugin_files_present(root).await, - "the read-only dir means nothing was actually removed" - ); - assert_eq!( - r.status, - GemSetupStatus::Error, - "a failed removal must report Error, not a false success" - ); - assert!( - r.error.is_some(), - "the failure carries the io error message" - ); - } - - #[tokio::test] - async fn test_remove_cleans_orphaned_gemspec() { - // plugins.rb gone (crash between the two removes, or a manual delete) - // but our generated gemspec is still there. Remove must clean the - // orphan — not report not_configured forever while a generated file - // lingers in the repo. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&gemspec_path(root), GEMSPEC).await; - - let r = remove_plugin_files(root, false).await; - assert_eq!( - r.status, - GemSetupStatus::Updated, - "an orphaned generated gemspec is ours to remove" - ); - assert!(!gemspec_path(root).exists(), "orphan gemspec removed"); - assert!(!plugin_dir(root).exists(), "emptied plugin dir pruned"); - } - - #[tokio::test] - async fn test_remove_spares_user_authored_gemspec() { - // Ownership is per file: our plugins.rb is removed, but a user-authored - // (marker-less) file at the gemspec path must never be deleted on the - // strength of plugins.rb's marker alone. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&plugins_rb_path(root), PLUGINS_RB).await; - write(&gemspec_path(root), "# my own gemspec\n").await; - - let r = remove_plugin_files(root, false).await; - assert_eq!(r.status, GemSetupStatus::Updated); - assert!(!plugins_rb_path(root).exists(), "our plugins.rb removed"); - assert!( - gemspec_path(root).exists(), - "marker-less user file at the gemspec path must be left alone" - ); - assert_eq!( - fs::read_to_string(gemspec_path(root)).await.unwrap(), - "# my own gemspec\n" - ); - } - - #[tokio::test] - async fn test_add_plugin_files_writes_each_template_to_its_own_path() { - // Guards the path↔content mapping: plugins.rb must get PLUGINS_RB and the - // gemspec must get GEMSPEC (not swapped). A swap would leave each file - // failing its own `starts_with(GENERATED_MARKER)` content expectations. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - add_plugin_files(root, false).await; - assert_eq!( - fs::read_to_string(plugins_rb_path(root)).await.unwrap(), - PLUGINS_RB, - "plugins.rb must receive the plugins.rb template" - ); - assert_eq!( - fs::read_to_string(gemspec_path(root)).await.unwrap(), - GEMSPEC, - "gemspec must receive the gemspec template" - ); - } - - #[tokio::test] - async fn test_add_plugin_files_rewrites_stale_content() { - // A drifted (hand-edited or older-version) plugins.rb must be re-synced to - // the current template, and the call must report `Updated` — not silently - // accept the stale bytes as already-configured. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &plugins_rb_path(root), - "# Code generated by stale\nold body\n", - ) - .await; - write(&gemspec_path(root), GEMSPEC).await; - let r = add_plugin_files(root, false).await; - assert_eq!( - r.status, - GemSetupStatus::Updated, - "stale plugins.rb is re-synced" - ); - assert_eq!( - fs::read_to_string(plugins_rb_path(root)).await.unwrap(), - PLUGINS_RB - ); - } - - #[tokio::test] - async fn test_add_plugin_files_syncs_only_the_drifted_file() { - // plugins.rb already matches; only the gemspec drifted. The call rewrites - // the gemspec, reports Updated, and leaves the matching plugins.rb intact. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&plugins_rb_path(root), PLUGINS_RB).await; - write(&gemspec_path(root), "# drifted gemspec\n").await; - let r = add_plugin_files(root, false).await; - assert_eq!(r.status, GemSetupStatus::Updated); - assert_eq!( - fs::read_to_string(gemspec_path(root)).await.unwrap(), - GEMSPEC - ); - assert_eq!( - fs::read_to_string(plugins_rb_path(root)).await.unwrap(), - PLUGINS_RB - ); - } - - #[tokio::test] - async fn test_remove_plugin_files_dry_run_keeps_files() { - // Dry-run remove reports the change but must not delete anything. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - add_plugin_files(root, false).await; - let r = remove_plugin_files(root, true).await; - assert_eq!( - r.status, - GemSetupStatus::Updated, - "dry-run reports the removal" - ); - assert!( - plugin_files_present(root).await, - "dry-run remove must not delete the plugin files" - ); - } - - #[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_all_three() { - // The "configured" signal must demand BOTH generated files AND the - // stamp's .gitignore entry, not any subset. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&plugins_rb_path(root), PLUGINS_RB).await; - assert!( - !plugin_files_present(root).await, - "plugins.rb alone is not 'configured' — the gemspec is required too" - ); - write(&gemspec_path(root), GEMSPEC).await; - assert!( - !plugin_files_present(root).await, - "check and setup must agree: setup would still write the stamp's \ - .gitignore entry, so this is not 'configured' yet" - ); - assert_eq!( - add_plugin_files(root, true).await.status, - GemSetupStatus::Updated, - "setup agrees it still has the .gitignore write to make" - ); - write(&stamp_gitignore_path(root), "/gem-plugin-stamp\n").await; - assert!(plugin_files_present(root).await); - } - - #[tokio::test] - async fn test_stamp_gitignore_add_and_remove_spare_user_lines() { - // A user's own .socket/.gitignore entries must survive both the add - // (line appended, not the file clobbered) and the remove (only our - // line stripped, file kept). - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&stamp_gitignore_path(root), "my-scratch-dir/\n").await; - - add_plugin_files(root, false).await; - assert_eq!( - fs::read_to_string(stamp_gitignore_path(root)) - .await - .unwrap(), - "my-scratch-dir/\n/gem-plugin-stamp\n", - "our line is appended after the user's" - ); - - remove_plugin_files(root, false).await; - assert_eq!( - fs::read_to_string(stamp_gitignore_path(root)) - .await - .unwrap(), - "my-scratch-dir/\n", - "only our line is stripped; the user's file survives" - ); - } - - // ── bundler machine-local plugin registration cleanup ──────────── - - /// The exact index bundler 4.0.18 wrote in the 2026-08 e2e campaign - /// repro (agent-b4/eject-setup-remove), path root substituted. - fn solo_index(root: &str) -> String { - format!( - "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n \ - after-install-all:\n - \"socket-patch\"\nload_paths:\n socket-patch:\n \ - - \"{root}/.socket/bundler-plugin/.\"\nplugin_paths:\n \ - socket-patch: \"{root}/.socket/bundler-plugin\"\nsources:\n" - ) - } - - #[test] - fn test_strip_registration_solo_plugin_empties_index() { - let stripped = strip_plugin_registration(&solo_index("/proj")) - .expect("bundler's own dump must parse") - .expect("socket-patch entries must be found"); - assert!( - !stripped.plugins_remain, - "socket-patch was the only plugin — nothing may remain" - ); - assert!( - !stripped.content.contains(PLUGIN_NAME), - "no socket-patch line may survive:\n{}", - stripped.content - ); - assert_eq!( - stripped.installed_dir.as_deref(), - Some(Path::new("/proj/.socket/bundler-plugin")), - "the recorded install dir is surfaced for containment-gated deletion" - ); - // The emptied hook events are dropped with their subscribers. - assert!(!stripped.content.contains("after-install")); - } - - #[test] - fn test_strip_registration_preserves_other_plugins_verbatim() { - let index = "---\ncommands:\n mycmd: \"other-plugin\"\nhooks:\n after-install:\n \ - - \"other-plugin\"\n - \"socket-patch\"\n after-install-all:\n \ - - \"socket-patch\"\nload_paths:\n other-plugin:\n - \"/x/other/.\"\n \ - socket-patch:\n - \"/proj/.socket/bundler-plugin/.\"\nplugin_paths:\n \ - other-plugin: \"/x/other\"\n socket-patch: \"/proj/.socket/bundler-plugin\"\nsources:\n"; - let stripped = strip_plugin_registration(index) - .expect("parses") - .expect("has our entries"); - assert!(stripped.plugins_remain, "other-plugin is still registered"); - assert_eq!( - stripped.content, - "---\ncommands:\n mycmd: \"other-plugin\"\nhooks:\n after-install:\n \ - - \"other-plugin\"\nload_paths:\n other-plugin:\n - \"/x/other/.\"\n\ - plugin_paths:\n other-plugin: \"/x/other\"\nsources:\n", - "only socket-patch lines leave; every kept line is byte-verbatim, \ - and the after-install-all event we emptied is dropped" - ); - } - - #[test] - fn test_strip_registration_none_when_not_ours() { - // Another plugin whose PATH merely mentions socket-patch: nothing - // registered under our name — nothing to strip, nothing to write. - let index = "---\ncommands:\nhooks:\n after-install:\n - \"other\"\nload_paths:\n \ - other:\n - \"/mono/socket-patch-fork/other/.\"\nplugin_paths:\n \ - other: \"/mono/socket-patch-fork/other\"\nsources:\n"; - assert!(strip_plugin_registration(index).expect("parses").is_none()); - } - - #[test] - fn test_strip_registration_refuses_unknown_shape() { - // Psych-style deeper nesting is NOT bundler's dialect: refuse rather - // than rewrite on a guess (the caller warns with the remedy). - let psych = "---\nhooks:\n after-install:\n - socket-patch\n"; - assert!(strip_plugin_registration(psych).is_err()); - let garbage = "socket-patch says hi\n"; - assert!(strip_plugin_registration(garbage).is_err()); - } - - #[test] - fn test_bundler_app_config_dir_resolution() { - use std::ffi::OsStr; - let root = Path::new("/proj"); - // Unset / empty → /.bundle. - assert_eq!( - bundler_app_config_dir(root, None), - Path::new("/proj/.bundle") - ); - assert_eq!( - bundler_app_config_dir(root, Some(OsStr::new(""))), - Path::new("/proj/.bundle") - ); - // Relative → resolved against the PROJECT ROOT (Bundler.app_config_path). - assert_eq!( - bundler_app_config_dir(root, Some(OsStr::new("bundle-config"))), - Path::new("/proj/bundle-config") - ); - // Absolute → taken as-is (the official ruby images' /usr/local/bundle). - assert_eq!( - bundler_app_config_dir(root, Some(OsStr::new("/usr/local/bundle"))), - Path::new("/usr/local/bundle") - ); - } - - #[tokio::test] - async fn test_remove_registration_missing_index_is_not_registered() { - let dir = tempfile::tempdir().unwrap(); - assert!(matches!( - remove_plugin_registration_at(dir.path(), None, false).await, - GemRegistrationCleanup::NotRegistered - )); - } - - #[tokio::test] - async fn test_remove_registration_solo_deletes_index_and_prunes_dirs() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - write(&index, &solo_index(&root.display().to_string())).await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!(!index.exists(), "emptied index deleted"); - assert!( - !root.join(".bundle").exists(), - "the plugin dir and an app-config dir holding nothing else are pruned" - ); - } - - #[tokio::test] - async fn test_remove_registration_keeps_nonempty_app_config_dir() { - // A real project's .bundle/ holds a config file too: the index (and - // the emptied plugin dir) go, the user's config survives. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - &root.join(".bundle/config"), - "---\nBUNDLE_PATH: \"vendor/bundle\"\n", - ) - .await; - let index = root.join(".bundle/plugin/index"); - write(&index, &solo_index(&root.display().to_string())).await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!(!root.join(".bundle/plugin").exists(), "plugin dir pruned"); - assert!( - root.join(".bundle/config").is_file(), - "the user's bundler config must survive" - ); - } - - #[tokio::test] - async fn test_remove_registration_rewrites_index_when_others_remain() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - write( - &index, - "---\ncommands:\nhooks:\n after-install:\n - \"other\"\n - \"socket-patch\"\n\ - load_paths:\n other:\n - \"/x/other/.\"\n socket-patch:\n - \"/proj/p/.\"\n\ - plugin_paths:\n other: \"/x/other\"\n socket-patch: \"/proj/p\"\nsources:\n", - ) - .await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - let body = fs::read_to_string(&index).await.unwrap(); - assert!(!body.contains("socket-patch"), "ours gone:\n{body}"); - assert!(body.contains("- \"other\""), "theirs kept:\n{body}"); - } - - #[tokio::test] - async fn test_remove_registration_dry_run_writes_nothing() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - let body = solo_index(&root.display().to_string()); - write(&index, &body).await; - - let r = remove_plugin_registration_at(root, None, true).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert_eq!( - fs::read_to_string(&index).await.unwrap(), - body, - "dry-run must not touch the index" - ); - } - - #[tokio::test] - async fn test_remove_registration_honors_bundle_app_config() { - // Relative BUNDLE_APP_CONFIG resolves against the project root — - // the same rule bundler applies (`Bundler.app_config_path`). - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join("bundle-config/plugin/index"); - write(&index, &solo_index(&root.display().to_string())).await; - // A decoy at the default location must NOT be the one cleaned. - let decoy = root.join(".bundle/plugin/index"); - write(&decoy, &solo_index("/elsewhere")).await; - - let r = - remove_plugin_registration_at(root, Some(std::ffi::OsStr::new("bundle-config")), false) - .await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!(!index.exists(), "the app-config index is the one cleared"); - assert!( - decoy.is_file(), - "the default-location index is out of scope when BUNDLE_APP_CONFIG points elsewhere" - ); - } - - #[tokio::test] - async fn test_remove_registration_unparseable_reports_residue() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - // Mentions socket-patch but is not bundler's dialect: never rewrite - // on a guess — report residue so the caller surfaces the remedy. - let body = "%TAG !u! tag:example\n---\nplugins: [socket-patch]\n"; - write(&index, body).await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Residue { .. }), "{r:?}"); - assert_eq!( - fs::read_to_string(&index).await.unwrap(), - body, - "an unrecognized index must survive byte-identical" - ); - } - - #[tokio::test] - async fn test_remove_registration_deletes_installed_dir_only_inside_plugin_root() { - // A `bundler plugin install`ed copy lives under .bundle/plugin — that - // dir goes. A recorded path OUTSIDE the plugin root (our path-sourced - // project dir, or anything else on the machine) is never touched here. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let inside = root.join(".bundle/plugin/gems/socket-patch-0.1.0"); - fs::create_dir_all(&inside).await.unwrap(); - write(&inside.join("plugins.rb"), "# installed copy\n").await; - let index = root.join(".bundle/plugin/index"); - write( - &index, - &format!( - "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n\ - load_paths:\n socket-patch:\n - \"{0}/.\"\nplugin_paths:\n \ - socket-patch: \"{0}\"\nsources:\n", - inside.display() - ), - ) - .await; - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!(!inside.exists(), "the in-root installed copy is deleted"); - - // Outside the plugin root: left alone. - let outside = root.join("elsewhere/socket-patch"); - fs::create_dir_all(&outside).await.unwrap(); - let index2 = root.join(".bundle/plugin/index"); - write( - &index2, - &format!( - "---\ncommands:\nhooks:\nload_paths:\n socket-patch:\n - \"{0}/.\"\n\ - plugin_paths:\n socket-patch: \"{0}\"\nsources:\n", - outside.display() - ), - ) - .await; - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!( - outside.is_dir(), - "a recorded dir outside the plugin root must never be deleted" - ); - } - - #[tokio::test] - async fn test_remove_registration_rejects_traversal_in_recorded_dir() { - // A committed `.bundle/plugin/index` is attacker-authored input: it - // can record ANY path as the plugin's install dir. `..` components - // let `/../../victim` pass a purely lexical - // `starts_with(plugin_root)` check while pointing outside the plugin - // root — such a dir must never be deleted. Bundler itself never - // records traversal paths, so rejecting them loses nothing. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let victim = root.join("victim"); - fs::create_dir_all(&victim).await.unwrap(); - write(&victim.join("precious.txt"), "do not delete\n").await; - // Lexically under the plugin root, physically the victim dir. - let evil = root.join(".bundle/plugin/../../victim"); - assert!( - evil.starts_with(root.join(".bundle/plugin")), - "precondition: the traversal path passes the lexical prefix check" - ); - write( - &root.join(".bundle/plugin/index"), - &format!( - "---\ncommands:\nhooks:\nload_paths:\n socket-patch:\n - \"{0}/.\"\n\ - plugin_paths:\n socket-patch: \"{0}\"\nsources:\n", - evil.display() - ), - ) - .await; - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!( - victim.join("precious.txt").is_file(), - "a recorded dir with `..` traversal must never be deleted" - ); - } - - #[tokio::test] - async fn test_discover_skips_directory_named_gemfile() { - // Bundler's own resolution (`SharedHelpers.find_file`) gates every - // candidate on `File.file?`, so a DIRECTORY named `Gemfile` is - // skipped and the walk continues to the real ancestor manifest. - // Gating on bare `metadata` (which any directory satisfies) selects - // the directory instead: `setup` then hands the Gemfile editor a - // directory and errors out, in a project where `bundle install` - // works fine off the ancestor. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("Gemfile"), "real manifest\n").await; - let sub = root.join("sub"); - fs::create_dir_all(sub.join("Gemfile")).await.unwrap(); - - let proj = discover_bundler_project(&sub).await.unwrap(); - assert_eq!(proj.root, root, "a dir named Gemfile is not a manifest"); - assert_eq!(proj.gemfile, root.join("Gemfile")); - } - - /// Plant a FIFO with a direct `mkfifo(2)` syscall — same helper as the - /// composer/find.rs/package_json FIFO tests: fork/exec flakes under - /// heavy parallel load and the syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_discover_skips_fifo_named_gemfile() { - // A FIFO named Gemfile stats fine but `bundle` ignores it - // (`File.file?` is false); selecting it hands the Gemfile editor a - // path whose plain `open(2)` blocks forever waiting for a writer. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&root.join("Gemfile"), "real manifest\n").await; - let sub = root.join("sub"); - fs::create_dir_all(&sub).await.unwrap(); - mkfifo(&sub.join("Gemfile")); - - let proj = discover_bundler_project(&sub).await.unwrap(); - assert_eq!(proj.gemfile, root.join("Gemfile")); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_check_fifo_plugins_rb_does_not_wedge() { - // A FIFO squatting on the generated plugins.rb path: a plain - // `read_to_string` open(2) waits for a writer that never comes, - // wedging `setup --check` indefinitely with no error and no timeout. - // Same class as the composer/find.rs `open_regular_file` guards. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir_all(plugin_dir(root)).await.unwrap(); - mkfifo(&plugins_rb_path(root)); - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a writer to release - // it so the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(present) = tokio::time::timeout(deadline, plugin_files_present(root)).await else { - let _ = std::fs::OpenOptions::new() - .write(true) - .open(plugins_rb_path(root)); - panic!("plugin_files_present must complete promptly with a FIFO plugins.rb"); - }; - assert!(!present, "a FIFO is not the generated plugin"); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_add_fifo_plugins_rb_does_not_wedge_and_replaces_it() { - // `setup` on the same layout: the read must not wedge, and the write - // must replace the FIFO via rename (a plain `fs::write` would block - // in open(2) waiting for a FIFO reader) — the path is generated - // territory, so anything squatting there is stale content to sync. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir_all(plugin_dir(root)).await.unwrap(); - mkfifo(&plugins_rb_path(root)); - - let deadline = std::time::Duration::from_secs(5); - let Ok(r) = tokio::time::timeout(deadline, add_plugin_files(root, false)).await else { - let _ = std::fs::OpenOptions::new() - .write(true) - .open(plugins_rb_path(root)); - panic!("add_plugin_files must complete promptly with a FIFO plugins.rb"); - }; - assert_eq!(r.status, GemSetupStatus::Updated); - assert_eq!( - fs::read_to_string(plugins_rb_path(root)).await.unwrap(), - PLUGINS_RB, - "the FIFO is replaced by the generated file" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_fifo_plugins_rb_does_not_wedge() { - // `setup --remove` reads the ownership marker; a FIFO must read as - // not-ours (spared) — never wedge the remove. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir_all(plugin_dir(root)).await.unwrap(); - mkfifo(&plugins_rb_path(root)); - - let deadline = std::time::Duration::from_secs(5); - let Ok(r) = tokio::time::timeout(deadline, remove_plugin_files(root, false)).await else { - let _ = std::fs::OpenOptions::new() - .write(true) - .open(plugins_rb_path(root)); - panic!("remove_plugin_files must complete promptly with a FIFO plugins.rb"); - }; - assert_eq!( - r.status, - GemSetupStatus::AlreadyConfigured, - "an unreadable non-regular file is not ours to remove" - ); - assert!( - std::fs::symlink_metadata(plugins_rb_path(root)).is_ok(), - "the FIFO is spared, like any marker-less file" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_registration_fifo_index_reports_residue_not_wedge() { - // A FIFO at bundler's plugin index must surface as Residue (with the - // read failure as the reason), never wedge `setup --remove`. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir_all(root.join(".bundle/plugin")) - .await - .unwrap(); - let index = root.join(".bundle/plugin/index"); - mkfifo(&index); - - let deadline = std::time::Duration::from_secs(5); - let Ok(r) = - tokio::time::timeout(deadline, remove_plugin_registration_at(root, None, false)).await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&index); - panic!("remove_plugin_registration must complete promptly with a FIFO index"); - }; - assert!(matches!(r, GemRegistrationCleanup::Residue { .. }), "{r:?}"); - assert!( - std::fs::symlink_metadata(&index).is_ok(), - "the unreadable index must survive untouched" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_add_fifo_stamp_gitignore_errors_not_wedges() { - // A FIFO at .socket/.gitignore: `setup` reads it twice (the - // needs-write probe and the append). Neither read may wedge, and the - // append must surface an Error rather than clobber the non-regular - // file with a fresh one. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::create_dir_all(root.join(".socket")).await.unwrap(); - let path = stamp_gitignore_path(root); - mkfifo(&path); - - let deadline = std::time::Duration::from_secs(5); - let Ok(r) = tokio::time::timeout(deadline, add_plugin_files(root, false)).await else { - let _ = std::fs::OpenOptions::new().write(true).open(&path); - panic!("add_plugin_files must complete promptly with a FIFO .gitignore"); - }; - assert_eq!(r.status, GemSetupStatus::Error); - assert!( - !std::fs::metadata(&path).unwrap().is_file(), - "the FIFO must not be replaced by a regular file" - ); - } - - #[tokio::test] - async fn test_add_never_clobbers_unreadable_stamp_gitignore() { - // gitignore entries are byte strings — a `.socket/.gitignore` with - // non-UTF-8 path bytes is legal, and `read_to_string` fails on it. - // Treating that failure as "empty file" and rewriting would destroy - // every user line: the add must surface an Error and leave the file - // byte-identical. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let path = stamp_gitignore_path(root); - fs::create_dir_all(path.parent().unwrap()).await.unwrap(); - let user_bytes: &[u8] = b"caf\xe9-scratch/\n"; - fs::write(&path, user_bytes).await.unwrap(); - - let r = add_plugin_files(root, false).await; - assert_eq!( - fs::read(&path).await.unwrap(), - user_bytes, - "the user's unreadable .gitignore must survive byte-identical" - ); - assert_eq!( - r.status, - GemSetupStatus::Error, - "an unreadable existing .gitignore is an error, not a silent clobber" - ); - } - - #[tokio::test] - async fn test_add_appends_gitignore_line_to_unterminated_file() { - // A .gitignore whose last line has no trailing newline must not have - // our entry glued onto it ("vendor//gem-plugin-stamp" ignores nothing). - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write(&stamp_gitignore_path(root), "vendor/").await; - add_plugin_files(root, false).await; - assert_eq!( - fs::read_to_string(stamp_gitignore_path(root)) - .await - .unwrap(), - "vendor/\n/gem-plugin-stamp\n" - ); - assert!(plugin_files_present(root).await); - } - - #[test] - fn test_strip_registration_removes_commands_and_sources_entries_keeps_unknown_section() { - // Bundler records command-declaring plugins under `commands` and - // source-declaring ones under `sources` — both map ENTRY VALUE → - // plugin name, so ours must be dropped by value while a foreign - // command survives byte-verbatim. A section this CLI has never heard - // of (a future bundler's addition) must also survive byte-verbatim - // and count toward plugins_remain, so the index is rewritten, never - // deleted out from under it. - let index = "---\ncommands:\n patch: \"socket-patch\"\n mycmd: \"other-plugin\"\n\ - hooks:\n after-install:\n - \"socket-patch\"\nload_paths:\n socket-patch:\n \ - - \"/proj/p/.\"\nplugin_paths:\n socket-patch: \"/proj/p\"\nsources:\n \ - https://example.test: \"socket-patch\"\nfrobs:\n future: \"thing\"\n"; - let stripped = strip_plugin_registration(index) - .expect("parses") - .expect("has our entries"); - assert!( - stripped.plugins_remain, - "the foreign command and the unknown section still hold entries" - ); - assert_eq!( - stripped.installed_dir.as_deref(), - Some(Path::new("/proj/p")) - ); - assert_eq!( - stripped.content, - "---\ncommands:\n mycmd: \"other-plugin\"\nhooks:\nload_paths:\n\ - plugin_paths:\nsources:\nfrobs:\n future: \"thing\"\n", - "our commands/sources entries leave by value; the foreign command \ - and the unknown `frobs` section survive byte-verbatim" - ); - assert!( - !stripped.content.contains("https://example.test"), - "the source mapping to socket-patch must be gone:\n{}", - stripped.content - ); - } - - #[test] - fn test_strip_registration_refuses_unrecognized_entry_and_section_lines() { - // A 2-space-indented line that is neither `- item`, `key: value`, - // nor `key:` is outside bundler's dialect: refuse with the line - // number (the caller surfaces the Residue remedy, never rewrites). - let err = strip_plugin_registration("---\nhooks:\n garbage entry\n") - .err() - .expect("an unrecognized entry line must refuse"); - assert!( - err.contains("line 3") && err.contains("unrecognized entry line"), - "refusal must name the line and the entry-line shape: {err}" - ); - // A single-space-indented header is not a top-level section. - let err = strip_plugin_registration("---\n hooks:\n") - .err() - .expect("a space-indented section line must refuse"); - assert!( - err.contains("line 2") && err.contains("unrecognized section line"), - "refusal must name the line and the section-line shape: {err}" - ); - // An embedded space in a section name is not bundler's dialect either. - let err = strip_plugin_registration("---\nbad section:\n") - .err() - .expect("a section name with a space must refuse"); - assert!( - err.contains("line 2") && err.contains("unrecognized section line"), - "refusal must name the line and the section-line shape: {err}" - ); - // An empty section name (a bare `:` line) is refused too. - let err = strip_plugin_registration("---\n:\n") - .err() - .expect("an empty section name must refuse"); - assert!( - err.contains("line 2") && err.contains("unrecognized section line"), - "refusal must name the line and the section-line shape: {err}" - ); - } - - #[tokio::test] - async fn test_remove_registration_foreign_index_is_not_registered_and_untouched() { - // The common real-world `--remove` state: bundler's index exists but - // registers only OTHER plugins (no "socket-patch" substring anywhere). - // The fast path must report NotRegistered and leave the foreign index - // byte-identical — never parse-and-rewrite it. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - let body = "---\ncommands:\nhooks:\n after-install:\n - \"other-plugin\"\n\ - load_paths:\n other-plugin:\n - \"/x/other/.\"\nplugin_paths:\n \ - other-plugin: \"/x/other\"\nsources:\n"; - write(&index, body).await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::NotRegistered), "{r:?}"); - assert_eq!( - fs::read_to_string(&index).await.unwrap(), - body, - "a foreign index must survive byte-identical" - ); - } - - #[tokio::test] - async fn test_remove_registration_substring_mention_is_not_registered_and_untouched() { - // A plugin NAMED with our name as a substring (socket-patch-extras): - // the coarse `contains(PLUGIN_NAME)` gate passes, but the strip - // compares full keys/values, finds nothing of ours, and returns - // Ok(None) → NotRegistered. Guards against false-positive stripping - // of a similarly-named plugin's registration. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - let body = "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch-extras\"\n\ - load_paths:\n socket-patch-extras:\n - \"/x/extras/.\"\nplugin_paths:\n \ - socket-patch-extras: \"/x/extras\"\nsources:\n"; - write(&index, body).await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::NotRegistered), "{r:?}"); - assert_eq!( - fs::read_to_string(&index).await.unwrap(), - body, - "a similarly-named plugin's registration must survive byte-identical" - ); - } - - #[tokio::test] - async fn test_remove_registration_cleans_index_lacking_plugin_paths_entry() { - // A partially hand-cleaned index: our hooks/load_paths entries remain - // but the `plugin_paths` section is empty, so the strip surfaces NO - // installed dir. The cleanup must take the installed_dir-None - // fall-through (nothing to delete under the plugin root) and still - // finish: emptied index deleted, dirs pruned. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let index = root.join(".bundle/plugin/index"); - write( - &index, - "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n\ - load_paths:\n socket-patch:\n - \"/proj/p/.\"\nplugin_paths:\nsources:\n", - ) - .await; - - let r = remove_plugin_registration_at(root, None, false).await; - assert!(matches!(r, GemRegistrationCleanup::Cleaned { .. }), "{r:?}"); - assert!(!index.exists(), "emptied index deleted"); - assert!( - !root.join(".bundle").exists(), - "plugin dir and empty app-config dir pruned even with no recorded install dir" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_registration_readonly_parent_delete_failure_is_residue() { - // Deleting the index requires write permission on `.bundle/plugin`. - // With that dir read-only the solo-plugin delete arm fails (EACCES), - // and the failure must surface as Residue carrying the delete reason - // (the caller turns it into the `bundler plugin uninstall` remedy) — - // never a false Cleaned while the index is in fact still there. - use std::os::unix::fs::PermissionsExt; - - if unsafe { libc::geteuid() } == 0 { - return; // root ignores mode bits — the delete cannot fail - } - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let plugin_root = root.join(".bundle/plugin"); - let index = plugin_root.join("index"); - let body = solo_index(&root.display().to_string()); - write(&index, &body).await; - fs::set_permissions(&plugin_root, std::fs::Permissions::from_mode(0o555)) - .await - .unwrap(); - - let r = remove_plugin_registration_at(root, None, false).await; - - // Restore so the tempdir can be cleaned up regardless of the outcome. - fs::set_permissions(&plugin_root, std::fs::Permissions::from_mode(0o755)) - .await - .unwrap(); - - match r { - GemRegistrationCleanup::Residue { reason, .. } => assert!( - reason.contains("could not delete it"), - "the delete failure must be the surfaced reason: {reason}" - ), - other => panic!("a failed index delete must report Residue, got {other:?}"), - } - assert_eq!( - fs::read_to_string(&index).await.unwrap(), - body, - "the index the delete could not remove must survive byte-identical" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_registration_readonly_parent_rewrite_failure_is_residue() { - // Same read-only parent, but another plugin remains registered: the - // rewrite arm stages a temp file next to the index, which the - // read-only dir refuses — Residue with the rewrite reason, and the - // multi-plugin index survives byte-identical (a torn or half-cleaned - // index would break EVERY plugin). - use std::os::unix::fs::PermissionsExt; - - if unsafe { libc::geteuid() } == 0 { - return; // root ignores mode bits — the rewrite cannot fail - } - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let plugin_root = root.join(".bundle/plugin"); - let index = plugin_root.join("index"); - let body = - "---\ncommands:\nhooks:\n after-install:\n - \"other\"\n - \"socket-patch\"\n\ - load_paths:\n other:\n - \"/x/other/.\"\n socket-patch:\n - \"/proj/p/.\"\n\ - plugin_paths:\n other: \"/x/other\"\n socket-patch: \"/proj/p\"\nsources:\n"; - write(&index, body).await; - fs::set_permissions(&plugin_root, std::fs::Permissions::from_mode(0o555)) - .await - .unwrap(); - - let r = remove_plugin_registration_at(root, None, false).await; - - fs::set_permissions(&plugin_root, std::fs::Permissions::from_mode(0o755)) - .await - .unwrap(); - - match r { - GemRegistrationCleanup::Residue { reason, .. } => assert!( - reason.contains("could not rewrite it"), - "the rewrite failure must be the surfaced reason: {reason}" - ), - other => panic!("a failed index rewrite must report Residue, got {other:?}"), - } - assert_eq!( - fs::read_to_string(&index).await.unwrap(), - body, - "the index the rewrite could not replace must survive byte-identical" - ); - } - - #[tokio::test] - async fn test_remove_leaves_stamp_free_user_gitignore_byte_identical() { - // `--remove` after our stamp line was hand-deleted (or the file was - // rewritten by another tool): nothing of ours is in the .gitignore, - // so the early return must leave it BYTE-identical — the CRLF ending - // proves the lines-split-and-rejoin reconstruction never ran (a - // rewrite would emit "my-scratch-dir/\n" and churn the user's file). - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - add_plugin_files(root, false).await; - let user_bytes = b"my-scratch-dir/\r\n"; - fs::write(stamp_gitignore_path(root), user_bytes) - .await - .unwrap(); - - let r = remove_plugin_files(root, false).await; - assert_eq!(r.status, GemSetupStatus::Updated, "plugin files were ours"); - assert!(!plugins_rb_path(root).exists(), "plugin files removed"); - assert_eq!( - fs::read(stamp_gitignore_path(root)).await.unwrap(), - user_bytes, - "a stamp-free user .gitignore must survive byte-identical (CRLF kept)" - ); - assert!( - root.join(".socket").is_dir(), - "a .socket/ that still holds user content is kept (prune is remove_dir, not _all)" - ); - } - - #[tokio::test] - async fn test_resync_with_intact_stamp_gitignore_appends_no_duplicate() { - // A template resync (stale plugins.rb, everything else intact) must - // NOT touch `.socket/.gitignore`: `add_stamp_gitignore` is an - // unconditional append, and the ignore_missing gate is the only thing - // keeping every resync from duplicating the stamp line. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - add_plugin_files(root, false).await; - write( - &plugins_rb_path(root), - "# Code generated by stale\nold body\n", - ) - .await; - - let r = add_plugin_files(root, false).await; - assert_eq!( - r.status, - GemSetupStatus::Updated, - "stale plugins.rb resynced" - ); - assert_eq!( - fs::read_to_string(plugins_rb_path(root)).await.unwrap(), - PLUGINS_RB - ); - assert_eq!( - fs::read_to_string(stamp_gitignore_path(root)) - .await - .unwrap(), - "/gem-plugin-stamp\n", - "the intact .gitignore must keep exactly one stamp line — no duplicate append" - ); - } -} diff --git a/crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl b/crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl deleted file mode 100644 index cd3b054f..00000000 --- a/crates/socket-patch-core/src/setup/gem/templates/gemspec.tmpl +++ /dev/null @@ -1,22 +0,0 @@ -# Code generated by `socket-patch setup`. DO NOT EDIT. -# -# Minimal gemspec for the in-tree socket-patch Bundler plugin. `setup` references -# it from the Gemfile via `plugin "socket-patch", path: File.expand_path(...)`; -# the directory must be committed so clones and CI have the plugin on disk. -# (Phase 2 replaces this in-tree plugin with a published `socket-patch-bundler` -# gem.) -Gem::Specification.new do |s| - s.name = "socket-patch" - s.version = "0.0.0" - s.summary = "Bundler plugin that keeps socket-patch gem patches applied on every bundle install." - s.description = s.summary - s.authors = ["Socket"] - s.license = "MIT" - s.files = ["plugins.rb"] - # The plugin dir is flat (plugins.rb at the root, no lib/). Bundler refuses - # to load a plugin whose require paths are missing on disk ("The following - # plugin paths don't exist: .../lib ... Continuing without installing - # plugin"), so the default `lib` must be overridden. - s.require_paths = ["."] - s.required_ruby_version = ">= 2.6.0" -end diff --git a/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl b/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl deleted file mode 100644 index 4c5fc5b9..00000000 --- a/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl +++ /dev/null @@ -1,306 +0,0 @@ -# Code generated by `socket-patch setup`. DO NOT EDIT. -# -# socket-patch Bundler plugin. Keeps the gem patches recorded in -# .socket/manifest.json applied by re-running the socket-patch CLI whenever -# Bundler touches the gem set. Without it, `bundle install` reinstalls a gem -# from its cached .gem and silently reverts any applied patch. -# -# When each trigger actually runs (verified against bundler 2.7 and 4.0; hook -# subscriptions are recorded in .bundle/plugin/index at plugin REGISTRATION, -# and bundler evaluates this file whenever a subscribed event first fires in a -# bundle process): -# -# * plugin registration — the FIRST `bundle install` after `setup` (or on a -# fresh clone) evaluates this file BEFORE any project gem is installed, so -# the load-time trigger is bootstrap-gated (no patch target exists yet) -# and quietly no-ops there; the install hooks below re-apply once the -# gems land. -# * every `bundle install` — fresh AND fully cached — fires the per-gem -# `after-install` events and then `after-install-all`; the forced -# `after-install-all` re-apply is the actual patch point. -# * `bundle pristine` fires ONLY the per-gem `after-install` events, so that -# digest-gated hook is what catches pristine's patch reversion in the same -# run. (A checkout registered by an older plugin version keeps its old -# subscription set until it re-registers — fresh clones and CI always -# re-register, a dev checkout can `rm -rf .bundle/plugin`.) -# * nothing fires on `bundle exec` / `bundle check` / plain `ruby`, and -# `gem pristine` bypasses bundler entirely — a reversion via those is only -# healed at the NEXT `bundle install`. -# -# A digest of (manifest + every committed file under .socket/ + Gemfile.lock + -# the on-disk content of every gem-patch target file) gates the non-forced -# triggers: identical to the last applied state -> fast exit; otherwise shell -# out and re-stamp. Folding the targets' actual content in means an -# out-of-band reversion (pristine, manual edit, wiped bundle path) flips the -# digest even when every committed input is byte-identical. The stamp is a -# pure digest cache at .socket/gem-plugin-stamp — machine-local state that -# `setup` keeps out of version control via .socket/.gitignore. Deleting it -# only forces one re-probe, and a stale copy that reaches version control -# anyway is harmless on a fresh clone: the bootstrap gate below keys on the -# patch targets existing on disk, never on the stamp. Older plugin versions -# stamped a fixed-name file under Bundler.bundle_path — the machine-global -# gem dir when no bundle path is configured, shared and clobbered across -# every project on the host — so that legacy stamp is deleted best-effort -# when seen. -# -# A patch failure NEVER breaks `bundle install`: it prints a warning naming -# what failed and the remediation (run `socket-patch apply --ecosystems gem` -# manually). Set SOCKET_PATCH_STRICT=1 to restore raise-on-failure -# (Bundler::BundlerError) for builds that must not proceed with unpatched -# gems. The socket-patch CLI must be on PATH (or pointed at by -# SOCKET_PATCH_BIN) wherever `bundle install` runs. - -require "digest" -require "fileutils" -require "json" - -module SocketPatch - # Bundler evaluates this file twice in a bootstrap install (registration + - # first hook load), so constant assignments are guarded against re-runs. - BIN_ENV = "SOCKET_PATCH_BIN".freeze unless defined?(BIN_ENV) - STRICT_ENV = "SOCKET_PATCH_STRICT".freeze unless defined?(STRICT_ENV) - STAMP_NAME = "gem-plugin-stamp".freeze unless defined?(STAMP_NAME) - LEGACY_STAMP_NAME = ".socket-patch-gem-stamp".freeze unless defined?(LEGACY_STAMP_NAME) - # Bundler's parallel installer can fire per-gem hooks from worker threads; - # one applier runs at a time so a single bundle process never races - # concurrent `socket-patch apply` children against each other. - APPLY_LOCK = Mutex.new unless defined?(APPLY_LOCK) - - module_function - - # plugins.rb lives at /.socket/bundler-plugin/plugins.rb, so the project - # root (where the Gemfile / .socket/manifest.json live) is two levels up. - def project_root - File.expand_path("../..", __dir__) - end - - def socket_dir - File.join(project_root, ".socket") - end - - def manifest_path - File.join(socket_dir, "manifest.json") - end - - def socket_bin - env = ENV[BIN_ENV] - env && !env.empty? ? env : "socket-patch" - end - - def strict? - %w[1 true].include?(ENV[STRICT_ENV].to_s) - end - - def bundle_path - Bundler.bundle_path.to_s - rescue StandardError - File.join(project_root, "vendor", "bundle") - end - - def stamp_path - File.join(socket_dir, STAMP_NAME) - end - - # The on-disk files the manifest's gem patches target: - # /gems/-[-]/. - # Paths are collected whether or not the file exists — `current_digest` - # folds an absence marker, so a gem appearing or vanishing flips the digest. - def patch_target_files - records = begin - JSON.parse(File.read(manifest_path)).fetch("patches", {}) - rescue StandardError - return [] - end - return [] unless records.is_a?(Hash) - gems_dir = File.join(bundle_path, "gems") - # Dir.glob treats `\` as an escape on EVERY platform, so a Windows-style - # bundle path (Bundler.bundle_path carries backslash separators through - # verbatim) would never match the platform-gem wildcard below: platform - # installs (nokogiri-1.15.0-x64-mingw-ucrt) drop out of the digest and a - # `bundle pristine` reversion of them leaves the stamp matching. Forward - # slashes are valid separators on Windows, so normalize the GLOB BASE - # only — the direct join below is not a pattern and stays byte-faithful. - glob_gems_dir = gems_dir.tr("\\", "/") - targets = [] - records.each do |purl, record| - next unless purl.is_a?(String) && purl.start_with?("pkg:gem/") - coordinate = purl.split("pkg:gem/", 2).last.split("?", 2).first - name, at, version = coordinate.rpartition("@") - next if at.empty? || name.empty? || version.empty? - files = record.is_a?(Hash) ? record["files"] : nil - next unless files.is_a?(Hash) - files.each_key do |key| - rel = key.to_s.sub(%r{\Apackage/}, "") - targets << File.join(gems_dir, "#{name}-#{version}", rel) - targets.concat(Dir.glob(File.join(glob_gems_dir, "#{name}-#{version}-*", rel))) - end - end - targets.uniq.sort - end - - # Files whose change must force a reapply: the manifest, every committed file - # under .socket/ (patch blobs etc. — the stamp itself excluded, or each write - # would invalidate the digest it records), Gemfile.lock, and the current - # on-disk state of every patch target. - def digest_inputs - inputs = [manifest_path] - lock = File.join(project_root, "Gemfile.lock") - inputs << lock if File.file?(lock) - if File.directory?(socket_dir) - Dir.glob(File.join(socket_dir, "**", "*")).sort.each do |p| - inputs << p if File.file?(p) && p != stamp_path - end - end - inputs.concat(patch_target_files) - inputs.uniq - end - - def current_digest - d = Digest::SHA256.new - digest_inputs.each do |path| - d.update(path) - d.update("\0") - d.update(File.file?(path) ? "+" : "-") - begin - d.update(File.binread(path)) - rescue StandardError - # Unreadable now -> contributes only its path + absence marker; a later - # readable state changes the digest and forces a reapply. - end - d.update("\0") - end - d.hexdigest - end - - def stamped?(digest) - File.file?(stamp_path) && File.read(stamp_path).strip == digest - rescue StandardError - false - end - - def write_stamp(digest) - FileUtils.mkdir_p(File.dirname(stamp_path)) - File.write(stamp_path, digest) - rescue StandardError - # Best-effort: a missing/unwritable stamp just means we re-probe next time. - end - - # Older plugin versions stamped under Bundler.bundle_path. It is never read - # anymore; delete it (best-effort, once per process) so it does not linger - # as an orphan in a shared gem dir. - def remove_legacy_stamp - return if @legacy_stamp_checked - @legacy_stamp_checked = true - legacy = File.join(bundle_path, LEGACY_STAMP_NAME) - File.delete(legacy) if File.file?(legacy) - rescue StandardError - # Best-effort cleanup only. - end - - # Tolerant by default: a patch failure must never break `bundle install` — - # the first install of a fresh checkout runs the applier before any project - # gem exists, and raising there deadlocks the project on its own bootstrap - # (plugin registration fails, so every retry fails identically). Warn once - # per process with the remediation; SOCKET_PATCH_STRICT=1 restores the raise - # for builds that must not proceed unpatched. The trailer states what the - # ACTIVE mode does — the strict raise must not claim the install continues. - def failure_trailer - if strict? - "Failing `bundle install` because #{STRICT_ENV} is set; unset it to " \ - "warn and continue instead." - else - "`bundle install` continues; set #{STRICT_ENV}=1 to make patch " \ - "failures fatal." - end - end - - def report_failure(message) - message = "#{message} #{failure_trailer}" - if strict? - raise(defined?(Bundler::BundlerError) ? Bundler::BundlerError.new(message) : message) - end - return if @warned - @warned = true - warn(message) - end - - # Idempotent applier behind every trigger. No manifest -> the project does - # not use socket-patch, nothing to do. - # force: skip the digest gate (the installer just changed the gem set). - # bootstrap_gate: bail while NONE of the manifest's gem-patch targets exist - # on disk. The load-time trigger and the per-gem after-install hook use it - # so a bootstrap install's early evaluations (plugin REGISTRATION runs - # before any project gem lands) never shell out, warn, or — in strict mode — - # raise while there is nothing to patch; the forced after-install-all pass - # does the first real apply once the gems exist. The gate reads only the - # live gem tree, never the stamp: a stale stamp committed by mistake cannot - # re-open the bootstrap deadlock on a fresh clone, and deleting the stamp - # costs one re-probe instead of disabling these triggers. - def apply!(force: false, bootstrap_gate: false) - APPLY_LOCK.synchronize do - return unless File.file?(manifest_path) - remove_legacy_stamp - return if bootstrap_gate && patch_target_files.none? { |t| File.file?(t) } - return if !force && stamped?(current_digest) - - ok = system( - socket_bin, "apply", - "--ecosystems", "gem", "--offline", "--silent", - "--cwd", project_root - ) - - if ok.nil? - report_failure( - "socket-patch: could not run `#{socket_bin} apply` — the gem patches in " \ - ".socket/manifest.json are NOT applied. Install the socket-patch CLI (or set " \ - "#{BIN_ENV} to its path), then run `socket-patch apply --ecosystems gem` " \ - "manually." - ) - return - elsif !ok - report_failure( - "socket-patch: `#{socket_bin} apply --ecosystems gem` failed — the gem patches " \ - "in .socket/manifest.json may NOT be applied. Run `socket-patch apply " \ - "--ecosystems gem` in #{project_root} to apply them manually." - ) - return - end - - if @warned - @warned = false - warn("socket-patch: gem patches applied; the earlier warning is resolved.") - end - # Recompute: the apply just rewrote the target files the digest folds in. - write_stamp(current_digest) - end - end -end - -# Trigger 1 — load time. Runs at plugin registration and whenever a subscribed -# hook event first loads the plugin in a bundle process. Bootstrap-gated on -# the patch targets existing on disk (never on the stamp — a committed stale -# stamp must not re-open the registration deadlock): on the bootstrap install -# no gems exist to patch, so this quietly defers to Trigger 3. In strict mode -# a genuine patch failure (Bundler::BundlerError) still propagates. -begin - SocketPatch.apply!(bootstrap_gate: true) -rescue StandardError => e - raise if defined?(Bundler::BundlerError) && e.is_a?(Bundler::BundlerError) -end - -# Trigger 2 — after each individual gem (re)install. The only event bundler -# fires during `bundle pristine`, so this is what catches pristine's patch -# reversion in the same run — even when the stamp was deleted, since the gate -# reads the gem tree, not the stamp. Digest- and bootstrap-gated: on a fresh -# install's per-gem events the targets are only just landing and Trigger 3 is -# about to do the real work. -Bundler::Plugin.add_hook("after-install") do |_spec_install| - SocketPatch.apply!(bootstrap_gate: true) -end - -# Trigger 3 — after the installer finishes (fresh AND fully-cached installs). -# Forced, because the install just changed the gem set; the applier is -# idempotent so a redundant run on an already-patched tree is a cheap no-op. -Bundler::Plugin.add_hook("after-install-all") do |_install| - SocketPatch.apply!(force: true) -end diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs deleted file mode 100644 index 6d6c34c1..00000000 --- a/crates/socket-patch-core/src/setup/gem/update.rs +++ /dev/null @@ -1,1253 +0,0 @@ -//! Add / remove the managed `plugin "socket-patch"` block in a Bundler -//! `Gemfile`, and statically check whether it is present. -//! -//! A Gemfile is Ruby, not a structured config, so this appends/strips a -//! clearly-marked, byte-exact block under a reversibility contract: idempotent, -//! `dry_run`-aware, `Updated`/`AlreadyConfigured`/`Error`, and a `--remove` that -//! restores the file byte-for-byte. - -use std::path::Path; - -use tokio::fs; - -use super::version::{probe_bundler, unsupported_bundler_message, BundlerProbe}; -use super::{ - add_plugin_files, remove_plugin_files, remove_plugin_registration_at, BundlerProject, - GemRegistrationCleanup, -}; -use crate::utils::fs::atomic_write_bytes_preserving_mode; - -/// Outcome of one setup edit. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GemSetupStatus { - Updated, - AlreadyConfigured, - Error, -} - -#[derive(Debug)] -pub struct GemEditResult { - /// Envelope `files[].kind` (`gemfile` | `gem_plugin` | - /// `gem_plugin_registration`). - pub kind: &'static str, - pub path: String, - pub status: GemSetupStatus, - pub error: Option, -} - -impl GemEditResult { - /// Build a result from an `Ok(changed)` / `Err(message)` outcome. - pub(super) fn from_result( - kind: &'static str, - path: String, - result: Result, - ) -> Self { - match result { - Ok(true) => Self { - kind, - path, - status: GemSetupStatus::Updated, - error: None, - }, - Ok(false) => Self { - kind, - path, - status: GemSetupStatus::AlreadyConfigured, - error: None, - }, - Err(e) => Self { - kind, - path, - status: GemSetupStatus::Error, - error: Some(e), - }, - } - } -} - -/// Stable substring identifying our managed block — `setup --check` and the -/// add/remove edits all key on it, so a user-authored `plugin` line is never -/// mistaken for ours. -const MANAGED_MARKER: &str = "# >>> socket-patch:managed"; - -/// The exact block `setup` appends to the Gemfile (trailing newline included). -/// `File.expand_path(..., __dir__)` resolves relative to the Gemfile's own dir, -/// so the reference is correct regardless of where `bundle` is invoked from. -/// The source MUST be `path:`, not `git:`: Bundler fetches a `git:` plugin via -/// `git clone `, and the generated dir is a plain directory (committing it -/// to the parent repo does not give it a `.git`), so a `git:` source fails -/// every `bundle install` with "repository ... does not exist". A `path:` -/// source loads the directory in place. -const MANAGED_BLOCK: &str = "\ -# >>> socket-patch:managed (added by `socket-patch setup`; do not edit) >>>\n\ -plugin 'socket-patch', path: File.expand_path('.socket/bundler-plugin', __dir__)\n\ -# <<< socket-patch:managed <<<\n"; - -/// What we append after the user's content: a blank-line separator + the block. -/// Removing this exact string restores the Gemfile byte-for-byte. -fn appended() -> String { - format!("\n{MANAGED_BLOCK}") -} - -/// Static check: does this Gemfile contain our managed plugin block? Pure -/// substring scan — exactly what a repo auditor reads. A user's own -/// `plugin "foo"` line does not match (the marker comment does). -pub fn is_plugin_directive_present(content: &str) -> bool { - content.contains(MANAGED_MARKER) -} - -/// Pure transform: append the managed block, or `None` if already present. -fn gemfile_add(content: &str) -> Option { - if is_plugin_directive_present(content) { - return None; - } - 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; - } - 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 -/// (`AlreadyConfigured` when already present). A missing Gemfile is an error -/// (we don't synthesize one — `discover_bundler_project` guarantees it exists). -/// `kind = "gemfile"`. -async fn edit_gemfile_add(gemfile: &Path, dry_run: bool) -> GemEditResult { - let result = async { - let content = fs::read_to_string(gemfile) - .await - .map_err(|e| e.to_string())?; - match gemfile_add(&content) { - None => Ok(false), - Some(new) => { - 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. 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())?; - } - Ok(true) - } - } - } - .await; - GemEditResult::from_result("gemfile", gemfile.display().to_string(), result) -} - -/// Strip the managed block from the Gemfile. Idempotent (already-absent → -/// `AlreadyConfigured`); a missing Gemfile is a no-op. -async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { - let result = async { - let content = match fs::read_to_string(gemfile).await { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(e) => return Err(e.to_string()), - }; - match gemfile_remove(&content) { - // Marker present but no removable form matched: the managed block - // was mutated (an edit inside it, a stripped final newline). The - // `plugin` directive may still be live, so this must be an Error — - // `remove_plugin_directive_at` early-returns on it, keeping the - // plugin dir the directive references. Reporting "not configured" - // instead lets the dir be deleted and every later `bundle install` - // exits 13 on the dangling `path:` source. - None if is_plugin_directive_present(&content) => Err(format!( - "the socket-patch managed block in {} has been edited and cannot \ - be removed automatically; delete the lines from \ - `{MANAGED_MARKER}` through `# <<< socket-patch:managed <<<` by \ - hand, then re-run `socket-patch setup --remove`", - gemfile.display() - )), - None => Ok(false), - Some(new) => { - if !dry_run { - atomic_write_bytes_preserving_mode(gemfile, new.as_bytes()) - .await - .map_err(|e| e.to_string())?; - } - Ok(true) - } - } - } - .await; - GemEditResult::from_result("gemfile", gemfile.display().to_string(), result) -} - -/// 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". -/// -/// Refused outright — dry-run included, so the preview never promises a wire -/// the real run would reject — when the project's bundler is below the -/// [`super::MIN_BUNDLER`] floor: bundler 1.x resolves the `plugin ... path:` -/// directive as an ordinary gem and every later `bundle install` exits 7 -/// before the plugin registers (see `version.rs`). The probe fails OPEN on -/// an undetectable version; `remove_plugin_directive` is never gated (it is -/// the recovery path for an already-wired 1.x project). -pub async fn add_plugin_directive(project: &BundlerProject, dry_run: bool) -> Vec { - let probe = probe_bundler(project).await; - add_plugin_directive_with(project, &probe, dry_run).await -} - -/// [`add_plugin_directive`] with the bundler probe supplied by the caller. -/// `probe_bundler` may spawn `bundle --version` (10 s cap) when the lock has -/// no `BUNDLED WITH`, so a caller that runs a dry-run preview and then the -/// real edit (the CLI's `setup`) probes once and passes the result to both. -pub async fn add_plugin_directive_with( - project: &BundlerProject, - probe: &BundlerProbe, - dry_run: bool, -) -> Vec { - if let BundlerProbe::Unsupported { version, source } = probe { - let mut message = unsupported_bundler_message(version, source); - // An ALREADY-wired project (wired before the floor existed, or on - // another machine) gets the recovery path by name — "Not wiring this - // project" alone would be misleading when the wiring is the problem. - if let Ok(content) = fs::read_to_string(&project.gemfile).await { - if is_plugin_directive_present(&content) { - message.push_str( - ". This project is already wired: run `socket-patch setup --remove` \ - to unwire it so `bundle install` works again", - ); - } - } - return vec![GemEditResult { - kind: "gemfile", - path: project.gemfile.display().to_string(), - status: GemSetupStatus::Error, - error: Some(message), - }]; - } - 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), then -/// delete the generated plugin directory, then clear bundler's machine-local -/// `.bundle/plugin` registration of the plugin. -/// -/// 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. -/// -/// The registration comes last (and only after the Gemfile un-wire held): -/// while the `plugin` directive is still in the Gemfile the registration is -/// live state bundler needs, not residue. Left behind after a successful -/// unwire, it makes every later `bundle install` print bundler's "plugin -/// paths don't exist ... Continuing without installing plugin socket-patch" -/// block forever, so the cleanup failure/refusal path surfaces the -/// `bundler plugin uninstall socket-patch` remedy as a `files[]` error. -pub async fn remove_plugin_directive( - project: &BundlerProject, - dry_run: bool, -) -> Vec { - let env = std::env::var_os("BUNDLE_APP_CONFIG"); - remove_plugin_directive_at(project, env.as_deref(), dry_run).await -} - -/// [`remove_plugin_directive`] with the `BUNDLE_APP_CONFIG` resolution input -/// made explicit (tests inject it so a machine's exported value — e.g. the -/// official ruby images' `/usr/local/bundle` — can neither fail them -/// spuriously nor point the cleanup at a real machine-local index; the public -/// entry reads the process env, exactly like bundler itself). -async fn remove_plugin_directive_at( - project: &BundlerProject, - app_config_env: Option<&std::ffi::OsStr>, - dry_run: bool, -) -> Vec { - let gemfile = edit_gemfile_remove(&project.gemfile, dry_run).await; - if gemfile.status == GemSetupStatus::Error { - return vec![gemfile]; - } - let mut results = vec![gemfile, remove_plugin_files(&project.root, dry_run).await]; - match remove_plugin_registration_at(&project.root, app_config_env, dry_run).await { - GemRegistrationCleanup::Cleaned { index } => results.push(GemEditResult { - kind: "gem_plugin_registration", - path: index.display().to_string(), - status: GemSetupStatus::Updated, - error: None, - }), - // The common pre-first-install case (bundler never registered the - // plugin): no entry — there was nothing machine-local to remove. - GemRegistrationCleanup::NotRegistered => {} - GemRegistrationCleanup::Residue { index, reason } => results.push(GemEditResult { - kind: "gem_plugin_registration", - path: index.display().to_string(), - status: GemSetupStatus::Error, - error: Some(format!( - "could not clear bundler's machine-local plugin registration at {} \ - ({reason}); run `bundler plugin uninstall socket-patch` to remove it, \ - or every later `bundle install` will warn about the unwired plugin", - index.display() - )), - }), - } - results -} - -#[cfg(test)] -mod tests { - use super::*; - - const GEMFILE: &str = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n"; - - async fn supported_project(root: &Path) -> BundlerProject { - // File-edit tests must reach the edit being exercised independently - // of whether the host has Bundler installed or which version it has. - fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); - fs::write(root.join("Gemfile.lock"), "BUNDLED WITH\n 2.7.2\n") - .await - .unwrap(); - super::super::discover_bundler_project(root).await.unwrap() - } - - #[test] - fn test_add_appends_block_and_is_idempotent() { - let out = gemfile_add(GEMFILE).unwrap(); - assert!( - out.starts_with(GEMFILE), - "original bytes preserved as a prefix" - ); - assert!(is_plugin_directive_present(&out)); - // `path:`-sourced, never `git:`: Bundler git-clones a `git:` plugin - // source, and the plain generated dir is uncloneable, breaking every - // `bundle install` on the wired project. - assert!(out.contains("plugin 'socket-patch', path:")); - assert!(out.contains("File.expand_path('.socket/bundler-plugin', __dir__)")); - // Idempotent. - assert!(gemfile_add(&out).is_none()); - } - - #[test] - fn test_add_then_remove_round_trips_byte_for_byte() { - let added = gemfile_add(GEMFILE).unwrap(); - let removed = gemfile_remove(&added).unwrap(); - assert_eq!( - removed, GEMFILE, - "remove must restore the original bytes exactly" - ); - } - - #[test] - fn test_remove_absent_is_noop() { - assert!(gemfile_remove(GEMFILE).is_none()); - } - - #[test] - fn test_user_plugin_line_is_not_detected_as_ours() { - let user = "source 'https://rubygems.org'\nplugin 'some-other-plugin'\n"; - assert!(!is_plugin_directive_present(user)); - // Adding ours leaves the user's line intact. - let out = gemfile_add(user).unwrap(); - assert!(out.contains("plugin 'some-other-plugin'")); - assert!(out.contains("plugin 'socket-patch'")); - } - - #[test] - fn test_round_trips_without_trailing_newline() { - // A Gemfile whose last line has no trailing newline must still restore - // byte-for-byte (add appends "\n"; remove strips exactly that). - let no_nl = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'"; - let added = gemfile_add(no_nl).unwrap(); - assert!(is_plugin_directive_present(&added)); - assert_eq!(gemfile_remove(&added).unwrap(), no_nl); - } - - #[test] - fn test_round_trips_empty_gemfile() { - let added = gemfile_add("").unwrap(); - assert!(is_plugin_directive_present(&added)); - assert_eq!(gemfile_remove(&added).unwrap(), ""); - } - - #[test] - fn test_remove_via_block_fallback_when_separator_edited_away() { - // User deleted the blank-line separator, leaving the block glued to a - // no-newline final line. find(&appended) misses; the block-only - // fallback still strips it. - let glued = format!("gem 'x'{MANAGED_BLOCK}"); - assert!(is_plugin_directive_present(&glued)); - assert_eq!(gemfile_remove(&glued).unwrap(), "gem 'x'"); - } - - #[test] - fn test_remove_reports_nothing_removed_when_block_body_edited() { - // Marker present but the block body was hand-edited so neither the - // "\n" nor the bare-block match fires. Removing nothing must NOT - // masquerade as a successful edit — the file is still configured. - let edited = format!( - "gem 'x'\n{MANAGED_MARKER} (added by `socket-patch setup`) >>>\nplugin 'socket-patch' # USER EDIT\n# <<< socket-patch:managed <<<\n" - ); - assert!(is_plugin_directive_present(&edited)); - assert!( - gemfile_remove(&edited).is_none(), - "an un-matchable edited block reports nothing-removed, not a no-op Updated" - ); - } - - #[test] - fn test_remove_preserves_user_gems_added_below_the_block() { - // Real-world flow: setup appends the block, then the user adds more - // gems AFTER it. `remove` must excise exactly our "\n" and leave - // the user's later additions intact with clean formatting — never strip - // a user line or glue two lines together. - let added = gemfile_add(GEMFILE).unwrap(); - let user_edited = format!("{added}gem 'extra', '2.0'\n"); - assert!(is_plugin_directive_present(&user_edited)); - assert_eq!( - gemfile_remove(&user_edited).unwrap(), - format!("{GEMFILE}gem 'extra', '2.0'\n"), - "only our block is removed; the user's later gems survive verbatim" - ); - } - - #[test] - fn test_remove_does_not_glue_lines_when_original_lacked_trailing_newline() { - // Original Gemfile has no final newline; setup's "\n" separator becomes - // the terminator of that last line. The user then adds gems AFTER our - // block. remove must not strip that separator along with the block — - // doing so glues `gem 'colorize', '1.1.0'` onto `gem 'extra', '2.0'` - // (one invalid Ruby line). - let no_nl = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'"; - let added = gemfile_add(no_nl).unwrap(); - let user_edited = format!("{added}gem 'extra', '2.0'\n"); - assert_eq!( - gemfile_remove(&user_edited).unwrap(), - format!("{no_nl}\ngem 'extra', '2.0'\n"), - "the separator newline must survive as the last line's terminator" - ); - } - - #[test] - fn test_round_trips_crlf_content_byte_for_byte() { - // A Windows-authored Gemfile uses CRLF line endings. add appends an - // LF-delimited block; remove must still restore the original CRLF bytes - // exactly (the separator/block we strip is our own LF, not the user's). - let crlf = "source 'https://rubygems.org'\r\ngem 'colorize', '1.1.0'\r\n"; - let added = gemfile_add(crlf).unwrap(); - assert!(is_plugin_directive_present(&added)); - assert_eq!( - gemfile_remove(&added).unwrap(), - crlf, - "CRLF user content restored byte-for-byte" - ); - } - - #[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. - let closing_only = "gem 'x'\n# <<< socket-patch:managed <<<\n"; - assert!(!is_plugin_directive_present(closing_only)); - } - - #[tokio::test] - async fn test_full_roundtrip_via_gems_rb() { - // 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(); - assert_eq!( - edit_gemfile_add(&gems_rb, false).await.status, - GemSetupStatus::Updated - ); - assert!(is_plugin_directive_present( - &fs::read_to_string(&gems_rb).await.unwrap() - )); - assert_eq!( - edit_gemfile_remove(&gems_rb, false).await.status, - GemSetupStatus::Updated - ); - assert_eq!(fs::read_to_string(&gems_rb).await.unwrap(), GEMFILE); - } - - #[tokio::test] - async fn test_remove_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let gemfile = dir.path().join("Gemfile"); - let configured = gemfile_add(GEMFILE).unwrap(); - fs::write(&gemfile, &configured).await.unwrap(); - let res = edit_gemfile_remove(&gemfile, true).await; - assert_eq!(res.status, GemSetupStatus::Updated); - assert_eq!( - fs::read_to_string(&gemfile).await.unwrap(), - configured, - "dry-run remove must not write" - ); - } - - #[tokio::test] - async fn test_edit_gemfile_missing_is_error() { - let dir = tempfile::tempdir().unwrap(); - let res = edit_gemfile_add(&dir.path().join("Gemfile"), false).await; - assert_eq!(res.status, GemSetupStatus::Error); - } - - #[tokio::test] - async fn test_edit_gemfile_remove_missing_is_noop() { - let dir = tempfile::tempdir().unwrap(); - let res = edit_gemfile_remove(&dir.path().join("Gemfile"), false).await; - assert_eq!(res.status, GemSetupStatus::AlreadyConfigured); - } - - #[tokio::test] - async fn test_add_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let gemfile = dir.path().join("Gemfile"); - fs::write(&gemfile, GEMFILE).await.unwrap(); - let res = edit_gemfile_add(&gemfile, true).await; - assert_eq!(res.status, GemSetupStatus::Updated); - assert_eq!( - fs::read_to_string(&gemfile).await.unwrap(), - GEMFILE, - "dry-run must not write" - ); - } - - // ── atomic-write contract (no truncation / no stage litter) ────── - // - // The Gemfile edit must go through stage+fsync+rename, never a bare - // truncating write, so a crash can't leave the user's committed Gemfile - // truncated or empty. - - #[cfg(unix)] - #[tokio::test] - async fn test_add_replaces_readonly_gemfile_atomically() { - use std::os::unix::fs::PermissionsExt; - // Oracle for the truncating-write bug: rename needs only directory - // write permission, while a bare `fs::write` must open the target - // itself for writing — so a read-only Gemfile distinguishes the two - // (EACCES under truncate, clean replace under stage+rename, same as - // the composer/npm/pypi/cargo/go manifest writers). - 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(0o444)).unwrap(); - - let res = edit_gemfile_add(&gemfile, false).await; - assert_eq!(res.status, GemSetupStatus::Updated, "err: {:?}", res.error); - assert!(is_plugin_directive_present( - &fs::read_to_string(&gemfile).await.unwrap() - )); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_replaces_readonly_gemfile_atomically() { - use std::os::unix::fs::PermissionsExt; - 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(0o444)).unwrap(); - - let res = edit_gemfile_remove(&gemfile, false).await; - assert_eq!(res.status, GemSetupStatus::Updated, "err: {:?}", res.error); - assert_eq!( - fs::read_to_string(&gemfile).await.unwrap(), - GEMFILE, - "read-only Gemfile restored byte-for-byte via stage+rename" - ); - } - - #[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(); - let gemfile = dir.path().join("Gemfile"); - fs::write(&gemfile, GEMFILE).await.unwrap(); - - assert_eq!( - edit_gemfile_add(&gemfile, false).await.status, - GemSetupStatus::Updated - ); - assert_eq!( - edit_gemfile_remove(&gemfile, false).await.status, - GemSetupStatus::Updated - ); - assert_eq!(fs::read_to_string(&gemfile).await.unwrap(), GEMFILE); - - // No half-written `.socket-stage-*` sibling left behind. - let mut rd = fs::read_dir(dir.path()).await.unwrap(); - while let Some(entry) = rd.next_entry().await.unwrap() { - let name = entry.file_name().to_string_lossy().into_owned(); - assert!(!name.starts_with(".socket-stage-"), "stage litter: {name}"); - } - } - - // ── 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(); - let project = supported_project(root).await; - // `.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 results = add_plugin_directive(&project, false).await; - - assert!( - results - .iter() - .any(|r| r.kind == "gem_plugin" && 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(); - let project = supported_project(root).await; - 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_at(&project, None, 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_remove_keeps_plugin_files_when_managed_block_is_unmatchable() { - // A benign mutation — the Gemfile lost its final newline (an editor - // without insert-final-newline, a trailing-whitespace trimmer) — leaves - // the marker present but every removable block form unmatchable, so the - // Gemfile edit removes nothing. The live `plugin ... path:` directive - // is still in the Gemfile, so the plugin dir must SURVIVE: deleting it - // breaks every later `bundle install` (exit 13), and the user must get - // an error naming the manual remedy, not a "not_configured" no-op. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let project = supported_project(root).await; - assert!(add_plugin_directive(&project, false) - .await - .iter() - .all(|r| r.status == GemSetupStatus::Updated)); - let content = fs::read_to_string(root.join("Gemfile")).await.unwrap(); - fs::write(root.join("Gemfile"), content.strip_suffix('\n').unwrap()) - .await - .unwrap(); - - let results = remove_plugin_directive_at(&project, None, false).await; - - assert!( - is_plugin_directive_present(&fs::read_to_string(root.join("Gemfile")).await.unwrap()), - "precondition: the directive is still in the Gemfile" - ); - assert!( - results - .iter() - .any(|r| r.kind == "gemfile" && r.status == GemSetupStatus::Error), - "an unmatchable managed block must surface as a gemfile error: {results:?}" - ); - assert!( - super::super::plugin_files_present(root).await, - "the plugin files must SURVIVE while the directive remains in the \ - Gemfile — deleting them breaks every `bundle install` (exit 13)" - ); - } - - // ── bundler version floor ───────────────────────────────────────── - // - // Bundler 1.x cannot load a `plugin ... path:` directive: `Plugin::DSL` - // undef_methods `:path` and the 1.x plugin installer supports only - // git/rubygems sources, so the directive is resolved as an ORDINARY GEM - // and every later `bundle install` dies with exit 7 ("Could not find gem - // 'socket-patch' ...") BEFORE plugin registration — an error that never - // names socket-patch. Wiring such a project is strictly worse than - // refusing (reproduced on bundler 1.17.3). The project's bundler is read - // from the lock's `BUNDLED WITH` section — deterministic, and present - // even where `bundle` is not on PATH. - - const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ - colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ - colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; - - #[tokio::test] - async fn test_add_refuses_bundler_1x_locked_project() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); - fs::write(root.join("Gemfile.lock"), LOCK_1X).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), - "wiring a bundler-1.x project must be refused as an error: {results:?}" - ); - let msg = results - .iter() - .find_map(|r| r.error.as_deref()) - .unwrap_or_default(); - assert!( - msg.contains("1.17.3") && msg.contains("2.2"), - "the refusal must name the detected bundler and the floor: {msg:?}" - ); - assert_eq!( - fs::read_to_string(root.join("Gemfile")).await.unwrap(), - GEMFILE, - "the Gemfile must NOT be wired — bundler 1.x resolves the plugin \ - directive as an ordinary gem and every later `bundle install` \ - exits 7" - ); - assert!( - !super::super::plugin_files_present(root).await, - "no plugin files may be generated for a refused project" - ); - } - - #[tokio::test] - async fn test_add_dry_run_also_refuses_bundler_1x() { - // The preview must refuse identically — a dry-run that previews the - // wiring while the real run errors would lie to the user. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); - fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); - let project = super::super::discover_bundler_project(root).await.unwrap(); - - let results = add_plugin_directive(&project, true).await; - assert!( - results.iter().any(|r| r.status == GemSetupStatus::Error), - "dry-run must surface the same refusal: {results:?}" - ); - } - - #[tokio::test] - async fn test_add_proceeds_on_bundler_2x_lock() { - // A supported lock must not trip the gate. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); - fs::write( - root.join("Gemfile.lock"), - LOCK_1X.replace("1.17.3", "2.7.2"), - ) - .await - .unwrap(); - let project = super::super::discover_bundler_project(root).await.unwrap(); - - let results = add_plugin_directive(&project, false).await; - assert!( - results.iter().all(|r| r.status == GemSetupStatus::Updated), - "a bundler-2.x lock must wire normally: {results:?}" - ); - } - - #[tokio::test] - async fn test_add_refusal_on_wired_1x_project_names_remove_recovery() { - // Re-running `setup` on an ALREADY-wired 1.x project must not stop at - // "Not wiring this project" — the wiring IS the problem there, and the - // refusal must hand the user the `setup --remove` recovery path. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Gemfile"), gemfile_add(GEMFILE).unwrap()) - .await - .unwrap(); - fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); - let project = super::super::discover_bundler_project(root).await.unwrap(); - - let results = add_plugin_directive(&project, false).await; - let msg = results - .iter() - .find_map(|r| r.error.as_deref()) - .unwrap_or_default(); - assert!( - msg.contains("setup --remove"), - "the refusal on a wired project must name the recovery path: {msg:?}" - ); - - // And the UNWIRED refusal must NOT claim the project is wired. - fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); - let results = add_plugin_directive(&project, false).await; - let msg = results - .iter() - .find_map(|r| r.error.as_deref()) - .unwrap_or_default(); - assert!( - !msg.contains("already wired"), - "an unwired project's refusal must not mention un-wiring: {msg:?}" - ); - } - - #[tokio::test] - async fn test_remove_still_unwires_bundler_1x_project() { - // `setup --remove` is the RECOVERY path for a project wired before - // the floor existed (or wired on another machine) — the gate must - // never block the un-wire. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Gemfile"), gemfile_add(GEMFILE).unwrap()) - .await - .unwrap(); - fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); - let project = super::super::discover_bundler_project(root).await.unwrap(); - - let results = remove_plugin_directive(&project, false).await; - assert!( - results.iter().all(|r| r.status != GemSetupStatus::Error), - "remove must not be blocked by the version gate: {results:?}" - ); - assert_eq!( - fs::read_to_string(root.join("Gemfile")).await.unwrap(), - GEMFILE, - "the recovery un-wire restores the Gemfile byte-for-byte" - ); - } - - #[tokio::test] - async fn test_remove_clears_bundler_plugin_registration_entry() { - // A project bundler has already installed once: the machine-local - // `.bundle/plugin/index` registration exists. `remove` must clear it - // and report the cleanup as its own `gem_plugin_registration` entry. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let project = supported_project(root).await; - assert!(add_plugin_directive(&project, false) - .await - .iter() - .all(|r| r.status == GemSetupStatus::Updated)); - let index = root.join(".bundle").join("plugin").join("index"); - fs::create_dir_all(index.parent().unwrap()).await.unwrap(); - fs::write( - &index, - format!( - "---\ncommands:\nhooks:\n after-install:\n - \"socket-patch\"\n \ - after-install-all:\n - \"socket-patch\"\nload_paths:\n socket-patch:\n \ - - \"{0}/.socket/bundler-plugin/.\"\nplugin_paths:\n \ - socket-patch: \"{0}/.socket/bundler-plugin\"\nsources:\n", - root.display() - ), - ) - .await - .unwrap(); - - let removed = remove_plugin_directive_at(&project, None, false).await; - assert!( - removed - .iter() - .any(|r| r.kind == "gem_plugin_registration" - && r.status == GemSetupStatus::Updated), - "the registration cleanup must be reported: {removed:?}" - ); - assert!( - !index.exists(), - "the socket-patch-only registration index must be gone" - ); - // Absent registration (the pre-first-install case): no entry at all. - fs::write(root.join("Gemfile"), gemfile_add(GEMFILE).unwrap()) - .await - .unwrap(); - assert!(add_plugin_directive(&project, false) - .await - .iter() - .all(|r| r.status != GemSetupStatus::Error)); - let removed = remove_plugin_directive_at(&project, None, false).await; - assert!( - removed.iter().all(|r| r.kind != "gem_plugin_registration"), - "no machine-local registration -> no registration entry: {removed:?}" - ); - } - - #[tokio::test] - async fn test_edit_gemfile_remove_read_error_is_error() { - // A non-NotFound read failure (here: a directory squatting on the - // Gemfile path, EISDIR on unix / access-denied on Windows) must - // surface as an Error result — NOT the missing-file no-op, which - // would let the remove flow proceed to delete the plugin dir while - // an unreadable Gemfile may still carry the live directive. - let dir = tempfile::tempdir().unwrap(); - let gemfile = dir.path().join("Gemfile"); - fs::create_dir(&gemfile).await.unwrap(); - - let res = edit_gemfile_remove(&gemfile, false).await; - assert_eq!(res.kind, "gemfile"); - assert_eq!( - res.status, - GemSetupStatus::Error, - "an unreadable Gemfile is an error, not a no-op: {res:?}" - ); - assert!( - res.error.as_deref().is_some_and(|e| !e.is_empty()), - "the read failure must be reported: {res:?}" - ); - } - - #[tokio::test] - async fn test_remove_on_unwired_gemfile_is_already_configured() { - // `setup --remove` on a project whose Gemfile exists but was never - // wired — a completely ordinary user flow. The Gemfile edit reports - // AlreadyConfigured (nothing to strip, no error), the bytes survive - // untouched, and the rest of the cleanup still runs without errors. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let gemfile = root.join("Gemfile"); - fs::write(&gemfile, GEMFILE).await.unwrap(); - - let res = edit_gemfile_remove(&gemfile, false).await; - assert_eq!(res.status, GemSetupStatus::AlreadyConfigured, "{res:?}"); - assert!(res.error.is_none(), "no error on a never-wired Gemfile"); - assert_eq!( - fs::read_to_string(&gemfile).await.unwrap(), - GEMFILE, - "an unwired Gemfile survives byte-for-byte" - ); - - // The full remove flow on the same fixture: the AlreadyConfigured - // gemfile result must NOT early-return — the file/registration - // cleanup steps still run, and nothing errors. - let project = super::super::discover_bundler_project(root).await.unwrap(); - let results = remove_plugin_directive_at(&project, None, false).await; - assert!( - results - .iter() - .any(|r| r.kind == "gemfile" && r.status == GemSetupStatus::AlreadyConfigured), - "the gemfile entry reports already-configured: {results:?}" - ); - assert!( - results.iter().all(|r| r.status != GemSetupStatus::Error), - "remove on a never-wired project is error-free: {results:?}" - ); - assert_eq!( - fs::read_to_string(&gemfile).await.unwrap(), - GEMFILE, - "the full remove flow leaves the unwired Gemfile untouched" - ); - } - - /// Plant a FIFO with a direct `mkfifo(2)` syscall — same helper as the - /// gem/mod.rs / composer / find.rs FIFO tests: fork/exec flakes under - /// heavy parallel load and the syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_remove_reports_registration_residue_with_uninstall_remedy() { - // A wired project whose machine-local `.bundle/plugin/index` cannot - // be read (a FIFO squatting there — the deterministic stand-in for - // any unreadable index). The unwire itself succeeds, and the - // registration residue must surface as a `gem_plugin_registration` - // files[] Error naming the index path and the - // `bundler plugin uninstall socket-patch` remedy — the user-facing - // recovery messaging for the "bundle install warns forever" trap. - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let gemfile = root.join("Gemfile"); - // Write the wired Gemfile directly (remove is never version-gated, - // so no lockfile pin is needed and no `bundle` probe ever runs). - fs::write(&gemfile, gemfile_add(GEMFILE).unwrap()) - .await - .unwrap(); - let project = super::super::discover_bundler_project(root).await.unwrap(); - let index = root.join(".bundle").join("plugin").join("index"); - fs::create_dir_all(index.parent().unwrap()).await.unwrap(); - mkfifo(&index); - - // On timeout the open is wedged in a `spawn_blocking` thread that the - // runtime waits for on shutdown; connect a writer to release it so - // the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(results) = - tokio::time::timeout(deadline, remove_plugin_directive_at(&project, None, false)).await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&index); - panic!("remove must complete promptly with a FIFO index"); - }; - - assert!( - results - .iter() - .any(|r| r.kind == "gemfile" && r.status == GemSetupStatus::Updated), - "the unwire itself succeeded: {results:?}" - ); - assert_eq!( - fs::read_to_string(&gemfile).await.unwrap(), - GEMFILE, - "the Gemfile is restored despite the registration residue" - ); - let residue = results - .iter() - .find(|r| r.kind == "gem_plugin_registration") - .expect("the registration residue must be reported as its own entry"); - assert_eq!(residue.status, GemSetupStatus::Error, "{residue:?}"); - assert_eq!(residue.path, index.display().to_string()); - let msg = residue.error.as_deref().unwrap_or_default(); - assert!( - msg.contains("bundler plugin uninstall socket-patch"), - "the error must hand the user the uninstall remedy: {msg:?}" - ); - assert!( - msg.contains(&index.display().to_string()), - "the error must name the index it could not clear: {msg:?}" - ); - assert!( - std::fs::symlink_metadata(&index).is_ok(), - "the unreadable index must survive untouched" - ); - } - - #[tokio::test] - async fn test_full_roundtrip_via_project() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - let project = supported_project(root).await; - - let added = add_plugin_directive(&project, false).await; - assert!(added.iter().all(|r| r.status == GemSetupStatus::Updated)); - assert!(is_plugin_directive_present( - &fs::read_to_string(root.join("Gemfile")).await.unwrap() - )); - assert!(super::super::plugin_files_present(root).await); - - // Idempotent re-run. - let again = add_plugin_directive(&project, false).await; - assert!(again - .iter() - .all(|r| r.status == GemSetupStatus::AlreadyConfigured)); - - let removed = remove_plugin_directive_at(&project, None, false).await; - assert!(removed.iter().all(|r| r.status == GemSetupStatus::Updated)); - assert_eq!( - fs::read_to_string(root.join("Gemfile")).await.unwrap(), - GEMFILE, - "Gemfile restored byte-for-byte" - ); - assert!(!super::super::plugin_files_present(root).await); - } -} diff --git a/crates/socket-patch-core/src/setup/gem/version.rs b/crates/socket-patch-core/src/setup/gem/version.rs deleted file mode 100644 index 61b196cd..00000000 --- a/crates/socket-patch-core/src/setup/gem/version.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! Bundler version floor for the generated plugin wiring. -//! -//! The `plugin "socket-patch", path: ...` directive `setup` writes needs -//! bundler >= 2.2. Bundler 1.x cannot load it: `Plugin::DSL` undef_methods -//! `:path` and the 1.x plugin installer supports only git/rubygems sources, -//! so the directive is resolved as an ORDINARY GEM and every later -//! `bundle install` dies with exit 7 ("Could not find gem 'socket-patch' -//! ...") BEFORE plugin registration — an error that never names -//! socket-patch, and in deployment mode adds a misleading "Perhaps the -//! lockfile is corrupted?" line (reproduced on bundler 1.17.3). Wiring such -//! a project is strictly worse than refusing. -//! -//! The probe reads, in order: -//! 1. the lock's `BUNDLED WITH` section (`Gemfile.lock`, or `gems.locked` -//! for a `gems.rb` project) — deterministic, present even where -//! `bundle` is not on PATH, and the best predictor of the bundler that -//! will actually run installs (RubyGems' version switching selects the -//! locked bundler when installed; bundler >= 2.3 auto-installs it); -//! 2. `bundle --version` in the project root — the machine's bundler, -//! for lock-less projects. Bundler 4 dropped the "Bundler version " -//! prefix and prints the bare version, so both spellings parse. -//! -//! When NEITHER source yields a version the probe reports [`BundlerProbe:: -//! Unknown`] and callers fail OPEN (wire as before): a machine without -//! bundler may be preparing a repo whose CI has a modern bundler, and -//! refusing there would block every such setup on a guess. - -use std::path::PathBuf; -use std::time::Duration; - -use super::BundlerProject; - -/// Upper bound on the `bundle --version` fallback probe. A wedged bundler -/// (broken RubyGems install, hung shim) must degrade to [`BundlerProbe:: -/// Unknown`] — fail open — rather than hang `setup`/`setup --check` forever. -const BUNDLE_VERSION_TIMEOUT: Duration = Duration::from_secs(10); - -/// Minimum bundler `(major, minor)` able to load a `plugin ... path:` -/// directive. -pub const MIN_BUNDLER: (u64, u64) = (2, 2); - -/// Outcome of probing the project's bundler version. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BundlerProbe { - /// A version at or above [`MIN_BUNDLER`] was detected. - Supported, - /// A version below [`MIN_BUNDLER`] was detected. `version` is the - /// detected version string; `source` names where it was read from - /// (for the refusal message). - Unsupported { version: String, source: String }, - /// No version could be determined (no lock, no `bundle` on PATH, or - /// unparseable output). Callers fail open. - Unknown, -} - -/// The lockfile paired with the project's manifest name: `gems.rb` locks to -/// `gems.locked`, `Gemfile` to `Gemfile.lock` (Bundler's own pairing). -fn lockfile_path(project: &BundlerProject) -> PathBuf { - let lock_name = match project.gemfile.file_name().and_then(|n| n.to_str()) { - Some("gems.rb") => "gems.locked", - _ => "Gemfile.lock", - }; - project.root.join(lock_name) -} - -/// Extract the version under a lock's `BUNDLED WITH` section: the first -/// non-empty line after the header, trimmed. -fn parse_bundled_with(lock: &str) -> Option { - let mut lines = lock.lines(); - while let Some(line) = lines.next() { - if line.trim() != "BUNDLED WITH" { - continue; - } - for candidate in lines.by_ref() { - let candidate = candidate.trim(); - if !candidate.is_empty() { - return looks_like_version(candidate).then(|| candidate.to_string()); - } - } - return None; - } - None -} - -/// Extract a version from `bundle --version` output. Bundler <= 3 prints -/// "Bundler version 2.7.2"; bundler 4 prints the bare "4.0.18". Take the -/// first whitespace token that parses as a dotted version. -fn parse_bundle_version_output(out: &str) -> Option { - out.split_whitespace() - .find(|tok| looks_like_version(tok)) - .map(str::to_string) -} - -/// A token counts as a version when it is `digits.digits[...]` — enough to -/// reject prose without a full semver parser. -fn looks_like_version(tok: &str) -> bool { - let mut parts = tok.split('.'); - let (Some(major), Some(minor)) = (parts.next(), parts.next()) else { - return false; - }; - !major.is_empty() - && major.bytes().all(|b| b.is_ascii_digit()) - && !minor.is_empty() - && minor.bytes().all(|b| b.is_ascii_digit()) -} - -/// Whether `version` (a `major.minor[...]` string) meets [`MIN_BUNDLER`]. -/// `None` when the leading components don't parse. -fn meets_floor(version: &str) -> Option { - let mut parts = version.split('.'); - let major: u64 = parts.next()?.parse().ok()?; - let minor: u64 = parts.next()?.parse().ok()?; - Some((major, minor) >= MIN_BUNDLER) -} - -/// Classify one detected `(version, source)` pair. -fn classify(version: String, source: String) -> BundlerProbe { - match meets_floor(&version) { - Some(true) => BundlerProbe::Supported, - Some(false) => BundlerProbe::Unsupported { version, source }, - // Unparseable leading components: treat as unknown, fail open. - None => BundlerProbe::Unknown, - } -} - -/// Probe the bundler version that will run this project's installs. See the -/// module docs for the source order and the fail-open contract. -pub async fn probe_bundler(project: &BundlerProject) -> BundlerProbe { - probe_bundler_with(project, probe_machine_bundler(project)).await -} - -/// Keep the machine fallback lazy and injectable without changing process-wide -/// PATH. The lockfile always wins, even when the fallback would disagree. -async fn probe_bundler_with( - project: &BundlerProject, - machine_version: impl std::future::Future>, -) -> BundlerProbe { - let lock_path = lockfile_path(project); - // Guarded read: a FIFO/device squatting on the lock path must fail fast - // to the `bundle --version` fallback, not wedge `setup`/`--check` - // forever in `open(2)` — same guard as every other raw read in this - // module tree. - if let Ok(lock) = crate::utils::fs::read_regular_to_string(&lock_path).await { - if let Some(version) = parse_bundled_with(&lock) { - let lock_name = lock_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "Gemfile.lock".to_string()); - return classify(version, format!("{lock_name} BUNDLED WITH")); - } - } - match machine_version.await { - Some(version) => classify(version, "`bundle --version`".to_string()), - None => BundlerProbe::Unknown, - } -} - -async fn probe_machine_bundler(project: &BundlerProject) -> Option { - // No lock (or no BUNDLED WITH): ask the machine's bundler. stdin nulled - // so the child can never block waiting for input; bounded by - // [`BUNDLE_VERSION_TIMEOUT`] (with `kill_on_drop` so a timed-out child is - // reaped, not leaked) so a wedged bundler degrades to `Unknown`. - let output = tokio::time::timeout( - BUNDLE_VERSION_TIMEOUT, - tokio::process::Command::new("bundle") - .arg("--version") - .current_dir(&project.root) - .stdin(std::process::Stdio::null()) - .kill_on_drop(true) - .output(), - ) - .await; - if let Ok(Ok(out)) = output { - if out.status.success() { - return parse_bundle_version_output(&String::from_utf8_lossy(&out.stdout)); - } - } - None -} - -/// The refusal message for an [`BundlerProbe::Unsupported`] project — shared -/// by `setup` (which refuses to wire) so the wording stays consistent. -pub fn unsupported_bundler_message(version: &str, source: &str) -> String { - format!( - "bundler {version} (from {source}) cannot load the socket-patch Bundler \ - plugin: the `plugin ... path:` directive needs bundler >= {}.{}, and on \ - 1.x every later `bundle install` fails resolving 'socket-patch' as an \ - ordinary gem (exit 7) before the plugin registers. Not wiring this \ - project. Upgrade bundler (`gem install bundler`, then `bundle update \ - --bundler`) and re-run `socket-patch setup`", - MIN_BUNDLER.0, MIN_BUNDLER.1 - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::fs; - - #[cfg(unix)] - fn mkfifo(path: &std::path::Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ - colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ - colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; - - async fn project_with(files: &[(&str, &str)]) -> (tempfile::TempDir, BundlerProject) { - let dir = tempfile::tempdir().unwrap(); - for (name, body) in files { - fs::write(dir.path().join(name), body).await.unwrap(); - } - let project = super::super::discover_bundler_project(dir.path()) - .await - .expect("fixture project must be discoverable"); - (dir, project) - } - - #[test] - fn test_parse_bundled_with() { - assert_eq!(parse_bundled_with(LOCK_1X).as_deref(), Some("1.17.3")); - assert_eq!( - parse_bundled_with("BUNDLED WITH\n 2.7.2\n").as_deref(), - Some("2.7.2") - ); - // Blank line(s) between the header and the version are skipped: the - // section yields the first NON-EMPTY line, not the first line. - assert_eq!( - parse_bundled_with("BUNDLED WITH\n\n 2.7.2\n").as_deref(), - Some("2.7.2") - ); - // No section, or a section followed by garbage → None. - assert_eq!(parse_bundled_with("GEM\n specs:\n"), None); - assert_eq!(parse_bundled_with("BUNDLED WITH\n not-a-version\n"), None); - assert_eq!(parse_bundled_with("BUNDLED WITH\n"), None); - } - - #[test] - fn test_parse_bundle_version_output_both_spellings() { - // bundler <= 3 prefix form and bundler 4's bare form. - assert_eq!( - parse_bundle_version_output("Bundler version 2.7.2\n").as_deref(), - Some("2.7.2") - ); - assert_eq!( - parse_bundle_version_output("4.0.18\n").as_deref(), - Some("4.0.18") - ); - assert_eq!(parse_bundle_version_output("command not found\n"), None); - } - - #[test] - fn test_meets_floor_boundaries() { - assert_eq!(meets_floor("1.17.3"), Some(false)); - assert_eq!(meets_floor("2.1.4"), Some(false)); - assert_eq!(meets_floor("2.2.0"), Some(true)); - assert_eq!(meets_floor("2.7.2"), Some(true)); - assert_eq!(meets_floor("4.0.18"), Some(true)); - // Bare major (no minor) or garbage: unknown, never a refusal. - assert_eq!(meets_floor("2"), None); - assert_eq!(meets_floor("abc"), None); - // Digits-only components that overflow u64 also fail to parse — - // the only way a `looks_like_version` token reaches `classify`'s - // fail-open arm. - assert_eq!(meets_floor("99999999999999999999.1"), None); - } - - #[tokio::test] - async fn test_probe_reads_gemfile_lock_bundled_with() { - let (_dir, project) = - project_with(&[("Gemfile", "source 'x'\n"), ("Gemfile.lock", LOCK_1X)]).await; - assert_eq!( - probe_bundler(&project).await, - BundlerProbe::Unsupported { - version: "1.17.3".to_string(), - source: "Gemfile.lock BUNDLED WITH".to_string(), - } - ); - } - - #[tokio::test] - async fn test_probe_supported_lock() { - let (_dir, project) = project_with(&[ - ("Gemfile", "source 'x'\n"), - ("Gemfile.lock", "BUNDLED WITH\n 2.7.2\n"), - ]) - .await; - assert_eq!(probe_bundler(&project).await, BundlerProbe::Supported); - } - - #[tokio::test] - async fn test_probe_gems_rb_pairs_with_gems_locked() { - // A gems.rb project locks to gems.locked — a stray Gemfile.lock (from - // before a rename) must NOT be consulted for it. - let (_dir, project) = project_with(&[ - ("gems.rb", "source 'x'\n"), - ("gems.locked", LOCK_1X), - ("Gemfile.lock", "BUNDLED WITH\n 2.7.2\n"), - ]) - .await; - assert_eq!( - probe_bundler(&project).await, - BundlerProbe::Unsupported { - version: "1.17.3".to_string(), - source: "gems.locked BUNDLED WITH".to_string(), - } - ); - } - - #[tokio::test] - async fn test_probe_lock_beats_machine_bundler() { - // With a lock present the machine's `bundle --version` is never - // consulted: RubyGems' version switching makes the LOCKED bundler the - // one that runs installs. (This also keeps the probe deterministic on - // hosts whose bundler differs from the project's.) - let (_dir, project) = project_with(&[ - ("Gemfile", "source 'x'\n"), - ("Gemfile.lock", "BUNDLED WITH\n 1.17.3\n"), - ]) - .await; - // The fallback must not even be polled when the lock has a version. - assert!(matches!( - probe_bundler_with(&project, async { panic!("lock must bypass machine probe") }).await, - BundlerProbe::Unsupported { .. } - )); - } - - #[tokio::test] - async fn test_probe_lock_overflow_version_is_unknown_fail_open() { - // A digits-only version whose major component overflows u64 gets past - // `looks_like_version` but not `meets_floor`: `classify` must fail - // OPEN (Unknown), never refuse. The lock branch answers directly — - // the machine's `bundle --version` is not consulted — so the verdict - // is deterministic on any host. - let (_dir, project) = project_with(&[ - ("Gemfile", "source 'x'\n"), - ("Gemfile.lock", "BUNDLED WITH\n 99999999999999999999.1\n"), - ]) - .await; - assert_eq!(probe_bundler(&project).await, BundlerProbe::Unknown); - } - - #[tokio::test] - async fn test_probe_lock_without_bundled_with_falls_through_to_machine() { - // A readable lock with no BUNDLED WITH section (pre-1.10 bundler - // locks, hand-trimmed locks) must fall through to the machine's - // `bundle --version` probe — not classify the sectionless lock. - let (_dir, project) = project_with(&[ - ("Gemfile", "source 'x'\n"), - ("Gemfile.lock", "GEM\n specs:\n"), - ]) - .await; - // Every fallback outcome is valid, including old system Bundler. - // Inject it so this exercises the decision instead of the test host. - for (version, expected) in [ - (Some("2.7.2"), BundlerProbe::Supported), - ( - Some("1.17.2"), - BundlerProbe::Unsupported { - version: "1.17.2".into(), - source: "`bundle --version`".into(), - }, - ), - (None, BundlerProbe::Unknown), - ] { - assert_eq!( - probe_bundler_with(&project, async { version.map(str::to_owned) }).await, - expected - ); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn test_probe_fifo_lockfile_does_not_wedge() { - // A FIFO squatting on Gemfile.lock: a plain `read_to_string` - // open(2) waits for a writer that never comes, wedging `setup`/ - // `--check` (and every Gemfile-block update, which probes first) - // forever inside `probe_bundler`. Same class as the mod.rs - // `read_regular_to_string` guards; the probe must skip the - // non-regular lock and fall through to the `bundle --version` - // probe (itself bounded by [`BUNDLE_VERSION_TIMEOUT`]). - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("Gemfile"), "source 'x'\n") - .await - .unwrap(); - mkfifo(&dir.path().join("Gemfile.lock")); - let project = super::super::discover_bundler_project(dir.path()) - .await - .expect("fixture project must be discoverable"); - - // Inject a prompt machine reply so this tests only the guarded - // lockfile read. On timeout the open is wedged in - // a `spawn_blocking` thread the runtime waits for on shutdown; - // connect a writer to release it so the test can FAIL instead of - // hanging the whole suite. - let deadline = Duration::from_secs(5); - let probe = probe_bundler_with(&project, async { Some("2.7.2".to_string()) }); - let Ok(probe) = tokio::time::timeout(deadline, probe).await else { - let _ = std::fs::OpenOptions::new() - .write(true) - .open(dir.path().join("Gemfile.lock")); - panic!("probe_bundler must complete promptly with a FIFO lockfile"); - }; - // The FIFO has no bytes to parse; the supplied machine reply wins. - assert_eq!(probe, BundlerProbe::Supported); - } - - #[test] - fn test_unsupported_message_names_version_floor_and_remedy() { - let msg = unsupported_bundler_message("1.17.3", "Gemfile.lock BUNDLED WITH"); - assert!(msg.contains("1.17.3")); - assert!(msg.contains(">= 2.2")); - assert!(msg.contains("gem install bundler")); - assert!(msg.contains("socket-patch setup")); - } -} diff --git a/crates/socket-patch-core/src/setup/mod.rs b/crates/socket-patch-core/src/setup/mod.rs deleted file mode 100644 index 96f9787a..00000000 --- a/crates/socket-patch-core/src/setup/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! 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. -//! -//! The backends: -//! -//! * [`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 deleted file mode 100644 index 7ba4dd16..00000000 --- a/crates/socket-patch-core/src/setup/npm.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! 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/setup/pypi/detect.rs b/crates/socket-patch-core/src/setup/pypi/detect.rs deleted file mode 100644 index f756b898..00000000 --- a/crates/socket-patch-core/src/setup/pypi/detect.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! Detect a Python project's dependency manager and probe for the hook dep. - -use std::path::Path; - -use crate::utils::fs::read_regular_to_string; -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` -/// carrier). A single, familiar line. Classic Poetry can't express an extra as -/// a bare key, so [`super::edit`] emits the equivalent -/// `socket-patch = { extras = ["hook"] }` there instead. -pub(crate) const HOOK_DEP: &str = "socket-patch[hook]"; - -/// Substrings (space-insensitive, lower-cased) that mean the hook is already -/// declared — the `socket-patch[hook]` extra, the standalone wheel, or the -/// underscore spelling. (The Poetry `extras = ["hook"]` form is detected -/// structurally by [`super::edit`], not by this textual check.) -const HOOK_MARKERS: &[&str] = &[ - "socket-patch[hook]", - "socket-patch-hook", - "socket_patch_hook", -]; - -/// Which Python dependency-management style a project uses. Drives both which -/// manifest/table `setup` edits and which lockfile (if any) to refresh. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PythonPackageManager { - Uv, - Poetry, - Pdm, - Hatch, - Pip, -} - -impl PythonPackageManager { - pub fn as_str(&self) -> &'static str { - match self { - Self::Uv => "uv", - Self::Poetry => "poetry", - Self::Pdm => "pdm", - Self::Hatch => "hatch", - Self::Pip => "pip", - } - } - - /// The lockfile-refresh invocations `(program, spellings)` for managers - /// whose frozen CI install reads a lockfile that must be regenerated - /// after editing the dependency list. Each arg-list is tried in order - /// until one succeeds: the first is the pin-preserving spelling where - /// the tool has a distinct one (`poetry lock --no-update` on Poetry 1.x — - /// bare `poetry lock` re-resolves the user's whole pinned set there; - /// `pdm lock --update-reuse`), the last is the bare `lock` accepted - /// everywhere (already pin-preserving on Poetry 2.x, where `--no-update` - /// was removed, and on uv). `None` for managers that resolve dependencies - /// directly from the manifest at install time (pip, hatch). - pub fn lock_commands(&self) -> Option<(&'static str, &'static [&'static [&'static str]])> { - match self { - Self::Uv => Some(("uv", &[&["lock"]])), - Self::Poetry => Some(("poetry", &[&["lock", "--no-update"], &["lock"]])), - Self::Pdm => Some(("pdm", &[&["lock", "--update-reuse"], &["lock"]])), - Self::Hatch | Self::Pip => None, - } - } -} - -/// Detect the dependency manager from lockfiles and `pyproject.toml` tables. -/// -/// Lockfiles are the strongest signal; `[tool.*]` tables come next; a project -/// with only `requirements.txt` / a PEP 621 `pyproject.toml` falls through to -/// `Pip`. -pub async fn detect_python_pm(cwd: &Path) -> PythonPackageManager { - if tokio::fs::metadata(cwd.join("uv.lock")).await.is_ok() { - return PythonPackageManager::Uv; - } - if tokio::fs::metadata(cwd.join("pdm.lock")).await.is_ok() { - return PythonPackageManager::Pdm; - } - if tokio::fs::metadata(cwd.join("poetry.lock")).await.is_ok() { - return PythonPackageManager::Poetry; - } - // Guarded read (shared with the gem/composer/npm setup twins): a FIFO - // planted at `pyproject.toml` fails fast to the `Pip` fallback instead of - // wedging `setup`/`--check` forever in an `open(2)` that waits for a - // writer — the `is_python_project` gate ahead of detection is - // metadata-only and does not filter these. - if let Ok(content) = read_regular_to_string(&cwd.join("pyproject.toml")).await { - // Header-anchored checks so a stray substring in a value/comment does - // not misclassify. - if has_table(&content, "tool.uv") { - return PythonPackageManager::Uv; - } - if has_table(&content, "tool.poetry") { - return PythonPackageManager::Poetry; - } - if has_table(&content, "tool.pdm") { - return PythonPackageManager::Pdm; - } - if has_table(&content, "tool.hatch") { - return PythonPackageManager::Hatch; - } - } - PythonPackageManager::Pip -} - -/// 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. -pub fn deps_contain_hook(text: &str) -> bool { - // Normalize per line: drop intra-line whitespace so `socket-patch [hook]` - // matches, but keep line boundaries intact. Stripping newlines too would - // glue adjacent specs together (this is called on whole-file content by - // `setup`'s state probe), turning a trailing `socket-patch` plus a following - // `[hook]` into a phantom marker — a false positive. - text.lines().any(|line| { - // Drop a `#` comment first (requirements.txt and TOML both comment - // with `#`): a commented-out `# socket-patch[hook]` declares nothing — - // pip never installs it — and a marker mentioned inside a trailing - // comment must not read as configured. - let spec = match line.find('#') { - Some(i) => &line[..i], - None => line, - }; - let normalized: String = spec - .to_lowercase() - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - if HOOK_MARKERS.iter().any(|m| normalized.contains(*m)) { - return true; - } - // PEP 503 makes `-`/`_`/`.` interchangeable in package names and PEP - // 508 lets the hook extra ride with others (`socket-patch[cli,hook]`), - // so pip installs the hook from spellings the markers above miss - // (`socket_patch[hook]`). Canonicalize and probe for the wheel name or - // a `socket-patch[...]` extras list containing `hook`. - let canon: String = normalized - .chars() - .map(|c| if c == '_' || c == '.' { '-' } else { c }) - .collect(); - if canon.contains("socket-patch-hook") { - return true; - } - canon.match_indices("socket-patch[").any(|(i, m)| { - let rest = &canon[i + m.len()..]; - match rest.find(']') { - Some(end) => rest[..end].split(',').any(|e| e == "hook"), - None => false, - } - }) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_deps_contain_hook_positive_forms() { - assert!(deps_contain_hook("socket-patch[hook]")); - assert!(deps_contain_hook("socket-patch [hook]")); - assert!(deps_contain_hook("Socket-Patch[hook]>=3.3.0")); - assert!(deps_contain_hook("socket-patch-hook==3.3.0")); - assert!(deps_contain_hook("socket_patch_hook")); - } - - #[test] - fn test_deps_contain_hook_pep503_and_combined_extras() { - // PEP 503: `-`, `_`, `.` are interchangeable in the name — pip - // installs the hook from all of these. - assert!(deps_contain_hook("socket_patch[hook]")); - assert!(deps_contain_hook("socket.patch[hook]==3.3.0")); - assert!(deps_contain_hook("Socket_Patch [hook]")); - assert!(deps_contain_hook("socket.patch_hook")); - // PEP 508: the hook extra combined with others still declares it. - assert!(deps_contain_hook("socket-patch[cli,hook]>=3.3.0")); - assert!(deps_contain_hook("socket-patch[ hook , cli ]")); - assert!(deps_contain_hook("socket_patch[cli,hook]")); - // Some other extra alone is NOT the hook, `hooky` is a different - // extra, and an unterminated bracket is not a spec. - assert!(!deps_contain_hook("socket_patch[cli]")); - assert!(!deps_contain_hook("socket-patch[hooky]")); - assert!(!deps_contain_hook("socket-patch[hook")); - } - - #[test] - fn test_deps_contain_hook_negative() { - // A plain socket-patch dependency is NOT the hook. - assert!(!deps_contain_hook("socket-patch>=3.3.0")); - assert!(!deps_contain_hook("requests==2.31.0")); - assert!(!deps_contain_hook("")); - } - - #[test] - fn test_deps_contain_hook_no_cross_line_glue() { - // `deps_contain_hook` is run on whole-file content by the setup state - // probe. Two unrelated specs on adjacent lines must NOT be glued into - // a phantom `socket-patch[hook]` marker. - let requirements = "socket-patch\n[hook]\nrequests\n"; - assert!(!deps_contain_hook(requirements)); - - // A wrapped TOML dependency array around a plain socket-patch dep also - // must not synthesize the marker across line breaks. - let pyproject = "dependencies = [\n \"socket-patch\",\n]\nextras = [\"hook\"]\n"; - assert!(!deps_contain_hook(pyproject)); - } - - #[test] - fn test_deps_contain_hook_real_marker_in_multiline() { - // The genuine hook spec on its own line within whole-file content is - // still detected (intra-line spaces tolerated). - let requirements = "requests==2.31.0\nsocket-patch [hook]\nflask\n"; - assert!(deps_contain_hook(requirements)); - let pyproject = "dependencies = [\n \"requests\",\n \"socket-patch[hook]>=3.3.0\",\n]\n"; - assert!(deps_contain_hook(pyproject)); - } - - #[test] - fn test_deps_contain_hook_commented_out_is_not_declared() { - // A commented-out spec declares nothing: pip never installs it, and - // the edit path (`requirements_add` strips comments before probing) - // would still add the hook — so the state probe / `setup --check` - // must not read it as configured. - assert!(!deps_contain_hook( - "# socket-patch[hook]\nrequests==2.31.0\n" - )); - // A marker mentioned inside another dep's trailing comment is not a - // declaration either. - assert!(!deps_contain_hook( - "requests==2.31.0 # TODO: add socket-patch[hook]\n" - )); - // But a real spec WITH a trailing comment is still declared. - assert!(deps_contain_hook( - "socket-patch[hook] # the .pth carrier\n" - )); - } - - #[test] - fn test_has_table() { - let toml = "[tool.poetry]\nname='x'\n[tool.poetry.dependencies]\n"; - assert!(has_table(toml, "tool.poetry")); - assert!(!has_table(toml, "tool.pdm")); - assert!(has_table("[project]\n", "project")); - // not fooled by a value that contains the text - assert!(!has_table("name = \"tool.poetry helper\"\n", "tool.poetry")); - } - - #[test] - fn test_has_table_trailing_comment_and_padding() { - // A trailing inline comment after the header is valid TOML and must - // not defeat detection (`trim_end_matches(']')` alone would leave the - // comment glued to the header). - assert!(has_table("[tool.uv] # the uv table\n", "tool.uv")); - assert!(has_table("[tool.uv.sources] # comment\n", "tool.uv")); - // Interior padding inside the brackets is also valid TOML. - assert!(has_table("[ tool.pdm ]\n", "tool.pdm")); - // Array-of-tables form, with a comment, still resolves the namespace. - assert!(has_table("[[tool.poetry.source]] # extra\n", "tool.poetry")); - // A sibling prefix must still not match (no spurious widening). - assert!(!has_table("[tool.uvicorn] # web\n", "tool.uv")); - } - - #[tokio::test] - async fn test_detect_uv_by_lock() { - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write(dir.path().join("uv.lock"), "") - .await - .unwrap(); - assert_eq!(detect_python_pm(dir.path()).await, PythonPackageManager::Uv); - } - - #[tokio::test] - async fn test_detect_poetry_by_table() { - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write( - dir.path().join("pyproject.toml"), - "[tool.poetry]\nname = \"x\"\n", - ) - .await - .unwrap(); - assert_eq!( - detect_python_pm(dir.path()).await, - PythonPackageManager::Poetry - ); - } - - /// mkfifo(2) directly rather than shelling out to the `mkfifo` binary — - /// same helper as the find.rs FIFO tests: fork/exec flakes under heavy - /// parallel load and the syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - /// A FIFO planted as `pyproject.toml` must not wedge detection. The - /// `is_python_project` gate ahead of `detect_python_pm` is metadata-only - /// (a FIFO stats fine), so a plain `read_to_string` open(2) here waits - /// for a writer that never comes, wedging `setup`/`--check` and the - /// configured-ecosystems probe indefinitely with no error and no - /// timeout. Same class as the `open_regular_file` guards in the - /// npm/composer/gem setup twins and the crawlers. The non-regular file - /// must instead fail fast to the `Pip` fallback. - #[cfg(unix)] - #[tokio::test] - async fn test_detect_fifo_pyproject_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("pyproject.toml"); - mkfifo(&fifo); - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a writer to release - // it so the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(pm) = tokio::time::timeout(deadline, detect_python_pm(dir.path())).await else { - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); - panic!("detect_python_pm must complete promptly with a FIFO pyproject.toml"); - }; - assert_eq!(pm, PythonPackageManager::Pip); - } - - #[tokio::test] - async fn test_detect_uv_by_tool_table() { - // A uv-configured project before its first `uv lock`: no uv.lock yet, - // only the `[tool.uv]` config table in pyproject.toml. - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n\n[tool.uv]\ndev-dependencies = []\n", - ) - .await - .unwrap(); - assert_eq!(detect_python_pm(dir.path()).await, PythonPackageManager::Uv); - } - - #[tokio::test] - async fn test_detect_pdm_by_tool_table() { - // A PDM project pre-first-lock: `[tool.pdm]` table, no pdm.lock. - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n\n[tool.pdm]\ndistribution = true\n", - ) - .await - .unwrap(); - assert_eq!( - detect_python_pm(dir.path()).await, - PythonPackageManager::Pdm - ); - } - - #[tokio::test] - async fn test_detect_hatch_by_tool_subtable() { - // Real Hatch projects rarely declare a bare `[tool.hatch]` — config - // lives in sub-tables like `[tool.hatch.envs.default]`. Detection - // must resolve the namespace prefix (has_table), and Hatch never has - // a lockfile, so the table branch is the ONLY signal. - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n\n[tool.hatch.envs.default]\ndependencies = []\n", - ) - .await - .unwrap(); - let pm = detect_python_pm(dir.path()).await; - assert_eq!(pm, PythonPackageManager::Hatch); - // The detected manager is surfaced in the JSON envelope via as_str. - assert_eq!(pm.as_str(), "hatch"); - } - - #[tokio::test] - async fn test_detect_table_precedence_pdm_before_hatch() { - // Documents the uv > poetry > pdm > hatch table ordering: a project - // carrying BOTH `[tool.pdm]` and `[tool.hatch.*]` (e.g. hatchling as - // build backend, pdm as the workflow tool) resolves to Pdm. - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write( - dir.path().join("pyproject.toml"), - "[tool.pdm]\n\n[tool.hatch.envs.default]\ndependencies = []\n", - ) - .await - .unwrap(); - assert_eq!( - detect_python_pm(dir.path()).await, - PythonPackageManager::Pdm - ); - } - - #[tokio::test] - async fn test_detect_pip_fallback() { - let dir = tempfile::tempdir().unwrap(); - tokio::fs::write(dir.path().join("requirements.txt"), "requests\n") - .await - .unwrap(); - assert_eq!( - detect_python_pm(dir.path()).await, - PythonPackageManager::Pip - ); - } - - #[test] - fn test_lock_commands() { - assert_eq!( - PythonPackageManager::Uv.lock_commands(), - Some(("uv", &[&["lock"][..]][..])) - ); - // Pin-preserving spelling first, bare `lock` fallback for versions - // that dropped the flag (Poetry 2.x). - assert_eq!( - PythonPackageManager::Poetry.lock_commands(), - Some(("poetry", &[&["lock", "--no-update"][..], &["lock"][..]][..])) - ); - assert_eq!( - PythonPackageManager::Pdm.lock_commands(), - Some(("pdm", &[&["lock", "--update-reuse"][..], &["lock"][..]][..])) - ); - assert_eq!(PythonPackageManager::Pip.lock_commands(), None); - assert_eq!(PythonPackageManager::Hatch.lock_commands(), None); - } -} diff --git a/crates/socket-patch-core/src/setup/pypi/edit.rs b/crates/socket-patch-core/src/setup/pypi/edit.rs deleted file mode 100644 index 926dbc54..00000000 --- a/crates/socket-patch-core/src/setup/pypi/edit.rs +++ /dev/null @@ -1,1297 +0,0 @@ -//! Add / remove the `socket-patch[hook]` dependency in a project's manifest. -//! -//! Two manifest kinds are supported: -//! * **pyproject.toml** — edited with `toml_edit` so the user's existing -//! formatting and comments are preserved. Targets the PEP 621 -//! `[project].dependencies` array, or a classic Poetry -//! `[tool.poetry.dependencies]` table when that is the only dependency -//! surface present. -//! * **requirements.txt** — a plain line append / removal. -//! -//! All operations are idempotent and honour `dry_run` (compute the result and -//! report status without writing). This mirrors the contracts of -//! [`crate::package_json::update`] for the npm side. - -use std::path::Path; -use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; - -use super::detect::{deps_contain_hook, HOOK_DEP}; -// Guarded read shared with the detect.rs/gem/composer/npm setup twins: a -// FIFO planted at `requirements.txt` / `pyproject.toml` fails fast to `Error` -// instead of wedging `setup` / `setup --remove` forever in an `open(2)` that -// waits for a writer — detection never opens the manifest it hands the edit -// path (a lockfile routes here without a read, and the Pip fallback targets -// `requirements.txt` sight-unseen). -use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; -use crate::utils::python_lock::preserve_line_endings; -use crate::utils::toml_edit_ext::ensure_table; -use crate::vendor::common::detect_eol; - -/// Which manifest format a path is. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ManifestKind { - Pyproject, - Requirements, -} - -/// Outcome of editing one manifest. Mirrors `package_json::update::UpdateStatus`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PthStatus { - Updated, - AlreadyConfigured, - Error, -} - -#[derive(Debug, Clone)] -pub struct PthEditResult { - pub path: String, - pub status: PthStatus, - pub error: Option, -} - -impl PthEditResult { - fn ok(path: &Path, status: PthStatus) -> Self { - Self { - path: path.display().to_string(), - status, - error: None, - } - } - fn err(path: &Path, msg: impl Into) -> Self { - Self { - path: path.display().to_string(), - status: PthStatus::Error, - error: Some(msg.into()), - } - } -} - -/// Shared tail of add/remove: `None` means already in the desired state, -/// `Some(new_content)` is written atomically (unless `dry_run`). -async fn finish( - path: &Path, - dry_run: bool, - outcome: Result, String>, -) -> PthEditResult { - match outcome { - Ok(None) => PthEditResult::ok(path, PthStatus::AlreadyConfigured), - Ok(Some(new_content)) => { - if !dry_run { - // Mode-preserving: these are user-owned manifests we merely - // edit; the plain writer's fresh stage inode would reset a - // 0600 pyproject.toml / requirements.txt to umask defaults. - if let Err(e) = - atomic_write_bytes_preserving_mode(path, new_content.as_bytes()).await - { - return PthEditResult::err(path, e.to_string()); - } - } - PthEditResult::ok(path, PthStatus::Updated) - } - Err(e) => PthEditResult::err(path, e), - } -} - -/// Add the hook dependency to a manifest. Idempotent. -pub async fn add_hook_dependency(path: &Path, kind: ManifestKind, dry_run: bool) -> PthEditResult { - let content = match read_regular_to_string(path).await { - Ok(c) => c, - // A missing requirements.txt is created (the pip-from-scratch path); - // a missing pyproject.toml is an error (we don't synthesize one). - Err(e) - if e.kind() == std::io::ErrorKind::NotFound && kind == ManifestKind::Requirements => - { - String::new() - } - Err(e) => return PthEditResult::err(path, e.to_string()), - }; - - let outcome = match kind { - ManifestKind::Pyproject => pyproject_add(&content), - ManifestKind::Requirements => Ok(requirements_add(&content)), - }; - finish(path, dry_run, outcome).await -} - -/// Remove the hook dependency from a manifest. Idempotent (already-absent -> -/// `AlreadyConfigured`, i.e. nothing to do). -pub async fn remove_hook_dependency( - path: &Path, - kind: ManifestKind, - dry_run: bool, -) -> PthEditResult { - let content = match read_regular_to_string(path).await { - Ok(c) => c, - // Nothing on disk → nothing to remove (idempotent no-op). - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return PthEditResult::ok(path, PthStatus::AlreadyConfigured) - } - Err(e) => return PthEditResult::err(path, e.to_string()), - }; - - let outcome = match kind { - ManifestKind::Pyproject => pyproject_remove(&content), - ManifestKind::Requirements => Ok(requirements_remove(&content)), - }; - finish(path, dry_run, outcome).await -} - -// ── requirements.txt ──────────────────────────────────────────────────────── -// The dominant-newline probe (`detect_eol`) keeps CRLF files CRLF. - -/// Returns `Some(new_content)` if a line was appended, `None` if already there. -fn requirements_add(content: &str) -> Option { - if deps_contain_hook(content) { - return None; - } - let nl = detect_eol(content); - let mut new = content.to_string(); - if !new.is_empty() && !new.ends_with('\n') { - new.push_str(nl); - } - new.push_str(HOOK_DEP); - new.push_str(nl); - Some(new) -} - -/// Returns `Some(new_content)` if any hook line was removed, `None` otherwise. -fn requirements_remove(content: &str) -> Option { - let kept: Vec<&str> = content.lines().filter(|l| !deps_contain_hook(l)).collect(); - if kept.len() == content.lines().count() { - return None; - } - let nl = detect_eol(content); - let mut new = kept.join(nl); - if !new.is_empty() { - new.push_str(nl); - } - Some(new) -} - -// ── pyproject.toml ─────────────────────────────────────────────────────────── - -/// Returns `Some(new_content)` if the doc was modified, `None` if the hook dep -/// was already present, or `Err` on malformed TOML / wrong-typed tables. -fn pyproject_add(content: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid pyproject.toml: {e}"))?; - - // Prefer PEP 621 `[project].dependencies` when there is a *real* PEP 621 - // surface; otherwise fall back to a classic Poetry `[tool.poetry]` table. - // A `[project]` table that exists only implicitly (e.g. conjured by a - // `[project.urls]` sub-table in a Poetry-1.x project) is NOT a real PEP 621 - // surface — routing such a project to PEP 621 would add a - // `[project].dependencies` that Poetry ignores at install time. The inner - // helpers detect an already-present hook dependency structurally (which the - // textual marker check can't, e.g. a Poetry `extras = ["hook"]` table). - let real_pep621 = doc - .get("project") - .and_then(Item::as_table) - .map(|t| !t.is_implicit() || t.contains_key("dependencies")) - .unwrap_or(false); - let has_poetry = doc - .get("tool") - .and_then(Item::as_table) - .and_then(|t| t.get("poetry")) - .and_then(Item::as_table) - .is_some(); - // PEP 621 forbids a field that is both listed in `dynamic` and set - // statically, so a project with `dynamic = ["dependencies"]` (setuptools/ - // hatch dynamic metadata, or Poetry 2.x keeping its dependency surface in - // `[tool.poetry.dependencies]`) must not gain a static array — every - // backend would refuse to build the manifest. - let dynamic_deps = doc - .get("project") - .and_then(Item::as_table) - .and_then(|t| t.get("dynamic")) - .and_then(Item::as_array) - .map(|a| a.iter().any(|v| v.as_str() == Some("dependencies"))) - .unwrap_or(false); - - let changed = if has_poetry && (!real_pep621 || dynamic_deps) { - poetry_add(&mut doc)? - } else if real_pep621 && !dynamic_deps { - pep621_add(&mut doc)? - } else if dynamic_deps { - return Err( - "pyproject.toml declares `[project].dependencies` as dynamic; adding a static \ - dependencies array would make the manifest invalid — declare the hook in the \ - source the dynamic metadata is resolved from (or use requirements.txt) instead" - .to_string(), - ); - } else { - // Neither surface exists (e.g. a `[build-system]`-only or tool-config-only - // pyproject.toml of a setup.py/setup.cfg project). Synthesizing a - // `[project]` table with only `dependencies` would make the manifest - // invalid — PEP 621 requires `name` and forbids declaring it dynamic — so - // pip/setuptools/uv would refuse to build. Fail closed instead. - return Err( - "pyproject.toml has no `[project]` or `[tool.poetry]` table to host the hook \ - dependency; declare project dependencies (or use requirements.txt) first" - .to_string(), - ); - }; - Ok(changed.then(|| render_pyproject(content, &doc))) -} - -fn pyproject_remove(content: &str) -> Result, String> { - let mut doc = content - .parse::() - .map_err(|e| format!("Invalid pyproject.toml: {e}"))?; - - let mut changed = false; - changed |= pep621_remove(&mut doc); - changed |= poetry_remove(&mut doc); - - Ok(changed.then(|| render_pyproject(content, &doc))) -} - -/// Render an edited pyproject document in the original's newline convention. -/// toml_edit (0.25.x) re-emits every line terminator as `\n`, untouched -/// lines included, so without this a CRLF checkout is rewritten wholesale -/// and `--remove` could never hand back the pre-setup bytes. -fn render_pyproject(original: &str, doc: &DocumentMut) -> String { - preserve_line_endings(original, doc.to_string()) -} - -fn pep621_add(doc: &mut DocumentMut) -> Result { - let root = doc.as_table_mut(); - let project = ensure_table(root, "project", false)?; - if !project.contains_key("dependencies") { - project.insert("dependencies", Item::Value(Value::Array(Array::new()))); - } - let deps = project - .get_mut("dependencies") - .and_then(Item::as_array_mut) - .ok_or("`project.dependencies` is not an array")?; - if deps - .iter() - .any(|v| v.as_str().map(deps_contain_hook).unwrap_or(false)) - { - return Ok(false); - } - deps.push(HOOK_DEP); - Ok(true) -} - -fn pep621_remove(doc: &mut DocumentMut) -> bool { - let deps = match doc - .get_mut("project") - .and_then(Item::as_table_mut) - .and_then(|p| p.get_mut("dependencies")) - .and_then(Item::as_array_mut) - { - Some(d) => d, - None => return false, - }; - let before = deps.len(); - deps.retain(|v| !v.as_str().map(deps_contain_hook).unwrap_or(false)); - deps.len() != before -} - -fn poetry_add(doc: &mut DocumentMut) -> Result { - let root = doc.as_table_mut(); - let tool = ensure_table(root, "tool", true)?; - let poetry = ensure_table(tool, "poetry", true)?; - let deps = ensure_table(poetry, "dependencies", false)?; - - // Classic Poetry can't express `socket-patch[hook]` as a key, so declare - // the equivalent: `socket-patch` carrying the `hook` extra. Already wired - // if a bare `socket-patch-hook` key exists or the extra is already present - // — matched canonically, since Poetry accepts any PEP 503 spelling. - if poetry_dep_key(deps, "socket-patch-hook").is_some() { - return Ok(false); - } - if let Some(key) = poetry_dep_key(deps, "socket-patch") { - let item = deps.get_mut(&key).expect("key came from this table"); - if item_has_hook_extra(item) { - return Ok(false); - } - // An existing `socket-patch` dep (bare string or a table): merge the - // `hook` extra in place, preserving its version / source / markers. - if let Some(tbl) = item.as_table_like_mut() { - let mut extras = tbl - .get("extras") - .and_then(Item::as_array) - .cloned() - .unwrap_or_default(); - extras.push("hook"); - tbl.insert("extras", Item::Value(Value::Array(extras))); - } else if let Some(version) = item.as_str().map(str::to_string) { - deps.insert(&key, Item::Value(hook_inline_table(&version))); - } else { - // Any other shape (e.g. Poetry's multiple-constraints array of - // tables) carries spec data a blanket replacement would destroy. - return Err( - "`tool.poetry.dependencies.socket-patch` has an unsupported shape; \ - add the `hook` extra to it manually" - .to_string(), - ); - } - return Ok(true); - } - deps.insert("socket-patch", Item::Value(hook_inline_table("*"))); - Ok(true) -} - -fn poetry_remove(doc: &mut DocumentMut) -> bool { - let deps = match doc - .get_mut("tool") - .and_then(Item::as_table_mut) - .and_then(|t| t.get_mut("poetry")) - .and_then(Item::as_table_mut) - .and_then(|p| p.get_mut("dependencies")) - .and_then(Item::as_table_mut) - { - Some(d) => d, - None => return false, - }; - - let mut changed = false; - // Drop a legacy bare `socket-patch-hook` key (any PEP 503 spelling). - if let Some(key) = poetry_dep_key(deps, "socket-patch-hook") { - deps.remove(&key); - changed = true; - } - // Strip the `hook` extra from a `socket-patch` dep table, leaving the rest - // of the spec intact. - if let Some(tbl) = poetry_dep_key(deps, "socket-patch") - .and_then(|key| deps.get_mut(&key)) - .and_then(Item::as_table_like_mut) - { - if let Some(extras) = tbl.get_mut("extras").and_then(Item::as_array_mut) { - let before = extras.len(); - extras.retain(|v| !v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("hook"))); - if extras.len() != before { - changed = true; - } - if extras.is_empty() { - tbl.remove("extras"); - } - } - } - changed -} - -/// PEP 503 canonical form of a package name: `-`/`_`/`.` are interchangeable -/// and comparison is case-insensitive. Poetry accepts any spelling as a -/// dependency key, so the structural helpers must match keys canonically — -/// the textual probe ([`super::detect::deps_contain_hook`]) already does. -fn canonical_pypi_name(name: &str) -> String { - name.to_lowercase() - .chars() - .map(|c| if c == '_' || c == '.' { '-' } else { c }) - .collect() -} - -/// Find the key in a Poetry dependencies table whose canonical form is -/// `canonical`, returning the user's spelling so edits land on it in place -/// (inserting under the canonical name next to a variant-spelled key would -/// declare the dependency twice — Poetry rejects that). -fn poetry_dep_key(deps: &Table, canonical: &str) -> Option { - deps.iter() - .map(|(k, _)| k) - .find(|k| canonical_pypi_name(k) == canonical) - .map(str::to_string) -} - -/// Build `{ version = "", extras = ["hook"] }`. -fn hook_inline_table(version: &str) -> Value { - let mut it = InlineTable::new(); - it.insert("version", Value::from(version)); - let mut extras = Array::new(); - extras.push("hook"); - it.insert("extras", Value::Array(extras)); - Value::InlineTable(it) -} - -/// True if a dependency item (inline table or sub-table) already carries the -/// `hook` extra (case-insensitively — PEP 685 normalizes extras names). -fn item_has_hook_extra(item: &Item) -> bool { - item.as_table_like() - .and_then(|t| t.get("extras")) - .and_then(Item::as_array) - .map(|a| { - a.iter() - .any(|v| v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("hook"))) - }) - .unwrap_or(false) -} - -/// True if a parsed `pyproject.toml` already declares the hook dependency in any -/// form `setup` could have written: a PEP 621 `[project].dependencies` entry, a -/// classic-Poetry `socket-patch` dep carrying the `hook` extra, or a legacy bare -/// `socket-patch-hook` key. -/// -/// This is the structural counterpart to the textual -/// [`super::detect::deps_contain_hook`]. It exists because `poetry_add` writes -/// the hook as `socket-patch = { version = "*", extras = ["hook"] }`, which has -/// no literal `socket-patch[hook]` substring — so the textual probe reports a -/// freshly-and-correctly-configured classic-Poetry project as *unconfigured*. -/// The `setup --check` / state probes must use this for `pyproject.toml` so a -/// round-trip (setup → check) is consistent. Falls back to the textual check on -/// unparseable TOML (best effort rather than a hard failure). -pub fn pyproject_contains_hook(content: &str) -> bool { - let doc = match content.parse::() { - Ok(d) => d, - Err(_) => return deps_contain_hook(content), - }; - - // PEP 621 `[project].dependencies` (the textual `socket-patch[hook]` spec, - // or the bare `socket-patch-hook` wheel). - let in_pep621 = doc - .get("project") - .and_then(Item::as_table) - .and_then(|p| p.get("dependencies")) - .and_then(Item::as_array) - .map(|deps| { - deps.iter() - .any(|v| v.as_str().map(deps_contain_hook).unwrap_or(false)) - }) - .unwrap_or(false); - if in_pep621 { - return true; - } - - // Classic Poetry `[tool.poetry.dependencies]`: a bare `socket-patch-hook` - // key, or a `socket-patch` dep carrying the `hook` extra. - if let Some(deps) = doc - .get("tool") - .and_then(Item::as_table) - .and_then(|t| t.get("poetry")) - .and_then(Item::as_table) - .and_then(|p| p.get("dependencies")) - .and_then(Item::as_table) - { - if poetry_dep_key(deps, "socket-patch-hook").is_some() { - return true; - } - if let Some(item) = poetry_dep_key(deps, "socket-patch").and_then(|key| deps.get(&key)) { - if item_has_hook_extra(item) { - return true; - } - } - } - - false -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── requirements.txt ───────────────────────────────────────────── - - #[test] - fn test_requirements_add() { - let out = requirements_add("requests==2.31.0\n").unwrap(); - assert!(out.contains("requests==2.31.0")); - assert!(out.contains("socket-patch[hook]")); - assert!(out.ends_with('\n')); - } - - #[test] - fn test_requirements_add_no_trailing_newline() { - let out = requirements_add("requests").unwrap(); - assert_eq!(out, "requests\nsocket-patch[hook]\n"); - } - - #[test] - fn test_requirements_add_idempotent() { - // The extra, the standalone wheel, and a pinned variant are all recognized. - assert!(requirements_add("socket-patch[hook]\n").is_none()); - assert!(requirements_add("socket-patch-hook\n").is_none()); - assert!(requirements_add("socket-patch-hook==3.3.0\n").is_none()); - } - - #[test] - fn test_requirements_remove() { - let out = requirements_remove("requests\nsocket-patch[hook]\n").unwrap(); - assert_eq!(out, "requests\n"); - } - - #[test] - fn test_requirements_remove_absent() { - assert!(requirements_remove("requests\n").is_none()); - } - - // ── pyproject PEP 621 ──────────────────────────────────────────── - - #[test] - fn test_pep621_add_to_existing_array() { - let toml = "[project]\nname = \"x\"\ndependencies = [\"requests\"]\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - assert!(out.contains("socket-patch[hook]")); - assert!(out.contains("requests")); - // Re-parse to confirm validity + idempotency. - assert!(pyproject_add(&out).unwrap().is_none()); - } - - #[test] - fn test_pep621_add_creates_dependencies() { - let toml = "[project]\nname = \"x\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - let deps = doc["project"]["dependencies"].as_array().unwrap(); - assert!(deps - .iter() - .any(|v| v.as_str() == Some("socket-patch[hook]"))); - } - - #[test] - fn test_pep621_preserves_other_content() { - let toml = "[build-system]\nrequires = [\"setuptools\"]\n\n[project]\nname = \"x\"\nversion = \"1.0\"\ndependencies = [\n \"requests\",\n]\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - assert!(out.contains("[build-system]")); - assert!(out.contains("version = \"1.0\"")); - assert!(out.contains("requests")); - assert!(out.contains("socket-patch[hook]")); - } - - #[test] - fn test_pep621_remove() { - let toml = "[project]\ndependencies = [\"requests\", \"socket-patch[hook]\"]\n"; - let out = pyproject_remove(toml).unwrap().unwrap(); - assert!(!out.contains("socket-patch[hook]")); - assert!(out.contains("requests")); - } - - // ── pyproject Poetry (the `socket-patch[hook]` equivalent: the - // `socket-patch` dep carrying the `hook` extra) ───────────────── - - #[test] - fn test_poetry_add_new_dep() { - let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - assert!( - item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]), - "poetry dep must carry the hook extra; got:\n{out}" - ); - // Idempotent. - assert!(pyproject_add(&out).unwrap().is_none()); - } - - #[test] - fn test_poetry_merges_extra_into_existing_dep() { - // An existing `socket-patch = "^3.3.0"` gains the hook extra, version kept. - let toml = - "[tool.poetry]\nname = \"x\"\n[tool.poetry.dependencies]\nsocket-patch = \"^3.3.0\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - let item = &doc["tool"]["poetry"]["dependencies"]["socket-patch"]; - assert!(item_has_hook_extra(item), "hook extra must be added"); - assert_eq!( - item.as_table_like() - .and_then(|t| t.get("version")) - .and_then(Item::as_str), - Some("^3.3.0"), - "existing version must be preserved" - ); - } - - #[test] - fn test_poetry_subtable_dependency_preserved() { - // A `[tool.poetry.dependencies.socket-patch]` sub-table gains the hook - // extra while keeping its version / source. - let toml = "[tool.poetry.dependencies.socket-patch]\nversion = \"^3.3.0\"\ngit = \"https://example.com/x.git\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - let sp = &doc["tool"]["poetry"]["dependencies"]["socket-patch"]; - assert!(item_has_hook_extra(sp), "hook extra must be added"); - assert_eq!( - sp.as_table_like() - .and_then(|t| t.get("git")) - .and_then(Item::as_str), - Some("https://example.com/x.git"), - "sub-table keys must survive" - ); - // Idempotent. - assert!(pyproject_add(&out).unwrap().is_none()); - } - - #[test] - fn test_poetry_remove_strips_extra() { - let toml = "[tool.poetry.dependencies]\nsocket-patch = {version = \"*\", extras = [\"hook\"]}\npython = \"^3.9\"\n"; - let out = pyproject_remove(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - assert!(!item_has_hook_extra( - &doc["tool"]["poetry"]["dependencies"]["socket-patch"] - )); - assert!(doc["tool"]["poetry"]["dependencies"] - .get("python") - .is_some()); - } - - #[test] - fn test_pep621_preferred_when_both_present() { - // poetry 2.x: both [project] and [tool.poetry] — edit the PEP 621 array. - let toml = "[project]\nname = \"x\"\ndependencies = []\n\n[tool.poetry]\nname = \"x\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - assert!(doc["project"]["dependencies"] - .as_array() - .unwrap() - .iter() - .any(|v| v.as_str() == Some("socket-patch[hook]"))); - } - - #[test] - fn test_invalid_toml_errors() { - assert!(pyproject_add("this is = = not toml [[[").is_err()); - } - - #[test] - fn test_pyproject_add_without_dep_surface_refuses() { - // A pyproject.toml with neither `[project]` nor `[tool.poetry]` (the - // classic setup.py/setup.cfg project that only carries `[build-system]` - // or tool config) has no dependency surface to host the hook. - // Synthesizing a `[project]` table with only `dependencies` makes the - // manifest invalid — PEP 621 requires `name` and forbids making it - // dynamic — so pip/setuptools/uv would refuse to build afterwards. - // The edit must error, not break the user's build. - let build_only = - "[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n"; - assert!( - pyproject_add(build_only).is_err(), - "must not synthesize a name-less [project] table" - ); - let tool_only = "[tool.black]\nline-length = 100\n"; - assert!(pyproject_add(tool_only).is_err()); - } - - #[test] - fn test_pep621_dynamic_dependencies_refused() { - // setuptools/hatch dynamic-metadata pattern: `dependencies` is declared - // dynamic and resolved from an external source at build time. PEP 621 - // forbids a field that is both listed in `dynamic` and set statically, - // so inserting a static `dependencies` array makes every backend refuse - // to build. The edit must fail closed instead of bricking the build. - let toml = "[project]\nname = \"x\"\ndynamic = [\"dependencies\"]\n\n\ - [tool.setuptools.dynamic]\ndependencies = {file = [\"requirements.txt\"]}\n"; - assert!( - pyproject_add(toml).is_err(), - "must not add a static dependencies array next to dynamic = [\"dependencies\"]" - ); - } - - #[test] - fn test_poetry2_dynamic_dependencies_routes_to_poetry() { - // Poetry 2.x documented pattern: a PEP 621 `[project]` table with - // `dynamic = ["dependencies"]` while the real dependency surface stays - // in `[tool.poetry.dependencies]`. The hook must land in the poetry - // table — a static `[project].dependencies` array is both ignored by - // Poetry at install time and invalid per PEP 621. - let toml = "[project]\nname = \"x\"\ndynamic = [\"dependencies\"]\n\n\ - [tool.poetry.dependencies]\npython = \"^3.9\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - assert!( - item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]), - "hook must be wired via the poetry table:\n{out}" - ); - assert!( - doc.get("project") - .and_then(|p| p.get("dependencies")) - .is_none(), - "must not synthesize a static [project].dependencies:\n{out}" - ); - // Idempotent through the same route. - assert!(pyproject_add(&out).unwrap().is_none()); - } - - #[test] - fn test_pep621_other_dynamic_fields_still_edited_statically() { - // Only `dependencies` being dynamic blocks the static edit; dynamic - // version (the common setuptools-scm case) keeps the PEP 621 path. - let toml = - "[project]\nname = \"x\"\ndynamic = [\"version\"]\ndependencies = [\"requests\"]\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - assert!(out.contains("socket-patch[hook]")); - } - - #[test] - fn test_poetry_add_multiconstraint_dep_not_clobbered() { - // Poetry's multiple-constraints form declares one dep as an ARRAY of - // constraint tables. That item is neither table-like nor a string, so - // the replace-fallback would silently overwrite the user's whole - // constraint set with `{version = "*", extras = ["hook"]}` — destroying - // their version pins and python markers. Refuse instead. - let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\n\ - socket-patch = [{version = \"^1.0\", python = \"^2.7\"}, {version = \"^2.0\", python = \"^3.7\"}]\n"; - assert!( - pyproject_add(toml).is_err(), - "a multi-constraint socket-patch dep must not be silently replaced" - ); - } - - #[test] - fn test_classic_poetry_with_project_urls_routes_to_poetry() { - // `[project.urls]` conjures an implicit `[project]` table; a Poetry 1.x - // project must still be edited in the Poetry table, not given a - // `[project].dependencies` Poetry ignores. - let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n\n[project.urls]\nHome = \"https://example.com\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - assert!( - item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]), - "must edit the poetry table, not create [project].dependencies; got:\n{out}" - ); - assert!(doc - .get("project") - .and_then(|p| p.get("dependencies")) - .is_none()); - } - - #[test] - fn test_requirements_preserves_crlf() { - let out = requirements_add("requests\r\n").unwrap(); - assert_eq!(out, "requests\r\nsocket-patch[hook]\r\n"); - let removed = requirements_remove(&out).unwrap(); - assert_eq!(removed, "requests\r\n"); - } - - /// A Windows (`core.autocrlf`) checkout's pyproject.toml is CRLF. toml_edit - /// renders every newline as LF, so `setup` must re-apply CRLF (or every - /// line of the manifest diffs) and `--remove` must hand back the exact - /// pre-setup bytes (CLI_CONTRACT property 8). - #[test] - fn test_pyproject_preserves_crlf() { - let original = - "[project]\r\nname = \"demo\"\r\ndependencies = [\r\n \"requests\",\r\n]\r\n"; - let added = pyproject_add(original).unwrap().expect("hook dep added"); - assert!(added.contains(HOOK_DEP), "hook added: {added:?}"); - assert!( - !added.replace("\r\n", "").contains('\n'), - "every newline must stay CRLF: {added:?}" - ); - // Idempotent on the CRLF output. - assert_eq!(pyproject_add(&added).unwrap(), None); - let removed = pyproject_remove(&added).unwrap().expect("hook dep removed"); - assert_eq!( - removed, original, - "--remove restores the CRLF bytes exactly" - ); - } - - // ── file-level NotFound handling (the create / no-op paths) ────── - - #[tokio::test] - async fn test_add_creates_missing_requirements() { - let dir = tempfile::tempdir().unwrap(); - let req = dir.path().join("requirements.txt"); // does not exist - let res = add_hook_dependency(&req, ManifestKind::Requirements, false).await; - assert_eq!(res.status, PthStatus::Updated); - let body = tokio::fs::read_to_string(&req).await.unwrap(); - assert_eq!(body, "socket-patch[hook]\n"); - } - - #[tokio::test] - async fn test_add_missing_pyproject_is_error() { - let dir = tempfile::tempdir().unwrap(); - let py = dir.path().join("pyproject.toml"); // does not exist - let res = add_hook_dependency(&py, ManifestKind::Pyproject, false).await; - assert_eq!(res.status, PthStatus::Error); - } - - #[tokio::test] - async fn test_remove_missing_file_is_noop() { - let dir = tempfile::tempdir().unwrap(); - let req = dir.path().join("requirements.txt"); // does not exist - let res = remove_hook_dependency(&req, ManifestKind::Requirements, false).await; - assert_eq!(res.status, PthStatus::AlreadyConfigured); - } - - #[tokio::test] - async fn test_add_dry_run_does_not_create() { - let dir = tempfile::tempdir().unwrap(); - let req = dir.path().join("requirements.txt"); - let res = add_hook_dependency(&req, ManifestKind::Requirements, true).await; - assert_eq!(res.status, PthStatus::Updated); - assert!(!req.exists(), "dry-run must not create the file"); - } - - // ── atomic-write contract (no truncation / no stage litter) ────── - // - // The edit must go through stage+fsync+rename, never a bare truncating - // write, so a crash can't leave the user's hand-authored manifest empty. - // A leaked `.socket-stage-*` sibling would mean the rename didn't complete. - - async fn count_stage_litter(dir: &Path) -> usize { - let mut rd = tokio::fs::read_dir(dir).await.unwrap(); - let mut n = 0; - while let Some(entry) = rd.next_entry().await.unwrap() { - if entry - .file_name() - .to_string_lossy() - .starts_with(".socket-stage-") - { - n += 1; - } - } - n - } - - #[tokio::test] - async fn test_add_pyproject_atomic_no_litter_and_intact() { - let dir = tempfile::tempdir().unwrap(); - let py = dir.path().join("pyproject.toml"); - let original = "[build-system]\nrequires = [\"setuptools\"]\n\n[project]\nname = \"x\"\ndependencies = [\"requests\"]\n"; - tokio::fs::write(&py, original).await.unwrap(); - - let res = add_hook_dependency(&py, ManifestKind::Pyproject, false).await; - assert_eq!(res.status, PthStatus::Updated); - - // No half-written stage file left behind. - assert_eq!(count_stage_litter(dir.path()).await, 0); - // The file is fully written, valid TOML, and preserved prior content. - let body = tokio::fs::read_to_string(&py).await.unwrap(); - let doc = body.parse::().unwrap(); - assert!(body.contains("[build-system]")); - let deps = doc["project"]["dependencies"].as_array().unwrap(); - assert!(deps.iter().any(|v| v.as_str() == Some("requests"))); - assert!(deps - .iter() - .any(|v| v.as_str() == Some("socket-patch[hook]"))); - } - - #[tokio::test] - async fn test_remove_requirements_atomic_no_litter() { - let dir = tempfile::tempdir().unwrap(); - let req = dir.path().join("requirements.txt"); - tokio::fs::write(&req, "requests\nsocket-patch[hook]\n") - .await - .unwrap(); - - let res = remove_hook_dependency(&req, ManifestKind::Requirements, false).await; - assert_eq!(res.status, PthStatus::Updated); - assert_eq!(count_stage_litter(dir.path()).await, 0); - assert_eq!(tokio::fs::read_to_string(&req).await.unwrap(), "requests\n"); - } - - // ── structural hook detection (pyproject_contains_hook) ────────── - // - // The `setup --check` probe must agree with what `setup` wrote. The classic - // Poetry form has no `socket-patch[hook]` substring, so the textual probe - // alone mis-reports a configured project as needing configuration. - - #[test] - fn test_pyproject_contains_hook_poetry_form_roundtrips() { - // Regression: poetry_add writes the structural `extras = ["hook"]` form; - // the textual probe can't see it, but the structural one must. - let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - assert!( - pyproject_contains_hook(&out), - "structural probe must see the poetry extras form:\n{out}" - ); - // This is precisely why the structural probe is needed: the textual one - // (used for requirements.txt) cannot detect the poetry form. - assert!( - !deps_contain_hook(&out), - "textual probe is (by design) blind to the poetry form; if this \ - ever becomes true the structural probe may be redundant:\n{out}" - ); - } - - #[test] - fn test_pyproject_contains_hook_pep621_and_wheel() { - // PEP 621 array, extra spelling. - assert!(pyproject_contains_hook( - "[project]\ndependencies = [\"requests\", \"socket-patch[hook]>=3.3.0\"]\n" - )); - // PEP 621 array, bare wheel spelling. - assert!(pyproject_contains_hook( - "[project]\ndependencies = [\"socket-patch-hook\"]\n" - )); - // Poetry bare-wheel key. - assert!(pyproject_contains_hook( - "[tool.poetry.dependencies]\nsocket-patch-hook = \"*\"\n" - )); - } - - #[test] - fn test_pyproject_contains_hook_negative() { - // A plain socket-patch dep (CLI only, no hook) is NOT the hook — in - // either surface. - assert!(!pyproject_contains_hook( - "[project]\ndependencies = [\"socket-patch>=3.3.0\"]\n" - )); - assert!(!pyproject_contains_hook( - "[tool.poetry.dependencies]\nsocket-patch = \"^3.3.0\"\n" - )); - // A socket-patch dep carrying some *other* extra is not the hook. - assert!(!pyproject_contains_hook( - "[tool.poetry.dependencies]\nsocket-patch = {version = \"*\", extras = [\"cli\"]}\n" - )); - // Empty / unrelated. - assert!(!pyproject_contains_hook("[project]\nname = \"x\"\n")); - } - - #[test] - fn test_pyproject_contains_hook_malformed_falls_back_to_textual() { - // Unparseable TOML: fall back to the textual probe rather than hard-fail. - assert!(pyproject_contains_hook( - "this = = not toml [[[ socket-patch[hook]" - )); - assert!(!pyproject_contains_hook("this = = not toml [[[ requests")); - } - - #[test] - fn test_pyproject_contains_hook_after_remove_is_false() { - // Round-trip: add then remove → structural probe reports not-configured. - let toml = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\nsocket-patch = \"^3.3.0\"\n"; - let added = pyproject_add(toml).unwrap().unwrap(); - assert!(pyproject_contains_hook(&added)); - let removed = pyproject_remove(&added).unwrap().unwrap(); - assert!( - !pyproject_contains_hook(&removed), - "after remove the hook must be gone:\n{removed}" - ); - } - - // ── mode preservation (user-owned manifests keep their permission bits) ── - // - // The rename-based atomic write swaps in a fresh stage inode; without the - // mode-preserving variant a 0600 private manifest silently becomes 0644. - - #[cfg(unix)] - #[tokio::test] - async fn test_edit_preserves_file_mode() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - - // A 0600 private pyproject.toml must stay private after add. - let py = dir.path().join("pyproject.toml"); - tokio::fs::write(&py, "[project]\nname = \"x\"\ndependencies = []\n") - .await - .unwrap(); - tokio::fs::set_permissions(&py, std::fs::Permissions::from_mode(0o600)) - .await - .unwrap(); - let res = add_hook_dependency(&py, ManifestKind::Pyproject, false).await; - assert_eq!(res.status, PthStatus::Updated); - let mode = tokio::fs::metadata(&py).await.unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "add must not reset pyproject.toml mode"); - - // A 0744 requirements.txt keeps its exec bit after remove (red under - // ANY umask: a 0666-created stage inode can never carry exec bits). - let req = dir.path().join("requirements.txt"); - tokio::fs::write(&req, "requests\nsocket-patch[hook]\n") - .await - .unwrap(); - tokio::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o744)) - .await - .unwrap(); - let res = remove_hook_dependency(&req, ManifestKind::Requirements, false).await; - assert_eq!(res.status, PthStatus::Updated); - let mode = tokio::fs::metadata(&req) - .await - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o744, "remove must not reset requirements.txt mode"); - } - - // ── PEP 503/685 spellings in the Poetry TABLE forms ────────────── - // - // `-`/`_`/`.` are interchangeable in package names and names/extras are - // case-insensitive; Poetry installs the hook from any spelling, so the - // structural helpers must recognize them the way the textual probe - // (`deps_contain_hook`) already does. - - #[test] - fn test_poetry_add_pep503_variant_keys_idempotent() { - // A hook already declared under a variant spelling must be recognized, - // not shadowed by a second entry for the same canonical package. - let wheel = - "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\nsocket_patch_hook = \"*\"\n"; - assert!(pyproject_add(wheel).unwrap().is_none()); - let extra = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\n\ - socket_patch = {version = \"^3.3.0\", extras = [\"hook\"]}\n"; - assert!(pyproject_add(extra).unwrap().is_none()); - // PEP 685: extras names are case-insensitive too. - let cased = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\n\ - socket-patch = {version = \"*\", extras = [\"Hook\"]}\n"; - assert!(pyproject_add(cased).unwrap().is_none()); - } - - #[test] - fn test_poetry_add_merges_into_pep503_variant_key() { - // An existing dep under a variant spelling gains the extra in place — - // not a duplicate `socket-patch` key canonicalizing to the same - // package, which Poetry rejects as a twice-declared dependency. - let toml = - "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\nSocket_Patch = \"^3.3.0\"\n"; - let out = pyproject_add(toml).unwrap().unwrap(); - let doc = out.parse::().unwrap(); - let deps = doc["tool"]["poetry"]["dependencies"].as_table().unwrap(); - assert!( - deps.get("socket-patch").is_none(), - "must not add a duplicate key:\n{out}" - ); - let item = deps.get("Socket_Patch").expect("user's spelling kept"); - assert!( - item_has_hook_extra(item), - "extra merged under the user's spelling:\n{out}" - ); - assert_eq!( - item.as_table_like() - .and_then(|t| t.get("version")) - .and_then(Item::as_str), - Some("^3.3.0"), - "existing version must be preserved" - ); - } - - #[test] - fn test_poetry_remove_pep503_variant_keys() { - // remove must unwire the hook regardless of spelling — leaving it - // declared means the .pth carrier keeps installing after `remove`. - let wheel = "[tool.poetry.dependencies]\nsocket_patch_hook = \"*\"\n"; - let out = pyproject_remove(wheel) - .unwrap() - .expect("variant wheel key must be removed"); - assert!(!pyproject_contains_hook(&out)); - - let extra = - "[tool.poetry.dependencies]\n\"socket.patch\" = {version = \"*\", extras = [\"Hook\"]}\n"; - let out = pyproject_remove(extra) - .unwrap() - .expect("variant extras form must be stripped"); - assert!(!pyproject_contains_hook(&out)); - } - - #[test] - fn test_pyproject_contains_hook_pep503_poetry_forms() { - assert!(pyproject_contains_hook( - "[tool.poetry.dependencies]\nsocket_patch_hook = \"*\"\n" - )); - assert!(pyproject_contains_hook( - "[tool.poetry.dependencies]\nSocket_Patch = {version = \"*\", extras = [\"hook\"]}\n" - )); - assert!(pyproject_contains_hook( - "[tool.poetry.dependencies]\nsocket-patch = {version = \"*\", extras = [\"Hook\"]}\n" - )); - // A different package that merely shares the prefix is not the hook. - assert!(!pyproject_contains_hook( - "[tool.poetry.dependencies]\nsocket-patchwork = \"*\"\n" - )); - } - - /// mkfifo(2) directly rather than shelling out to the `mkfifo` binary — - /// same helper as the detect.rs / find.rs FIFO tests: fork/exec flakes - /// under heavy parallel load and the syscall needs no process at all. - #[cfg(unix)] - fn mkfifo(path: &Path) { - use std::os::unix::ffi::OsStrExt; - let c_path = - std::ffi::CString::new(path.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() - ); - } - - /// A FIFO planted as the manifest must not wedge the edit path. The - /// manifest paths handed to add/remove come from detection, which never - /// opens the file it targets for editing (a poetry.lock next to a FIFO - /// pyproject.toml routes here without a read; the Pip fallback targets - /// requirements.txt sight-unseen), so a plain `read_to_string` open(2) - /// waits for a writer that never comes, wedging `setup` / `setup - /// --remove` indefinitely with no error and no timeout. Same class as - /// the `open_regular_file` guards in the detect.rs/gem/composer/npm - /// twins. The non-regular file must instead fail fast to `Error`. - #[cfg(unix)] - #[tokio::test] - async fn test_edit_fifo_manifest_does_not_wedge() { - let dir = tempfile::tempdir().unwrap(); - let req = dir.path().join("requirements.txt"); - let py = dir.path().join("pyproject.toml"); - mkfifo(&req); - mkfifo(&py); - - // On timeout the open is wedged in a `spawn_blocking` thread that - // the runtime waits for on shutdown; connect a writer to release - // it so the test can FAIL instead of hanging the whole suite. - let deadline = std::time::Duration::from_secs(5); - let Ok(res) = tokio::time::timeout( - deadline, - add_hook_dependency(&req, ManifestKind::Requirements, false), - ) - .await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&req); - panic!("add_hook_dependency must complete promptly with a FIFO manifest"); - }; - assert_eq!(res.status, PthStatus::Error); - - let Ok(res) = tokio::time::timeout( - deadline, - remove_hook_dependency(&py, ManifestKind::Pyproject, false), - ) - .await - else { - let _ = std::fs::OpenOptions::new().write(true).open(&py); - panic!("remove_hook_dependency must complete promptly with a FIFO manifest"); - }; - assert_eq!(res.status, PthStatus::Error); - - // The failed add must not have replaced the user's FIFO with a - // regular requirements.txt via the missing-file create path. - use std::os::unix::fs::FileTypeExt; - let ft = std::fs::symlink_metadata(&req).unwrap().file_type(); - assert!(ft.is_fifo(), "the squatting FIFO must be left untouched"); - } - - #[tokio::test] - async fn test_dry_run_does_no_io_for_pyproject() { - let dir = tempfile::tempdir().unwrap(); - let py = dir.path().join("pyproject.toml"); - let original = "[project]\nname = \"x\"\ndependencies = [\"requests\"]\n"; - tokio::fs::write(&py, original).await.unwrap(); - - let res = add_hook_dependency(&py, ManifestKind::Pyproject, true).await; - assert_eq!(res.status, PthStatus::Updated); - // Dry-run must neither stage nor mutate the original. - assert_eq!(count_stage_litter(dir.path()).await, 0); - assert_eq!(tokio::fs::read_to_string(&py).await.unwrap(), original); - } - - // ── finish()'s failure arms through the async wrappers ─────────── - // - // Every error-shape test above calls the pure transforms directly; these - // pin the wrapper glue: the atomic-write Err arm and the transform Err arm - // must both surface as `PthStatus::Error` with the message attached, and - // must leave the user's file untouched. - - #[cfg(unix)] - #[tokio::test] - async fn test_add_write_failure_readonly_parent_is_error() { - use std::os::unix::fs::PermissionsExt; - // A read-only PARENT directory blocks the stage-sibling creation while - // the manifest itself stays readable — so the read and transform - // succeed and the failure lands exactly in finish()'s write arm. - let dir = tempfile::tempdir().unwrap(); - let req = dir.path().join("requirements.txt"); - let original = "requests\n"; - tokio::fs::write(&req, original).await.unwrap(); - tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)) - .await - .unwrap(); - - let res = add_hook_dependency(&req, ManifestKind::Requirements, false).await; - - // Restore before any assertion can unwind, so the tempdir cleans up. - tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)) - .await - .unwrap(); - - assert_eq!(res.status, PthStatus::Error); - assert!( - res.error.is_some(), - "the write failure must carry a message: {res:?}" - ); - assert_eq!( - tokio::fs::read_to_string(&req).await.unwrap(), - original, - "a failed write must leave the manifest byte-for-byte untouched" - ); - assert_eq!( - count_stage_litter(dir.path()).await, - 0, - "the failed stage creation must not leave litter behind" - ); - } - - #[tokio::test] - async fn test_add_malformed_pyproject_via_wrapper_is_error() { - // The path `setup` actually takes on a real broken manifest: the - // transform's Err must route through finish() to `Error`, not panic - // and not touch the file. - let dir = tempfile::tempdir().unwrap(); - let py = dir.path().join("pyproject.toml"); - let original = "this is = = not toml [[[\n"; - tokio::fs::write(&py, original).await.unwrap(); - - let res = add_hook_dependency(&py, ManifestKind::Pyproject, false).await; - assert_eq!(res.status, PthStatus::Error); - assert!( - res.error - .as_deref() - .is_some_and(|e| e.contains("Invalid pyproject.toml")), - "the parse failure must be attributed to pyproject.toml: {res:?}" - ); - assert_eq!( - tokio::fs::read_to_string(&py).await.unwrap(), - original, - "a failed transform must leave the manifest untouched" - ); - assert_eq!( - count_stage_litter(dir.path()).await, - 0, - "no stage may be created for a failed transform" - ); - } - - // ── remove_hook_dependency's Pyproject routing ──────────────────── - - #[tokio::test] - async fn test_remove_pyproject_via_wrapper_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let py = dir.path().join("pyproject.toml"); - let original = - "[project]\nname = \"x\"\ndependencies = [\"requests\", \"socket-patch[hook]\"]\n"; - tokio::fs::write(&py, original).await.unwrap(); - - let res = remove_hook_dependency(&py, ManifestKind::Pyproject, false).await; - assert_eq!(res.status, PthStatus::Updated, "err: {:?}", res.error); - - let body = tokio::fs::read_to_string(&py).await.unwrap(); - assert!( - !pyproject_contains_hook(&body), - "the hook must be gone after remove:\n{body}" - ); - assert!(body.contains("\"requests\""), "other deps survive:\n{body}"); - // Byte-preservation-sensitive subsystem: the wrapper must write - // exactly what the pure transform produced — untouched lines verbatim. - assert_eq!( - body, - pyproject_remove(original).unwrap().unwrap(), - "the wrapper must persist the pure transform's output byte-for-byte" - ); - assert!( - body.contains("[project]\nname = \"x\"\n"), - "untouched header and name line survive verbatim:\n{body}" - ); - assert_eq!(count_stage_litter(dir.path()).await, 0); - - // Second remove: nothing left to strip → idempotent no-op. - let res = remove_hook_dependency(&py, ManifestKind::Pyproject, false).await; - assert_eq!(res.status, PthStatus::AlreadyConfigured); - assert_eq!( - tokio::fs::read_to_string(&py).await.unwrap(), - body, - "the no-op remove must not rewrite the file" - ); - } - - // ── poetry_remove: socket-patch table without extras ───────────── - - #[test] - fn test_poetry_remove_table_without_extras_is_noop() { - // A table-shaped `socket-patch` dep with no `extras` array has nothing - // of ours to strip: `changed` must stay false (Ok(None)) and the - // user's version spec must be left alone. - let toml = "[tool.poetry.dependencies]\nsocket-patch = {version = \"^3.3.0\"}\n"; - assert!( - pyproject_remove(toml).unwrap().is_none(), - "a socket-patch dep without extras is not ours to touch" - ); - } -} diff --git a/crates/socket-patch-core/src/setup/pypi/mod.rs b/crates/socket-patch-core/src/setup/pypi/mod.rs deleted file mode 100644 index 998d6235..00000000 --- a/crates/socket-patch-core/src/setup/pypi/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Python `.pth` post-install hook setup. -//! -//! Where npm-family ecosystems get an automatic post-install patch hook via a -//! `package.json` `postinstall` script ([`crate::package_json`]), Python has no -//! universal installer hook. Instead, `socket-patch setup` declares a committed -//! dependency on the `socket-patch-hook` wheel (via the `socket-patch[hook]` -//! extra); installing that wheel lays a startup `.pth` into site-packages that -//! re-applies patches after any install — package-manager-agnostic, because it -//! rides on the interpreter's startup hook rather than any one installer. -//! -//! This module is the Rust side: detecting the project's dependency manager -//! ([`detect`]) and editing its manifest(s) to add/remove the hook dependency -//! ([`edit`]). All actual patching stays in `socket-patch apply`. -//! -//! The committed dependency line is the single source of truth that the hook is -//! active — there is no separate marker/audit file (git history is the audit -//! trail), so nothing can drift out of sync with the manifest. - -pub mod detect; -pub mod edit; diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index 3a1dd4bb..752a83eb 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -50,7 +50,6 @@ enum PatchTelemetryEventType { PatchListed, PatchRepaired, PatchRepairFailed, - PatchSetup, // OpenVEX attestation (added in #81) VexGenerated, VexFailed, @@ -75,7 +74,6 @@ impl PatchTelemetryEventType { Self::PatchListed => "patch_listed", Self::PatchRepaired => "patch_repaired", Self::PatchRepairFailed => "patch_repair_failed", - Self::PatchSetup => "patch_setup", Self::VexGenerated => "vex_generated", Self::VexFailed => "vex_failed", } @@ -790,7 +788,7 @@ pub async fn track_patch_fetch_failed( } // --------------------------------------------------------------------------- -// Inspection / housekeeping trackers: list / repair / setup +// Inspection / housekeeping trackers: list / repair // --------------------------------------------------------------------------- /// Track a successful `list`. Reports the number of patches surfaced. @@ -850,22 +848,6 @@ pub async fn track_patch_repair_failed( .await; } -/// Track a successful `setup`. Reports the detected package manager so -/// we can tell which install hooks are exercised in the wild: the -/// `+`-joined in-scope tags, where the npm-family tag is `npm`, `pnpm` or -/// `vlt` (e.g. `vlt+pypi`), or `none`. -pub async fn track_patch_setup(manager: &str, api_token: Option<&str>, org_slug: Option<&str>) { - fire( - PatchTelemetryEventType::PatchSetup, - "setup", - serde_json::json!({ "manager": manager }), - None::<&str>, - api_token, - org_slug, - ) - .await; -} - // --------------------------------------------------------------------------- // OpenVEX trackers // --------------------------------------------------------------------------- @@ -1252,7 +1234,6 @@ mod tests { PatchTelemetryEventType::PatchRepairFailed.as_str(), "patch_repair_failed" ); - assert_eq!(PatchTelemetryEventType::PatchSetup.as_str(), "patch_setup"); // OpenVEX assert_eq!( PatchTelemetryEventType::VexGenerated.as_str(), diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 18f768dc..32a8c788 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -142,7 +142,7 @@ fn is_vlx_cache_dir(dir: &Path) -> bool { crate::utils::fs::read_regular_to_string_sync(&dir.join("package.json")) .ok() .and_then(|text| { - serde_json::from_str::(crate::package_json::detect::strip_bom(&text)) + serde_json::from_str::(crate::utils::serde::strip_bom(&text)) .ok() }) .is_some_and(|pkg| pkg.get("name").and_then(|n| n.as_str()) == Some("vlx")) diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index b03dc302..a9e2b2e2 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -31,7 +31,6 @@ use std::path::{Path, PathBuf}; -use std::fs::FileType; use tokio::fs::DirEntry; /// List the immediate children of `path`. @@ -370,7 +369,8 @@ pub async fn remove_link(path: &Path) -> std::io::Result<()> { /// be treated as scannable-but-non-recurseable). The returned /// `FileType` is the symlink-aware kind from `entry.file_type()`, /// not the resolved-target kind from `metadata()`. -pub(crate) async fn entry_file_type(entry: &DirEntry) -> Option { +#[cfg(test)] +pub(crate) async fn entry_file_type(entry: &DirEntry) -> Option { entry.file_type().await.ok() } diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 8ec5ad07..a08d79a9 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -201,7 +201,7 @@ fn rewrite_project( .any(|v| matches!(v.as_str(), Some("dependencies" | "optional-dependencies"))) }) { - return Err("dynamic project dependencies require the install hook".into()); + return Err("dynamic project dependencies require agent mode".into()); } if let Some(dependencies) = project .as_table_like_mut() @@ -230,7 +230,7 @@ fn rewrite_project( { let group_matches = rewrite_array(dependencies, name, version, url)?; if group_matches > 0 && url.starts_with("{root:uri}") { - return Err("Hatch does not expand root placeholders in dependency groups; use environment dependencies or the install hook".into()); + return Err("Hatch does not expand root placeholders in dependency groups; use environment dependencies or agent mode".into()); } matched += group_matches; } @@ -245,7 +245,7 @@ fn rewrite_environments( url: &str, ) -> Result { if hatch.get("sources").is_some() || hatch.get("env").is_some() { - return Err("Hatch sources and environment plugins require the install hook".into()); + return Err("Hatch sources and environment plugins require agent mode".into()); } let mut matched = 0; if let Some(environments) = hatch @@ -265,7 +265,7 @@ fn rewrite_environments( .is_some_and(|kind| kind != "virtual") { return Err( - "Hatch sources, overrides and custom environments require the install hook" + "Hatch sources, overrides and custom environments require agent mode" .into(), ); } @@ -420,7 +420,7 @@ pub fn plan( .clone(); } if matched == 0 { - return Err(format!("{name}=={version} has no explicit Hatch declaration; transitive-only dependencies require the install hook")); + return Err(format!("{name}=={version} has no explicit Hatch declaration; transitive-only dependencies require agent mode")); } let permission = if project_matched > 0 { let external = external_keys.iter().any(|key| key == "metadata"); diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 80a6dda9..d9b40398 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -23,13 +23,4 @@ pub mod socket_dir; 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 5.0. -pub use crate::api::date; -pub use crate::crawlers::fuzzy_match; -pub use crate::manifest::cleanup_blobs; -pub use crate::telemetry; - pub mod hatch; diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index 81bb2a22..2c9a074b 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -113,13 +113,6 @@ pub fn command_for(program: &Path) -> Command { Command::new(program) } -/// [`resolve_tool`] + [`command_for`]: the command for the tool `name` found -/// on an absolute PATH entry, or `None` when there is none — the caller -/// decides how "not installed" degrades (a warning, a refusal). -pub fn tool_command(name: &str) -> Option { - resolve_tool(name).map(|program| command_for(&program)) -} - /// Run an external binary with the given args and return its /// stdout, trimmed, when the spawn succeeded AND the process exited /// with a success status AND stdout is non-empty after trimming. @@ -253,7 +246,7 @@ mod tests { assert_eq!(out.as_deref(), Some("forwarded")); } - // ───────────────────────── resolve_tool / tool_command ───────────────────────── + // ───────────────────────── resolve_tool / command_for ───────────────────────── /// Mark an existing file executable (no-op off Unix: PATHEXT rules there). fn set_executable(path: &Path) { @@ -448,11 +441,4 @@ mod tests { let exe = resolve_tool_with("bun", &exe_only).expect("bun.exe resolves"); assert_eq!(command_for(&exe).get_program(), exe.as_os_str()); } - - /// `tool_command` reads the REAL environment: a name that cannot be on - /// any PATH yields None (the caller's "not installed" arm). - #[test] - fn tool_command_is_none_for_an_absent_tool() { - assert!(tool_command("definitely-not-a-real-binary-1234567").is_none()); - } } diff --git a/crates/socket-patch-core/src/utils/serde.rs b/crates/socket-patch-core/src/utils/serde.rs index 7f5f288c..666563d2 100644 --- a/crates/socket-patch-core/src/utils/serde.rs +++ b/crates/socket-patch-core/src/utils/serde.rs @@ -19,3 +19,12 @@ where { map.iter().collect::>().serialize(serializer) } + +/// Strip a leading UTF-8 BOM. npm and Node tolerate (and strip) a BOM in +/// package.json, and cargo accepts one in Cargo.toml — files saved by Windows +/// editors commonly carry one — but serde_json (and vex's TOML line scanner) +/// reject it, so every parse of user-supplied manifest content must go through +/// this first or toolchain-valid manifests error out. +pub(crate) fn strip_bom(content: &str) -> &str { + content.strip_prefix('\u{feff}').unwrap_or(content) +} 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 5259a4e8..fcd85f7d 100644 --- a/crates/socket-patch-core/src/utils/toml_edit_ext.rs +++ b/crates/socket-patch-core/src/utils/toml_edit_ext.rs @@ -1,7 +1,5 @@ //! Small structured-TOML helpers shared by every module that edits or sniffs -//! 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. +//! TOML (`vendor::cargo_config`, `vendor::pypi`, `vendor::pypi_uv`). use toml_edit::{Item, Table}; diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 88cf6331..0c6e89f3 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -287,7 +287,7 @@ pub(super) async fn stage_patch_pack( // bundled-deps refusal below. let text = String::from_utf8_lossy(&bytes); if let Ok(pkg) = - serde_json::from_str::(crate::package_json::detect::strip_bom(&text)) + serde_json::from_str::(crate::utils::serde::strip_bom(&text)) { if declares_bundled_deps(&pkg) { return Err(Box::new(refused( diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index 82cfd580..1887f0ff 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -801,7 +801,7 @@ async fn read_manifest(dir: &Path) -> Result { let text = crate::utils::fs::read_regular_to_string(&dir.join("package.json")) .await .map_err(|e| format!("package.json unreadable: {e}"))?; - serde_json::from_str(crate::package_json::detect::strip_bom(&text)) + serde_json::from_str(crate::utils::serde::strip_bom(&text)) .map_err(|e| format!("package.json is not parseable JSON: {e}")) } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index b3d9c0b6..2492d1e2 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -224,8 +224,8 @@ pub async fn finish_hosted_wheel_metadata( } const SETUP_ALTERNATIVE: &str = - "use the `socket-patch setup` .pth install hook instead, which patches installed \ - site-packages without lockfile edits"; + "use agent mode instead (`scan --mode agent`, then `socket-patch apply` after each \ + install), which patches installed site-packages without lockfile edits"; /// Route the project to a wiring flavor, first match wins. Lockfiles are the /// authoritative "this tool manages installs" signal, so locks are compared @@ -2148,7 +2148,7 @@ mod tests { let err = detect_pypi_flavor(tmp.path(), None).await.unwrap_err(); assert_eq!(err.0, "pypi_uv_no_lockfile"); assert!(err.1.contains("uv lock")); - assert!(err.1.contains("socket-patch setup")); + assert!(err.1.contains("scan --mode agent")); let tmp = tempfile::tempdir().unwrap(); touch( @@ -2210,7 +2210,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let err = detect_pypi_flavor(tmp.path(), None).await.unwrap_err(); assert_eq!(err.0, "pypi_no_requirements"); - assert!(err.1.contains("socket-patch setup")); + assert!(err.1.contains("scan --mode agent")); } /// mkfifo(2) directly rather than shelling out to the `mkfifo` binary — diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index a00b4daf..c82d73e0 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -133,7 +133,7 @@ async fn require_environment_context_support(root: &Path) -> Result<(), Failure> return Ok(()); } } - Err(("pypi_hatch_unsupported", "vendored environment dependencies require Hatch >=1.2 on PATH for root URI expansion; upgrade Hatch or use the install hook".into())) + Err(("pypi_hatch_unsupported", "vendored environment dependencies require Hatch >=1.2 on PATH for root URI expansion; upgrade Hatch or use agent mode".into())) } async fn write_files( diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 942e6c67..00f4e113 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -450,8 +450,8 @@ async fn plan_requirements( "pypi_extras_unsupported", format!( "{}: the {canon_name} pin declares extras, which a vendored wheel path \ - line cannot express; remove the extras or use the `socket-patch setup` \ - .pth install hook instead", + line cannot express; remove the extras or use agent mode \ + (`scan --mode agent` + `socket-patch apply`) instead", file.rel ), )); @@ -461,7 +461,7 @@ async fn plan_requirements( "pypi_requirement_not_pinned", format!( "{}: {canon_name} is not pinned to =={version}; pin it exactly or use \ - the `socket-patch setup` .pth install hook instead", + agent mode (`scan --mode agent` + `socket-patch apply`) instead", file.rel ), )); @@ -477,8 +477,8 @@ async fn plan_requirements( "pypi_requirements_outside_root", format!( "{}: {canon_name} is pinned in a requirements include outside the project \ - root, which vendor cannot edit; inline it or use the `socket-patch setup` \ - .pth install hook instead", + root, which vendor cannot edit; inline it or use agent mode \ + (`scan --mode agent` + `socket-patch apply`) instead", file.rel ), )); diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index fe434149..49c708f5 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -599,7 +599,7 @@ fn check_declarations( format!("{pkg_rel} is missing; run `vlt install` first"), ) })?; - let value: Value = serde_json::from_str(crate::package_json::detect::strip_bom(text)) + let value: Value = serde_json::from_str(crate::utils::serde::strip_bom(text)) .map_err(|_| (OUT_OF_SYNC, format!("{pkg_rel} is not valid JSON")))?; let declared = ["dependencies", "devDependencies", "optionalDependencies"] .iter() @@ -732,7 +732,7 @@ pub async fn vlt_vendor_preflight( .join(PACKAGE_JSON); if let Ok(text) = read_regular_to_string(&store).await { if let Ok(pkg) = - serde_json::from_str::(crate::package_json::detect::strip_bom(&text)) + serde_json::from_str::(crate::utils::serde::strip_bom(&text)) { if super::npm_common::declares_bundled_deps(&pkg) { return Err(( diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index c7c54822..214a47d0 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -1015,8 +1015,7 @@ mod tests { use super::*; use crate::constants::npm_family::{ - VLT_CONFIG, VLT_HIDDEN_LOCK_REL, VLT_LEGACY_WORKSPACES, VLT_LOCK, VLT_SETUP_MARKERS, - VLT_STORE_DIR, + VLT_CONFIG, VLT_HIDDEN_LOCK_REL, VLT_LEGACY_WORKSPACES, VLT_LOCK, VLT_STORE_DIR, }; const UUID: &str = "0b1f6e2a-3c4d-4e5f-8a9b-0c1d2e3f4a5b"; @@ -2523,15 +2522,6 @@ mod tests { assert_eq!(VLT_HIDDEN_LOCK_REL, "node_modules/.vlt-lock.json"); assert_eq!(VLT_STORE_DIR, "node_modules/.vlt"); assert_eq!(VLT_LEGACY_WORKSPACES, "vlt-workspaces.json"); - assert_eq!( - VLT_SETUP_MARKERS, - [ - "vlt-lock.json", - "vlt.json", - "node_modules/.vlt-lock.json", - "node_modules/.vlt" - ] - ); } #[test] diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 618a1ebf..13a58f2e 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -37,7 +37,7 @@ use std::path::Path; // 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; +use crate::utils::serde::strip_bom; /// Version-extracting parser for one manifest flavor, keyed by file name in /// the priority table inside [`detect_product`]. diff --git a/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs b/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs index e7fe1eb2..9f516cbc 100644 --- a/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs +++ b/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs @@ -17,8 +17,7 @@ //! discoverable. The second is a GAP pin (`#[ignore]`): crawling from the repo //! root should aggregate every subproject's gems. It is the executable spec for //! the intended multi-lockfile discovery; un-ignore it when that ships. See -//! CLI_CONTRACT.md "Setup command contract" → "Monorepo / multi-project -//! discovery model". +//! CLI_CONTRACT.md "Monorepo / multi-project discovery model". use std::path::Path; @@ -83,7 +82,7 @@ async fn gem_crawl_from_subproject_cwd_finds_its_own_gems() { // ── GAP: aggregate crawl from the repo root (multi-lockfile) ────────────── #[tokio::test] -#[ignore = "gap: non-npm crawlers (gem/python/go/composer) are cwd-only and do not discover per-subproject lockfiles from the repo root; see CLI_CONTRACT 'Setup command contract' → Monorepo / multi-project discovery model"] +#[ignore = "gap: non-npm crawlers (gem/python/go/composer) are cwd-only and do not discover per-subproject lockfiles from the repo root; see CLI_CONTRACT 'Monorepo / multi-project discovery model'"] async fn gem_crawl_from_repo_root_discovers_all_subproject_lockfiles() { let tmp = tempfile::tempdir().unwrap(); let backend = tmp.path().join("backend"); diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 6aa419da..d3fbc903 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -951,8 +951,8 @@ async fn crawl_all_discovers_deeply_nested_transitive_deps() { // patchable — exactly like a direct dependency (apply is path-agnostic). The // other nested tests stage only 2 levels; this pins 4, so a regression that // capped recursion depth (or stopped descending after the first nested - // node_modules) would surface here. See CLI_CONTRACT "Setup command contract" - // → "Monorepo / multi-project discovery model". + // node_modules) would surface here. See CLI_CONTRACT "Monorepo / + // multi-project discovery model". let tmp = tempfile::tempdir().unwrap(); let nm = tmp.path().join("node_modules"); diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index dd91d398..cca7f92b 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -433,13 +433,10 @@ 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 -/// `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 -/// install (no vendor/bundle) yields zero gem paths and every scan/apply -/// there is a silent no-op. +/// (`Bundler::SharedHelpers.default_gemfile`). The crawler's project gate +/// must recognize the same spelling; otherwise a `gems.rb` 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] diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 4bc48634..c4aa7b8e 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -91,13 +91,13 @@ UX policy and are ignored. ## Deferred (designated homes, no implementation yet) - **Project-level behavioral defaults** (`ecosystems`, `downloadMode`, - `vendorSource`): if demand materializes, they go in the manifest `setup` - block (`setup.defaults`, camelCase) — the manifest already controls what - gets patched, so behavioral defaults there grant no new capability, and - the serde struct simply has no fields for URLs/credentials/interlocks. + `vendorSource`): if demand materializes, they go in a manifest + `defaults` block (camelCase) — the manifest already controls what gets + patched, so behavioral defaults there grant no new capability, and the + serde struct simply has no fields for URLs/credentials/interlocks. Requires teaching the TS zod twin - (`npm/socket-patch/src/schema/manifest-schema.ts`) to model `setup`. - Precedence would be flag > env > `setup.defaults` > default. + (`npm/socket-patch/src/schema/manifest-schema.ts`) to model it. + Precedence would be flag > env > manifest `defaults` > default. - **Env cleanup sweep**: core's direct env readers (`SOCKET_OFFLINE` in `utils/env_compat.rs`, `SOCKET_TELEMETRY_DISABLED` in `telemetry.rs`) still match only `1|true`, unlike `parse_bool_flag`'s vocabulary (the CLI diff --git a/docs/design/v5-plan.md b/docs/design/v5-plan.md index 5d131f99..12ef9b56 100644 --- a/docs/design/v5-plan.md +++ b/docs/design/v5-plan.md @@ -120,6 +120,18 @@ patch-UI review. `socket-patch-hook` wheel publishing (or mark them deprecated/unpublished; confirm with owner before deleting release workflows). Keep `apply`. Docs: agent mode = `scan --mode agent` + `socket-patch apply` in CI. +- **Status (branch `v5/remove-setup-and-ui`):** subcommand, core `setup/` + + `package_json/`, setup tests, `setup-e2e` feature, setup-matrix CI job, + `tests/setup_matrix/`, `scripts/setup-matrix.sh` and the setup-only + `Dockerfile.gem-b1`/`gem-b4` are deleted. vex's "Property 7" filter went + with it (agent patches attest on verification; `setup.manual` is parsed + but ignored). The `socket-patch-hook` wheel and `socket-patch-bundler` gem + are out of `publish-pypi.yml` / `publish-rubygems.yml`, + `build-pypi-wheels.py` and `version-sync.sh`, and the `socket-patch[hook]` + extra is dropped. **Owner decision pending:** `pypi/socket-patch-hook/` + and `gem/socket-patch-bundler/` sources are kept (frozen, README marked + deprecated) — delete them, and optionally yank/deprecate the published + packages and remove their PyPI/RubyGems trusted publishers, once confirmed. ### WS8 — Patch UI streamlining *(branch `v5/ui`)* - `-h` shows ~8 options (hide_short_help for the rest); hide deprecated diff --git a/docs/ecosystems.md b/docs/ecosystems.md index d1d6d3ca..4bb553b1 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -14,15 +14,15 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Ecosystem | agent (`--mode agent`) | vendored (`--mode vendored`) | hosted (`--mode hosted`) | |-----------|------------------------|------------------------------|--------------------------| -| npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write); `setup` postinstall hook | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | -| PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | +| npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | +| PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | -| Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | -| NuGet (`nuget`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | -| Composer (`composer`) | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | -| Deno (`deno`) | ✅ apply-only — no install hook (`setup` reports `no_files`); declare in `setup.manual` for VEX coverage | ❌ refused (`vendor_unsupported_ecosystem`) | ❌ not supported | +| Maven (`maven`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | +| NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | +| Composer (`composer`) | ✅ in place (`vendor/`) | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | +| Deno (`deno`) | ✅ in place (the only mode for Deno) | ❌ refused (`vendor_unsupported_ecosystem`) | ❌ not supported | > **Maven / NuGet sidecar caveat**: Maven and NuGet are fully enabled in every mode. > In-place (agent-mode) patching leaves the caches' own checksum sidecars stale: NuGet's @@ -288,13 +288,7 @@ directory) or a link still into the vendored directory. replace each file instead of writing through it, so vlt 1.2's shared store (hardlinked on Linux) and every other project linked to it keep their bytes. A patch survives `vlt install`, `install `, `uninstall` and frozen installs; `vlt ci` and deleting -`node_modules` restore the upstream bytes, which is what the setup hook is for. - -**Setup.** vlt gets npm's `npx @socketsecurity/socket-patch apply --silent --ecosystems -npm` postinstall hook, wired at the workspace root only (vlt runs the root hook once per -install that changes the graph, never on a no-op install). vlt before 1.0.0-rc.13 never -runs a root `postinstall` without an `install` script: `setup` still wires it and warns -`vlt_root_scripts_not_run`. A failing hook aborts and rolls back the whole `vlt install`. +`node_modules` restore the upstream bytes; run `socket-patch apply` after them. **Locale.** vlt sorts its lock with the process locale for one key, so byte-stability is claimed only for `en`-equivalent locales (`LANG` unset, `C`, `POSIX` or `en_US`); every vlt diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index 2d730358..fa174297 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -19,7 +19,7 @@ A build backend alone does not change which existing pip inputs are wired. A range, transitive-only declaration, dynamic dependency metadata, custom environment plugin, source table or conditional override is refused before -writing. Use the install hook for these shapes. Hosted PEP 735 groups are +writing. Use agent mode (`scan --mode agent` + `socket-patch apply` in CI) for these shapes. Hosted PEP 735 groups are supported; vendored groups are refused because Hatch does not expand `{root:uri}` within dependency groups. Unknown direct sources require an explicit revert before patching. diff --git a/docs/testing/poetry-compatibility.md b/docs/testing/poetry-compatibility.md index 381c9419..28848071 100644 --- a/docs/testing/poetry-compatibility.md +++ b/docs/testing/poetry-compatibility.md @@ -145,7 +145,7 @@ python3 scripts/backtest-poetry.py \ --cli /tmp/socket-patch-under-test \ --cli-revision "$(git rev-parse --short HEAD)" \ --output /tmp/socket-patch-poetry-backtest \ - --modes hosted vendored agent agent-oot setup \ + --modes hosted vendored agent agent-oot \ --shapes direct populated crlf pep621 python3 scripts/backtest-poetry.py --render-doc-table /tmp/socket-patch-poetry-backtest/summary.json ``` @@ -162,9 +162,8 @@ verifies, Poetry's own relock keeps the source, and `rollback` restores every byte and clears the ledgers. Shapes: `direct` (the committed native fixture), `populated` (legacy locks with real upstream hashes filled in — today's PyPI JSON API leaves old Poetry's `[metadata.files]` empty), `crlf`, and `pep621` -(2.x `[project]` tables with `package-mode = false`). Modes `agent-oot` -(Poetry's default out-of-tree virtualenv via `poetry run`) and `setup` -(`socket-patch setup` on a Poetry project, then `poetry lock`) are +(2.x `[project]` tables with `package-mode = false`). Mode `agent-oot` +(Poetry's default out-of-tree virtualenv via `poetry run`) is informational. Rust coverage of the rewriters: `cargo test -p socket-patch-core --lib diff --git a/docs/testing/vendored-production-e2e.md b/docs/testing/vendored-production-e2e.md index 0d694500..e7d81330 100644 --- a/docs/testing/vendored-production-e2e.md +++ b/docs/testing/vendored-production-e2e.md @@ -49,7 +49,7 @@ three every run and fails first with the offending PURL named. |-----------|------|------------|-----------------------------| | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | `Socket Community Patch` header | | PyPI | `pkg:pypi/urllib3@1.26.18` | one of three (picked by the CLI's `api::ranking`; the suite accepts any) | `Socket Community Patch` header | -| RubyGems | `pkg:gem/activestorage@6.0.3` | any of the `GEM_PATCHES` table (each patch marks a different file). The hosted doc lists 6 live UUIDs; `GEM_PATCHES` carries only the first 4 and lacks `9c2b4925` and the merged `01019627`, which v5 ranking now prefers | `Socket Community Patch` header | +| RubyGems | `pkg:gem/activestorage@6.0.3` | any of the `GEM_PATCHES` table (each patch marks a different file): the same 6 live UUIDs as the hosted doc, including `9c2b4925` and the merged `01019627` that v5 ranking prefers | `Socket Community Patch` header | If a required patch is withdrawn, update the catalog constants at the top of `e2e_vendored_production.rs` **and** the table above (same procedure as the diff --git a/docs/testing/vlt-compatibility.md b/docs/testing/vlt-compatibility.md index 7129edf7..eb0b7f4c 100644 --- a/docs/testing/vlt-compatibility.md +++ b/docs/testing/vlt-compatibility.md @@ -32,8 +32,8 @@ socket-patch alike. | Layer | Where | What it proves | |---|---|---| | Unit and golden | `socket-patch-core` lib tests; `tests/redirect_golden.rs` (`npm/vlt/*`, shared with depscan's TS rewriter), `tests/redirect_golden_reverse_replay.rs`, `tests/vlt_locks.rs` (captured locks of every era, and vendored wiring byte for byte against what real `vlt ci`, `vlt install --frozen-lockfile` and `vlt install ` keep on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14) | DepID codec, collation (`tests/fixtures/vlt/collation-golden.json`), the node-line grammar, the hosted slot rewrite and its slot revert, vendored lock surgery and its inverse, lock inventory, VEX discovery | -| Hermetic suites | `in_process_redirect`, `in_process_vendor`, `in_process_rollback_hosted`, `in_process_vendor_bun_takeover`, `repair_vendor_flavors_e2e`, `e2e_vex_lockfile`, `setup_invariants`, … (the 3-OS `test` job) | every code of the vlt support, the artifact preflight against a wiremock server, the heal, takeovers, repair, the CLI surface | -| Real-vlt capstones | the five binaries below plus the vlt legs of `e2e_hosted_production` / `e2e_vendored_production` | real installs: `vlt ci` and frozen installs of patched locks, byte-stable locks, integrity enforcement, the heal on a warm tree, re-saves, upgrades, the hardlinked global store, the setup hook | +| Hermetic suites | `in_process_redirect`, `in_process_vendor`, `in_process_rollback_hosted`, `in_process_vendor_bun_takeover`, `repair_vendor_flavors_e2e`, `e2e_vex_lockfile`, … (the 3-OS `test` job) | every code of the vlt support, the artifact preflight against a wiremock server, the heal, takeovers, repair, the CLI surface | +| Real-vlt capstones | the five binaries below plus the vlt legs of `e2e_hosted_production` / `e2e_vendored_production` | real installs: `vlt ci` and frozen installs of patched locks, byte-stable locks, integrity enforcement, the heal on a warm tree, re-saves, upgrades, the hardlinked global store | | Native backtest | `scripts/backtest-vlt.py` | the production service end to end, per release, mode and project shape, against an oracle of the documented boundaries | `docs/testing/vlt-coverage.json` maps every vlt code and advisory variant, @@ -203,7 +203,6 @@ them per OS). | `mode_migration_vlt` | `migration` | `vendored_then_hosted`, `hosted_then_vendored`, `dry_run_parity`, `scoped_unwind_one_of_two`, `rollback_from_mixed`, `agent_apply_yields_to_vendored`, `agent_apply_after_hosted`, `hosted_scan_keeps_agent_patched_tree`, `agent_rollback_after_takeovers`, `pm_switch_npm_to_vlt`, `pm_switch_vlt_to_npm`, `flavor_changed`, `upgrade_hosted`, `upgrade_vendored` | | `e2e_safety_vlt` | `safety` | `linux_auto`, `explicit_hardlink`, `private_copies`, `cross_device_cache`, `agent_rollback`, `peer_fanout`, `hosted_heal`, `vendored_build`, `vendor_revert_and_repair`, `layout_note` | | `e2e_vlt` | `agent` | `scan_apply_rollback_list`, `get_and_remove`, `install_then_apply_patches_file`, `transitive_only_dep_apply_patches_store`, `lockfile_supplement`, `launcher`, `persistence_survives`, `persistence_reverted_by_reinstall`, `reruns_and_vex` | -| `e2e_vlt` | `setup` | `hook_fires_per_reify`, `root_scripts_advisory`, `workspace_root_only`, `twice_no_duplicate`, `hook_failure`, `hook_abort_leaves_no_staging` | | `e2e_hosted_production` | `production` | `hosted_install_proof` | | `e2e_vendored_production` | `production` | `vendored_install_proof` | @@ -264,9 +263,6 @@ store-linker knob, `unset` when not given), `cache_root` and `upgrade` | as above | `all` | `cache_root=unset` | safety | `cross_device_cache` | `no-cache-root` | | a scalar `registry` makes lock-driven installs re-resolve from public npm | `1.0.0-rc.7 … 1.0.0-rc.29` | — | agent | `scan_apply_rollback_list`, `launcher` | `non-hermetic-registry` | | `npm:` alias specs resolve against public npm even with `registries.npm` | `1.0.0-rc.30 … 1.0.0-rc.32` | — | agent | `scan_apply_rollback_list` | `non-hermetic-registry` | -| root `pre*`/`post*` scripts run without an `install` script | `< 1.0.0-rc.13` | — | setup | `hook_fires_per_reify`, `workspace_root_only`, `hook_failure` | `root-postinstall-not-run` | -| a failing hook's abort on Windows (rollback EBUSY fix from 1.0.5) | `all` | `os!=windows` | setup | `hook_abort_leaves_no_staging` | `windows-only` | -| as above | `< 1.0.5` | — | setup | `hook_abort_leaves_no_staging` | `pre-ebusy-fix` | | `lockfileVersion` 0 with legacy (`·`/`§`) DepIDs | `0.0.0-19 … 1.0.0-rc.14` | — | — | — | — | | `lockfileVersion` 1, tilde DepIDs | `>= 1.0.0-rc.15` | — | — | — | — | | a plain `vlt install` re-extracts a stale installed copy (`no_cleanup_stays_stale` expects the patch there) | `== 0.0.0-14` | — | — | — | — | @@ -291,6 +287,5 @@ The legs run on Linux, macOS and Windows. The Linux-default `auto` store linker (hardlinks, `safety/linux_auto`) cannot run on macOS or Windows; it is covered on Linux by the CI `e2e` row `e2e_safety_vlt` on ubuntu with vlt 1.2.0 and by `vlt-compatibility.yml`'s store-linker rows, and was also run -locally in `node:24-slim` under Docker. Windows-only legs -(`setup/hook_abort_leaves_no_staging`, the junction and dir-symlink store -cases) run on the Windows rows. +locally in `node:24-slim` under Docker. Windows-only cases +(the junction and dir-symlink store cases) run on the Windows rows. diff --git a/docs/testing/vlt-coverage.json b/docs/testing/vlt-coverage.json index 27daf119..58434708 100644 --- a/docs/testing/vlt-coverage.json +++ b/docs/testing/vlt-coverage.json @@ -112,12 +112,6 @@ "vendor_vlt_refuses_gitignored_payload", "a_gitignored_payload_refuses_and_unwinds", "a_root_socket_ignore_refuses_before_any_write" - ], - "vlt_root_scripts_not_run": [ - "setup_vlt_before_rc13_emits_the_definite_advisory", - "setup_vlt_without_a_usable_vlt_reads_the_lock", - "setup_vlt_advisory_prints_on_stderr_in_human_mode", - "vlt_pinned_matrix_setup_root_scripts_advisory" ] }, "advisoryVariants": { @@ -206,18 +200,6 @@ ], "redirect_vlt_artifact_unverifiable: left pinned by an earlier run": [ "scan_redirect_vlt_artifact_already_pinned_failure_left_pinned" - ], - "vlt_root_scripts_not_run: definite (vlt --version below rc.13)": [ - "setup_vlt_before_rc13_emits_the_definite_advisory", - "vlt_pinned_matrix_setup_root_scripts_advisory" - ], - "vlt_root_scripts_not_run: may (lock sniff)": [ - "setup_vlt_without_a_usable_vlt_reads_the_lock", - "setup_vlt_rc13_and_later_reads_the_lock_it_would_not_write", - "setup_vlt_version_probe_times_out_to_the_lock_sniff" - ], - "vlt_root_scripts_not_run: none (unparseable --version)": [ - "setup_vlt_unparseable_version_emits_no_advisory" ] }, "modeCommand": { @@ -322,13 +304,6 @@ "find_by_purls_resolves_vlt_store_transitives", "crawl_all_inventories_vlt_store_exactly_once", "test_decode_vlt_dep_id_table" - ], - "setup": [ - "setup_detects_vlt_from_lockfile", - "setup_detects_vlt_from_vlt_json", - "setup_vlt_workspace_only_updates_root", - "vlt_pinned_matrix_setup_hook_fires_per_reify", - "vlt_pinned_matrix_setup_hook_failure" ] }, "eraOs": { diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 4ea50420..a968f430 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -79,10 +79,6 @@ What socket-patch does with those files: | revert (`rollback`, `remove`, takeovers) | byte-exact; a ledger recorded before a uniform LF ↔ CRLF checkout flip is replayed respelled; a mixed lock refuses as drift | byte-exact; a lock mixed after vendoring gets the restored entry in the terminator of the entry it replaces | | mode takeover into this mode | the berry gates (line endings, `cacheKey`, `compressionLevel`) run BEFORE the vendored wiring is reverted; a refused purl stays vendored, byte-identical | the backend's project gates (both files' line endings, `cacheKey`, `compressionLevel`) run BEFORE the hosted redirect is reverted; a refused purl stays hosted, byte-identical | -`setup` / `setup --remove` write `package.json` in the same layout-keeping -way (BOM, indent, ending, trailing newline), so the pair round-trips -byte-exactly on a CRLF manifest too. - Every reader — manifest-less `vex`, the lockfile inventory, the npm flavor sniff, `repair` — splits CRLF lines like LF ones and skips a leading BOM. The shared hosted golden fixtures stay LF: their TypeScript twin in the diff --git a/gem/socket-patch-bundler/README.md b/gem/socket-patch-bundler/README.md index e30f56fc..c95528a3 100644 --- a/gem/socket-patch-bundler/README.md +++ b/gem/socket-patch-bundler/README.md @@ -1,5 +1,10 @@ # socket-patch-bundler +> **Deprecated — no longer published.** `socket-patch setup` (which wired this +> plugin) was removed in socket-patch v5, and this gem is no longer built or +> published. In agent mode, run `socket-patch apply` in CI after +> `bundle install` instead. The source is kept for reference only. + A [Bundler plugin](https://bundler.io/guides/bundler_plugins.html) that keeps the gem patches recorded in your project's `.socket/manifest.json` applied on every `bundle install` — cached **and** fresh — by re-running the diff --git a/gem/socket-patch/lib/socket_patch/launcher.rb b/gem/socket-patch/lib/socket_patch/launcher.rb index 531f4351..557c772c 100644 --- a/gem/socket-patch/lib/socket_patch/launcher.rb +++ b/gem/socket-patch/lib/socket_patch/launcher.rb @@ -33,7 +33,7 @@ def run(argv) # Windows has no exec() that replaces the process cleanly for console # apps; spawn + wait and propagate the child's real exit status (a # blanket 1 would erase the CLI's meaningful non-zero codes, e.g. - # `setup --check`'s needs-configuration signal). + # `vex`'s usage-error 2 vs nothing-attested 1). ok = system(bin, *argv) raise LauncherError, "could not run #{bin}" if ok.nil? exit($?.exitstatus || 1) diff --git a/gem/socket-patch/socket-patch.gemspec b/gem/socket-patch/socket-patch.gemspec index 46724695..81545bf2 100644 --- a/gem/socket-patch/socket-patch.gemspec +++ b/gem/socket-patch/socket-patch.gemspec @@ -4,8 +4,8 @@ # run it downloads the prebuilt binary for the host platform from the matching # GitHub release (`v`), verifies it against SHA256SUMS, caches it, and # execs it. `gem install socket-patch` therefore puts `socket-patch` on PATH — -# useful in Bundler/Ruby environments where the gem ecosystem's setup hook needs -# the CLI present. Set `SOCKET_PATCH_BIN` to an existing binary to skip the +# useful in Bundler/Ruby environments (e.g. a CI `socket-patch apply` step after +# `bundle install`). Set `SOCKET_PATCH_BIN` to an existing binary to skip the # download (airgapped CI). The version is synced with the workspace by # `scripts/version-sync.sh`. Gem::Specification.new do |s| diff --git a/pypi/socket-patch-hook/README.md b/pypi/socket-patch-hook/README.md index 3058d030..e1efc4fa 100644 --- a/pypi/socket-patch-hook/README.md +++ b/pypi/socket-patch-hook/README.md @@ -1,5 +1,11 @@ # socket-patch-hook +> **Deprecated — no longer published.** `socket-patch setup` (which added the +> `socket-patch[hook]` dependency) was removed in socket-patch v5, and this +> wheel is no longer built or published; the `socket-patch[hook]` extra is +> gone too. In agent mode, run `socket-patch apply` in CI after install +> instead. The source is kept for reference only. + A tiny, package-manager-agnostic **post-install hook** for [`socket-patch`](https://pypi.org/project/socket-patch/). diff --git a/pypi/socket-patch/pyproject.toml b/pypi/socket-patch/pyproject.toml index 0f505912..adc2bc40 100644 --- a/pypi/socket-patch/pyproject.toml +++ b/pypi/socket-patch/pyproject.toml @@ -21,15 +21,6 @@ classifiers = [ "Topic :: Software Development :: Build Tools", ] -[project.optional-dependencies] -# `pip install socket-patch[hook]` additionally installs the -# package-manager-agnostic .pth startup hook that re-applies patches after -# install. Unpinned so the hook updates independently of the CLI. `setup` -# commits `socket-patch[hook]` (this extra), which pulls in both the CLI and the -# hook wheel. The hook runs the binary bundled in the installed `socket_patch` -# package and falls back to PATH only when that package is absent. -hook = ["socket-patch-hook"] - [project.urls] Homepage = "https://github.com/SocketDev/socket-patch" Repository = "https://github.com/SocketDev/socket-patch" diff --git a/pypi/socket-patch/socket_patch/__init__.py b/pypi/socket-patch/socket_patch/__init__.py index b4cf04ec..f754d553 100644 --- a/pypi/socket-patch/socket_patch/__init__.py +++ b/pypi/socket-patch/socket_patch/__init__.py @@ -7,7 +7,8 @@ def _resolve_binary(): """Locate the bundled socket-patch binary, or return ``None``. Single source of truth for binary discovery, reused by both ``main()`` (the - console-script entry point) and the ``socket_patch_hook`` startup hook. Never + console-script entry point) and the legacy ``socket_patch_hook`` startup hook + (no longer published since v5, but older installs still import this). Never raises: returns ``None`` if the binary can't be found, so callers that run at interpreter startup stay safe. """ diff --git a/scripts/backtest-poetry.py b/scripts/backtest-poetry.py index 499fef96..08421321 100755 --- a/scripts/backtest-poetry.py +++ b/scripts/backtest-poetry.py @@ -17,8 +17,8 @@ over the installed fresh clone: `.socket/manifest.json` deleted (online, and offline from the ledger), the ledgers deleted too (lockfile discovery + the public patch API), `--offline` without ledgers (`record_unavailable`) and the -lock reverted with the ledgers kept (never attested, `--no-verify` too). Extra modes: `agent-oot` (Poetry's default out-of-tree -venv) and `setup`. Shapes: `direct` (native lock), `populated` (legacy locks +lock reverted with the ledgers kept (never attested, `--no-verify` too). Extra mode: `agent-oot` (Poetry's default out-of-tree +venv). Shapes: `direct` (native lock), `populated` (legacy locks with real hashes filled in, as 2020-era locks have), `crlf`, `pep621` (2.x). Needs network (PyPI + patch.socket.dev), uv, and no Socket token. @@ -55,7 +55,7 @@ "2.3.4", "2.4.3", ] -MODES = ["hosted", "vendored", "agent", "agent-oot", "setup"] +MODES = ["hosted", "vendored", "agent", "agent-oot"] SHAPES = ["direct", "populated", "crlf", "pep621"] PROJECT = """[tool.poetry] @@ -443,31 +443,6 @@ def check(name, value, note=None): venv = project / ".venv" python = venv / "bin/python" - # ------------------------------------------------------------ setup - if mode == "setup": - senv = dict(env) - senv["PATH"] = str(tool / "bin") + os.pathsep + senv.get("PATH", "") - # `setup` shells out to that Poetry; give it the case's isolated - # HOME too (Poetry <= 1.1's shared HTTP-cache lock, see poetry_env). - senv["HOME"] = poetry_env(project)["HOME"] - r = Run(cli_cmd(project, "setup"), project, senv, case / "setup.log") - info["setupExit"] = r.rc - try: - info["setupEnvelope"] = r.json() - except Exception: - info["setupOutput"] = (r.out + r.err)[-1500:] - info["pyprojectChanged"] = (project / "pyproject.toml").read_bytes() != pristine_pyproject - info["pyprojectDiff"] = (project / "pyproject.toml").read_text() - info["lockChanged"] = (project / "poetry.lock").read_bytes() != pristine_lock - # Can Poetry itself resolve the committed hook dependency? - rl = Run([poetry, "lock", "-n"] + (["--no-update"] if (1, 1) <= v < (2, 0) else []), project, poetry_env(project), case / "setup-relock.log") - info["poetryLockAfterSetup"] = {"exit": rl.rc, "tail": (rl.out + rl.err)[-600:]} - chk = Run(cli_cmd(project, "setup", "--check"), project, senv, case / "setup-check.log") - info["setupCheckExit"] = chk.rc - row["passed"] = r.rc == 0 and info["pyprojectChanged"] and rl.rc == 0 - row["expected"] = "informational: setup edits pyproject; poetry must resolve socket-patch[hook]" - return row - # -------------------------------------------------------- agent-oot if mode == "agent-oot": penv = dict(env) @@ -537,8 +512,10 @@ def check(name, value, note=None): res = oracle(oot_venv / "bin/python", list(after), project, case / "oracle-4.log") check("rollbackRestoresUpstream", rb.ok() and bool(before) and all(res.get(n) == h for n, h in before.items()), {"exit": rb.rc, "oracle": res}) check("rollbackClearsManifest", not (project / ".socket/manifest.json").exists() or json.loads((project / ".socket/manifest.json").read_text()).get("patches") == {}) - row["passed"] = all(checks[k] for k in checks if k != "bareScanSeesPoetryVenv") - row["expected"] = "bareScanSeesPoetryVenv is informational (known crawler gap); the rest must pass" + # The crawler finds Poetry's out-of-tree venv (virtualenvs.path), so + # the bare scan seeing it is required like every other check. + row["passed"] = all(checks.values()) + row["expected"] = "every check must pass, bareScanSeesPoetryVenv included" return row # ------------------------------------------------- hosted / vendored / agent @@ -735,14 +712,12 @@ def wanted(version, shape, mode): return False if shape == "pep621" and v < (2, 0): return False - if shape in ("crlf", "pep621") and mode in ("agent", "agent-oot", "setup"): + if shape in ("crlf", "pep621") and mode in ("agent", "agent-oot"): return False - if shape == "populated" and mode in ("agent", "agent-oot", "setup"): + if shape == "populated" and mode in ("agent", "agent-oot"): return False if mode == "agent-oot" and version.startswith("0."): return False - if mode == "setup" and version not in ("1.1.15", "1.8.5", "2.4.3"): - return False return True jobs = [(v, s, m) for v in args.versions for s in args.shapes for m in args.modes if wanted(v, s, m)] @@ -856,8 +831,6 @@ def render_table(summary): if "manifestlessVex" in info: ml = [k for k in ("vexManifestDeleted", "vexLedgerOffline", "vexLedgersDeleted", "vexOfflineRecordUnavailable", "vexRevertedUnwired") if r["checks"].get(k)] notes.append(f"manifest-less vex {len(ml)}/5") - if "poetryLockAfterSetup" in info: - notes.append(f"poetry lock after setup exit {info['poetryLockAfterSetup']['exit']}") lines.append(f"| {r['poetry']} | {r['shape']} | {r['mode']} | {'PASS' if r['passed'] else 'FAIL'} | {failed} | {'; '.join(notes)} |") for e in summary.get("errors", []): lines.append(f"| {e.get('poetry')} | {e.get('shape')} | {e.get('mode')} | ERROR | {e['error'][-160:].replace(chr(10), ' ')} | |") diff --git a/scripts/build-pypi-wheels.py b/scripts/build-pypi-wheels.py index e1fcd392..47718e87 100755 --- a/scripts/build-pypi-wheels.py +++ b/scripts/build-pypi-wheels.py @@ -190,11 +190,6 @@ def build_wheel( f"Summary: {metadata['description']}\n" f"License: {metadata['license']}\n" f"Requires-Python: {metadata['requires_python']}\n" - # `pip install socket-patch[hook]` additionally installs the - # package-manager-agnostic .pth post-install hook (a separate - # pure-python wheel). Unpinned so the hook can update independently. - f"Provides-Extra: hook\n" - f'Requires-Dist: socket-patch-hook; extra == "hook"\n' ) if metadata.get("readme"): metadata_header += "Description-Content-Type: text/markdown\n" @@ -242,79 +237,6 @@ def build_wheel( return wheel_path -DIST_NAME_HOOK = "socket_patch_hook" -PKG_NAME_HOOK = "socket-patch-hook" - - -def build_hook_wheel(version: str, hook_dir: Path, dist_dir: Path) -> Path: - """Build the pure-python ``socket-patch-hook`` wheel (``py3-none-any``). - - Unlike the platform wheels, this ships no binary. It contains the - ``socket_patch_hook`` package and — crucially — a top-level - ``socket_patch_hook.pth`` that pip installs into the site-packages root, so - Python executes it at interpreter startup. It depends on ``socket-patch`` - (the binary wheel) for the actual ``apply``. - """ - init_path = hook_dir / "socket_patch_hook" / "__init__.py" - pth_path = hook_dir / "socket_patch_hook.pth" - readme_path = hook_dir / "README.md" - init_py = init_path.read_bytes() - pth = pth_path.read_bytes() - readme = readme_path.read_text() if readme_path.exists() else "" - - wheel_name = f"{DIST_NAME_HOOK}-{version}-py3-none-any.whl" - wheel_path = dist_dir / wheel_name - dist_info = f"{DIST_NAME_HOOK}-{version}.dist-info" - - files = [] - # The package module. - files.append((f"{DIST_NAME_HOOK}/__init__.py", init_py, False)) - # The startup hook — at the wheel root so it installs to site-packages. - files.append(("socket_patch_hook.pth", pth, False)) - - # No Requires-Dist on socket-patch: the hook is version-agnostic and finds - # whatever `socket-patch` CLI is on PATH at runtime (provisioned separately). - metadata_content = ( - f"Metadata-Version: 2.1\n" - f"Name: {PKG_NAME_HOOK}\n" - f"Version: {version}\n" - f"Summary: Package-manager-agnostic post-install patch hook for socket-patch\n" - f"License: MIT\n" - f"Requires-Python: >=3.8\n" - ) - if readme: - metadata_content += "Description-Content-Type: text/markdown\n" - metadata_content += f"\n{readme}" - files.append((f"{dist_info}/METADATA", metadata_content.encode(), False)) - - # Pure-python: Root-Is-Purelib true so the .pth lands in site-packages. - wheel_content = ( - "Wheel-Version: 1.0\n" - "Generator: build-pypi-wheels.py\n" - "Root-Is-Purelib: true\n" - "Tag: py3-none-any\n" - ).encode() - files.append((f"{dist_info}/WHEEL", wheel_content, False)) - - record_lines = [] - for name, data, _ in files: - record_lines.append(f"{name},{sha256_digest(data)},{len(data)}") - record_name = f"{dist_info}/RECORD" - record_lines.append(f"{record_name},,") - files.append((record_name, "\n".join(record_lines).encode(), False)) - - with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf: - for name, data, _ in files: - info_obj = zipfile.ZipInfo(name) - info_obj.external_attr = ( - stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH - ) << 16 - info_obj.compress_type = zipfile.ZIP_DEFLATED - zf.writestr(info_obj, data) - - return wheel_path - - def main(): parser = argparse.ArgumentParser( description="Build platform-tagged PyPI wheels for socket-patch" @@ -326,8 +248,8 @@ def main(): ) parser.add_argument( "--artifacts", - default=None, - help="Directory containing build artifacts (required unless --hook-only)", + required=True, + help="Directory containing build artifacts", ) parser.add_argument( "--dist", @@ -339,42 +261,16 @@ def main(): default=None, help="Directory containing pyproject.toml (default: pypi/socket-patch relative to script)", ) - parser.add_argument( - "--hook-dir", - default=None, - help="Directory of the socket-patch-hook package (default: pypi/socket-patch-hook)", - ) - parser.add_argument( - "--hook-only", - action="store_true", - help="Build only the pure-python socket-patch-hook wheel (no binary artifacts needed)", - ) - parser.add_argument( - "--skip-hook", - action="store_true", - help="Skip building the socket-patch-hook wheel", - ) args = parser.parse_args() dist_dir = Path(args.dist) dist_dir.mkdir(parents=True, exist_ok=True) repo_root = Path(__file__).resolve().parent.parent - hook_dir = Path(args.hook_dir) if args.hook_dir else repo_root / "pypi" / "socket-patch-hook" built = [] skipped = [] - # The pure-python hook wheel needs no platform artifacts. - if args.hook_only: - wheel_path = build_hook_wheel(args.version, hook_dir, dist_dir) - size_kb = wheel_path.stat().st_size / 1024 - print(f"Built hook wheel: {wheel_path.name} ({size_kb:.1f} KB)") - return - - if not args.artifacts: - parser.error("--artifacts is required unless --hook-only is given") - artifacts_dir = Path(args.artifacts) if args.pyproject_dir: @@ -407,12 +303,6 @@ def main(): print(f" -> {wheel_path.name} ({size_mb:.1f} MB)") built.append(wheel_path) - if not args.skip_hook: - hook_wheel = build_hook_wheel(args.version, hook_dir, dist_dir) - size_kb = hook_wheel.stat().st_size / 1024 - print(f" -> {hook_wheel.name} ({size_kb:.1f} KB) [pure-python hook]") - built.append(hook_wheel) - print(f"\nBuilt {len(built)} wheel(s) in {dist_dir}/") if skipped: print(f"Skipped {len(skipped)} target(s) (artifact not found): {', '.join(skipped)}") diff --git a/scripts/burn-down-tests.ts b/scripts/burn-down-tests.ts index 3a9535eb..c54d402a 100644 --- a/scripts/burn-down-tests.ts +++ b/scripts/burn-down-tests.ts @@ -33,7 +33,7 @@ * --features cargo features for the suite + single-test runs * (default: none — every ecosystem is unconditional; * intentionally NOT --all-features, which would pull - * in the infra-gated docker-e2e / setup-e2e suites). + * in the infra-gated docker-e2e suites). * --test-cmd Override the full-suite enumeration command * (default: cargo test --workspace --features * --no-fail-fast). diff --git a/scripts/harden-tests.config.ts b/scripts/harden-tests.config.ts index fe24dfaf..99540638 100644 --- a/scripts/harden-tests.config.ts +++ b/scripts/harden-tests.config.ts @@ -44,7 +44,7 @@ export default function render(ctx: FileCtx): string { // often hides here: a helper that swallows errors, a fake fixture that never // exercises the real path, an assertion shim that always passes. const isHarness = - /(^|\/)(common|setup_matrix_common|helpers?|support|fixtures?)(\/|$)/.test( + /(^|\/)(common|helpers?|support|fixtures?)(\/|$)/.test( ctx.relInCrate, ) || ctx.name === "mod.rs"; diff --git a/scripts/optimize-test-perf.config.ts b/scripts/optimize-test-perf.config.ts index 548ec6fb..38a03352 100644 --- a/scripts/optimize-test-perf.config.ts +++ b/scripts/optimize-test-perf.config.ts @@ -49,14 +49,14 @@ export const model = "claude-opus-5"; // feature gates, and naming one (`cargo`, `golang`, `maven`, …) 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 +// ecosystems, minus the cfg-gated `docker-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( + /(^|\/)(common|helpers?|support|fixtures?)(\/|$)/.test( ctx.relInCrate, ) || ctx.name === "mod.rs"; diff --git a/scripts/setup-matrix.sh b/scripts/setup-matrix.sh deleted file mode 100755 index 9c962a55..00000000 --- a/scripts/setup-matrix.sh +++ /dev/null @@ -1,299 +0,0 @@ -#!/usr/bin/env bash -# ===================================================================== -# setup-matrix.sh — orchestrate and query the `socket-patch setup` -# end-to-end test matrix. -# -# The matrix asks, for every supported ecosystem/package-manager: -# "does `socket-patch setup` configure things so that a normal install -# applies the project's patches?" Each case runs the flow driver -# (tests/setup_matrix/run-case.sh) which prepares a project + committed -# patch set, optionally runs `socket-patch setup`, runs the native -# install, and checks whether the patch landed on disk. -# -# Results are classified against the recorded baseline in matrix.json: -# pass meets the ideal AND matches the recorded baseline -# known_gap fails the ideal but exactly as recorded (expected today) -# progress better than the recorded baseline (update baseline!) -# known_regression would be a regression, but is on the temporary -# `known_regressions` allowlist in matrix.json (a tracked, -# non-blocking bug; auto-recovers to pass/progress when fixed) -# regression diverged from the baseline the wrong way (this is the -# only thing that makes `run` exit non-zero) -# error the driver could not produce a result -# -# Subcommands: -# build [--ecosystem E]... build base + per-ecosystem images -# run [--ecosystem E] [--pm P] [--scenario S] [--host] [--out FILE] [--verbose] -# list [--json] enumerate every matrix case -# query [--status S] [--ecosystem E] [--pm P] [--scenario S] filter latest results -# results print the latest aggregate -# -# CLI/agent-friendly: `list`/`query`/`results` emit JSON; `run` writes a -# machine-readable report to tests/setup_matrix/results/latest.json. -# ===================================================================== -set -uo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -SM_DIR="$REPO_ROOT/tests/setup_matrix" -MATRIX="$SM_DIR/matrix.json" -DRIVER="$SM_DIR/run-case.sh" -RESULTS_DIR="$SM_DIR/results" -LATEST="$RESULTS_DIR/latest.json" - -ALL_ECOSYSTEMS=(npm pypi cargo gem golang maven composer nuget deno) - -die() { echo "error: $*" >&2; exit 1; } -need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not on PATH"; } - -usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; } - -need jq -[ -f "$MATRIX" ] || die "matrix spec not found: $MATRIX" - -# Emit one TSV row per case, honoring filters. Covers all three layouts: -# single (targets x scenarios), workspace (workspace_targets x -# workspace_scenarios) and monorepo (monorepo_targets x monorepo_scenarios). -# Columns: id eco pm image hook_family baseline_supported package version -# purl manifest_key apply_ecosystems scenario patchset run_setup -# expect_applied layout -cases_tsv() { # $1=eco-filter ("" = all) $2=pm-filter $3=scenario-filter - jq -r --arg eco "${1:-}" --arg pm "${2:-}" --arg scn "${3:-}" ' - def rows($targets; $scenarios; $layout): - $targets[] as $t | $scenarios[] as $s - | select($eco == "" or $t.ecosystem == $eco) - | select($pm == "" or $t.pm == $pm) - | select($scn == "" or $s.id == $scn) - | [ ($t.ecosystem + "/" + $t.pm + "/" + $s.id), - $t.ecosystem, $t.pm, $t.image, ($t.hook_family // ""), - ($t.baseline_supported|tostring), - $t.package, $t.version, $t.purl, $t.manifest_key, $t.apply_ecosystems, - $s.id, $s.patchset, ($s.run_setup|tostring), ($s.expect_applied|tostring), - $layout ] - | @tsv; - rows(.targets; .scenarios; "single"), - rows((.workspace_targets // []); (.workspace_scenarios // []); "workspace"), - rows((.monorepo_targets // []); (.monorepo_scenarios // []); "monorepo") - ' "$MATRIX" -} - -marker() { jq -r '.marker' "$MATRIX"; } -alt_marker() { jq -r '.alt_marker' "$MATRIX"; } - -# --------------------------------------------------------------------- build -cmd_build() { - local ecos=(); - while [ $# -gt 0 ]; do case "$1" in - --ecosystem) ecos+=("$2"); shift 2;; - *) die "build: unknown arg '$1'";; - esac; done - [ ${#ecos[@]} -eq 0 ] && ecos=("${ALL_ECOSYSTEMS[@]}") - need docker - echo ">> building base image" >&2 - docker build -f "$REPO_ROOT/tests/docker/Dockerfile.base" -t socket-patch-test-base:latest "$REPO_ROOT" \ - || die "base image build failed" - local e - for e in "${ecos[@]}"; do - echo ">> building $e image" >&2 - docker build -f "$REPO_ROOT/tests/docker/Dockerfile.$e" -t "socket-patch-test-$e:latest" "$REPO_ROOT" \ - || die "$e image build failed" - done - echo ">> done" >&2 -} - -# --------------------------------------------------------------------- list -cmd_list() { - local as_json=0 - while [ $# -gt 0 ]; do case "$1" in --json) as_json=1; shift;; *) die "list: unknown arg '$1'";; esac; done - if [ "$as_json" = 1 ]; then - jq '[ .targets[] as $t | .scenarios[] as $s | - { id: ($t.ecosystem+"/"+$t.pm+"/"+$s.id), ecosystem:$t.ecosystem, pm:$t.pm, - scenario:$s.id, image:$t.image, hook_family:$t.hook_family, - baseline_supported:$t.baseline_supported, expect_applied:$s.expect_applied } ]' "$MATRIX" - else - printf '%-46s %-9s %-8s %-11s %-22s %s\n' ID ECO PM LAYOUT SCENARIO EXPECT - cases_tsv "" "" "" | while IFS=$'\t' read -r id eco pm image hook bsup pkg ver purl key aeco scn pset rsetup expect layout; do - printf '%-46s %-9s %-8s %-11s %-22s %s\n' "$id" "$eco" "$pm" "$layout" "$scn" "$expect" - done - fi -} - -# --------------------------------------------------------------------- run -resolve_host_bin() { - if [ -n "${SOCKET_PATCH_BIN:-}" ]; then echo "$SOCKET_PATCH_BIN"; return; fi - for c in "$REPO_ROOT/target/release/socket-patch" "$REPO_ROOT/target/debug/socket-patch"; do - [ -x "$c" ] && { echo "$c"; return; } - done - command -v socket-patch 2>/dev/null || echo "" -} - -cmd_run() { - local eco="" pm="" scn="" host=0 out="$LATEST" verbose=0 - while [ $# -gt 0 ]; do case "$1" in - --ecosystem) eco="$2"; shift 2;; - --pm) pm="$2"; shift 2;; - --scenario) scn="$2"; shift 2;; - --host) host=1; shift;; - --out) out="$2"; shift 2;; - --verbose) verbose=1; shift;; - *) die "run: unknown arg '$1'";; - esac; done - - local MARK ALT; MARK="$(marker)"; ALT="$(alt_marker)" - mkdir -p "$RESULTS_DIR" - local jsonl; jsonl="$(mktemp)" - - if [ "$host" = 0 ]; then need docker; fi - local host_bin="" - if [ "$host" = 1 ]; then - host_bin="$(resolve_host_bin)" - [ -n "$host_bin" ] || die "host mode: no socket-patch binary found (build it or set SOCKET_PATCH_BIN)" - echo ">> host mode, binary: $host_bin" >&2 - fi - - # Allowlist of cases that are a tracked, non-blocking `known_regression` - # (see matrix.json `known_regressions`): they should work per the baseline but - # currently don't, and must not fail the job while the bug is fixed. - local known_regressions; known_regressions="$(jq -c '.known_regressions // []' "$MATRIX")" - - local total=0 - while IFS=$'\t' read -r id eco_ pm_ image hook bsup pkg ver purl key aeco scn_ pset rsetup expect layout; do - [ -z "$id" ] && continue - total=$((total+1)) - echo ">> [$total] $id (layout=$layout)" >&2 - - # Common SM_* env for the driver. - local -a base_env=( - "SM_ID=$id" "SM_ECOSYSTEM=$eco_" "SM_PM=$pm_" "SM_SCENARIO=$scn_" - "SM_LAYOUT=$layout" - "SM_PATCHSET=$pset" "SM_RUN_SETUP=$([ "$rsetup" = true ] && echo 1 || echo 0)" - "SM_EXPECT_APPLIED=$([ "$expect" = true ] && echo 1 || echo 0)" - "SM_PACKAGE=$pkg" "SM_VERSION=$ver" "SM_PURL=$purl" - "SM_MANIFEST_KEY=$key" "SM_APPLY_ECOSYSTEMS=$aeco" - "SM_MARKER=$MARK" "SM_ALT_MARKER=$ALT" - ) - - local raw="" rc=0 - if [ "$host" = 1 ]; then - if [ "$verbose" = 1 ]; then - raw="$(env "${base_env[@]}" "SOCKET_PATCH_BIN=$host_bin" bash "$DRIVER")"; rc=$? - else - raw="$(env "${base_env[@]}" "SOCKET_PATCH_BIN=$host_bin" bash "$DRIVER" 2>/dev/null)"; rc=$? - fi - else - local -a docker_env=() - local kv; for kv in "${base_env[@]}"; do docker_env+=(-e "$kv"); done - if [ "$verbose" = 1 ]; then - raw="$(docker run --rm "${docker_env[@]}" "socket-patch-test-$image:latest" bash -c "$(cat "$DRIVER")")"; rc=$? - else - raw="$(docker run --rm "${docker_env[@]}" "socket-patch-test-$image:latest" bash -c "$(cat "$DRIVER")" 2>/dev/null)"; rc=$? - fi - fi - - # The driver prints the result JSON as the last line of stdout. - local result; result="$(printf '%s\n' "$raw" | grep -E '^\{.*"actual_applied"' | tail -n1)" - - # baseline_applied = expect_applied AND baseline_supported. - local bl=false - if [ "$expect" = true ] && [ "$bsup" = true ]; then bl=true; fi - - if [ -n "$result" ] && printf '%s' "$result" | jq -e . >/dev/null 2>&1; then - printf '%s\n' "$result" | jq -c --argjson bl "$bl" --arg img "$image" --arg hk "$hook" --arg lay "$layout" \ - --arg cid "$id" --argjson kr "$known_regressions" ' - . as $r | - ($r.actual_applied == $r.expect_applied) as $ideal | - ($r.actual_applied == $bl) as $base | - (if $ideal and $base then "pass" - elif $ideal and ($base|not) then "progress" - elif ($ideal|not) and $base then "known_gap" - else "regression" end) as $cls0 | - # A regression that is on the temporary allowlist is downgraded to the - # non-blocking `known_regression` (still tracked; auto-recovers to a - # `pass`/`progress` when fixed — then remove it from matrix.json). - (if $cls0 == "regression" and ($kr | index($cid)) then "known_regression" else $cls0 end) as $cls | - $r + {baseline_applied:$bl, classification:$cls, layout:$lay, image:$img, hook_family:$hk, driver_rc:'"$rc"'} - ' >> "$jsonl" - else - # No parseable result — surface as an error case. - jq -nc --arg id "$id" --arg eco "$eco_" --arg pm "$pm_" --arg scn "$scn_" \ - --arg pset "$pset" --arg img "$image" --arg hk "$hook" --arg lay "$layout" --argjson bl "$bl" ' - { id:$id, ecosystem:$eco, pm:$pm, scenario:$scn, patchset:$pset, - expect_applied:null, actual_applied:null, baseline_applied:$bl, - classification:"error", layout:$lay, image:$img, hook_family:$hk, driver_rc:'"$rc"', - notes:"driver produced no parseable result" }' >> "$jsonl" - fi - done < <(cases_tsv "$eco" "$pm" "$scn") - - # Aggregate + summarize. - jq -s --arg generated "$(date -u +%FT%TZ)" ' - { generated:$generated, - summary: ( reduce .[] as $c ( - {total:0,pass:0,known_gap:0,progress:0,known_regression:0,regression:0,error:0}; - .total += 1 | .[$c.classification] += 1 ) ), - cases: . }' "$jsonl" > "$out" - rm -f "$jsonl" - [ "$out" != "$LATEST" ] && cp "$out" "$LATEST" - - print_summary "$out" - local regressions; regressions="$(jq -r '.summary.regression' "$out")" - if [ "$regressions" -gt 0 ]; then - echo "!! $regressions regression(s) — a case that should work no longer does" >&2 - return 1 - fi - return 0 -} - -print_summary() { # $1 = results file - local f="$1" - echo "" >&2 - printf '%-44s %-8s %-6s %-6s %s\n' CASE PM APPLIED EXPECT STATUS >&2 - jq -r '.cases[] | [ .id, .pm, (.actual_applied|tostring), (.expect_applied|tostring), .classification ] | @tsv' "$f" \ - | while IFS=$'\t' read -r id pm act exp cls; do - printf '%-44s %-8s %-6s %-6s %s\n' "$id" "$pm" "$act" "$exp" "$cls" >&2 - done - echo "" >&2 - jq -r '.summary | "total=\(.total) pass=\(.pass) known_gap=\(.known_gap) progress=\(.progress) known_regression=\(.known_regression) regression=\(.regression) error=\(.error)"' "$f" >&2 - local prog; prog="$(jq -r '.summary.progress' "$f")" - [ "$prog" -gt 0 ] && echo ">> $prog case(s) now BETTER than baseline — consider updating baseline_supported in matrix.json" >&2 - local kr; kr="$(jq -r '.summary.known_regression' "$f")" - [ "$kr" -gt 0 ] && echo ">> $kr case(s) are a tracked known_regression (allowlisted in matrix.json, non-blocking) — fix the hook + remove from the list" >&2 - echo ">> full report: $f" >&2 -} - -# --------------------------------------------------------------------- query / results -cmd_query() { - local status="" eco="" pm="" scn="" lay="" - while [ $# -gt 0 ]; do case "$1" in - --status) status="$2"; shift 2;; - --ecosystem) eco="$2"; shift 2;; - --pm) pm="$2"; shift 2;; - --scenario) scn="$2"; shift 2;; - --layout) lay="$2"; shift 2;; - *) die "query: unknown arg '$1'";; - esac; done - [ -f "$LATEST" ] || die "no results yet — run '$0 run' first" - jq --arg st "$status" --arg eco "$eco" --arg pm "$pm" --arg scn "$scn" --arg lay "$lay" ' - [ .cases[] - | select($st == "" or .classification == $st) - | select($eco == "" or .ecosystem == $eco) - | select($pm == "" or .pm == $pm) - | select($scn == "" or .scenario == $scn) - | select($lay == "" or .layout == $lay) ]' "$LATEST" -} - -cmd_results() { - [ -f "$LATEST" ] || die "no results yet — run '$0 run' first" - cat "$LATEST" -} - -# --------------------------------------------------------------------- dispatch -[ $# -ge 1 ] || { usage; exit 1; } -sub="$1"; shift || true -case "$sub" in - build) cmd_build "$@";; - run) cmd_run "$@";; - list) cmd_list "$@";; - query) cmd_query "$@";; - results) cmd_results "$@";; - -h|--help|help) usage;; - *) die "unknown subcommand '$sub' (try: build run list query results)";; -esac diff --git a/scripts/tests/test_check_vlt_legs.py b/scripts/tests/test_check_vlt_legs.py index 4d40783a..1ede0191 100644 --- a/scripts/tests/test_check_vlt_legs.py +++ b/scripts/tests/test_check_vlt_legs.py @@ -165,8 +165,6 @@ def test_era_skips_follow_the_boundaries(self): self.assertEqual(es("vendored", "absent_version_refused", "0.0.0-16"), "ran") self.assertEqual(es("vendored", "absent_version_refused", "1.2.0"), "skip:lockfile-version-present") self.assertEqual(es("safety", "agent_rollback", "1.1.1"), "skip:no-global-store") - self.assertEqual(es("setup", "hook_fires_per_reify", "1.0.0-rc.12"), "skip:root-postinstall-not-run") - self.assertEqual(es("setup", "hook_fires_per_reify", "1.0.0-rc.13"), "ran") def test_knobs_select_the_safety_legs(self): m = self.manifest diff --git a/scripts/tests/test_vlt_coverage.py b/scripts/tests/test_vlt_coverage.py index 25de75f1..e7262432 100644 --- a/scripts/tests/test_vlt_coverage.py +++ b/scripts/tests/test_vlt_coverage.py @@ -22,7 +22,7 @@ "redirect_vlt_sibling_lockfiles", "redirect_vlt_no_lockfile", "redirect_vlt_artifact_unverifiable", "redirect_vlt_reinstall_required", "vendor_vlt_transitive_unsupported", "vendor_vlt_lock_out_of_sync", "vendor_vlt_legacy_lockfile", "vendor_vlt_reinstall_required", - "vendor_flavor_changed", "vendor_artifact_gitignored", "vlt_root_scripts_not_run", + "vendor_flavor_changed", "vendor_artifact_gitignored", ] MODE_COMMANDS = [ "hosted/scan", "hosted/get", "hosted/rollback", "hosted/remove", "hosted/vex", @@ -30,7 +30,6 @@ "vendored/revert", "vendored/repair", "vendored/remove", "vendored/list", "vendored/vex", "vendored/takeover (hosted to vendored)", "vendored/scan --prune", "agent/scan", "agent/get", "agent/apply", "agent/rollback", "agent/remove", "agent/list", "agent/vex", "agent/crawl", - "setup", ] FN = re.compile(r"^(?P\s*)(?:pub(?:\([a-z]+\))?\s+)?(?:async\s+)?fn\s+(?P[A-Za-z0-9_]+)") ASSERTS = re.compile(r"assert|expect|\.contains\(|matches!") @@ -155,8 +154,7 @@ def test_every_advisory_variant_maps_to_existing_tests(self): self.assertIn(key.split(":", 1)[0], CODES, key) self.assertTrue(names, key) self.check_names(names) - for code in ("redirect_vlt_reinstall_required", "redirect_vlt_artifact_unverifiable", - "vlt_root_scripts_not_run"): + for code in ("redirect_vlt_reinstall_required", "redirect_vlt_artifact_unverifiable"): self.assertTrue(any(k.startswith(code + ":") for k in variants), code) def test_every_mode_command_cell_maps_to_existing_tests(self): diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 48fde5f7..7b06e4ef 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -3,8 +3,7 @@ # - Cargo.toml (workspace version + socket-patch-core exact pin) # - npm/socket-patch/package.json (+ optionalDependencies, package-lock.json) # - npm/socket-patch-*/package.json (per-platform packages) -# - pypi/socket-patch/pyproject.toml + pypi/socket-patch-hook/pyproject.toml -# - gem/socket-patch-bundler/socket-patch-bundler.gemspec +# - pypi/socket-patch/pyproject.toml # - gem/socket-patch/socket-patch.gemspec + lib/socket_patch/launcher.rb set -euo pipefail @@ -69,21 +68,8 @@ pyproject="$REPO_ROOT/pypi/socket-patch/pyproject.toml" sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$pyproject" rm -f "$pyproject.bak" -# Update the PyPI hook package version. The release build (build-pypi-wheels.py) -# injects --version at wheel-build time, so this keeps the source-of-truth -# pyproject.toml in sync for local builds and avoids a stale version field. -hook_pyproject="$REPO_ROOT/pypi/socket-patch-hook/pyproject.toml" -sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$hook_pyproject" -rm -f "$hook_pyproject.bak" - -# Update the Ruby Bundler-plugin gem version (Phase 2 scaffolding). The in-tree -# plugin is the active mechanism today; keep the published gem's version in sync -# so a release publishes a version matching the CLI. -gemspec="$REPO_ROOT/gem/socket-patch-bundler/socket-patch-bundler.gemspec" -if [ -f "$gemspec" ]; then - sed -i.bak "s/s\.version *= *\".*\"/s.version = \"$VERSION\"/" "$gemspec" - rm -f "$gemspec.bak" -fi +# pypi/socket-patch-hook and gem/socket-patch-bundler are frozen: `setup` +# was removed in v5 and neither is built or published any more. # Update the RubyGems CLI launcher gem (gemspec version + the VERSION constant # the launcher uses to pick the matching GitHub release binary). diff --git a/tests/docker/Dockerfile.gem-b1 b/tests/docker/Dockerfile.gem-b1 deleted file mode 100644 index 748070cf..00000000 --- a/tests/docker/Dockerfile.gem-b1 +++ /dev/null @@ -1,46 +0,0 @@ -# gem (Ruby) bundler-1 matrix image: Ruby 3.1 + bundler 1.17.3 + socket-patch. -# -# Bundler 1.17.3 is the last 1.x release. It calls `untaint`, which Ruby 3.2 -# removed, so this image pins ruby:3.1 (the newest Ruby that still carries the -# method as a no-op) on bookworm so the socket-patch binary compiled against -# the base image's glibc still runs. Ruby 3.1 ships a default bundler 2.3.x; -# BUNDLER_VERSION forces the binstub to select 1.17.3 in every process. -# -# webrick is baked in (removed from Ruby stdlib in 3.0) so matrix probe -# scripts can run an in-container loopback mock server under `--network none`. -# -# Used by the bundler-version legs in setup_matrix_gem.rs (the >= 2.2 plugin -# floor: bundler 1.x cannot load `plugin ... path:` directives). These legs -# do NOT run in CI (the CI setup-matrix job drives scripts/setup-matrix.sh -# against the plain `gem` image only) — build locally: -# -# docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . -# docker build -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:latest . -# -# BASE_IMAGE is overridable so a fix under test can be verified without -# touching the shared :latest tags: -# docker build --build-arg BASE_IMAGE=socket-patch-test-base:mytag \ -# -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:mytag . -ARG BASE_IMAGE=socket-patch-test-base:latest -FROM ${BASE_IMAGE} AS sptool - -FROM ruby:3.1-slim-bookworm - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - curl \ - git \ - && rm -rf /var/lib/apt/lists/* \ - && gem install bundler -v 1.17.3 --no-document \ - && gem install webrick --no-document - -ENV BUNDLER_VERSION=1.17.3 - -COPY --from=sptool /usr/local/bin/socket-patch /usr/local/bin/socket-patch -RUN ruby --version && gem --version && bundle --version \ - && bundle --version | grep -q 'Bundler version 1\.17\.3' \ - && socket-patch --version - -WORKDIR /workspace diff --git a/tests/docker/Dockerfile.gem-b4 b/tests/docker/Dockerfile.gem-b4 deleted file mode 100644 index 033c27e2..00000000 --- a/tests/docker/Dockerfile.gem-b4 +++ /dev/null @@ -1,45 +0,0 @@ -# gem (Ruby) bundler-4 matrix image: Ruby 3.4 + bundler ~> 4.0 + socket-patch. -# -# Bundler 4 is the current major (3 was skipped). Ruby 3.4's default bundler -# is 2.6.x; the binstub selects the newest installed version, so installing -# any 4.x makes `bundle` resolve to it without extra pinning. Bundler 4 writes -# a CHECKSUMS section into fresh locks by default — the flavor the hosted-mode -# rewrite canary in e2e_redirect_gem_build.rs pins. -# -# webrick is baked in (removed from Ruby stdlib in 3.0) so matrix probe -# scripts can run an in-container loopback mock server under `--network none`. -# -# Companion to Dockerfile.gem-b1 for bundler-version matrix runs (the plain -# `gem` image pins bundler ~> 2.7; this one covers the current major). Not -# built in CI — build locally: -# -# docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . -# docker build -f tests/docker/Dockerfile.gem-b4 -t socket-patch-test-gem-b4:latest . -# -# BASE_IMAGE is overridable so a fix under test can be verified without -# touching the shared :latest tags: -# docker build --build-arg BASE_IMAGE=socket-patch-test-base:mytag \ -# -f tests/docker/Dockerfile.gem-b4 -t socket-patch-test-gem-b4:mytag . -ARG BASE_IMAGE=socket-patch-test-base:latest -FROM ${BASE_IMAGE} AS sptool - -FROM ruby:3.4-slim-bookworm - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - curl \ - git \ - && rm -rf /var/lib/apt/lists/* \ - && gem install bundler -v '~> 4.0' --no-document \ - && gem install webrick --no-document - -COPY --from=sptool /usr/local/bin/socket-patch /usr/local/bin/socket-patch -# bundler 4 dropped the "Bundler version " prefix from `bundle --version` -# (it prints the bare version), so the sanity grep matches the bare form. -RUN ruby --version && gem --version && bundle --version \ - && bundle --version | grep -qE '^(Bundler version )?4\.' \ - && socket-patch --version - -WORKDIR /workspace diff --git a/tests/docker/Dockerfile.npm b/tests/docker/Dockerfile.npm index 2eba0343..5d80e5b1 100644 --- a/tests/docker/Dockerfile.npm +++ b/tests/docker/Dockerfile.npm @@ -30,10 +30,9 @@ RUN for i in 1 2 3; do \ done \ && ln -s /root/.bun/bin/bun /usr/local/bin/bun -# Enable pnpm and yarn via corepack (bundled with Node 22). The -# setup-matrix suite (tests/setup_matrix/) drives the npm-family -# package managers — npm, yarn, pnpm, bun and (installed below) vlt — -# through the `socket-patch setup` install-hook flow. `corepack prepare … --activate` pins and +# Enable pnpm and yarn via corepack (bundled with Node 22) so the image +# carries every npm-family package manager — npm, yarn, pnpm, bun and +# (installed below) vlt. `corepack prepare … --activate` pins and # activates a known version so the binaries resolve without a network # round-trip at test time. Additive: the existing docker_e2e_npm tests # are unaffected. @@ -49,8 +48,8 @@ RUN corepack enable \ # the activated versions. ENV COREPACK_DEFAULT_TO_LATEST=0 -# vlt, for the setup-matrix `pm: vlt` cases (non-gating extras: the -# gating vlt setup assertions live in the real-vlt capstones). Retried like +# vlt (a non-gating extra: the gating vlt assertions live in the real-vlt +# capstones). Retried like # bun: one registry blip must not fail the image build. Its telemetry is # off for every test run. RUN for i in 1 2 3; do \ diff --git a/tests/docker/Dockerfile.pypi b/tests/docker/Dockerfile.pypi index ac962dbc..01e5c178 100644 --- a/tests/docker/Dockerfile.pypi +++ b/tests/docker/Dockerfile.pypi @@ -6,8 +6,8 @@ # user flow. # # uv/poetry/pdm/hatch/pipenv are installed from PyPI so the one image can -# drive every Python package manager the setup-matrix suite exercises -# (tests/setup_matrix/) plus the pypi package-manager vendor suite (pipenv). +# drive every Python package manager (the pypi package-manager vendor suite +# uses pipenv). # The `--break-system-packages` flag is what Debian-packaged pip3 requires # to install into the system site-packages; it's safe inside the disposable # test container. Additive: the existing docker_e2e_pypi tests (pip + uv) diff --git a/tests/docker/README.md b/tests/docker/README.md index 67611479..fbf06684 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -92,107 +92,8 @@ spike-validated against; bundler >= 2.7 needs ruby >= 3.2, newer than Debian 12's apt ruby). The gem suite covers both the default no-CHECKSUMS lock and a `lockfile_checksums` twin (`bundle lock --add-checksums`). -## Bundler-version matrix images (`Dockerfile.gem-b1`, `Dockerfile.gem-b4`) - -The plain `Dockerfile.gem` pins bundler `~> 2.7`. Two sibling images cover -the ends of the bundler spectrum. Only `gem-b1` feeds a gated leg (in -`crates/socket-patch-cli/tests/setup_matrix_gem.rs`); `gem-b4` is a manual -bundler-4 image with no gated leg: - -- `Dockerfile.gem-b1` — ruby 3.1 + **bundler 1.17.3** (last 1.x). Drives the - bundler `>= 2.2` plugin floor: gem `setup` must refuse to wire a 1.x - project (bundler 1.x cannot load `plugin ... path:` directives) and - `bundle install` must keep working after the refusal. -- `Dockerfile.gem-b4` — ruby 3.4 + **bundler ~> 4.0** (current major, bare - `bundle --version` output, CHECKSUMS locks by default). - -These images are NOT built in CI (the CI `setup-matrix` job drives -`scripts/setup-matrix.sh` against the plain `gem` image only). Build them -locally before running the gated legs: - -```sh -docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . -docker build -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:latest . -docker build -f tests/docker/Dockerfile.gem-b4 -t socket-patch-test-gem-b4:latest . -cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_gem -``` - -The legs soft-skip (loudly) when Docker or the image is absent. Both -Dockerfiles take a `BASE_IMAGE` build-arg so a binary under test can be baked -in without overwriting the shared `:latest` tags; the test honors a -`SOCKET_PATCH_GEM_B1_IMAGE` env var to point at such a uniquely-tagged image. -NOTE: the legs run the binary BAKED INTO the image — rebuild base + image -after changing setup code or they test a stale binary. - -## Host mode (no Docker) - -Set `SOCKET_PATCH_TEST_HOST=1` to run the tests against host-installed -toolchains instead of containers. Tests assume the relevant package -manager (`npm`, `pip`, `gem`, `cargo`, `go`, `mvn`, `composer`, -`dotnet`) is on `$PATH`. Useful for iterating on a single ecosystem's -test logic without paying the docker-spin-up cost on every edit. - -```sh -SOCKET_PATCH_TEST_HOST=1 cargo test -p socket-patch-cli \ - --features docker-e2e --test docker_e2e_npm -``` - -## CI - -`.github/workflows/ci.yml` runs an `e2e-docker` matrix across all 9 -ecosystems on every PR. Each matrix slot: -1. Builds the base image (no GitHub Actions cache — left out on purpose - because of zizmor's cache-poisoning audit). -2. Builds the per-ecosystem image. -3. Runs the matching `docker_e2e_` test, plus that ecosystem's vendor - capstone where it has one in this job (composer, nuget, pypi_pm). - -The separate `e2e` job is the per-PR real-toolchain host matrix. The -live-API smoke suites (`e2e_npm`, `e2e_pypi`, `e2e_gem`, `e2e_scan`) are -not in CI; run them by hand with `--ignored`. - -## Adding a new ecosystem - -1. Add `tests/docker/Dockerfile.` — `FROM socket-patch-test-base:latest` - plus the toolchain install. -2. Add `tests/docker_e2e_.rs` — copy any existing test, swap the - PURL/UUID, install command, and `--ecosystems ` flag. -3. Add `` to the matrix in `.github/workflows/ci.yml`'s - `e2e-docker` job. - -## How fixtures are served - -Each test starts a `wiremock::MockServer` bound to `0.0.0.0` on a random -port. The container runs with -`--add-host=host.docker.internal:host-gateway`, then the test passes -`http://host.docker.internal:` as `SOCKET_API_URL`. The -wiremock returns canned responses for the 3 endpoints scan/get/apply -exercise: -- `POST /v0/orgs//patches/batch` — discovery -- `GET /v0/orgs//patches/by-package/` — per-package -- `GET /v0/orgs//patches/view/` — full patch with inline - base64 `blobContent` (consumed by the apply path) - Fixtures are synthetic. Real Socket patches are not required to exist for the tested PURLs — what's validated is that the crawler discovers real installed packages and the CLI dispatches correctly through the ecosystem. -## Related: the `setup`-flow matrix - -A separate, **experimental** suite lives under `tests/setup_matrix/` and -reuses these same per-ecosystem images. Where `docker_e2e_*` drives -`scan → apply` explicitly, the setup-matrix instead runs `socket-patch -setup` and then a *native install* to check whether the configured -install hook applies the patch on its own — the thing `setup` is meant -to enable. It also adds the npm-family package managers (pnpm/yarn via -corepack, and vlt 1.2.0 via `npm install -g`) and the Python ones -(uv/poetry/pdm/hatch), which is why `Dockerfile.npm` and `Dockerfile.pypi` -install those tools. `Dockerfile.npm` is on Node 22 (>= 22.22, vlt 1.2.0's -engine floor) and sets `VLT_TELEMETRY=0`. The vlt cases are non-gating -extras: the gating vlt `setup` assertions run against real vlt releases in -`crates/socket-patch-cli/tests/e2e_vlt.rs`. See -`tests/setup_matrix/README.md` for details and the -`scripts/setup-matrix.sh` runner. That suite's CI job (`setup-matrix`) -is **non-blocking** (`continue-on-error: true`) and is expected to fail -for ecosystems whose hooks `setup` does not yet configure. diff --git a/tests/setup_matrix/README.md b/tests/setup_matrix/README.md deleted file mode 100644 index adf8b8a1..00000000 --- a/tests/setup_matrix/README.md +++ /dev/null @@ -1,194 +0,0 @@ -# `setup`-flow test matrix (experimental) - -This suite verifies the **intended** end-to-end behavior of -`socket-patch setup`: that after `setup` configures a project, a normal -package-manager install applies the project's patches *on its own*, with -no explicit `scan`/`apply` step. - -It is **experimental and non-blocking**. `setup` configures npm-family, -pip/uv/hatch (the `.pth` hook), Bundler (plugin) and Composer hooks; the -remaining ecosystems are the *expected-to-fail* `known_gap` cases. The suite encodes the **aspirational** end state and records a -per-case **baseline** of what works now — the failing cases are a TODO -list for `setup`, not a broken test. - -## The flow (per case) - -Every case runs the same four steps via the bash driver `run-case.sh`: - -0. **prepare** a throwaway project: declare the dependency and commit a - patch set (`.socket/manifest.json` + `.socket/blobs/`). -1. **`socket-patch setup`** — configure install hooks (skipped in the - `no_setup_control` scenario). -2. **native install** — `npm install` / `pip install` / `cargo fetch` / - … for the package manager under test. -3. **check** — is the patch's marker now on disk in the installed file? - -The apply step is fully offline (`SOCKET_OFFLINE=1 SOCKET_FORCE=1`, -inherited by the hook), so the only network use is the real package -install. No Socket API is contacted. - -## Dimensions - -`ecosystem × package-manager × scenario` — see `matrix.json` (the single -source of truth, consumed by both the runner script and the Rust -wrappers). - -- **Package managers:** npm, yarn, pnpm, bun, vlt · pip, uv, poetry, pdm, - hatch · cargo · bundler · go · mvn · composer · dotnet · deno. -- **Scenarios (single-project):** - - `baseline_with_setup` — setup + install ⇒ patch applied *(ideal)*. - - `no_setup_control` — **ablation (setup not run)**: install only ⇒ NOT applied *(the hook is the cause)*. - - `patch_missing` — **ablation (patch missing)**: setup runs and the hook fires, but no `.socket/` patch set is committed ⇒ runs UNPATCHED *(the committed patch is the cause)*. - - `empty_patchset` — manifest present but with zero patches ⇒ NOT applied. - - `wrong_target_patchset` — manifest targets a different package ⇒ NOT applied. - - `alt_content_patchset` — a second patch set ⇒ its marker applied *(content tracks the manifest)*. - - The two **ablations** are the controls that confirm `setup` is correct: - each is identical to `baseline_with_setup` except for the single removed - factor (the setup step, or the committed patch), and each must run - unpatched. The workspace and monorepo layouts carry the same pair - (`*_no_setup`, `*_patch_missing`). - -## Layouts - -The driver's `SM_LAYOUT` selects the project shape (each layout has its -own `*_targets` / `*_scenarios` sections in `matrix.json`): - -- **`single`** *(default)* — one project, one dependency. The 17-PM grid above. -- **`workspace`** — a **nested workspace/monorepo**: a root + several - members (incl. a deeply-nested one and a member that does *not* use the - patched package). Models real-world monorepo deployments and exercises - `setup`'s workspace handling — npm/yarn write the hook to **every** - member, pnpm and vlt only to the **root** — plus the cross-workspace apply - on a single root install. Covered PMs: **npm, pnpm, yarn, vlt** (apply; - the dependency hoists / lands in the pnpm or vlt store and is patched - once) and - **pip** (nested `requirements.txt` files) + **uv** (uv workspace, one - shared `.venv`) as Python gaps. Scenarios: `workspace_with_setup`, - `workspace_no_setup`, `workspace_patch_missing`. -- **`monorepo`** — a **polyglot all-ecosystem repo**: an npm workspace - alongside python/rust/go/php/ruby/nuget/deno manifests. Confirms `setup` - works in a mixed environment — it must configure the npm hooks and - **not choke** on the foreign manifests; a root `npm install` then - patches the npm slice. Runs in the npm image (the only one with the npm - toolchain), so the foreign manifests are present to test setup's - robustness, not installed. Scenarios: `monorepo_with_setup`, - `monorepo_no_setup`, `monorepo_patch_missing`. - -> Real-world wiring note surfaced by the workspace layout: the install -> hook's `apply` must run with the package manager's per-script cwd (root -> for the project, the member dir for each member) — so member -> postinstalls find no manifest and no-op while the root applies. Forcing -> a single cwd makes every member target the root manifest and fail -> mid-install with "no packages found on disk". The driver therefore does -> **not** pin `SOCKET_CWD`. - -## vlt cases - -The `pm: vlt` rows (single and workspace) are **non-gating extras**: every -vlt `setup` assertion that gates a release lives in the real-vlt capstone -`crates/socket-patch-cli/tests/e2e_vlt.rs` (suite `setup`). They run vlt -1.2.0 from the npm image (`npm install -g vlt@1.2.0`, Node >= 22.22) and -follow the npm-family round trip: - -- the scaffold declares the dependency up front and writes a `vlt.json`, - which is both the marker `setup` detects vlt by and the registry config - (`config.registry` + `config.registries.npm`) vlt >= 1.0.0-rc.33 needs to - install at all; the workspace scaffold declares its members in vlt.json - `workspaces`, the only place vlt reads them; -- `setup` writes npm's `npx` hook (vlt ships through npm, so `npx` is - always there; `vlx` is never used), root package only for a workspace; -- the native install is `vlt install`, whose root `postinstall` fires on - every install that changes the graph (vlt >= 1.0.0-rc.13), with - `VLT_TELEMETRY=0`. - -A failing hook aborts and rolls back the whole `vlt install`, exactly as it -fails `npm install`, so `wrong_target_patchset` (a manifest whose only -patch targets an absent package makes `apply` exit 1) reports a failed -install for vlt as it does for npm. - -## Result classification - -Each case's `actual` is compared against both the aspirational `expect` -and the recorded `baseline`: - -| classification | meaning | -|---|---| -| `pass` | meets the ideal and matches the baseline | -| `known_gap` | fails the ideal, exactly as recorded — expected today, non-blocking | -| `progress` | better than the recorded baseline — update `baseline_supported` in `matrix.json`! | -| `regression` | diverged from the baseline the wrong way — the only thing that fails the runner | -| `known_regression` | would be a regression, but is on the matrix.json `known_regressions` allowlist (tracked, non-blocking) | -| `error` | the driver produced no parseable result | - -The Rust wrappers (`tests/setup_matrix_.rs`) assert the **ideal** -(`actual == expect`), so they are red for `known_gap` cases — that is the -intended "TODO list" view. The `scripts/setup-matrix.sh` runner uses the -**baseline** view and only exits non-zero on a `regression`. - -## Running it - -Requires a Docker daemon (default) or host-installed toolchains -(`SOCKET_PATCH_TEST_HOST=1`). - -```sh -# Build the shared base + a per-ecosystem image. -scripts/setup-matrix.sh build --ecosystem npm - -# Run all npm-family cases and write a JSON report. -scripts/setup-matrix.sh run --ecosystem npm - -# Filter to a single package manager / scenario. -scripts/setup-matrix.sh run --ecosystem pypi --pm uv -scripts/setup-matrix.sh run --scenario no_setup_control - -# Run the nested-workspace and polyglot-monorepo cases. -scripts/setup-matrix.sh run --scenario workspace_with_setup -scripts/setup-matrix.sh run --scenario monorepo_with_setup - -# Query the last results (agent-friendly JSON). -scripts/setup-matrix.sh query --status known_gap -scripts/setup-matrix.sh query --status regression -scripts/setup-matrix.sh query --layout workspace -scripts/setup-matrix.sh list --json - -# Host mode (no Docker; needs the toolchains + a built binary on PATH). -SOCKET_PATCH_TEST_HOST=1 scripts/setup-matrix.sh run --ecosystem npm --host -``` - -Or via `cargo test` (the aspirational view; gated by the `setup-e2e` -feature; soft-skips when the image isn't built): - -```sh -cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_npm -SOCKET_PATCH_TEST_HOST=1 cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_npm -``` - -## Files - -- `matrix.json` — declarative case list: `targets`×`scenarios` (single), - `workspace_targets`×`workspace_scenarios`, `monorepo_targets`×`monorepo_scenarios`, + markers. -- `run-case.sh` — self-contained flow driver (one case → JSON result), - layout-aware (`SM_LAYOUT=single|workspace|monorepo`); generates the - runner shims inline so it can be piped into a container. -- `shims/{npx,pnpm}` — reference copies of the PATH shims that route - `npx`/`pnpm dlx @socketsecurity/socket-patch` to the locally-built - binary (so the hook runs the binary under test, not a registry fetch). -- `results/latest.json` — most recent aggregate report (git-ignored). -- `../docker/Dockerfile.{npm,pypi,…}` — the per-ecosystem images - (npm/pypi extended with the extra package managers). -- `../../crates/socket-patch-cli/tests/setup_matrix_.rs` — thin Rust - wrappers around the same driver (incl. `setup_matrix_monorepo.rs`; the - npm/pypi wrappers add `*_workspace` tests). - -## Adding a package manager / ecosystem - -1. Add a `targets[]` entry to `matrix.json` (image, package, purl, - manifest key, whether `setup` supports it today via - `baseline_supported`). -2. Teach `run-case.sh` how to scaffold + install + resolve the target - file for the new `pm` (the `scaffold_project` / `run_install` / - `resolve_target` case statements). -3. If a new toolchain is needed, add it to the relevant - `tests/docker/Dockerfile.`. -4. Add a `#[test]` for the `pm` in the matching `setup_matrix_.rs`. diff --git a/tests/setup_matrix/matrix.json b/tests/setup_matrix/matrix.json deleted file mode 100644 index 2e19fbdf..00000000 --- a/tests/setup_matrix/matrix.json +++ /dev/null @@ -1,318 +0,0 @@ -{ - "_comment": [ - "Declarative source of truth for the `socket-patch setup` end-to-end test matrix.", - "Consumed by BOTH scripts/setup-matrix.sh (jq) and the Rust wrappers", - "crates/socket-patch-cli/tests/setup_matrix_.rs (serde_json).", - "", - "A 'case' is the cross-product (target x scenario). expect_applied comes from", - "the scenario (the ASPIRATIONAL ideal); baseline_supported on the target says", - "whether `setup` ACTUALLY wires a working install hook today. The classifier in", - "the orchestrator compares actual vs both: meeting the ideal => pass; failing the", - "ideal but matching the recorded baseline => known_gap (non-blocking); diverging", - "from the baseline in the wrong direction => regression (blocking the optional job).", - "", - "Packages, PURLs, manifest keys and install layouts are reused verbatim from the", - "existing tests/docker_e2e_.rs so the fixtures are known-valid.", - "NOTE: pypi uses NO `package/` prefix in the manifest key (the python crawler", - "reports the site-packages root); every other ecosystem uses `package/`." - ], - - "marker": "SOCKET-PATCH-SETUP-MATRIX-MARKER", - "alt_marker": "SOCKET-PATCH-SETUP-MATRIX-ALT-MARKER", - - "_known_regressions_comment": [ - "TEMPORARY allowlist of cases (by `//` id) that the", - "baseline records as supported (baseline_supported=true — they SHOULD work,", - "and DID) but currently do NOT apply the patch after `setup` + install.", - "Listing a case here downgrades its `regression` classification to the", - "non-blocking `known_regression` so the experimental matrix job stays green", - "while the underlying bug is tracked, WITHOUT pretending the case is an", - "unimplemented gap (baseline_supported stays true, so when the hook is fixed", - "the case auto-recovers to `pass` and should simply be removed from this", - "list). These are pre-existing (present on main): the pnpm root-postinstall", - "hook and the pip/uv/hatch `.pth` hook are not re-applying the patch on a", - "fresh install (npm/yarn/bun work). Remove an entry once its hook is fixed." - ], - "known_regressions": [ - "npm/pnpm/baseline_with_setup", - "npm/pnpm/alt_content_patchset", - "npm/pnpm/workspace_with_setup", - "pypi/pip/baseline_with_setup", - "pypi/pip/alt_content_patchset", - "pypi/uv/baseline_with_setup", - "pypi/uv/alt_content_patchset", - "pypi/hatch/baseline_with_setup", - "pypi/hatch/alt_content_patchset" - ], - - "scenarios": [ - { - "id": "baseline_with_setup", - "run_setup": true, - "patchset": "primary", - "expect_applied": true, - "description": "Prepare deps + committed patch set, run `socket-patch setup`, run the native install. The install hook should apply the patch (the ideal)." - }, - { - "id": "no_setup_control", - "run_setup": false, - "patchset": "primary", - "expect_applied": false, - "description": "Negative control: identical fixture but setup is NOT run. With no hook configured, the install must NOT apply the patch." - }, - { - "id": "empty_patchset", - "run_setup": true, - "patchset": "empty", - "expect_applied": false, - "description": "Different patch set: an empty manifest. Even with setup, nothing should be applied." - }, - { - "id": "wrong_target_patchset", - "run_setup": true, - "patchset": "wrong", - "expect_applied": false, - "description": "Different patch set: a manifest that patches a different, non-installed package. The installed package must be left untouched." - }, - { - "id": "alt_content_patchset", - "run_setup": true, - "patchset": "alt", - "expect_applied": true, - "description": "Different patch set: a second fixture whose blob carries the ALT marker. Proves the applied bytes track the active manifest (alt marker present, primary marker absent)." - }, - { - "id": "patch_missing", - "run_setup": true, - "patchset": "none", - "expect_applied": false, - "description": "Ablation: setup runs and the install hook fires, but NO patch set is committed (no .socket/). The install must run UNPATCHED — proving the committed patch is what changes the code, not setup/install alone." - } - ], - - "targets": [ - { - "ecosystem": "npm", "pm": "npm", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "yarn", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "pnpm", "image": "npm", "hook_family": "pnpm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "bun", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "vlt", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - - { - "ecosystem": "pypi", "pm": "pip", "image": "pypi", "hook_family": "pth", - "baseline_supported": true, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - }, - { - "ecosystem": "pypi", "pm": "uv", "image": "pypi", "hook_family": "pth", - "baseline_supported": true, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - }, - { - "ecosystem": "pypi", "pm": "poetry", "image": "pypi", "hook_family": "none", - "baseline_supported": false, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - }, - { - "ecosystem": "pypi", "pm": "pdm", "image": "pypi", "hook_family": "none", - "baseline_supported": false, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - }, - { - "ecosystem": "pypi", "pm": "hatch", "image": "pypi", "hook_family": "pth", - "baseline_supported": true, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - }, - - { - "ecosystem": "cargo", "pm": "cargo", "image": "cargo", "hook_family": "none", - "baseline_supported": false, - "package": "cfg-if", "version": "1.0.0", "purl": "pkg:cargo/cfg-if@1.0.0", - "manifest_key": "package/src/lib.rs", "apply_ecosystems": "cargo" - }, - - { - "ecosystem": "gem", "pm": "bundler", "image": "gem", "hook_family": "bundler-plugin", - "baseline_supported": true, - "package": "colorize", "version": "1.1.0", "purl": "pkg:gem/colorize@1.1.0", - "manifest_key": "package/lib/colorize.rb", "apply_ecosystems": "gem" - }, - - { - "ecosystem": "golang", "pm": "go", "image": "golang", "hook_family": "none", - "baseline_supported": false, - "package": "github.com/gin-gonic/gin", "version": "v1.9.1", - "purl": "pkg:golang/github.com/gin-gonic/gin@v1.9.1", - "manifest_key": "package/gin.go", "apply_ecosystems": "golang" - }, - - { - "ecosystem": "maven", "pm": "mvn", "image": "maven", "hook_family": "none", - "baseline_supported": false, - "package": "org.apache.commons:commons-lang3", "version": "3.12.0", - "purl": "pkg:maven/org.apache.commons/commons-lang3@3.12.0", - "manifest_key": "package/commons-lang3-3.12.0.pom", "apply_ecosystems": "maven" - }, - - { - "ecosystem": "composer", "pm": "composer", "image": "composer", "hook_family": "composer-event", - "baseline_supported": true, - "package": "monolog/monolog", "version": "3.5.0", "purl": "pkg:composer/monolog/monolog@3.5.0", - "manifest_key": "package/src/Monolog/Logger.php", "apply_ecosystems": "composer" - }, - - { - "ecosystem": "nuget", "pm": "dotnet", "image": "nuget", "hook_family": "none", - "baseline_supported": false, - "package": "Newtonsoft.Json", "version": "13.0.3", "purl": "pkg:nuget/newtonsoft.json@13.0.3", - "manifest_key": "package/LICENSE.md", "apply_ecosystems": "nuget" - }, - - { - "ecosystem": "deno", "pm": "deno", "image": "deno", "hook_family": "npm-via-deno", - "baseline_supported": false, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - } - ], - - "_workspace_comment": [ - "Nested-workspace layouts (run-case.sh SM_LAYOUT=workspace): a root +", - "several workspace members (incl. a deeply-nested one and a member that", - "does NOT use the patched package). Models real monorepos and exercises", - "`setup`'s workspace handling — npm/yarn write the hook to every member,", - "pnpm and vlt only to the root — plus the cross-workspace apply on the root", - "install. npm/yarn/pnpm/vlt should apply (baseline_supported true); Python", - "workspaces (uv workspace, pip nested-requirements) are gaps." - ], - "workspace_scenarios": [ - { - "id": "workspace_with_setup", - "run_setup": true, - "patchset": "primary", - "expect_applied": true, - "description": "Nested workspace: setup at root, then a root-level install must apply the patch to the (hoisted/store-linked) dependency used across members." - }, - { - "id": "workspace_no_setup", - "run_setup": false, - "patchset": "primary", - "expect_applied": false, - "description": "Ablation (setup not run): workspace install without setup must NOT apply — the hook is the cause." - }, - { - "id": "workspace_patch_missing", - "run_setup": true, - "patchset": "none", - "expect_applied": false, - "description": "Ablation (patch missing): workspace setup + install with NO committed patch set must run unpatched across the workspace." - } - ], - "workspace_targets": [ - { - "ecosystem": "npm", "pm": "npm", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "pnpm", "image": "npm", "hook_family": "pnpm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "yarn", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "npm", "pm": "vlt", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - }, - { - "ecosystem": "pypi", "pm": "pip", "image": "pypi", "hook_family": "none", - "baseline_supported": false, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - }, - { - "ecosystem": "pypi", "pm": "uv", "image": "pypi", "hook_family": "none", - "baseline_supported": false, - "package": "six", "version": "1.16.0", "purl": "pkg:pypi/six@1.16.0", - "manifest_key": "six.py", "apply_ecosystems": "pypi" - } - ], - - "_monorepo_comment": [ - "Polyglot monorepo (SM_LAYOUT=monorepo): an npm workspace alongside", - "python/rust/go/php/ruby/nuget/deno manifests. Confirms `setup` works in", - "a mixed environment — it must configure the npm hooks and NOT choke on", - "the foreign manifests; a root `npm install` then patches the npm slice.", - "Runs in the npm image (the only one with the npm toolchain); the foreign", - "manifests are present to test setup's robustness, not installed." - ], - "monorepo_scenarios": [ - { - "id": "monorepo_with_setup", - "run_setup": true, - "patchset": "primary", - "expect_applied": true, - "description": "All ecosystems present: setup at root, then npm install applies the patch to the npm workspace dependency; setup must not error on the foreign manifests." - }, - { - "id": "monorepo_no_setup", - "run_setup": false, - "patchset": "primary", - "expect_applied": false, - "description": "Ablation (setup not run): polyglot monorepo install without setup must NOT apply." - }, - { - "id": "monorepo_patch_missing", - "run_setup": true, - "patchset": "none", - "expect_applied": false, - "description": "Ablation (patch missing): polyglot monorepo setup + install with NO committed patch set must run unpatched." - } - ], - "monorepo_targets": [ - { - "ecosystem": "monorepo", "pm": "mono", "image": "npm", "hook_family": "npm", - "baseline_supported": true, - "package": "minimist", "version": "1.2.2", "purl": "pkg:npm/minimist@1.2.2", - "manifest_key": "package/index.js", "apply_ecosystems": "npm" - } - ] -} diff --git a/tests/setup_matrix/results/.gitignore b/tests/setup_matrix/results/.gitignore deleted file mode 100644 index d5ae1810..00000000 --- a/tests/setup_matrix/results/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Generated setup-matrix run reports; keep the directory, ignore contents. -* -!.gitignore diff --git a/tests/setup_matrix/run-case.sh b/tests/setup_matrix/run-case.sh deleted file mode 100755 index 11618d88..00000000 --- a/tests/setup_matrix/run-case.sh +++ /dev/null @@ -1,883 +0,0 @@ -#!/usr/bin/env bash -# ===================================================================== -# setup-matrix flow driver — runs ONE (ecosystem, pm, scenario) case of -# the `socket-patch setup` end-to-end matrix and emits a JSON result. -# -# This script is the single source of truth for the flow: -# 0. prepare a project with the dependency + a committed patch set -# 1. (optionally) run `socket-patch setup` to configure install hooks -# 2. run the native install command for the package manager -# 3. check whether the patch was applied (marker present on disk) -# -# It is invoked by BOTH scripts/setup-matrix.sh (orchestrator) and the -# Rust wrappers (crates/socket-patch-cli/tests/setup_matrix_.rs), -# either inside a Docker container (script piped to `bash -c`) or on the -# host. It is fully self-contained: it generates the npx/pnpm shims -# inline so no extra files need to be copied into the container. -# -# The driver only REPORTS (expected vs actual). Pass/fail/known-gap/ -# regression classification is done by the caller against the recorded -# baseline in matrix.json. -# -# Inputs (environment, all SM_*-prefixed): -# SM_ID stable case id (for the JSON result) -# SM_ECOSYSTEM npm|pypi|cargo|gem|golang|maven|composer|nuget|deno -# SM_PM npm|yarn|pnpm|bun|vlt|pip|uv|poetry|pdm|hatch|cargo| -# bundler|go|mvn|composer|dotnet|deno -# SM_SCENARIO scenario id (echoed back) -# SM_PATCHSET primary|alt|empty|wrong|none -# SM_LAYOUT single|workspace|monorepo -# SM_RUN_SETUP 1|0 — run `socket-patch setup` before install -# SM_EXPECT_APPLIED 1|0 — the aspirational expectation -# SM_PACKAGE dependency name (e.g. minimist, six, cfg-if) -# SM_VERSION dependency version (e.g. 1.2.2) -# SM_PURL manifest key PURL (e.g. pkg:npm/minimist@1.2.2) -# SM_MANIFEST_KEY file key in the patch record (e.g. package/index.js, -# or `six.py` for pypi — NO package/ prefix) -# SM_APPLY_ECOSYSTEMS ecosystem token used to build the "wrong" PURL -# SM_MARKER primary marker string spliced into the patched blob -# SM_ALT_MARKER alternate marker (alt_content_patchset) -# SOCKET_PATCH_BIN path to the binary under test (default: socket-patch on PATH) -# SM_WORKDIR scratch dir (default: a fresh mktemp -d) -# ===================================================================== - -set -uo pipefail - -# Route all ordinary output to stderr; the final JSON goes to the saved -# stdout (fd 3) so the result line is the ONLY thing on real stdout. -exec 3>&1 1>&2 - -SM_ID="${SM_ID:-unknown}" -SM_ECOSYSTEM="${SM_ECOSYSTEM:-}" -SM_PM="${SM_PM:-}" -SM_SCENARIO="${SM_SCENARIO:-}" -SM_PATCHSET="${SM_PATCHSET:-primary}" -SM_RUN_SETUP="${SM_RUN_SETUP:-1}" -SM_EXPECT_APPLIED="${SM_EXPECT_APPLIED:-0}" -SM_PACKAGE="${SM_PACKAGE:-}" -SM_VERSION="${SM_VERSION:-}" -SM_PURL="${SM_PURL:-}" -SM_MANIFEST_KEY="${SM_MANIFEST_KEY:-package/index.js}" -SM_APPLY_ECOSYSTEMS="${SM_APPLY_ECOSYSTEMS:-npm}" -SM_MARKER="${SM_MARKER:-SOCKET-PATCH-SETUP-MATRIX-MARKER}" -SM_ALT_MARKER="${SM_ALT_MARKER:-SOCKET-PATCH-SETUP-MATRIX-ALT-MARKER}" -SM_LAYOUT="${SM_LAYOUT:-single}" - -ZEROHASH="0000000000000000000000000000000000000000000000000000000000000000" -UUID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" -WRONG_PURL="pkg:${SM_APPLY_ECOSYSTEMS}/sm-setup-matrix-absent@9.9.9" - -SP_BIN="${SOCKET_PATCH_BIN:-$(command -v socket-patch 2>/dev/null || echo socket-patch)}" -export SOCKET_PATCH_BIN="$SP_BIN" - -NOTES="" -note() { NOTES="${NOTES}${NOTES:+; }$*"; } -log() { printf '[setup-matrix:%s] %s\n' "$SM_ID" "$*"; } - -# --- JSON emit (hand-rolled; values are simple, sanitized) ------------ -json_str() { printf '%s' "$1" | tr -d '\r' | tr '\n' ' ' | sed 's/\\/\\\\/g; s/"/\\"/g'; } -emit_result() { - local actual="$1" primary_present="$2" setup_exit="$3" install_exit="$4" target="$5" status="$6" - printf '{"id":"%s","ecosystem":"%s","pm":"%s","scenario":"%s","patchset":"%s","run_setup":%s,"expect_applied":%s,"actual_applied":%s,"applied_before_setup":%s,"applied_after_remove":%s,"primary_marker_present":%s,"setup_exit":%s,"install_exit":%s,"check_before_setup_exit":%s,"check_after_setup_exit":%s,"remove_exit":%s,"check_after_remove_exit":%s,"target":"%s","status":"%s","notes":"%s"}\n' \ - "$(json_str "$SM_ID")" "$(json_str "$SM_ECOSYSTEM")" "$(json_str "$SM_PM")" \ - "$(json_str "$SM_SCENARIO")" "$(json_str "$SM_PATCHSET")" \ - "$([ "$SM_RUN_SETUP" = 1 ] && echo true || echo false)" \ - "$([ "$SM_EXPECT_APPLIED" = 1 ] && echo true || echo false)" \ - "$actual" "${APPLIED_BEFORE_SETUP:-null}" "${APPLIED_AFTER_REMOVE:-null}" "$primary_present" \ - "$setup_exit" "$install_exit" \ - "${CHECK_BEFORE_SETUP_EXIT:-null}" "${CHECK_AFTER_SETUP_EXIT:-null}" "${REMOVE_EXIT:-null}" "${CHECK_AFTER_REMOVE_EXIT:-null}" \ - "$(json_str "$target")" "$(json_str "$status")" "$(json_str "$NOTES")" >&3 -} - -# --- git-sha256 (blob \0 + content) ------------------------------ -git_sha256() { # $1 = file - local len; len="$(wc -c < "$1")" - { printf 'blob %d\0' "$len"; cat "$1"; } | sha256sum | cut -d' ' -f1 -} - -# --- inline npx/pnpm shims (kept in sync with tests/setup_matrix/shims/) -- -write_shims() { # $1 = shim dir - local d="$1"; mkdir -p "$d" - cat > "$d/npx" <<'SHIM' -#!/usr/bin/env bash -set -uo pipefail -sp_bin="${SOCKET_PATCH_BIN:-socket-patch}" -shim_dir="${SETUP_MATRIX_SHIM_DIR:-}" -clean_path="$PATH" -[ -n "$shim_dir" ] && clean_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vxF "$shim_dir" | paste -sd: -)" -real_npx="$(PATH="$clean_path" command -v npx 2>/dev/null || true)" -i=0 -for arg in "$@"; do - case "$arg" in - @socketsecurity/socket-patch|@socketsecurity/socket-patch@*|@socketsecurity/socket-patch/*) - shift "$((i + 1))"; exec "$sp_bin" "$@" ;; - esac - i=$((i + 1)) -done -[ -n "$real_npx" ] && exec "$real_npx" "$@" -echo "setup-matrix npx shim: real npx not found: $*" >&2; exit 127 -SHIM - cat > "$d/pnpm" <<'SHIM' -#!/usr/bin/env bash -set -uo pipefail -sp_bin="${SOCKET_PATCH_BIN:-socket-patch}" -shim_dir="${SETUP_MATRIX_SHIM_DIR:-}" -clean_path="$PATH" -[ -n "$shim_dir" ] && clean_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vxF "$shim_dir" | paste -sd: -)" -real_pnpm="$(PATH="$clean_path" command -v pnpm 2>/dev/null || true)" -if [ "${1:-}" = "dlx" ] || [ "${1:-}" = "exec" ]; then - case "${2:-}" in - @socketsecurity/socket-patch|@socketsecurity/socket-patch@*) shift 2; exec "$sp_bin" "$@" ;; - esac -fi -[ -n "$real_pnpm" ] && exec "$real_pnpm" "$@" -echo "setup-matrix pnpm shim: real pnpm not found: $*" >&2; exit 127 -SHIM - chmod +x "$d/npx" "$d/pnpm" -} - -# --- committed patch fixture ------------------------------------------ -# The patched blob is RUNNABLE code that emits the marker on stdout when the -# file is executed, so verification can RUN the patched module with the -# ecosystem's standard runner (node/bun/python) and observe the marker at -# runtime — not merely scan the file for the string. Compiled/loaded -# ecosystems we can't execute keep an inert comment (verified by reading the -# file; see `run_file`). -marker_blob() { # $1 = marker -> runnable payload on stdout - case "$SM_ECOSYSTEM" in - npm|deno|monorepo) printf 'console.log("%s");\n' "$1" ;; - pypi) printf 'print("%s")\n' "$1" ;; - *) printf '/* %s */\n' "$1" ;; - esac -} - -write_manifest() { # $1=purl $2=key $3=afterHash $4=beforeHash (default: zero) - local before="${4:-$ZEROHASH}" - cat > .socket/manifest.json </dev/null 2>&1 \ - && gem unpack "${SM_PACKAGE}-${SM_VERSION}.gem" >/dev/null 2>&1) \ - && [ -f "$target" ]; then - git_sha256 "$target" - else - # The function runs inside $(...): route the log PAST the capture pipe. - log "gem beforeHash probe failed; falling back to the zero placeholder" >&2 - printf '%s' "$ZEROHASH" - fi - rm -rf "$dir" -} - -build_fixture() { - # Ablation: no patch set committed at all (no .socket/). Even with a - # working install hook, apply finds no manifest and no-ops, so the - # install must run unpatched. Distinct from `empty` (manifest present - # but with zero patches). - if [ "$SM_PATCHSET" = none ]; then - note "no patch fixture committed (ablation: patch missing)" - return - fi - mkdir -p .socket/blobs - # Per-case scratch file for the blob. MUST NOT be a fixed path like - # /tmp/sm_blob: the Rust matrix wrappers run the package-manager test fns in - # parallel, so a shared path races (one case hashes a blob another just - # overwrote → afterHash mismatch → apply no-ops). - local blob_tmp; blob_tmp="$(mktemp)" - case "$SM_PATCHSET" in - empty) - printf '{"patches":{}}\n' > .socket/manifest.json - note "empty manifest" ;; - wrong) - # A patch for a package that is NOT installed: nothing should match. - marker_blob "$SM_MARKER" > "$blob_tmp" - local h; h="$(git_sha256 "$blob_tmp")"; cp "$blob_tmp" ".socket/blobs/$h" - write_manifest "$WRONG_PURL" "$SM_MANIFEST_KEY" "$h" - note "manifest targets absent purl $WRONG_PURL" ;; - alt) - marker_blob "$SM_ALT_MARKER" > "$blob_tmp" - local h; h="$(git_sha256 "$blob_tmp")"; cp "$blob_tmp" ".socket/blobs/$h" - write_manifest "$SM_PURL" "$SM_MANIFEST_KEY" "$h" "$(resolve_before_hash)" ;; - *) # primary - marker_blob "$SM_MARKER" > "$blob_tmp" - local h; h="$(git_sha256 "$blob_tmp")"; cp "$blob_tmp" ".socket/blobs/$h" - write_manifest "$SM_PURL" "$SM_MANIFEST_KEY" "$h" "$(resolve_before_hash)" ;; - esac - rm -f "$blob_tmp" -} - -# --- per-PM project scaffold (must exist before setup runs) ----------- -scaffold_project() { - case "$SM_PM" in - npm|yarn|bun) - printf '{"name":"sm-proj","version":"0.0.0","private":true}\n' > package.json ;; - pnpm) - # pnpm only runs the ROOT postinstall on `pnpm install` (not on - # `pnpm add`), so the dependency is declared up front and installed - # via a bare `pnpm install`. The stub lockfile is the pnpm marker - # that makes `setup` detect pnpm and write the `pnpm dlx` hook. - cat > package.json < pnpm-lock.yaml ;; - vlt) - # vlt runs the root postinstall on every install that changes the - # graph (vlt >= 1.0.0-rc.13), so the dependency is declared up front - # like pnpm's. vlt.json is the vlt marker `setup` detects, and it - # carries the registry config vlt >= 1.0.0-rc.33 needs to install. - cat > package.json < vlt.json ;; - deno) - cat > package.json < deno.json < requirements.txt ;; - uv) - # A PEP 621 pyproject + uv.lock makes `setup` detect a uv project. - cat > pyproject.toml < uv.lock ;; - poetry) - cat > pyproject.toml <"] -package-mode = false - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" -EOF - ;; - pdm) - cat > pyproject.toml < pyproject.toml < Cargo.toml < src/main.rs ;; - bundler) - cat > Gemfile < go.mod ;; - composer) - # `setup` wires its hook into composer.json's script events, so the - # manifest must exist BEFORE setup runs — without it setup reports - # `no_files`, the hook is never written, and `composer require` below - # can't re-apply the patch. The dependency is left to `composer require` - # (same division of labour as npm/yarn/bun above). 4-space indent is - # what composer itself writes, so a reformat by setup would show up as - # a diff here too. - cat > composer.json </dev/null 2>&1 \ - || "$venv/bin/python" -m ensurepip --upgrade >/dev/null 2>&1 || true - "$venv/bin/python" -m pip install --quiet --no-deps "$SOCKET_PATCH_HOOK_WHEEL" \ - || note "hook wheel install failed" ;; - esac - # The hook resolves `socket-patch` off PATH (it isn't pip-installed here). - ln -sf "$SP_BIN" "$venv/bin/socket-patch" 2>/dev/null || true -} - -# Start an interpreter so the `.pth` hook fires (models a CI app start / -# the next python invocation after install). No-op if there is no venv. -pth_trigger() { # $1=venv dir - local venv="$1" - [ -x "$venv/bin/python" ] || return 0 - PATH="$PWD/$venv/bin:$PATH" "$venv/bin/python" -c "pass" >/dev/null 2>&1 || true -} - -# vlt.json with the public registry configured every way vlt eras read it; -# $1 = an optional `"workspaces": ...,` member to prepend. -vlt_json() { - printf '{ %s"config": { "registry": "https://registry.npmjs.org/", "registries": { "npm": "https://registry.npmjs.org/" } } }\n' "${1:-}" -} - -# --- per-PM native install (the hook, if configured, fires here) ------ -run_install() { - case "$SM_PM" in - npm) npm install --silent --no-audit --no-fund "$SM_PACKAGE@$SM_VERSION" ;; - yarn) yarn add --silent "$SM_PACKAGE@$SM_VERSION" ;; - pnpm) pnpm install --no-frozen-lockfile ;; - bun) bun add "$SM_PACKAGE@$SM_VERSION" ;; - vlt) vlt install ;; - deno) deno install --allow-scripts ;; - pip) - python3 -m venv venv - pth_install_into_venv venv pip - ./venv/bin/pip install --disable-pip-version-check --quiet --no-cache-dir "$SM_PACKAGE==$SM_VERSION" - pth_trigger venv ;; - uv) - uv venv venv - pth_install_into_venv venv uv - uv pip install --python venv/bin/python --quiet "$SM_PACKAGE==$SM_VERSION" - pth_trigger venv ;; - # poetry / pdm are resolver-based: `add` re-resolves the whole manifest - # (which setup edited to add `socket-patch[hook]`, whose `hook` extra pulls - # the unpublished socket-patch-hook wheel) against a package index. - # In this hermetic test the hook wheel isn't published, so resolution - # fails — these PMs can't be exercised without a local index, so they stay - # documented gaps (baseline_supported:false). The .pth mechanism itself is - # package-manager-agnostic (proven by pip/uv/hatch). - poetry) poetry config virtualenvs.in-project true --local && poetry add --no-interaction "$SM_PACKAGE@$SM_VERSION" ;; - pdm) pdm config python.use_venv true >/dev/null 2>&1; pdm add "$SM_PACKAGE==$SM_VERSION" ;; - hatch) - HATCH_DATA_DIR="$PWD/.hatch" hatch env create - HATCH_DATA_DIR="$PWD/.hatch" hatch run python -c "import ${SM_PACKAGE//-/_}" - # hatch manages its env outside .venv; install the hook + fire an - # interpreter through `hatch run`. - if [ "$SM_RUN_SETUP" = 1 ] && [ -n "${SOCKET_PATCH_HOOK_WHEEL:-}" ] && [ -f "${SOCKET_PATCH_HOOK_WHEEL:-}" ]; then - HATCH_DATA_DIR="$PWD/.hatch" hatch run pip install --no-deps "$SOCKET_PATCH_HOOK_WHEEL" \ - || note "hatch hook wheel install failed" - fi - HATCH_DATA_DIR="$PWD/.hatch" hatch run python -c "pass" || true ;; - cargo) cargo fetch ;; - bundler) bundle config set --local path vendor/bundle && bundle install ;; - go) GOFLAGS=-mod=mod go mod download "$SM_PACKAGE@$SM_VERSION" ;; - mvn) mvn -q -B dependency:get -Dartifact="$SM_PACKAGE:$SM_VERSION" ;; - composer) composer require --quiet --no-interaction "$SM_PACKAGE:$SM_VERSION" ;; - dotnet) dotnet new classlib -o . --force >/dev/null 2>&1 && dotnet add package "$SM_PACKAGE" --version "$SM_VERSION" ;; - *) echo "unknown pm: $SM_PM"; return 2 ;; - esac -} - -# --- resolve the on-disk file the patch would land in ----------------- -resolve_target() { - local rel="${SM_MANIFEST_KEY#package/}" - local base; base="$(basename "$rel")" - case "$SM_ECOSYSTEM" in - npm|deno) printf '%s\n' "$PWD/node_modules/$SM_PACKAGE/$rel" ;; - # Exclude vendored copies (pip/setuptools bundle their own six.py under - # */_vendor/*); the patch lands in the installed package at the - # site-packages root. - pypi) find "$PWD" -name "$base" -not -path '*/_vendor/*' 2>/dev/null | head -1 ;; - cargo) find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -path "*/${SM_PACKAGE}-${SM_VERSION}/${rel}" 2>/dev/null | head -1 ;; - gem) find "$PWD/vendor" -path "*/${SM_PACKAGE}-${SM_VERSION}/${rel}" 2>/dev/null | head -1 ;; - golang) local gmc; gmc="$(go env GOMODCACHE 2>/dev/null || echo "${GOPATH:-$HOME/go}/pkg/mod")"; find "$gmc" -path "*/$(basename "$SM_PACKAGE")@${SM_VERSION}/${rel}" 2>/dev/null | head -1 ;; - maven) find "$HOME/.m2/repository" -name "$base" 2>/dev/null | head -1 ;; - composer) printf '%s\n' "$PWD/vendor/${SM_PACKAGE}/${rel}" ;; - nuget) local lc; lc="$(printf '%s' "$SM_PACKAGE" | tr '[:upper:]' '[:lower:]')"; find "${NUGET_PACKAGES:-$HOME/.nuget/packages}" -path "*/${lc}/${SM_VERSION}/${rel}" 2>/dev/null | head -1 ;; - esac -} - -# --- workspace scaffold (root + nested members) ---------------------- -# Models a real monorepo where multiple (incl. deeply-nested) workspace -# members depend on the package being patched, plus a member that does -# NOT — so `setup`'s workspace handling (npm: every member; pnpm: root -# only) and the root install's cross-workspace apply are both exercised. -ws_member_js() { # $1=dir $2=name (declares the dep) - mkdir -p "$1" - cat > "$1/package.json" < package.json <<'EOF' -{ "name": "sm-root", "version": "0.0.0", "private": true, - "workspaces": ["packages/*", "packages/group/*"] } -EOF - ws_member_js packages/app "@sm/app" - ws_member_js packages/lib "@sm/lib" - ws_member_js packages/group/nested "@sm/nested" - mkdir -p packages/util # member with NO dependency on the patched pkg - printf '{ "name": "@sm/util", "version": "0.0.0", "private": true }\n' > packages/util/package.json ;; - pnpm) - printf '{ "name": "sm-root", "version": "0.0.0", "private": true }\n' > package.json - cat > pnpm-workspace.yaml <<'EOF' -packages: - - 'packages/*' - - 'packages/group/*' -EOF - ws_member_js packages/app "@sm/app" - ws_member_js packages/lib "@sm/lib" - ws_member_js packages/group/nested "@sm/nested" - mkdir -p packages/util - printf '{ "name": "@sm/util", "version": "0.0.0", "private": true }\n' > packages/util/package.json ;; - vlt) - # vlt reads workspaces only from vlt.json; `setup` wires the root alone. - printf '{ "name": "sm-root", "version": "0.0.0", "private": true }\n' > package.json - vlt_json '"workspaces": ["packages/*", "packages/group/*"], ' > vlt.json - ws_member_js packages/app "@sm/app" - ws_member_js packages/lib "@sm/lib" - ws_member_js packages/group/nested "@sm/nested" - mkdir -p packages/util - printf '{ "name": "@sm/util", "version": "0.0.0", "private": true }\n' > packages/util/package.json ;; - uv) - # uv workspace: virtual root + members; the shared dep is installed - # into one root .venv by `uv sync`. - cat > pyproject.toml < "packages/$m/pyproject.toml" < packages/app/requirements.txt - echo "$SM_PACKAGE==$SM_VERSION" > packages/lib/requirements.txt - printf -- '-r packages/app/requirements.txt\n-r packages/lib/requirements.txt\n' > requirements.txt ;; - esac -} - -run_install_workspace() { - case "$SM_PM" in - npm) npm install --silent --no-audit --no-fund ;; - yarn) yarn install --silent ;; - pnpm) pnpm install --no-frozen-lockfile ;; - vlt) vlt install ;; - uv) uv sync ;; - pip) python3 -m venv venv && ./venv/bin/pip install --disable-pip-version-check --quiet --no-cache-dir -r requirements.txt ;; - esac -} - -# --- all-ecosystem monorepo scaffold --------------------------------- -# A polyglot repo: an npm workspace (the slice `setup` supports AND the -# npm image can install) alongside python/rust/go/php/ruby/nuget/deno -# manifests. The point is to confirm `setup` works in this environment — -# it must configure the npm hooks and NOT choke on the foreign manifests. -scaffold_monorepo() { - cat > package.json <<'EOF' -{ "name": "sm-monorepo", "version": "0.0.0", "private": true, - "workspaces": ["packages/js-*"] } -EOF - ws_member_js packages/js-app "@mono/js-app" - ws_member_js packages/js-nested "@mono/js-nested" - mkdir -p packages/py-svc - cat > packages/py-svc/pyproject.toml <<'EOF' -[project] -name = "py-svc" -version = "0.0.0" -requires-python = ">=3.9" -dependencies = ["six==1.16.0"] -EOF - printf 'six==1.16.0\n' > packages/py-svc/requirements.txt - mkdir -p packages/rust-lib/src - printf '[package]\nname = "rust-lib"\nversion = "0.0.0"\nedition = "2021"\n\n[dependencies]\ncfg-if = "=1.0.0"\n' > packages/rust-lib/Cargo.toml - printf '// lib\n' > packages/rust-lib/src/lib.rs - mkdir -p packages/go-mod && printf 'module mono/go\n\ngo 1.21\n' > packages/go-mod/go.mod - mkdir -p packages/php-web && printf '{ "name": "mono/php", "require": { "monolog/monolog": "3.5.0" } }\n' > packages/php-web/composer.json - mkdir -p packages/ruby-gem && printf "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'\n" > packages/ruby-gem/Gemfile - mkdir -p packages/deno-app && printf '{ "name": "mono/deno", "version": "0.0.0" }\n' > packages/deno-app/deno.json - mkdir -p packages/nuget-app && printf '\n' > packages/nuget-app/app.csproj -} - -run_install_monorepo() { - npm install --silent --no-audit --no-fund -} - -# --- resolve candidate on-disk file(s) for verification -------------- -# For single layout: one path. For workspace/monorepo: search the tree -# (hoisted root node_modules, pnpm store, member dirs, shared venv). -resolve_targets() { - local rel="${SM_MANIFEST_KEY#package/}" - local base; base="$(basename "$rel")" - if [ "$SM_LAYOUT" = single ]; then - resolve_target - return - fi - case "$SM_ECOSYSTEM" in - npm|deno|monorepo) find "$PWD" -path "*/node_modules/$SM_PACKAGE/$rel" 2>/dev/null ;; - pypi) find "$PWD" -name "$base" -not -path '*/_vendor/*' 2>/dev/null ;; - *) resolve_target ;; - esac -} - -# --- native install dispatch (layout-aware) -------------------------- -do_install() { - case "$SM_LAYOUT" in - workspace) run_install_workspace ;; - monorepo) run_install_monorepo ;; - *) run_install ;; - esac -} - -# Wipe installed modules so the NEXT install re-fetches a pristine copy and -# re-fires the lifecycle hook. This is what lets us observe the patch-apply -# BEHAVIOR (marker present/absent on a freshly installed file) at each stage -# of the (setup)·(install) sequence, rather than inspecting package.json. -reset_modules() { - rm -rf node_modules packages/*/node_modules 2>/dev/null || true -} - -# Execute a single patched file with the ecosystem's STANDARD runner so the -# patched code actually runs; its stdout/stderr (where the marker would be -# printed) is emitted for the caller to inspect. npm→node, bun→bun, deno→deno, -# pip→the venv's python3, uv→uv run, poetry/pdm/hatch→their `run`. For -# compiled/loaded ecosystems we cannot execute (cargo/go/maven/nuget/gem/ -# composer) we `cat` the file so its inert marker comment is still observed — -# matching the previous file-based behavior for those gaps. -run_file() { # $1 = absolute path to the resolved package file - case "$SM_ECOSYSTEM" in - npm|monorepo) - case "$SM_PM" in - bun) bun "$1" ;; - *) node "$1" ;; - esac ;; - deno) deno run -A "$1" ;; - pypi) - # Run the patched module with the in-project venv interpreter directly. - # Going through ` run` re-resolves the project, which (after setup) - # includes the committed `socket-patch[hook]` dependency, whose hook wheel - # is unpublished in this hermetic test, so the resolve would fail for a - # reason unrelated to whether six.py is patched. Direct execution faithfully runs the on-disk - # patched file and observes its marker. (hatch manages its env outside - # an in-project .venv, and its skip-install env doesn't re-resolve, so it - # keeps using `hatch run`.) - case "$SM_PM" in - uv) ./venv/bin/python "$1" ;; - poetry) ./.venv/bin/python "$1" ;; - pdm) ./.venv/bin/python "$1" ;; - hatch) HATCH_DATA_DIR="$PWD/.hatch" hatch run python "$1" ;; - pip) ./venv/bin/python "$1" ;; - *) python3 "$1" ;; - esac ;; - *) cat "$1" ;; - esac -} - -# Decide whether the patch was applied by RUNNING every on-disk copy of the -# patched file and checking whether the marker appears in its runtime output. -# Sets APPLIED / PRIMARY_PRESENT / TARGET. -verify_applied() { - local check_marker="$SM_MARKER" - [ "$SM_PATCHSET" = alt ] && check_marker="$SM_ALT_MARKER" - APPLIED=false - PRIMARY_PRESENT=null - TARGET="" - local n_found=0 cand out - while IFS= read -r cand; do - [ -n "$cand" ] && [ -f "$cand" ] || continue - n_found=$((n_found + 1)) - [ -z "$TARGET" ] && TARGET="$cand" - out="$(run_file "$cand" 2>&1)" - if printf '%s' "$out" | grep -q "$check_marker"; then APPLIED=true; TARGET="$cand"; fi - if printf '%s' "$out" | grep -q "$SM_MARKER"; then PRIMARY_PRESENT=true; fi - done < <(resolve_targets) - [ "$PRIMARY_PRESENT" = null ] && [ "$n_found" -gt 0 ] && PRIMARY_PRESENT=false - log "verify(run): marker '$check_marker' in runtime output=$APPLIED (candidates=$n_found, target=${TARGET:-})" -} - -# The full check/remove round trip runs only for npm-family cases; the other -# supported hooks use the simple single-install flow. -is_npm_family() { - [[ "$SM_PM" =~ ^(npm|yarn|pnpm|bun|vlt)$ ]] || [ "$SM_LAYOUT" = monorepo ] -} - -# ============================ main ==================================== -log "binary: $SP_BIN ($("$SP_BIN" --version 2>/dev/null || echo '??')) layout=$SM_LAYOUT" - -WORKDIR="${SM_WORKDIR:-$(mktemp -d)}" -PROJ="$WORKDIR/proj" -mkdir -p "$PROJ" -cd "$PROJ" || { emit_result false null null null "" fail; exit 0; } -note "proj=$PROJ" - -# 0. dependencies + committed patch set -case "$SM_LAYOUT" in - workspace) scaffold_workspace ;; - monorepo) scaffold_monorepo ;; - *) scaffold_project ;; -esac -build_fixture - -# npm-family (incl. deno-via-npm and the monorepo's npm slice) need the -# runner shim so the hook's `npx`/`pnpm dlx @socketsecurity/socket-patch` -# resolves to $SP_BIN instead of the npm registry. -if [[ "$SM_PM" =~ ^(npm|yarn|pnpm|bun|vlt|deno)$ ]] || [ "$SM_LAYOUT" = monorepo ]; then - SHIM_DIR="$PROJ/.sp-shims" - write_shims "$SHIM_DIR" - export SETUP_MATRIX_SHIM_DIR="$SHIM_DIR" - export PATH="$SHIM_DIR:$PATH" - log "shims installed at $SHIM_DIR (PATH prepended)" -fi - -# Hermetic apply env inherited by the install hook's `socket-patch apply`. -export SOCKET_OFFLINE=true SOCKET_FORCE=true SOCKET_API_TOKEN=fake SOCKET_ORG_SLUG=test-org -export SOCKET_TELEMETRY_DISABLED=1 VLT_TELEMETRY=0 -# Isolate the pypi `.pth` hook's change-detection stamp per case so runs -# don't bleed into each other (the stamp lives under XDG_CACHE_HOME). -export XDG_CACHE_HOME="$WORKDIR/.cache" -# Give the case its own home so every package manager's cache, store and -# credentials stay inside WORKDIR. Under `--host` these run on the -# developer's machine, where the default is their real home. The vars -# below outrank HOME for their tools, so they are pointed inside it -# rather than left to leak. -# -# RUSTUP_HOME is the exception: rustup's toolchains live under the real -# home (defaulting to $HOME/.rustup when unset) and are shared, not -# per-case. Pin it to that location BEFORE HOME is redirected, or rustup -# resolves toolchains under the empty fake home and `cargo fetch` fails -# even though CARGO_HOME is set. An already-exported RUSTUP_HOME (e.g. the -# cargo Docker image) is preserved. -export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" -# Version-manager roots get the same treatment: rbenv/pyenv/nvm/fnm/volta/ -# asdf/sdkman/mise shims resolve their root from $HOME (or the XDG dirs, -# which are also redirected below) by default, so a redirected HOME with no -# root pinned makes ruby/python/node fail to launch at all under --host. -# Seed each root from the real home first — only when the variable is not -# already exported and the default directory actually exists, so this is a -# no-op on machines (and containers) without that manager. Same list as -# cache_env.rs TOOLCHAIN_ROOTS. -for _vm in RBENV_ROOT:.rbenv PYENV_ROOT:.pyenv NVM_DIR:.nvm \ - FNM_DIR:.fnm VOLTA_HOME:.volta ASDF_DIR:.asdf ASDF_DATA_DIR:.asdf \ - SDKMAN_DIR:.sdkman MISE_DATA_DIR:.local/share/mise \ - MISE_CONFIG_DIR:.config/mise; do - _vm_var="${_vm%%:*}" - _vm_dir="$HOME/${_vm#*:}" - if [ -z "${!_vm_var:-}" ] && [ -d "$_vm_dir" ]; then - export "$_vm_var=$_vm_dir" - fi -done -unset _vm _vm_var _vm_dir -mkdir -p "$WORKDIR/home" -# asdf/mise read the global tool selection from ~/.tool-versions and accept -# no absolute path to it from the environment — carry the file itself over. -if [ -f "$HOME/.tool-versions" ]; then - cp "$HOME/.tool-versions" "$WORKDIR/home/.tool-versions" -fi -export HOME="$WORKDIR/home" -export XDG_DATA_HOME="$WORKDIR/home/.local/share" XDG_CONFIG_HOME="$WORKDIR/home/.config" XDG_STATE_HOME="$WORKDIR/home/.local/state" -export CARGO_HOME="$WORKDIR/home/.cargo" GOPATH="$WORKDIR/home/go" GOMODCACHE="$WORKDIR/home/go/pkg/mod" GOCACHE="$WORKDIR/home/.cache/go-build" -export PNPM_HOME="$WORKDIR/home/.pnpm" COREPACK_HOME="$WORKDIR/home/.corepack" NUGET_PACKAGES="$WORKDIR/home/.nuget/packages" -# Maven ignores $HOME entirely: the JVM computes `user.home` from the -# passwd entry (getpwuid / NSHomeDirectory), so without this mvn keeps -# writing its ~/.m2 into the REAL home while the resolve_target maven arm -# (and the socket binary, which is $HOME-based) look in the case home. -# -Duser.home moves the whole ~/.m2 (repository + settings) with the case. -export MAVEN_OPTS="${MAVEN_OPTS:+$MAVEN_OPTS }-Duser.home=$WORKDIR/home" -# NOTE: deliberately do NOT export SOCKET_CWD. The install hook's apply -# must run with whatever cwd the package manager sets for the lifecycle -# script — the project root for a single project, and the *member* dir -# for each workspace member. In a workspace, member postinstalls thus -# find no manifest in their own dir and no-op (exit 0), while the root -# postinstall (manifest present) applies. Forcing SOCKET_CWD=root would -# make every member apply target the root manifest and fail with "no -# packages found on disk" mid-install, breaking `npm install`. - -# 1-3. Configure + install + verify. -# -# For npm-family cases that run setup we exercise the FULL behavioral sequence -# — (install)·(setup)·(install)·(remove)·(install) — observing both the patch -# marker and `setup --check` at each stage. A clean reinstall precedes every -# observation so the lifecycle hook acts on a pristine package. This verifies -# behavior end-to-end rather than reading package.json: -# * patch: NOT applied before setup → applied after setup → NOT applied after remove -# * check: fails before setup → passes after the post-setup install (hook -# present AND patches applied on disk) → fails after remove -# -# Every other case (run_setup=0, or non-npm-family ecosystems) keeps the simple -# single-install flow, preserving the existing aspirational expect_applied -# classification untouched. -SETUP_EXIT="null" -CHECK_BEFORE_SETUP_EXIT="null" -CHECK_AFTER_SETUP_EXIT="null" -REMOVE_EXIT="null" -CHECK_AFTER_REMOVE_EXIT="null" -APPLIED_BEFORE_SETUP=null -APPLIED_AFTER_REMOVE=null -INSTALL_EXIT="null" - -if is_npm_family && [ "$SM_RUN_SETUP" = 1 ]; then - # (1) BEFORE setup: no hook configured → install must NOT apply the patch. - log "[before-setup] install for pm=$SM_PM (layout=$SM_LAYOUT)" - do_install; log "[before-setup] install exit=$?" - verify_applied; APPLIED_BEFORE_SETUP="$APPLIED" - - # (2) check must report "needs configuration" (non-zero) before setup. - "$SP_BIN" setup --check --json; CHECK_BEFORE_SETUP_EXIT=$? - log "check-before-setup exit=$CHECK_BEFORE_SETUP_EXIT" - - # (3) setup must succeed. (Its `--check` is probed AFTER the next install: - # since contract property 4 shipped, check also verifies on-disk patch - # consistency, and right after `setup` the pre-hook install from (1) is - # legitimately still unpatched — check would fail there by design.) - log "running: socket-patch setup --yes" - "$SP_BIN" setup --yes --json; SETUP_EXIT=$? - log "setup exit=$SETUP_EXIT" - [ -f package.json ] && { log "package.json scripts after setup:"; grep -A6 '"scripts"' package.json || true; } - - # (4) AFTER setup: clean reinstall → the hook fires → MAIN applied result; - # NOW check must report "configured" (zero): hooks present AND patches - # applied on disk — the contract's correctly-patched state. - reset_modules - log "[after-setup] install for pm=$SM_PM (layout=$SM_LAYOUT)" - do_install; INSTALL_EXIT=$? - log "[after-setup] install exit=$INSTALL_EXIT" - verify_applied # sets the canonical APPLIED / PRIMARY_PRESENT / TARGET - "$SP_BIN" setup --check --json; CHECK_AFTER_SETUP_EXIT=$? - log "check-after-setup exit=$CHECK_AFTER_SETUP_EXIT" - - # (5) remove, then check must report "needs configuration" (non-zero) again. - log "running: socket-patch setup --remove --yes" - "$SP_BIN" setup --remove --yes --json; REMOVE_EXIT=$? - log "remove exit=$REMOVE_EXIT" - [ -f package.json ] && { log "package.json scripts after remove:"; grep -A6 '"scripts"' package.json || true; } - "$SP_BIN" setup --check --json; CHECK_AFTER_REMOVE_EXIT=$? - log "check-after-remove exit=$CHECK_AFTER_REMOVE_EXIT" - - # (6) AFTER remove: clean reinstall → no hook → must NOT apply the patch. - # Preserve the main (after-setup) result while re-probing the disk. - _MAIN_APPLIED="$APPLIED"; _MAIN_PRIMARY="$PRIMARY_PRESENT"; _MAIN_TARGET="$TARGET" - reset_modules - log "[after-remove] install for pm=$SM_PM (layout=$SM_LAYOUT)" - do_install; log "[after-remove] install exit=$?" - verify_applied; APPLIED_AFTER_REMOVE="$APPLIED" - APPLIED="$_MAIN_APPLIED"; PRIMARY_PRESENT="$_MAIN_PRIMARY"; TARGET="$_MAIN_TARGET" -else - # Simple flow: optional setup (no-op where there is no package.json), one - # install, one verify. - if [ "$SM_RUN_SETUP" = 1 ]; then - log "running: socket-patch setup --yes" - "$SP_BIN" setup --yes --json; SETUP_EXIT=$? - log "setup exit=$SETUP_EXIT" - [ -f package.json ] && { log "package.json scripts after setup:"; grep -A6 '"scripts"' package.json || true; } - fi - log "running install for pm=$SM_PM (layout=$SM_LAYOUT)" - do_install; INSTALL_EXIT=$? - log "install exit=$INSTALL_EXIT" - verify_applied -fi - -# Driver-level status: did actual match the aspirational expectation? -want=$([ "$SM_EXPECT_APPLIED" = 1 ] && echo true || echo false) -STATUS=fail -[ "$APPLIED" = "$want" ] && STATUS=pass - -emit_result "$APPLIED" "$PRIMARY_PRESENT" "$SETUP_EXIT" "$INSTALL_EXIT" "${TARGET:-}" "$STATUS" -exit 0 diff --git a/tests/setup_matrix/shims/npx b/tests/setup_matrix/shims/npx deleted file mode 100755 index d64dd9e1..00000000 --- a/tests/setup_matrix/shims/npx +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# setup-matrix test shim for `npx`. -# -# The hook that `socket-patch setup` writes into package.json is -# npx @socketsecurity/socket-patch apply --silent --ecosystems npm -# In a hermetic test we do NOT want `npx` to fetch the published wrapper -# package from the npm registry — we want it to run the locally-built -# binary under test. This shim, prepended to PATH, intercepts exactly that -# invocation and execs the local binary; every other `npx` call is -# delegated to the real `npx`. -# -# This is a TEST FIXTURE, not a change to socket-patch behavior. It is the -# standalone/reference copy; tests/setup_matrix/run-case.sh embeds a -# functionally equivalent, condensed copy inline so the driver is -# self-contained when piped into a container. Keep the two in sync. -set -uo pipefail - -sp_bin="${SOCKET_PATCH_BIN:-socket-patch}" -shim_dir="${SETUP_MATRIX_SHIM_DIR:-}" - -# Resolve the real npx by searching PATH with our own shim dir removed. -clean_path="$PATH" -if [ -n "$shim_dir" ]; then - clean_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vxF "$shim_dir" | paste -sd: -)" -fi -real_npx="$(PATH="$clean_path" command -v npx 2>/dev/null || true)" - -# If any argument names our package, drop everything up to and including it -# and exec the local binary with the remaining apply args. -i=0 -for arg in "$@"; do - case "$arg" in - @socketsecurity/socket-patch|@socketsecurity/socket-patch@*|@socketsecurity/socket-patch/*) - shift "$((i + 1))" - exec "$sp_bin" "$@" - ;; - esac - i=$((i + 1)) -done - -if [ -n "$real_npx" ]; then - exec "$real_npx" "$@" -fi -echo "setup-matrix npx shim: real npx not found and args are not our package: $*" >&2 -exit 127 diff --git a/tests/setup_matrix/shims/pnpm b/tests/setup_matrix/shims/pnpm deleted file mode 100755 index e497e398..00000000 --- a/tests/setup_matrix/shims/pnpm +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# setup-matrix test shim for `pnpm`. -# -# For pnpm projects `socket-patch setup` writes the hook -# pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm -# `pnpm dlx` always downloads from the registry, so it cannot be satisfied -# from a local file: dependency. This shim, prepended to PATH, intercepts -# `pnpm dlx @socketsecurity/socket-patch …` (and `pnpm exec …`) and execs -# the locally-built binary; every other `pnpm` invocation — crucially the -# real `pnpm install` / `pnpm add` — is delegated unchanged to the real -# pnpm. -# -# TEST FIXTURE only. Reference copy; run-case.sh embeds an equivalent copy. -set -uo pipefail - -sp_bin="${SOCKET_PATCH_BIN:-socket-patch}" -shim_dir="${SETUP_MATRIX_SHIM_DIR:-}" - -clean_path="$PATH" -if [ -n "$shim_dir" ]; then - clean_path="$(printf '%s' "$PATH" | tr ':' '\n' | grep -vxF "$shim_dir" | paste -sd: -)" -fi -real_pnpm="$(PATH="$clean_path" command -v pnpm 2>/dev/null || true)" - -if [ "${1:-}" = "dlx" ] || [ "${1:-}" = "exec" ]; then - case "${2:-}" in - @socketsecurity/socket-patch|@socketsecurity/socket-patch@*) - shift 2 - exec "$sp_bin" "$@" - ;; - esac -fi - -if [ -n "$real_pnpm" ]; then - exec "$real_pnpm" "$@" -fi -echo "setup-matrix pnpm shim: real pnpm not found: $*" >&2 -exit 127 From 469a67119bf0349562773927da9f4306f80afcc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:03:21 +0000 Subject: [PATCH 2/7] Streamline the patch UI (v5 WS8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `-h` lists about eight options per command (`cli_command()` marks the rest hide_short_help; `--help` is unchanged); `scan --apply/--vendor` are hidden (still accepted). - Human warnings drop the `(code)` tag (`Warning: …`, `GC: skipped: …`); JSON keeps every code. Error lines keep theirs. - Human text says "hosted", not "redirect" (JSON keys unchanged). - npm's allow-remote notice is one line; `--verbose`/JSON keep the full policy text. - One `ui::next_steps` renderer for hosted and vendored results. - Hosted and vendored `get` never prompt: top-ranked patch per package, like scan, in JSON too. Agent-mode `get` keeps its picker and confirm. - `list` with nothing to list says `No patches in this project. Run \`socket-patch scan\`.` (exit codes unchanged: 1 missing, 0 empty). - One cancel line (`ui::CANCELLED`) and one paid upsell (`ui::PAID_UPGRADE`). - `get`'s self-enforced flag conflicts and `rollback --one-off` exit 2, like every other usage error. Docs: CLI_CONTRACT (human output conventions, exit codes, get prompts), README, CHANGELOG [Unreleased], v5 plan status. Tests updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- CHANGELOG.md | 30 +++ README.md | 19 +- crates/socket-patch-cli/CLI_CONTRACT.md | 21 +- crates/socket-patch-cli/src/args.rs | 6 +- crates/socket-patch-cli/src/commands/apply.rs | 8 +- crates/socket-patch-cli/src/commands/get.rs | 112 ++++----- crates/socket-patch-cli/src/commands/list.rs | 30 ++- .../socket-patch-cli/src/commands/remove.rs | 42 ++-- .../socket-patch-cli/src/commands/repair.rs | 10 +- .../src/commands/repair_vendor.rs | 2 +- .../socket-patch-cli/src/commands/rollback.rs | 53 ++-- .../socket-patch-cli/src/commands/scan/gc.rs | 8 +- .../src/commands/scan/hosted.rs | 227 +++++++++++------- .../src/commands/scan/hosted/python.rs | 2 +- .../src/commands/scan/hosted/vlt.rs | 2 +- .../socket-patch-cli/src/commands/scan/mod.rs | 83 +++---- .../src/commands/scan/render.rs | 6 +- .../socket-patch-cli/src/commands/update.rs | 17 +- .../socket-patch-cli/src/commands/vendor.rs | 88 ++++--- crates/socket-patch-cli/src/commands/vex.rs | 4 +- .../src/commands/vex_sources.rs | 6 +- .../socket-patch-cli/src/hosted_memory/mod.rs | 2 +- .../src/hosted_memory/redirect.rs | 2 +- crates/socket-patch-cli/src/lib.rs | 86 ++++++- crates/socket-patch-cli/src/ui/mod.rs | 11 +- crates/socket-patch-cli/src/ui/text.rs | 32 +++ .../socket-patch-cli/tests/apply_network.rs | 2 +- .../tests/cli_gem_variant_mismatch_policy.rs | 8 +- .../socket-patch-cli/tests/cli_parse_list.rs | 14 +- .../tests/cli_remove_silent.rs | 10 +- crates/socket-patch-cli/tests/cli_sigpipe.rs | 2 +- ...overage_fix_scan_hosted_dryrun_vendored.rs | 10 +- .../tests/covgap_commands_apply.rs | 4 +- .../tests/covgap_commands_get.rs | 16 +- .../tests/covgap_commands_remove.rs | 16 +- .../tests/covgap_commands_repair_vendor.rs | 2 +- .../tests/covgap_commands_rollback.rs | 26 +- .../tests/covgap_commands_scan_hosted.rs | 67 +++--- .../tests/covgap_commands_scan_mod.rs | 22 +- .../tests/covgap_commands_update.rs | 4 +- .../tests/covgap_commands_vendor.rs | 6 +- .../tests/covgap_commands_vex.rs | 2 +- .../socket-patch-cli/tests/covgap_output.rs | 6 +- .../tests/e2e_redirect_gem_stale_install.rs | 4 +- .../tests/e2e_safety_yarn_pnp.rs | 6 +- .../tests/get_edge_cases_e2e.rs | 2 +- .../socket-patch-cli/tests/get_modes_e2e.rs | 14 +- .../tests/help_text_hygiene.rs | 26 +- .../tests/in_process_gem_config_warning.rs | 4 +- .../socket-patch-cli/tests/in_process_get.rs | 4 +- .../tests/in_process_get_modes.rs | 2 +- .../tests/in_process_redirect.rs | 8 +- .../tests/in_process_redirect_poetry.rs | 2 +- .../tests/interactive_prompts_e2e.rs | 8 +- .../tests/output_modes_e2e.rs | 10 +- .../tests/redirect_npm_allow_remote.rs | 20 +- .../tests/repair_invariants.rs | 2 +- .../tests/rollback_invariants.rs | 13 +- .../socket-patch-cli/tests/scan_invariants.rs | 4 +- .../tests/scan_ordered_concurrency_e2e.rs | 4 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 2 +- .../tests/vlt_e2e_common/fixture.rs | 2 +- .../tests/vlt_hosted_common/mod.rs | 4 +- docs/design/v5-plan.md | 16 ++ 64 files changed, 777 insertions(+), 506 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e906da1..836e222b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,36 @@ into the new version's section — see docs/releasing.md. `socket-patch-core`, along with the setup-only `npm_family` table column (`FileRow::detects_pnpm`) and `VLT_SETUP_MARKERS`. +### Changed (BREAKING): patch UI streamlining + +- **Hosted and vendored `get` never prompt.** Like `scan`, they take the + top-ranked accessible patch per package with no picker and no + confirmation, in `--json` too (no `selection_required` outside agent + mode). Agent-mode `get` keeps its picker and `Download and apply N + patches?` prompt. +- **`get` and `rollback` usage errors exit 2** (were 1): `get`'s + `--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`, + `--mode hosted|vendored --save-only`, `--one-off`, a malformed forced + identifier, and `rollback --one-off`. Every usage error now exits 2. +- **`list` in a project with no patches** prints `No patches in this + project. Run \`socket-patch scan\`.` (stdout) instead of `Error: Manifest + not found at …` / `No patches found in manifest.`. Exit codes are + unchanged (1 with no manifest and no ledger record, 0 for an empty + manifest); `--json` keeps the `manifest_not_found` envelope. +- **Human output:** warning lines no longer carry the `(code)` tag + (`Warning: …`, `GC: skipped: …`); the codes stay in the JSON envelope. + Error lines keep theirs (`Error (): …`). Hosted mode is called "hosted", not "redirect", in human + text (`Switched 2 packages to hosted patches; rewrote 1 file.`). npm's + `allow-remote` notice is one line (full text under `--verbose` and in + `--json`). Hosted and vendored runs share one numbered `Next steps:` + block. Every declined prompt prints `Cancelled; no changes made.`, and + scan/get share one paid-plan upsell line. +- **`-h` is short**: about eight options per command (`--json`, + `--verbose`, `--dry-run`, `--yes` where the command prompts, and the + command's main flags); `--help` still lists everything. The deprecated + `scan --apply` / `--vendor` spellings are hidden from both (still + accepted). + ### Changed (BREAKING) - **Vendored runs refuse lock-text failures before downloading them.** diff --git a/README.md b/README.md index 22237395..c26d89d3 100644 --- a/README.md +++ b/README.md @@ -634,8 +634,8 @@ socket-patch scan [PATHS]... [options] | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX](#inline-vex-on-apply--scan--vendor). | | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | -> Deprecated spellings: `--apply` (== `--mode agent`) and `--vendor` (== `--mode -> vendored`). `--detached` is a hidden no-op kept for compatibility (vendored mode is +> Deprecated, hidden spellings (still accepted): `--apply` (== `--mode agent`) and +> `--vendor` (== `--mode vendored`). `--detached` is a hidden no-op kept for compatibility (vendored mode is > always manifest-free); it is still an error without vendored mode. **Examples:** @@ -795,9 +795,11 @@ socket-patch vendor --json ### `list` -List the patches in this project: the hosted redirect ledger's records (labeled +List the patches in this project: the hosted ledger's records (labeled `Mode: hosted`), the vendor ledger's (`Mode: vendored`), and any agent-mode entries in -`.socket/manifest.json`. +`.socket/manifest.json`. A project with none prints `No patches in this project. Run +\`socket-patch scan\`.` (exit 1 when there is no manifest or ledger record at all, 0 for +an empty manifest). **Usage:** ```bash @@ -850,8 +852,9 @@ flag. Like `scan`, `get` defaults to hosted mode; pass `--mode vendored`, or `--mode agent` for the manifest + in-place apply (implied by `--save-only` and `--global`). When a package has several patches, `get` picks the same one `scan` does (see -[Which patch is picked](#which-patch-is-picked)). Unlike `scan`, `get` prompts before -applying (`--yes` or a non-TTY stdin accepts). +[Which patch is picked](#which-patch-is-picked)). Hosted and vendored `get` never +prompt, like `scan`; agent-mode `get` asks before applying (`--yes` or a non-TTY stdin +accepts). Alias: `download`. And as a shortcut, `socket-patch ` with a bare patch UUID is rewritten to `socket-patch get `. @@ -1328,8 +1331,8 @@ socket-patch apply --json | jq '.status' # "success", "partialFailure", "noManifest", or "error" ``` -`scan` never prompts, so CI needs no `--yes` for it. The commands that do confirm -(`get`, `rollback`, `remove`) auto-proceed when stdin is not a TTY. Progress +`scan` and hosted/vendored `get` never prompt, so CI needs no `--yes` for them. The +commands that do confirm (agent-mode `get`, `rollback`, `remove`) auto-proceed when stdin is not a TTY. Progress indicators and ANSI colors are automatically suppressed when output is piped. The exact JSON shapes, exit codes, and stability guarantees are specified in diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8041eed7..2e9aee43 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -166,7 +166,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --vendor` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the hosted redirect ledger claims keeps the loop's refusal (its takeover revert rewrites the lock the gates read), as does every purl when that ledger is malformed; other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor_rerun_no_network_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). -* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. +* **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. `--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. @@ -272,6 +272,17 @@ Human mode also prints `Note:` lines: superseded records, fetch failures, `--off **Output.** A manifest-less run honors every `vex` output convention: `--output -` (or `-O -`) prints the document to stdout; `--dry-run` still discovers, fetches records and verifies, but writes nothing and leaves a previous document at the path alone (`[dry-run] Would write …`, `dryRun: true`); an embedded `--vex` under `--dry-run` skips generation with the shared `Skipping VEX generation (--dry-run: nothing was …).` line. +## Human output conventions (v5.0) + +Human (non-`--json`) output is not a stable interface, but these rules hold: + +* Warning lines carry no machine code: `Warning: ` (and `GC: skipped: .`); error lines keep theirs (`Error (): …`) so a `--silent` run stays grep-able. The stable codes stay in the JSON envelope (`warnings[].code`, `error.code`, `errorCode`). +* Hosted mode is called "hosted" in human text (`Switched N packages to hosted patches; rewrote M files.`); JSON keys and codes keep their `redirect*` names. +* npm's `allow-remote` notice prints as one line (`Note: set \`allow-remote=all\` in .npmrc …` or `Warning: npm >=12 refuses the hosted patches until …`); the full `redirect_npm_allow_remote` detail is in `--json` and under `--verbose`. +* Hosted and vendored runs that change the project end with one shared `Next steps:` block (commit, reinstall + `socket-patch vex`, then any extra step). +* A declined prompt prints `Cancelled; no changes made.`; the paid-plan upsell is `Upgrade to a paid Socket plan to access all patches: https://socket.dev/pricing`. +* `-h` lists about eight common options per command; `--help` lists all of them. `scan --apply` / `--vendor` are hidden (still accepted). + ## Agent mode in CI (v5.0: `setup` removed) **Removed in v5.0 (MAJOR):** the `setup` subcommand and every install hook it wired (npm @@ -1356,7 +1367,9 @@ patches:" listing, and the `selection_required` `options[]` array — so `updates[].newUuid` names that same patch. `scan` never shows a picker: it always takes the top-ranked downloadable -patch. On `get`, free/unauthorized callers with more than one candidate +patch. Neither does hosted or vendored `get` (v5.0): no picker, no +confirmation, no `selection_required` — the top-ranked accessible patch per +package, in `--json` too. On agent-mode `get`, free/unauthorized callers with more than one candidate for a PURL still get the interactive picker (or `selection_required` in `--json`); the ranking decides the presented order and hence the highlighted default, not the outcome. `--yes` answers the picker with @@ -1431,9 +1444,9 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`, `--mode hosted\|vendored --save-only`, the unimplemented `--one-off`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`), as does `rollback --one-off` — previously `1` (MAJOR). | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (v5.0: both print `No patches in this project. Run \`socket-patch scan\`.` on stdout in human mode, the missing-manifest case no longer as an `Error:` line; `--json` keeps the `manifest_not_found` envelope) (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index d7b0abca..6f2f4e53 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -91,7 +91,7 @@ pub(crate) fn parse_bool_flag(s: &str) -> Result { /// flags (listed first, under "Options") are not buried among the ~24 shared /// ones. Set per-arg rather than as the struct's `next_help_heading`: that /// would leak onto the subcommand-local flags declared after the flatten. -const GLOBAL_OPTIONS: &str = "Global options"; +pub(crate) const GLOBAL_OPTIONS: &str = "Global options"; // Arguments inherited by every subcommand via `#[command(flatten)]`. // @@ -303,7 +303,7 @@ pub struct GlobalArgs { /// backoff until the lock frees or the budget elapses. Only meaningful /// for the commands that take the lock (`apply`, `rollback`, `repair`, /// `remove`, `vendor`, `get` and `scan` when they record, apply, - /// vendor or redirect patches); + /// vendor or host patches); /// other commands accept it silently. Every holder removes the lock file on exit, so a leftover /// from a crashed run never contends. #[arg(help_heading = GLOBAL_OPTIONS, long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] @@ -364,7 +364,7 @@ pub struct GlobalArgs { pub no_npm_allow_remote_config: bool, /// Hosted mode (`scan`/`get --mode hosted`, and `rollback`/`remove` of - /// hosted redirects): do NOT remove stale vlt installed copies + /// hosted patches): do NOT remove stale vlt installed copies /// (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt` /// entries) after `vlt-lock.json` is repointed or restored. vlt never /// refreshes an installed copy on its own, so opting out means running diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index bbf06034..a0dae200 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -69,7 +69,7 @@ fn warn_mismatch_overwrites(result: &ApplyResult, common: &GlobalArgs) { fn format_mismatch_warning(purl: &str, file: &str, dry_run: bool) -> String { let what = if dry_run { "would apply" } else { "applied" }; format!( - "Warning (content_mismatch_overwritten): {purl} {file} did not match the patch's \ + "Warning: {purl} {file} did not match the patch's \ expected original content; {what} the full verified patched content instead \ (pass --strict to fail on mismatches)" ) @@ -1034,11 +1034,11 @@ pub(crate) async fn run_locked( // own `Error:` diagnostic (already printed, even under // --silent); they exist for the JSON envelope. if !is_stage_failure_code(&w.code) { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } } for skip in &fallback_skips { - eprintln!("Warning (gem_fallback_home_skipped): {}", skip.detail()); + eprintln!("Warning: {}", skip.detail()); } } @@ -3109,7 +3109,7 @@ mod tests { fn mismatch_messages_follow_dry_run_tense() { assert_eq!( format_mismatch_warning("pkg:npm/nuxt@4.5.0", "dist/index.mjs", false), - "Warning (content_mismatch_overwritten): pkg:npm/nuxt@4.5.0 dist/index.mjs did \ + "Warning: pkg:npm/nuxt@4.5.0 dist/index.mjs did \ not match the patch's expected original content; applied the full verified \ patched content instead (pass --strict to fail on mismatches)" ); diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 76fe2a8b..f88cb3a8 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -942,17 +942,14 @@ fn format_all_narrowed(skips: &[serde_json::Value]) -> String { } } -/// The confirmation question for `n` selected patches. -fn format_confirm_prompt(mode: super::scan::ScanMode, n: usize, save_only: bool) -> String { +/// The agent-mode confirmation question for `n` selected patches (hosted +/// and vendored `get` never prompt). +fn format_confirm_prompt(save_only: bool, n: usize) -> String { let patches = crate::ui::plural(n, "patch", "patches"); - match mode { - super::scan::ScanMode::Agent if save_only => format!("Download {patches}?"), - super::scan::ScanMode::Agent => format!("Download and apply {patches}?"), - super::scan::ScanMode::Vendored => format!("Download and vendor {patches}?"), - super::scan::ScanMode::Hosted => format!( - "Redirect {} to the hosted patch server?", - crate::ui::plural(n, "package", "packages") - ), + if save_only { + format!("Download {patches}?") + } else { + format!("Download and apply {patches}?") } } @@ -975,9 +972,8 @@ fn no_packages_message(global: bool) -> String { /// `patch` names it (a purl, or the uuid when the purl is unknown). fn format_paid_required(patch: &str) -> String { format!( - "This patch requires a paid subscription to download.\n \ - Patch: {patch}\n \ - Upgrade at: https://socket.dev/pricing" + "This patch requires a paid Socket plan.\n Patch: {patch}\n{}", + crate::ui::PAID_UPGRADE ) } @@ -1176,7 +1172,7 @@ pub(crate) fn select_patches( return Err(1); } Err(SelectError::Cancelled) => { - eprintln!("Selection cancelled."); + eprintln!("{}", crate::ui::CANCELLED); return Err(0); } } @@ -2661,19 +2657,18 @@ pub async fn run(args: GetArgs) -> i32 { args.common.json, "Only one of --id, --cve, --ghsa, or --package can be specified", ); - return 1; + return 2; } if args.one_off && args.save_only { report_error( args.common.json, "--one-off and --save-only cannot be used together", ); - return 1; + return 2; } // v5: hosted by default, like scan. `--save-only` (records a manifest // entry) and global installs (no project lockfile) mean agent mode. - // Conflicts use get's exit-1 report_error style (scan's self-enforced - // conflicts exit 2 — documented carve-out in CLI_CONTRACT.md). + // Usage errors exit 2, like clap's and scan's (v5.0). let mode = args.mode.unwrap_or(if args.save_only || args.common.is_global() { super::scan::ScanMode::Agent } else { @@ -2689,7 +2684,7 @@ pub async fn run(args: GetArgs) -> i32 { mode.cli_name() ), ); - return 1; + return 2; } if args.one_off { // The flag parses but is not implemented: fail loudly rather than @@ -2697,7 +2692,7 @@ pub async fn run(args: GetArgs) -> i32 { // not-yet-implemented contract; rejected before any network or disk // activity. report_error(args.common.json, "One-off get mode is not yet implemented"); - return 1; + return 2; } // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the // network; operations that need remote data fail loudly). Every `get` @@ -2731,7 +2726,7 @@ pub async fn run(args: GetArgs) -> i32 { if args.id || args.cve || args.ghsa { if let Some(err) = forced_identifier_error(&args.identifier, id_type) { report_error(args.common.json, err); - return 1; + return 2; } } @@ -3043,8 +3038,8 @@ pub async fn run(args: GetArgs) -> i32 { "{}", format_search_results(&all, search_response.can_access_paid_patches, color) ); - println!("All available patches require a paid subscription."); - println!(" Upgrade at: https://socket.dev/pricing"); + println!("All available patches require a paid Socket plan."); + println!("{}", crate::ui::PAID_UPGRADE); } return 0; } @@ -3091,8 +3086,8 @@ pub async fn run(args: GetArgs) -> i32 { // by --json (stderr; the envelope carries them too) — but --silent // mutes them like scan does. if !args.common.silent { - for (code, detail) in &narrow_warnings { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in &narrow_warnings { + eprintln!("Warning: {detail}"); } } if accessible.is_empty() { @@ -3150,10 +3145,18 @@ pub async fn run(args: GetArgs) -> i32 { // Smart patch selection: pick one patch per PURL. `accessible` is // non-empty here and every entry passes the selector's tier filter, so // the selection is never empty (one patch per purl group, or `Err`). + // Hosted and vendored `get` never prompt (v5.0): like `scan`, they take + // the top-ranked accessible patch per package, in JSON mode too. + let auto_pick = mode != super::scan::ScanMode::Agent; + let select_common = if auto_pick { + super::scan::selection_args(&args.common) + } else { + args.common.clone() + }; let selected = match select_patches( &accessible, - search_response.can_access_paid_patches, - &args.common, + auto_pick || search_response.can_access_paid_patches, + &select_common, ) { Ok(s) => s, Err(code) => return code, @@ -3168,7 +3171,7 @@ pub async fn run(args: GetArgs) -> i32 { && !selection_prompted( &accessible, search_response.can_access_paid_patches, - &args.common, + &select_common, ) { print!("{}", format_selected_patches(&selected, color)); @@ -3197,14 +3200,17 @@ pub async fn run(args: GetArgs) -> i32 { return agent_dry_run(&args, &selected, &narrow_skips, &narrow_warnings).await; } - // Confirm before acting (default YES), with mode-appropriate wording. - // Dry runs skip the prompt: nothing mutates, so nothing to confirm. - let prompt = format_confirm_prompt(mode, selected.len(), args.save_only); - if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { - if !quiet { - eprintln!("Cancelled; no changes made."); + // Agent mode confirms before acting (default YES). Dry runs skip the + // prompt: nothing mutates, so nothing to confirm. Hosted and vendored + // runs never prompt (v5.0), like `scan`. + if mode == super::scan::ScanMode::Agent && !args.common.dry_run { + let prompt = format_confirm_prompt(args.save_only, selected.len()); + if !crate::ui::confirm(&prompt, true, &args.common) { + if !quiet { + eprintln!("{}", crate::ui::CANCELLED); + } + return 0; } - return 0; } match mode { @@ -5541,32 +5547,10 @@ mod tests { } #[test] - fn confirm_prompts_per_mode() { - use super::super::scan::ScanMode; - assert_eq!( - format_confirm_prompt(ScanMode::Agent, 1, false), - "Download and apply 1 patch?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Agent, 2, false), - "Download and apply 2 patches?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Agent, 1, true), - "Download 1 patch?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Vendored, 3, false), - "Download and vendor 3 patches?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Hosted, 1, false), - "Redirect 1 package to the hosted patch server?" - ); - assert_eq!( - format_confirm_prompt(ScanMode::Hosted, 0, false), - "Redirect 0 packages to the hosted patch server?" - ); + fn confirm_prompts_agent_mode() { + assert_eq!(format_confirm_prompt(false, 1), "Download and apply 1 patch?"); + assert_eq!(format_confirm_prompt(false, 2), "Download and apply 2 patches?"); + assert_eq!(format_confirm_prompt(true, 1), "Download 1 patch?"); } #[test] @@ -5594,9 +5578,9 @@ mod tests { fn paid_required_text() { assert_eq!( format_paid_required("pkg:npm/a@1"), - "This patch requires a paid subscription to download.\n \ - Patch: pkg:npm/a@1\n \ - Upgrade at: https://socket.dev/pricing" + "This patch requires a paid Socket plan.\n \ + Patch: pkg:npm/a@1\n\ + Upgrade to a paid Socket plan to access all patches: https://socket.dev/pricing" ); } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 6bbc5e76..1db07c44 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -288,11 +288,15 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { lines.join("\n") } +/// The human line for a project with nothing to list (an empty manifest, or +/// no manifest and no ledger records at all). +const NO_PATCHES: &str = "No patches in this project. Run `socket-patch scan`."; + /// The whole human listing for stdout: a count header, then the entries /// separated by one blank line (none after the last). fn format_listing(entries: &[ListEntry<'_>], color: bool) -> String { if entries.is_empty() { - return "No patches found in manifest.".to_string(); + return NO_PATCHES.to_string(); } let mut out = format!( "Found {}:\n\n", @@ -378,12 +382,22 @@ pub async fn run(args: ListArgs) -> i32 { ); if manifest.is_none() && entries.is_empty() { // No manifest AND no ledger records: nothing is listable anywhere. - emit_error( - &args, - "manifest_not_found", - format!("Manifest not found at {}", manifest_path.display()), - warnings, - ); + // Exit 1 (unchanged), so scripts can tell "nothing here" from a + // listing; humans get the plain empty-project line, JSON keeps the + // `manifest_not_found` envelope. + if args.common.json { + emit_error( + &args, + "manifest_not_found", + format!("Manifest not found at {}", manifest_path.display()), + warnings, + ); + } else if args.common.silent { + // `--silent` is "errors only", and this run exits 1: say why. + eprintln!("{NO_PATCHES}"); + } else { + println!("{NO_PATCHES}"); + } return 1; } @@ -973,7 +987,7 @@ mod tests { #[test] fn format_listing_counts_and_separates_entries() { - assert_eq!(format_listing(&[], false), "No patches found in manifest."); + assert_eq!(format_listing(&[], false), NO_PATCHES); let manifest = sample_manifest(); let one = combined_entries(Some(&manifest), None, None); let out = format_listing(&one, false); diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index ec46e247..edd0892d 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -97,7 +97,7 @@ async fn emit_not_found( } } -/// Print the hosted leg's run-level advisories (`Warning (): …`) on +/// Print the hosted leg's run-level advisories (`Warning: …`) on /// stderr — never under `--silent` / `--json` (JSON carries them in the /// envelope's `warnings[]`). Printed as soon as the leg returns, so a /// human run that then fails still says what it did to the files. @@ -105,8 +105,8 @@ fn print_hosted_leg_warnings(common: &GlobalArgs, warnings: &[(String, String)]) if common.silent || common.json { return; } - for (code, detail) in warnings { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in warnings { + eprintln!("Warning: {detail}"); } } @@ -184,7 +184,7 @@ fn remove_prompt( if hosted > 0 { clauses.push(format!( "unwind {}", - plural(hosted, "hosted redirect", "hosted redirects") + plural(hosted, "hosted patch", "hosted patches") )); } let question = super::rollback::as_question(&super::rollback::join_clauses(&clauses)); @@ -543,7 +543,7 @@ pub async fn run(args: RemoveArgs) -> i32 { ); if !crate::ui::confirm(&prompt, true, &args.common) { if loud { - println!("Removal cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } @@ -764,8 +764,8 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(e) => { if loud { eprintln!( - "Warning: cannot read the hosted redirect ledger ({e}); hosted \ - redirects were not examined" + "Warning: cannot read the hosted ledger ({e}); hosted \ + patches were not examined" ); } } @@ -794,7 +794,7 @@ pub async fn run(args: RemoveArgs) -> i32 { }; if args.preserve_state && !leg.reverted.is_empty() && loud { eprintln!( - "Note: hosted redirects have no preservable local state; \ + "Note: hosted patches have no preservable local state; \ their ledger records were dropped with the unwound wiring." ); } @@ -1183,7 +1183,7 @@ async fn revert_vendored_matches( let result = revert_vendor_entry(&args.common.cwd, key, state, opts).await; for w in &result.warnings { if loud { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } leg.skipped.push( PatchEvent::new(PatchAction::Skipped, key.clone()) @@ -1353,13 +1353,13 @@ fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'stat match err { HostedUnwindError::Persist(e) => ( "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), + format!("failed to persist the hosted ledger: {e}"), ), HostedUnwindError::Unsupported(purls) => ( "hosted_revert_unsupported", format!( "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ - `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ + `socket-patch rollback` to unwind ALL hosted patches, or re-run \ `scan --mode hosted` to normalize.{note}", purls.join(", ") ), @@ -1367,9 +1367,9 @@ fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'stat HostedUnwindError::Failed { what, why } => ( "hosted_revert_failed", if manifest_backed { - format!("could not unwind hosted redirect for {what}: {why}.{note}") + format!("could not unwind the hosted patch for {what}: {why}.{note}") } else { - format!("could not unwind hosted redirect for {what}: {why}") + format!("could not unwind the hosted patch for {what}: {why}") }, ), } @@ -1398,7 +1398,7 @@ async fn remove_hosted_only( args.common.dry_run, "hosted_state_retained", format!( - "{} matches only hosted redirect records; removing one means unwinding \ + "{} matches only hosted records; removing one means unwinding \ its lockfile redirect, which --skip-rollback prevents", args.identifier ), @@ -1410,9 +1410,9 @@ async fn remove_hosted_only( eprintln!( "The following {} {} unwound and removed:", if hosted_matches.len() == 1 { - "hosted redirect" + "hosted patch" } else { - "hosted redirects" + "hosted patches" }, if args.common.dry_run { "would be" @@ -1428,7 +1428,7 @@ async fn remove_hosted_only( // `--dry-run` previews without mutating — nothing to confirm. let prompt = format!( "Remove {} and unwind {} lockfile wiring?", - plural(hosted_matches.len(), "hosted redirect", "hosted redirects"), + plural(hosted_matches.len(), "hosted patch", "hosted patches"), if hosted_matches.len() == 1 { "its" } else { @@ -1437,7 +1437,7 @@ async fn remove_hosted_only( ); if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if loud { - println!("Removal cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } @@ -1568,7 +1568,7 @@ async fn remove_ledger_only( }; if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if loud { - println!("Removal cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } @@ -1849,12 +1849,12 @@ mod tests { ); assert_eq!( remove_prompt(1, false, false, 0, 1), - "Remove 1 patch, roll back its files, and unwind 1 hosted redirect?" + "Remove 1 patch, roll back its files, and unwind 1 hosted patch?" ); assert_eq!( remove_prompt(1, false, false, 2, 1), "Remove 1 patch, roll back its files, revert 2 vendored artifacts, and unwind 1 \ - hosted redirect?" + hosted patch?" ); assert_eq!( remove_prompt(1, true, false, 1, 0), diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4fb8ed4d..c9ba4773 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -258,9 +258,9 @@ fn format_found_missing(n: usize, noun: ArtifactNoun) -> String { /// Why a hosted-only project has nothing to repair (the JSON skip /// reason; the human line adds the period). -const HOSTED_ONLY_REASON: &str = "Hosted redirects need no local repair; re-run \ - `scan --mode hosted` to refresh the lockfile redirects (it also re-checks for stale \ - pre-redirect installs)"; +const HOSTED_ONLY_REASON: &str = "Hosted patches need no local repair; re-run \ + `scan --mode hosted` to refresh the lockfile (it also re-checks for stale \ + pre-hosted installs)"; /// Step 1's line when no patch artifact is missing: why there is nothing /// to download (no manifest, as in a vendored-only project, or an empty @@ -709,8 +709,8 @@ mod tests { ); assert_eq!( HOSTED_ONLY_REASON, - "Hosted redirects need no local repair; re-run `scan --mode hosted` to refresh \ - the lockfile redirects (it also re-checks for stale pre-redirect installs)" + "Hosted patches need no local repair; re-run `scan --mode hosted` to refresh \ + the lockfile (it also re-checks for stale pre-hosted installs)" ); } diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 662f5cd1..9c997f0f 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -545,7 +545,7 @@ fn soft_restore_without_fingerprint( /// `detail` by the callers so attribution survives the run-level move. fn warn_wiring_unknown(env: &mut Envelope, common: &GlobalArgs, detail: String) { if !common.silent && !common.json { - eprintln!("Warning (vendor_wiring_unknown): {detail}"); + eprintln!("Warning: {detail}"); } env.warnings.push(RunWarning { code: "vendor_wiring_unknown".to_string(), diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index d8ff421a..7e70c246 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -207,15 +207,15 @@ fn rollback_prompt( if hosted > 0 { clauses.push(format!( "unwind {}", - plural(hosted, "hosted redirect", "hosted redirects") + plural(hosted, "hosted patch", "hosted patches") )); } else if leftover_edits > 0 { clauses.push(format!( "replay {}", plural( leftover_edits, - "leftover hosted redirect edit", - "leftover hosted redirect edits" + "leftover hosted wiring edit", + "leftover hosted wiring edits" ) )); } @@ -969,7 +969,7 @@ async fn run_vendored_leg( let result = revert_vendor_entry(&common.cwd, key, state, opts).await; for w in &result.warnings { if loud { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } out.warnings.push((w.code.to_string(), w.detail.clone())); } @@ -1085,9 +1085,9 @@ pub(crate) async fn run_hosted_leg( } if !common.json && !common.silent { if common.dry_run { - println!("Would unwind hosted redirect for {purl}"); + println!("Would unwind the hosted patch for {purl}"); } else { - println!("Unwound hosted redirect for {purl}"); + println!("Unwound the hosted patch for {purl}"); } } out.edited_files @@ -1096,7 +1096,7 @@ pub(crate) async fn run_hosted_leg( } Err(e) => { if !common.json { - eprintln!("Error: Failed to unwind hosted redirect for {purl}: {e}"); + eprintln!("Error: Failed to unwind the hosted patch for {purl}: {e}"); } out.failed.push((purl.clone(), e)); } @@ -1106,9 +1106,9 @@ pub(crate) async fn run_hosted_leg( } else { if !common.json { eprintln!( - "Error: Cannot unwind hosted redirect for {purl}: no per-purl revert exists for \ + "Error: Cannot unwind the hosted patch for {purl}: no per-purl revert exists for \ this ecosystem. Run an unscoped `socket-patch rollback` to unwind ALL \ - hosted redirects, or re-run `scan --mode hosted` to normalize." + hosted patches, or re-run `scan --mode hosted` to normalize." ); } out.unsupported.push(purl.clone()); @@ -1130,7 +1130,7 @@ pub(crate) async fn run_hosted_leg( let why = format!("{} ({})", refusal.reason, files.join(", ")); if !common.json { eprintln!( - "Error: Cannot unwind hosted redirect edits ({}): {why}", + "Error: Cannot unwind hosted wiring edits ({}): {why}", refusal.group ); } @@ -1144,17 +1144,17 @@ pub(crate) async fn run_hosted_leg( if replay.dropped_records.iter().any(|p| p == &purl) { if !common.json && !common.silent { if common.dry_run { - println!("Would unwind hosted redirect for {purl}"); + println!("Would unwind the hosted patch for {purl}"); } else { - println!("Unwound hosted redirect for {purl}"); + println!("Unwound the hosted patch for {purl}"); } } out.reverted.push(purl); } else if !out.failed.iter().any(|(p, _)| p.starts_with("group:")) { - let why = "hosted redirect edits could not be replayed"; + let why = "hosted wiring edits could not be replayed"; // Errors print even under --silent: this drives exit 1. if !common.json { - eprintln!("Error: Failed to unwind hosted redirect for {purl}: {why}"); + eprintln!("Error: Failed to unwind the hosted patch for {purl}: {why}"); } out.failed.push((purl, why.into())); } @@ -1215,7 +1215,8 @@ pub async fn run(args: RollbackArgs) -> i32 { } else { eprintln!("Error: {msg}"); } - return 1; + // A usage error (v5.0: exit 2, like every other one). + return 2; } // An unparseable glob is a usage error — same exit-2 stderr shape as @@ -1570,7 +1571,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // The core error already carries the recovery steps; only say // what this run skipped. format!( - "the hosted leg was skipped: cannot read the hosted redirect ledger: {}", + "the hosted leg was skipped: cannot read the hosted ledger: {}", redirect_state_result .as_ref() .expect_err("checked corrupt above") @@ -1622,7 +1623,7 @@ pub async fn run(args: RollbackArgs) -> i32 { ); if !crate::ui::confirm(&prompt, true, &args.common) { if !args.common.json && !args.common.silent { - println!("Rollback cancelled."); + println!("{}", crate::ui::CANCELLED); } return 0; } @@ -1694,7 +1695,7 @@ pub async fn run(args: RollbackArgs) -> i32 { .await { let msg = - format!("failed to persist the hosted redirect ledger: {e}"); + format!("failed to persist the hosted ledger: {e}"); if !args.common.json { eprintln!("Error: {}", capitalize_first(&msg)); } @@ -1846,7 +1847,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if args.preserve_state && !hosted_leg.reverted.is_empty() { run_warnings.push(( "hosted_state_not_preservable".into(), - "hosted redirects have no preservable local state: their ledger \ + "hosted patches have no preservable local state: their ledger \ records were dropped with the unwound wiring; re-run \ `scan --mode hosted` to re-wire" .into(), @@ -2143,8 +2144,8 @@ pub async fn run(args: RollbackArgs) -> i32 { // failures, hosted replay notes, ...): the JSON envelope's // `warnings[]`, one stderr line each here. if !args.common.json && !args.common.silent { - for (code, detail) in &human_warnings { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in &human_warnings { + eprintln!("Warning: {detail}"); } } @@ -4682,12 +4683,12 @@ mod tests { assert_eq!( rollback_prompt(1, 0, 1, 0), "Roll back 1 patch, remove it from the local manifest, and unwind 1 hosted \ - redirect?" + patch?" ); - assert_eq!(rollback_prompt(0, 0, 3, 0), "Unwind 3 hosted redirects?"); + assert_eq!(rollback_prompt(0, 0, 3, 0), "Unwind 3 hosted patches?"); assert_eq!( rollback_prompt(0, 0, 0, 1), - "Replay 1 leftover hosted redirect edit?" + "Replay 1 leftover hosted wiring edit?" ); assert_eq!( rollback_prompt(0, 1, 0, 0), @@ -4723,8 +4724,8 @@ mod tests { assert_eq!(capitalize_first("can't, really"), "Can't, really"); // Values the user may copy back are never altered. assert_eq!( - capitalize_first("pkg:npm/a@1 matches only hosted redirect records"), - "pkg:npm/a@1 matches only hosted redirect records" + capitalize_first("pkg:npm/a@1 matches only hosted records"), + "pkg:npm/a@1 matches only hosted records" ); assert_eq!( capitalize_first("a1b2c3d4-0000-4000-8000-000000000000 matches nothing"), diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 35b205a3..eb307099 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -330,13 +330,13 @@ pub(super) fn format_gc_line(gc: &GcSummary, preview: bool) -> Option { /// Human-readable line(s) for the vendored-state half of a GC pass (and /// the lock-skip reason / failed rewrites, when the pass could not run or /// persist in full), in order; empty when there is nothing to report. -/// Split out so the contract's human GC vocabulary (`GC: skipped (): +/// Split out so the contract's human GC vocabulary (`GC: skipped: /// .`, one `GC: .` per warning, `GC: failed to revert N /// vendored entry/entries: …`) is unit-testable without capturing stdout. pub(super) fn format_gc_vendored_lines(gc: &GcSummary) -> Vec { let mut lines = Vec::new(); - if let Some((code, message)) = &gc.skipped { - lines.push(format!("GC: skipped ({code}): {message}.")); + if let Some((_, message)) = &gc.skipped { + lines.push(format!("GC: skipped: {message}.")); } for (_, detail) in &gc.warnings { lines.push(format!("GC: {detail}.")); @@ -1524,7 +1524,7 @@ mod tests { assert_eq!( format_gc_vendored_lines(&gc), vec![ - "GC: skipped (lock_held): another socket-patch process is operating in this \ + "GC: skipped: another socket-patch process is operating in this \ directory." .to_string(), "GC: could not update manifest.".to_string(), diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 156f8bf4..88a96591 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -159,7 +159,7 @@ pub(crate) const PNPM_TRUST_TRADEOFF_AND_CAUTION: &str = (minimumReleaseAge / trustPolicy re-checks) for ALL lockfile entries, \ not just the patched ones — the per-entry sha512 integrity pins are \ still enforced. Do NOT follow pnpm's advice to rebuild the lockfile \ - (`pnpm clean --lockfile`): that silently discards the redirect and \ + (`pnpm clean --lockfile`): that silently discards the hosted patches and \ reinstalls the vulnerable upstream artifact. pnpm <=10 ignores the \ setting and installs work unchanged"; @@ -182,7 +182,7 @@ pub(crate) fn pnpm_trust_manual_guidance(server: &str) -> String { `trustLockfile: true` in pnpm-workspace.yaml so every install \ accepts the patched artifacts. Do NOT follow pnpm's advice to \ rebuild the lockfile (`pnpm clean --lockfile`): that silently \ - discards the redirect and reinstalls the vulnerable upstream \ + discards the hosted patches and reinstalls the vulnerable upstream \ artifact. pnpm <=10 installs work unchanged", pnpm_trust_policy_preamble(server), ) @@ -200,10 +200,10 @@ pub(crate) fn pnpm_trust_legacy_detail(server: &str) -> String { lock read by pnpm 1–8, which have no \ lockfile trust policy: no trust step exists or is needed. Do NOT regenerate the lockfile \ (deleting it, or re-resolving on a newer pnpm): that silently \ - discards the redirect and reinstalls the vulnerable upstream \ + discards the hosted patches and reinstalls the vulnerable upstream \ artifact. If the project later moves to pnpm >=9, re-run \ `socket-patch scan --mode hosted` so the regenerated lock is \ - redirected (and trust-configured) again" + switched to hosted (and trust-configured) again" ) } @@ -220,7 +220,7 @@ pub(crate) fn pnpm_trust_workspace_unreadable_detail(server: &str, err: &std::io or add `trustLockfile: true` to it yourself so every install \ accepts the patched artifacts. Do NOT follow pnpm's advice to \ rebuild the lockfile (`pnpm clean --lockfile`): that silently \ - discards the redirect and reinstalls the vulnerable upstream \ + discards the hosted patches and reinstalls the vulnerable upstream \ artifact. pnpm <=10 installs work unchanged", pnpm_trust_policy_preamble(server), ) @@ -414,6 +414,38 @@ const NPM_ALLOW_REMOTE_TRADEOFF: &str = dependencies. npm <=11 installs work unchanged (npm 11 already defaults to \ `all`; npm <=10 has no such setting)"; +/// The default human form of a `redirect_npm_allow_remote` warning: one +/// line saying whether the project `.npmrc` now carries `allow-remote=all` +/// or the user must set it. `detail` is one of the `npm_allow_remote_*` +/// texts below; `--verbose` and `--json` show it in full. +pub(crate) fn npm_allow_remote_one_line(detail: &str) -> String { + const MORE: &str = "(details: --verbose)"; + if detail.contains("`allow-remote=all` was written to a new") + || detail.contains("`allow-remote=all` was appended to the existing") + { + format!( + "Note: set `allow-remote=all` in .npmrc so npm >=12 installs the hosted \ + patches; commit it with the lockfile {MORE}." + ) + } else if detail.contains("`allow-remote=all` would be") { + format!( + "Note: would set `allow-remote=all` in .npmrc so npm >=12 installs the \ + hosted patches {MORE}." + ) + } else if detail.contains("already sets `allow-remote=all`") { + format!( + "Note: .npmrc already sets `allow-remote=all`, so npm >=12 installs the \ + hosted patches; keep it committed {MORE}." + ) + } else { + format!( + "Warning: npm >=12 refuses the hosted patches until `allow-remote=all` is \ + set (in .npmrc, or `npm ci --allow-remote=all`); it was not set \ + automatically {MORE}." + ) + } +} + /// The policy preamble shared by every `allow-remote` warning variant: what /// was repointed, and how npm >= 12 fails without the setting. fn npm_allow_remote_preamble(hosts: &[&str]) -> String { @@ -743,7 +775,7 @@ fn gem_stale_install_warning( let list = paths.join(", "); let detail = if gem_dir.starts_with(cwd) { format!( - "{purl} was redirected to the Socket patch registry, but a stale \ + "{purl} was switched to its hosted patch, but a stale \ UNPATCHED install is already materialized at {} — `bundle install` \ reuses the installed gem (and its cached .gem) without refetching, \ and `--force`/`--redownload` reinstall from the stale cache, so \ @@ -754,7 +786,7 @@ fn gem_stale_install_warning( ) } else { format!( - "{purl} was redirected to the Socket patch registry, but a stale \ + "{purl} was switched to its hosted patch, but a stale \ UNPATCHED install is materialized in the shared gem home at {} — \ `bundle install` reuses it without refetching, so the vulnerable \ upstream code stays live. That gem home is shared by every \ @@ -777,7 +809,7 @@ fn gem_stale_cache_warning(purl: &str, cache_path: &Path) -> serde_json::Value { serde_json::json!({ "code": "redirect_gem_stale_install", "detail": format!( - "{purl} was redirected to the Socket patch registry, but the \ + "{purl} was switched to its hosted patch, but the \ project's committed bundler cache still holds an UNPATCHED \ archive at {} — bundler installs from vendor/cache in preference \ to fetching, so installs (fresh checkouts included) keep \ @@ -1093,7 +1125,7 @@ pub(super) async fn run_redirect( } else if code == 0 && !args.common.silent { // Unreachable from scan (it never prompts, so selection // cannot be cancelled); kept for a code-0 selection error. - eprintln!("Nothing was redirected."); + eprintln!("No changes made."); } return code; } @@ -1710,7 +1742,7 @@ pub(crate) async fn run_redirect_selected( "code": "redirect_vendored_revert_failed", "detail": format!( "{purl} is vendored and its vendored state could not be \ - reverted ({}); NOT redirected — run `socket-patch vendor \ + reverted ({}); NOT switched to hosted — run `socket-patch vendor \ --revert` to clean up, then re-run `scan --mode hosted`", outcome.error.as_deref().unwrap_or("unknown error") ), @@ -1720,9 +1752,9 @@ pub(crate) async fn run_redirect_selected( takeover_pre_warnings.push(serde_json::json!({ "code": "redirect_would_revert_vendored", "detail": format!( - "{purl} is currently vendored; the hosted redirect will \ + "{purl} is currently vendored; the hosted wiring will \ revert its vendored wiring, ledger entry, and committed \ - artifact first, then redirect (mode takeover)" + artifact first, then switch to hosted (mode takeover)" ), })); dry_run_takeover.push((purl.clone(), uuid.clone())); @@ -1742,7 +1774,7 @@ pub(crate) async fn run_redirect_selected( "code": "redirect_vendored_revert_failed", "detail": format!( "{purl} is vendored and its vendored state could not be \ - reverted ({}); NOT redirected — run `socket-patch vendor \ + reverted ({}); NOT switched to hosted — run `socket-patch vendor \ --revert` to clean up, then re-run `scan --mode hosted`", outcome.error.as_deref().unwrap_or("unknown error") ), @@ -1769,7 +1801,7 @@ pub(crate) async fn run_redirect_selected( "code": "redirect_vendored_revert_failed", "detail": format!( "{purl}: vendored wiring reverted but the vendored ledger \ - could not be updated ({e}); NOT redirected — fix \ + could not be updated ({e}); NOT switched to hosted — fix \ .socket/vendor/state.json and re-run" ), })); @@ -1779,7 +1811,7 @@ pub(crate) async fn run_redirect_selected( "code": "redirect_takeover_reverted_vendored", "detail": format!( "{purl} was vendored; reverted its vendored wiring, ledger \ - entry, and committed artifact before redirecting (mode \ + entry, and committed artifact before switching to hosted (mode \ takeover: the project is now fully hosted for this package)" ), })); @@ -2213,7 +2245,7 @@ pub(crate) async fn run_redirect_selected( "detail": "pnpm-lock.yaml was edited outside `rush update`; if \ preventManualShrinkwrapChanges is enabled, `rush install` fails until \ - `rush update` refreshes repo-state.json (the redirect survives `rush \ + `rush update` refreshes repo-state.json (the hosted wiring survives `rush \ update`)", })); } @@ -2806,7 +2838,7 @@ pub(crate) async fn run_redirect_selected( record_warnings.push(serde_json::json!({ "code": "record_fetch_failed", "detail": format!( - "{purl} redirected, but its patch record could not be fetched; \ + "{purl} was switched to hosted, but its patch record could not be fetched; \ it will be missing from VEX until `socket-patch scan --mode \ hosted` is re-run" ), @@ -3285,7 +3317,13 @@ pub(crate) async fn run_redirect_selected( eprintln!("{line}"); } for (code, detail) in &human_warnings { - let detail = if *code == "redirect_pnpm_trust_lockfile" && pnpm_rerun_only { + let detail = if *code == "redirect_npm_allow_remote" && !common.verbose { + // One line by default; the full policy text (the + // tradeoff, every manual recovery) is in `--json` and + // `--verbose`. + eprintln!("{}", npm_allow_remote_one_line(detail)); + continue; + } else if *code == "redirect_pnpm_trust_lockfile" && pnpm_rerun_only { pnpm_trust_rerun_reminder() } else { detail @@ -3294,7 +3332,7 @@ pub(crate) async fn run_redirect_selected( } if let Some(statements) = vex_statements { eprintln!( - "Wrote OpenVEX document with {} to {} (redirected patches are attested \ + "Wrote OpenVEX document with {} to {} (hosted patches are attested \ from the ledger, not hash-verified — their bytes are fetched at install \ time; run `socket-patch vex` after installing to verify against the \ installed tree).", @@ -3307,7 +3345,7 @@ pub(crate) async fn run_redirect_selected( } else if vex.vex.is_some() && common.dry_run { eprintln!( "{}", - crate::commands::vex::format_vex_dry_run_skip("redirected") + crate::commands::vex::format_vex_dry_run_skip("rewritten") ); } if !common.dry_run { @@ -3442,13 +3480,13 @@ fn split_sentences(text: &str) -> Vec { out } -/// One human warning: `Warning (): `. The pnpm trustLockfile -/// guidance is a paragraph of separate instructions, so it renders as a +/// One human warning: `Warning: ` (the code is JSON-only). The pnpm +/// trustLockfile guidance is a paragraph of separate instructions, so it renders as a /// headline plus one ` - ` bullet per sentence. With `width` (stderr is a /// terminal) every line is word-wrapped; without it (a pipe or a CI log) /// each sentence stays on one line so the text remains greppable. fn format_warning(code: &str, detail: &str, width: Option) -> String { - let prefix = format!("Warning ({code}): "); + let prefix = "Warning: "; let detail = sentence_case(detail.trim()); let (headline, bullets) = if code == "redirect_pnpm_trust_lockfile" { let mut sentences = split_sentences(&detail).into_iter(); @@ -3460,7 +3498,7 @@ fn format_warning(code: &str, detail: &str, width: Option) -> String { let mut lines: Vec = Vec::new(); match width { Some(w) => { - lines.extend(wrap_words(&headline, w, &prefix, " ")); + lines.extend(wrap_words(&headline, w, prefix, " ")); for b in &bullets { lines.extend(wrap_words(b, w, " - ", " ")); } @@ -3478,9 +3516,9 @@ fn format_warning(code: &str, detail: &str, width: Option) -> String { /// lock was redirected and trust configured by an earlier run, whose /// output carried the full text; `--json` still carries it every time). fn pnpm_trust_rerun_reminder() -> &'static str { - "pnpm-lock.yaml is already redirected and pnpm-workspace.yaml already sets \ + "pnpm-lock.yaml already uses hosted patches and pnpm-workspace.yaml already sets \ `trustLockfile: true`; keep both committed, and never rebuild the lockfile \ - (`pnpm clean --lockfile`), which discards the redirect" + (`pnpm clean --lockfile`), which discards the hosted patches" } /// The stdout summary line. @@ -3493,16 +3531,16 @@ fn format_redirect_summary(redirected: usize, files: usize, dry_run: bool) -> St use crate::ui::plural; if redirected > 0 && files == 0 { return format!( - "{} already redirected; nothing to rewrite.", + "{} already on hosted patches; nothing to rewrite.", plural(redirected, "package is", "packages are") ); } let pkgs = plural(redirected, "package", "packages"); let files = plural(files, "file", "files"); if dry_run { - format!("Would redirect {pkgs} and rewrite {files} (--dry-run: nothing was changed).") + format!("Would switch {pkgs} to hosted patches and rewrite {files} (--dry-run: nothing was changed).") } else { - format!("Redirected {pkgs}; rewrote {files}.") + format!("Switched {pkgs} to hosted patches; rewrote {files}.") } } @@ -3560,11 +3598,11 @@ fn format_unredirected( let (skip_lead, unpinned_lead) = if nothing_redirected { (" ", " ") } else { - ("Skipped ", "Not redirected ") + ("Skipped ", "Not hosted ") }; let mut lines = Vec::new(); if nothing_redirected { - lines.push("No patches could be redirected:".to_string()); + lines.push("No patches could be switched to hosted:".to_string()); } for (purl, reason) in skipped { lines.push(format!( @@ -3574,7 +3612,7 @@ fn format_unredirected( } for purl in unconfirmed { lines.push(format!( - "{unpinned_lead}{purl}: no lockfile entry pinning it could be redirected{see}" + "{unpinned_lead}{purl}: no lockfile entry pinning it could be rewritten{see}" )); } lines @@ -3630,7 +3668,7 @@ fn format_next_steps( let mut commit: Vec = Vec::new(); if vendored_removed { commit.push(if ledger_written { - ".socket/vendor/ (the redirect ledger, plus the removed vendored ledger entries and \ + ".socket/vendor/ (the hosted ledger, plus the removed vendored ledger entries and \ artifacts)" .to_string() } else { @@ -3644,20 +3682,21 @@ fn format_next_steps( .iter() .any(|f| f == "package-lock.json" || f == "npm-shrinkwrap.json"); let hint = if npm { " (e.g. `npm ci`)" } else { "" }; - let mut steps = vec![ - format!("Commit {} to keep the redirect.", join_names(&commit, 6)), - format!( - "Reinstall from the updated lockfile{hint} so the installed packages pick up the \ - patched artifacts, then run `socket-patch vex` to verify them." - ), - ]; + let mut extra = Vec::new(); if files .iter() .any(|f| f == socket_patch_core::constants::npm_family::VLT_LOCK) { - steps.push("vlt: commit vlt-lock.json; CI should run `vlt ci`".to_string()); + extra.push("vlt: commit vlt-lock.json; CI should run `vlt ci`.".to_string()); } - steps + crate::ui::next_steps( + &format!("{} to keep the hosted patches", join_names(&commit, 6)), + &format!( + "Reinstall from the updated lockfile{hint} so the installed packages pick up the \ + patched artifacts" + ), + &extra, + ) } /// Merge this run's vlt node edits into the recorded ones. A fresh edit @@ -5040,27 +5079,27 @@ mod tests { fn redirect_summary_singular_plural_and_dry_run() { assert_eq!( format_redirect_summary(1, 1, false), - "Redirected 1 package; rewrote 1 file." + "Switched 1 package to hosted patches; rewrote 1 file." ); assert_eq!( format_redirect_summary(2, 3, false), - "Redirected 2 packages; rewrote 3 files." + "Switched 2 packages to hosted patches; rewrote 3 files." ); assert_eq!( format_redirect_summary(0, 0, false), - "Redirected 0 packages; rewrote 0 files." + "Switched 0 packages to hosted patches; rewrote 0 files." ); assert_eq!( format_redirect_summary(1, 1, true), - "Would redirect 1 package and rewrite 1 file (--dry-run: nothing was changed)." + "Would switch 1 package to hosted patches and rewrite 1 file (--dry-run: nothing was changed)." ); assert_eq!( format_redirect_summary(0, 0, true), - "Would redirect 0 packages and rewrite 0 files (--dry-run: nothing was changed)." + "Would switch 0 packages to hosted patches and rewrite 0 files (--dry-run: nothing was changed)." ); assert_eq!( format_redirect_summary(2, 5, true), - "Would redirect 2 packages and rewrite 5 files (--dry-run: nothing was changed)." + "Would switch 2 packages to hosted patches and rewrite 5 files (--dry-run: nothing was changed)." ); } @@ -5071,11 +5110,11 @@ mod tests { for dry in [false, true] { assert_eq!( format_redirect_summary(1, 0, dry), - "1 package is already redirected; nothing to rewrite." + "1 package is already on hosted patches; nothing to rewrite." ); assert_eq!( format_redirect_summary(3, 0, dry), - "3 packages are already redirected; nothing to rewrite." + "3 packages are already on hosted patches; nothing to rewrite." ); } } @@ -5141,31 +5180,31 @@ mod tests { "Skipped pkg:npm/lodash@4.17.20: not entitled to this patch (paid plan or no \ org access)" .to_string(), - "Not redirected pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be \ - redirected (see the warning below)" + "Not hosted pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be \ + rewritten (see the warning below)" .to_string(), ] ); assert_eq!( format_unredirected(&[], &unconfirmed, false, 2), vec![ - "Not redirected pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be \ - redirected (see the warnings below)" + "Not hosted pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be \ + rewritten (see the warnings below)" .to_string(), ] ); assert_eq!( format_unredirected(&[], &unconfirmed, true, 0), vec![ - "No patches could be redirected:".to_string(), - " pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be redirected" + "No patches could be switched to hosted:".to_string(), + " pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be rewritten" .to_string(), ] ); assert_eq!( format_unredirected(&skipped, &[], true, 0), vec![ - "No patches could be redirected:".to_string(), + "No patches could be switched to hosted:".to_string(), " pkg:npm/lodash@4.17.20: not entitled to this patch (paid plan or no org \ access)" .to_string(), @@ -5197,8 +5236,8 @@ mod tests { "Failed to write x: y" ); assert_eq!( - sentence_case("the redirect ledger ./a is malformed"), - "The redirect ledger ./a is malformed" + sentence_case("the hosted ledger ./a is malformed"), + "The hosted ledger ./a is malformed" ); assert_eq!(sentence_case("pnpm >=11 rejects"), "pnpm >=11 rejects"); for tool in ["vlt", "vlx", "vlr"] { @@ -5241,7 +5280,7 @@ mod tests { // Counts characters, not bytes. let lines = wrap_words("ééé ééé ééé", 8, "", ""); assert_eq!(lines, vec!["ééé ééé", "ééé"]); - for line in wrap_words(&"word ".repeat(50), 30, "Warning (x): ", " ") { + for line in wrap_words(&"word ".repeat(50), 30, "Warning: ", " ") { assert!(line.chars().count() <= 30, "{line}"); } } @@ -5303,7 +5342,7 @@ mod tests { "no package-lock.json present", None ), - "Warning (redirect_npm_no_lockfile): No package-lock.json present" + "Warning: No package-lock.json present" ); let long = "word ".repeat(40); let wrapped = format_warning("c", &long, Some(40)); @@ -5312,7 +5351,7 @@ mod tests { wrapped.lines().all(|l| l.chars().count() <= 40), "{wrapped}" ); - assert!(wrapped.starts_with("Warning (c): Word word"), "{wrapped}"); + assert!(wrapped.starts_with("Warning: Word word"), "{wrapped}"); assert!( wrapped.lines().skip(1).all(|l| l.starts_with(" ")), "{wrapped}" @@ -5325,7 +5364,7 @@ mod tests { Do NOT rebuild the lockfile. Run `socket-patch vex` after installation."; assert_eq!( format_warning("redirect_pnpm_trust_lockfile", detail, None), - "Warning (redirect_pnpm_trust_lockfile): pnpm-lock.yaml was repointed at the \ + "Warning: pnpm-lock.yaml was repointed at the \ server; so it goes.\n - Note: a tradeoff.\n - Do NOT rebuild the lockfile.\n \ - Run `socket-patch vex` after installation." ); @@ -5335,15 +5374,15 @@ mod tests { } assert_eq!( wrapped, - "Warning (redirect_pnpm_trust_lockfile): pnpm-lock.yaml was\n \ - repointed at the server; so it goes.\n - Note: a tradeoff.\n - Do NOT \ + "Warning: pnpm-lock.yaml was repointed at the server; so it\n \ + goes.\n - Note: a tradeoff.\n - Do NOT \ rebuild the lockfile.\n - Run `socket-patch vex` after installation." ); // Continuation lines of a long bullet are indented under its text. let bullet = "Head. Do NOT follow the advice to rebuild the lockfile, which discards it."; assert_eq!( format_warning("redirect_pnpm_trust_lockfile", bullet, Some(40)), - "Warning (redirect_pnpm_trust_lockfile): Head.\n - Do NOT follow the advice to \ + "Warning: Head.\n - Do NOT follow the advice to \ rebuild\n the lockfile, which discards it." ); } @@ -5384,11 +5423,13 @@ mod tests { assert_eq!( format_next_steps(&["package-lock.json".to_string()], true, false), vec![ - "Commit .socket/vendor/redirect-state.json and package-lock.json to keep the \ - redirect." + "Next steps:".to_string(), + " 1. Commit .socket/vendor/redirect-state.json and package-lock.json to keep \ + the hosted patches." .to_string(), - "Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages \ - pick up the patched artifacts, then run `socket-patch vex` to verify them." + " 2. Reinstall from the updated lockfile (e.g. `npm ci`) so the installed \ + packages pick up the patched artifacts, then run `socket-patch vex` to verify \ + the installed patches." .to_string(), ] ); @@ -5401,10 +5442,10 @@ mod tests { false, ); assert_eq!( - steps[0], - "Commit pnpm-lock.yaml and pnpm-workspace.yaml to keep the redirect." + steps[1], + " 1. Commit pnpm-lock.yaml and pnpm-workspace.yaml to keep the hosted patches." ); - assert!(!steps[1].contains("npm ci"), "{}", steps[1]); + assert!(!steps[2].contains("npm ci"), "{}", steps[2]); } #[test] @@ -5412,12 +5453,12 @@ mod tests { let steps = format_next_steps(&["vlt-lock.json".to_string()], true, false); assert_eq!( steps.last().map(String::as_str), - Some("vlt: commit vlt-lock.json; CI should run `vlt ci`") + Some(" 3. vlt: commit vlt-lock.json; CI should run `vlt ci`.") ); assert!( !format_next_steps(&["package-lock.json".to_string()], true, false) .iter() - .any(|s| s.starts_with("vlt:")) + .any(|s| s.contains("vlt:")) ); } @@ -5593,14 +5634,14 @@ mod tests { #[test] fn next_steps_after_a_takeover_name_the_removed_vendored_state() { assert_eq!( - format_next_steps(&["package-lock.json".to_string()], true, true)[0], - "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ - entries and artifacts) and package-lock.json to keep the redirect." + format_next_steps(&["package-lock.json".to_string()], true, true)[1], + " 1. Commit .socket/vendor/ (the hosted ledger, plus the removed vendored ledger \ + entries and artifacts) and package-lock.json to keep the hosted patches." ); assert_eq!( - format_next_steps(&["pnpm-lock.yaml".to_string()], false, true)[0], - "Commit .socket/vendor/ (the removed vendored ledger entries and artifacts) and \ - pnpm-lock.yaml to keep the redirect." + format_next_steps(&["pnpm-lock.yaml".to_string()], false, true)[1], + " 1. Commit .socket/vendor/ (the removed vendored ledger entries and artifacts) and \ + pnpm-lock.yaml to keep the hosted patches." ); } @@ -5739,10 +5780,29 @@ mod tests { .contains("symbolic link")); } } + + #[test] + fn npm_allow_remote_one_line_covers_every_variant() { + use super::npm_allow_remote_one_line; + let hosts = ["patch.socket.dev"]; + let cases = [ + (npm_allow_remote_configured_detail(&hosts, true, false), "Note: set"), + (npm_allow_remote_configured_detail(&hosts, false, false), "Note: set"), + (npm_allow_remote_configured_detail(&hosts, true, true), "Note: would set"), + (npm_allow_remote_already_detail(&hosts), "Note: .npmrc already"), + (npm_allow_remote_user_set_detail(&hosts, "none"), "Warning: npm >=12"), + (npm_allow_remote_env_set_detail(&hosts, "npm_config_allow_remote", "none"), "Warning: npm >=12"), + (npm_allow_remote_manual_detail(&hosts), "Warning: npm >=12"), + (npm_allow_remote_unreadable_detail(&hosts, "is a symlink"), "Warning: npm >=12"), + ]; + for (detail, start) in cases { + let line = npm_allow_remote_one_line(&detail); + assert!(line.starts_with(start), "{line}"); + assert!(!line.contains('\n') && line.ends_with("(details: --verbose)."), "{line}"); + } + } } -/// The one-pass multi-needle confirmation probe answers exactly what the -/// per-candidate `any()` oracle answers. #[cfg(test)] mod probe_equivalence_tests { use super::{candidate_presence_needles, candidate_present_oracle, npm_lock_url_needles}; @@ -5907,4 +5967,5 @@ mod probe_equivalence_tests { } } } + } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs index c43f7690..dd96fdce 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs @@ -132,7 +132,7 @@ pub(super) async fn stale_install_warnings( out.warnings.push(serde_json::json!({ "code": "redirect_pypi_stale_install", "detail": format!( - "{purl} was redirected to a hosted patch, but installed files in {} \ + "{purl} was switched to a hosted patch, but installed files in {} \ still differ from the patched hashes. {remedy} The installed files \ were left unchanged.", site.display() diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs index d819af6b..9766f156 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -325,7 +325,7 @@ fn with_optional_kept(mut detail: String, optional_left: usize, held: &str) -> S } const VLT_UPDATE_NOTE: &str = - " Note: `vlt update` re-resolves from the registry and drops these redirects."; + " Note: `vlt update` re-resolves from the registry and drops these hosted patches."; fn reinstall_detail(tally: &HealTally) -> String { let held = "unpatched copies of optional dependencies"; diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 20ab9896..d209c52b 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -286,9 +286,9 @@ pub struct ScanArgs { #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE")] pub batch_size: Option, - /// Deprecated spelling of `--mode agent`: download the selected patches - /// and apply them in place - #[arg(long, default_value_t = false)] + // Hidden, deprecated spelling of `--mode agent`: download the selected + // patches and apply them in place. + #[arg(long, default_value_t = false, hide = true)] pub apply: bool, /// Garbage-collect after the scan: prune manifest entries for @@ -306,13 +306,10 @@ pub struct ScanArgs { #[arg(long, default_value_t = false)] pub sync: bool, - /// Deprecated spelling of `--mode vendored`: vendor every patched - /// dependency the scan selects into the committable `.socket/vendor/` - /// tree instead of applying patches in place. The patch records live in - /// the vendor ledger (`.socket/vendor/state.json`), never in - /// `.socket/manifest.json`; a package vendored at an older patch is - /// re-vendored. Combine with `--prune` to garbage-collect stale state - #[arg(long, default_value_t = false, conflicts_with_all = ["apply", "sync"])] + // Hidden, deprecated spelling of `--mode vendored`: vendor every + // patched dependency the scan selects into the committable + // `.socket/vendor/` tree instead of applying patches in place. + #[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync"])] pub vendor: bool, /// Accepted for compatibility; has no effect @@ -330,10 +327,10 @@ pub struct ScanArgs { pub redirect: bool, /// How discovered patches are consumed [default: hosted]. A `--prune` - /// or `--global` scan with no mode only reports. `--vendor` and - /// `--apply` are older spellings of `--mode vendored` and `--mode agent` - // Each mode is equivalent to one boolean flag (hosted == the hidden - // `--redirect`, vendored == `--vendor`, agent == `--apply`/`--sync`). + /// or `--global` scan with no mode only reports + // The hidden `--vendor` and `--apply` are older spellings of + // `--mode vendored` and `--mode agent`. Each mode is equivalent to one + // boolean flag (hosted == the hidden `--redirect`, vendored == `--vendor`, agent == `--apply`/`--sync`). // Combining `--mode` with a boolean from a DIFFERENT mode is rejected in // `resolve_mode_flags`; the same mode spelled both ways is accepted. #[arg(long = "mode", value_enum)] @@ -612,7 +609,7 @@ async fn discover_selected( /// `common` for `select_patches`: scan never prompts, so it always takes /// the top-ranked patch, and with `json` off it never gets /// `selection_required` (scan has no "re-run with the chosen UUID" path). -fn selection_args(common: &GlobalArgs) -> GlobalArgs { +pub(crate) fn selection_args(common: &GlobalArgs) -> GlobalArgs { GlobalArgs { json: false, yes: true, @@ -1050,7 +1047,7 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo // EVERY vendored package including the ones still live in the // lockfile — `remove ` is the per-package equivalent. format!( - "hosted redirect superseded the vendored ledger for: {list}. \ + "hosted wiring superseded the vendored ledger for: {list}. \ `.socket/vendor/state.json` still claims these package(s) and their \ committed artifacts under `.socket/vendor/` are now orphaned — the \ lockfile points at the hosted patch server, not the vendored files. \ @@ -1073,8 +1070,8 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo // only revert data (FileEdit originals) and VEX records for OTHER // packages that are still hosted-redirected. format!( - "vendored artifacts superseded the hosted redirect ledger for: {list}. \ - `.socket/vendor/redirect-state.json` still records a hosted redirect for \ + "vendored artifacts superseded the hosted ledger for: {list}. \ + `.socket/vendor/redirect-state.json` still records hosted wiring for \ these package(s), but the lockfile now points at the committed \ `.socket/vendor/` files. The vendored flows (`socket-patch vendor`, \ `scan --mode vendored`) reconcile npm-family and cargo package(s) \ @@ -1086,7 +1083,7 @@ pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: boo entries under `records` AND their matching entries under `edits`, \ so audits and VEX do not read superseded wiring. \ Both halves matter: the leftover `edits` are that package's stale \ - pre-redirect originals, which a later redirect revert would replay \ + pre-hosted originals, which a later hosted revert would replay \ over the live vendored wiring — and an `edits` entry left behind \ still names the package, so a ledger whose last record you just \ deleted keeps reading as superseded and this warning keeps firing. \ @@ -1111,8 +1108,8 @@ pub(super) fn mode_takeover_reconciled_detail( // Only a run that actually unwound the hosted npm allow-remote // auto-config says so (with its npm >= 12 caveat). let npmrc = if npmrc_unwound { - " The hosted redirect's `.npmrc` `allow-remote=all` auto-config was \ - unwound too (a redirect-created file deleted, an appended line \ + " The hosted wiring's `.npmrc` `allow-remote=all` auto-config was \ + unwound too (a created file deleted, an appended line \ removed): the vendored `file:` specs do not need it. If you later \ restore the hosted lock wiring with `vendor --revert`, npm >=12 \ refuses it (EALLOWREMOTE) until `allow-remote=all` is back — re-run \ @@ -1122,7 +1119,7 @@ pub(super) fn mode_takeover_reconciled_detail( "" }; format!( - "vendored artifacts superseded the hosted redirect ledger for: {list}; \ + "vendored artifacts superseded the hosted ledger for: {list}; \ reconciled automatically. Both halves of each superseded entry — the \ package's `records` entry AND its matching `edits` — were dropped \ from `.socket/vendor/redirect-state.json` (an emptied ledger is \ @@ -1185,7 +1182,7 @@ pub(super) fn push_run_warning( detail: String, ) { if !common.silent && !common.json { - eprintln!("Warning ({code}): {detail}"); + eprintln!("Warning: {detail}"); } env.warnings.push(crate::json_envelope::RunWarning { code: code.to_string(), @@ -1387,7 +1384,7 @@ pub(super) async fn hosted_wiring_retained_purls( pub(super) fn hosted_wiring_retained_detail(retained: &[String]) -> String { let list = retained.join(", "); format!( - "agent-mode scan left the hosted redirect wiring live for: {list}. \ + "agent-mode scan left the hosted wiring live for: {list}. \ The lockfile still resolves these package(s) to the hosted patch \ server and `.socket/vendor/redirect-state.json` still records the \ redirect — an agent run patches installed files in place but does \ @@ -1396,11 +1393,11 @@ pub(super) fn hosted_wiring_retained_detail(retained: &[String]) -> String { in hosted mode (`scan --mode hosted`), migrate to committed \ artifacts with `scan --mode vendored` (which takes these \ package(s) over in the lockfile and reconciles the superseded \ - redirect ledger entries), or unwind the redirects with \ + hosted ledger entries), or unwind the hosted patches with \ `socket-patch rollback`. Do not delete \ `.socket/vendor/redirect-state.json` by hand: it holds the \ recorded pre-redirect lockfile originals (the only revert data) \ - and the redirect records VEX reads." + and the hosted records VEX reads." ) } @@ -1634,7 +1631,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // Hosted mode runs no GC: say so once up front on the human path. The // `--json` path carries it in `redirect.warnings[]`. if hosted && prune && !args.common.json && !args.common.silent { - eprintln!("Warning ({REDIRECT_PRUNE_IGNORED}): {REDIRECT_PRUNE_IGNORED_DETAIL}"); + eprintln!("Warning: {REDIRECT_PRUNE_IGNORED_DETAIL}"); } // Resolved up-front (rather than at the GC site) because the embedded @@ -1814,8 +1811,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if package_count == 0 { status.finish(); if human { - for (code, detail) in &layout_refusals { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in &layout_refusals { + eprintln!("Warning: {detail}"); } // Hosted mode already printed its own prune-ignored warning. if prune && !hosted { @@ -1937,8 +1934,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !lockfile_only.purls.is_empty() { eprintln!("{}", render::lockfile_only_note(lockfile_only.purls.len())); } - for (code, detail) in &layout_refusals { - eprintln!("Warning ({code}): {detail}"); + for (_, detail) in &layout_refusals { + eprintln!("Warning: {detail}"); } } @@ -2379,14 +2376,14 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !vendored_skip_purls.is_empty() { let detail = vendored_ownership_retained_detail(&vendored_skip_purls); if !args.common.silent { - eprintln!("Warning ({VENDORED_OWNERSHIP_RETAINED}): {detail}"); + eprintln!("Warning: {detail}"); } push_scan_json_warning(&mut result, VENDORED_OWNERSHIP_RETAINED, &detail); } if !hosted_retained.is_empty() { let detail = hosted_wiring_retained_detail(&hosted_retained); if !args.common.silent { - eprintln!("Warning ({HOSTED_WIRING_RETAINED}): {detail}"); + eprintln!("Warning: {detail}"); } push_scan_json_warning(&mut result, HOSTED_WIRING_RETAINED, &detail); } @@ -2590,9 +2587,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { "{}", ui::paint(&render::paid_extra_line(paid_patches), "33", use_color), ); - println!( - "\nUpgrade to Socket's paid plan to access all patches: https://socket.dev/pricing" - ); + println!("\n{}", ui::PAID_UPGRADE); } } @@ -2618,7 +2613,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if downloadable_count == 0 { if !silent { - println!("\nNo downloadable patches (paid subscription required)."); + println!("\nNo downloadable patches: every patch found requires a paid Socket plan."); } warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); return finish_human(0).await; @@ -2914,7 +2909,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { hosted_wiring_retained_purls(&args.common, redirect_state.as_ref(), &all_purls).await; if !hosted_retained.is_empty() { eprintln!( - "Warning ({HOSTED_WIRING_RETAINED}): {}", + "Warning: {}", hosted_wiring_retained_detail(&hosted_retained) ); } @@ -3286,8 +3281,8 @@ mod tests { assert!(vendored.contains("pkg:npm/minimist@1.2.2")); assert!(vendored.contains("redirect-state.json")); assert!( - !vendored.contains("Remove the stale redirect ledger"), - "must not advise deleting the redirect ledger: {vendored}" + !vendored.contains("Remove the stale hosted ledger"), + "must not advise deleting the hosted ledger: {vendored}" ); assert!( vendored.contains("Do not delete"), @@ -4081,7 +4076,7 @@ mod tests { "vendored remediation must be per-package: {vendored}" ); assert!( - !vendored.contains("Remove the stale redirect ledger"), + !vendored.contains("Remove the stale hosted ledger"), "vendored remediation must not advise deleting the ledger: {vendored}" ); assert!( @@ -4170,13 +4165,13 @@ mod tests { assert_eq!( overlapping_ledger_purls(root).await, vec!["pkg:npm/minimist@1.2.2".to_string()], - "an edits-only redirect ledger must still count as overlapping" + "an edits-only hosted ledger must still count as overlapping" ); let takeover = classify_overlap_takeover(&common_at(root), root).await; assert_eq!( takeover.vendored, vec!["pkg:npm/minimist@1.2.2".to_string()], - "the vendored takeover of a degraded redirect ledger must be flagged" + "the vendored takeover of a degraded hosted ledger must be flagged" ); assert!(takeover.redirect.is_empty(), "{takeover:?}"); } @@ -4621,7 +4616,7 @@ mod tests { // Both halves dropped; the emptied ledger is deleted outright. assert!( load_ledger(root).await.is_none(), - "an emptied redirect ledger must be deleted" + "an emptied hosted ledger must be deleted" ); // Fires once: the reconciled project no longer overlaps. diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 34adcadc..12bfa5e8 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -150,7 +150,7 @@ pub(super) fn summary_line(packages: usize, patches: usize, all_accessible: bool pub(super) fn paid_extra_line(paid: usize) -> String { let verb = if paid == 1 { "is" } else { "are" }; format!( - " + {} {verb} available with a paid subscription", + " + {} {verb} available with a paid Socket plan", plural(paid, "additional patch", "additional patches") ) } @@ -649,11 +649,11 @@ mod tests { fn paid_extra_line_agrees_in_number() { assert_eq!( paid_extra_line(1), - " + 1 additional patch is available with a paid subscription" + " + 1 additional patch is available with a paid Socket plan" ); assert_eq!( paid_extra_line(3), - " + 3 additional patches are available with a paid subscription" + " + 3 additional patches are available with a paid Socket plan" ); } diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index dc8fe2be..d75138fd 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -144,16 +144,6 @@ fn confirm_prompt(current: &semver::Version, target: &semver::Version) -> String } } -/// The line after a declined [`confirm_prompt`], naming the same action. -fn cancelled_message(current: &semver::Version, target: &semver::Version) -> &'static str { - if target < current { - "Downgrade cancelled." - } else if target == current { - "Reinstall cancelled." - } else { - "Update cancelled." - } -} /// The result line after a successful install, naming the same action as /// [`confirm_prompt`]. @@ -356,7 +346,7 @@ pub async fn run(args: UpdateArgs) -> i32 { let prompt = confirm_prompt(¤t, &target_version); if !crate::ui::confirm(&prompt, true, &args.common) { if !quiet { - eprintln!("{}", cancelled_message(¤t, &target_version)); + eprintln!("{}", crate::ui::CANCELLED); } return 1; } @@ -497,10 +487,7 @@ mod tests { } #[test] - fn cancel_and_result_lines_match_the_prompt() { - assert_eq!(cancelled_message(&v("4.0.0"), &v("9.9.9")), "Update cancelled."); - assert_eq!(cancelled_message(&v("4.0.0"), &v("3.0.0")), "Downgrade cancelled."); - assert_eq!(cancelled_message(&v("4.0.0"), &v("4.0.0")), "Reinstall cancelled."); + fn result_lines_match_the_prompt() { let p = std::path::Path::new("/opt/sp/socket-patch"); assert_eq!( installed_message(&v("4.0.0"), &v("9.9.9"), p), diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5da61dd9..2fc8faff 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -409,13 +409,12 @@ fn advisory_tier(code: &str) -> AdvisoryTier { } /// The human line for a vendor advisory, or `None` when it is hidden at -/// this verbosity. The stable code is kept on real warnings (it is what -/// a user searches for); notes carry only the detail. +/// this verbosity. The stable code is JSON-only (`warnings[].code`). fn format_advisory(code: &str, detail: &str, verbose: bool) -> Option { match advisory_tier(code) { AdvisoryTier::Verbose if !verbose => None, AdvisoryTier::Verbose | AdvisoryTier::Note => Some(format!("Note: {detail}")), - AdvisoryTier::Warning => Some(format!("Warning ({code}): {detail}")), + AdvisoryTier::Warning => Some(format!("Warning: {detail}")), } } @@ -635,7 +634,7 @@ pub(crate) fn note_classic_migration_risk( return; }; if !common.silent && !common.json { - eprintln!("Warning ({}): {}", w.code, w.detail); + eprintln!("Warning: {}", w.detail); } env.warnings.push(RunWarning { code: w.code.to_string(), @@ -2420,7 +2419,7 @@ pub(crate) async fn vendor_records_reusing( PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( "redirect_ledger_corrupt", format!( - "cannot vendor over a possibly-live hosted redirect: \ + "cannot vendor over a possibly-live hosted wiring: \ {corrupt}" ), ), @@ -2473,8 +2472,8 @@ pub(crate) async fn vendor_records_reusing( &VendorWarning::new( "vendor_would_revert_redirect", format!( - "{} is hosted-redirected; a non-dry-run vendor will \ - revert the hosted redirect edits first, then vendor \ + "{} is wired to hosted patches; a non-dry-run vendor will \ + revert the hosted wiring edits first, then vendor \ (mode takeover)", normalize_purl(candidate) ), @@ -2502,7 +2501,7 @@ pub(crate) async fn vendor_records_reusing( PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( "redirect_revert_failed", format!( - "cannot vendor over the live hosted redirect: \ + "cannot vendor over the live hosted wiring: \ {detail}" ), ), @@ -2510,7 +2509,7 @@ pub(crate) async fn vendor_records_reusing( report_vendor_failure( common, candidate, - &format!("cannot revert the hosted redirect: {detail}"), + &format!("cannot revert the hosted wiring: {detail}"), ); continue; } @@ -2567,7 +2566,7 @@ pub(crate) async fn vendor_records_reusing( // closed for this purl. has_errors = true; let detail = format!( - "reverted the hosted redirect but could not update \ + "reverted the hosted wiring but could not update \ .socket/vendor/redirect-state.json: {e}" ); report_vendor_failure(common, candidate, &detail); @@ -2596,8 +2595,8 @@ pub(crate) async fn vendor_records_reusing( &VendorWarning::new( "vendor_takeover_reverted_redirect", format!( - "{} was hosted-redirected; reverted {reverted_what} \ - and dropped the redirect-ledger record before \ + "{} was wired to hosted patches; reverted {reverted_what} \ + and dropped the hosted-ledger record before \ vendoring (mode takeover)", normalize_purl(candidate) ), @@ -2611,7 +2610,7 @@ pub(crate) async fn vendor_records_reusing( PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( "redirect_revert_failed", format!( - "cannot vendor over the live hosted redirect: \ + "cannot vendor over the live hosted wiring: \ {detail}" ), ), @@ -2619,7 +2618,7 @@ pub(crate) async fn vendor_records_reusing( report_vendor_failure( common, candidate, - &format!("cannot revert the hosted redirect: {detail}"), + &format!("cannot revert the hosted wiring: {detail}"), ); continue; } @@ -3000,45 +2999,54 @@ pub(crate) async fn vendor_records_reusing( // package.json `pnpm.overrides` mirror is ignored), so pnpm-wired // runs must name that file among the committables: a checkout // that loses it silently unvendors on the next install. - if wired_flavors.contains("pnpm") { - println!( - "Commit .socket/vendor/, package.json, pnpm-lock.yaml, and \ - pnpm-workspace.yaml to make the patches portable (pnpm >=11 reads \ - the vendored override only from pnpm-workspace.yaml)." - ); + let commit = if wired_flavors.contains("pnpm") { + ".socket/vendor/, package.json, pnpm-lock.yaml, and pnpm-workspace.yaml to \ + make the patches portable (pnpm >=11 reads the vendored override only from \ + pnpm-workspace.yaml)" } else if wired_flavors.contains("vlt") { - println!("{VLT_COMMIT_HINT}"); + VLT_COMMIT_HINT } else { - println!( - "Commit .socket/vendor/ and the updated lockfiles to make the patches \ - portable." - ); - } - if wired_flavors.contains("bun") && common.cwd.join("bun.lockb").exists() { - println!("For binary Bun workspaces, also commit the workspace members' .socket/vendor/ tarballs recorded in the vendor ledger."); - } + ".socket/vendor/ and the updated lockfiles to make the patches portable" + }; let mut installs: Vec<&str> = wired_flavors .iter() .filter_map(|f| flavor_install_command(f)) .collect(); installs.sort_unstable(); - for cmd in installs { - println!( - "Run `{cmd}` to update the installed tree — vendoring rewires the \ - lockfile only, so the current node_modules keeps the unpatched bytes \ - until reinstalled." + installs.dedup(); + let reinstall = if installs.is_empty() { + "Reinstall from the updated lockfile so the installed packages pick up the \ + vendored artifacts" + .to_string() + } else { + let cmds: Vec = installs.iter().map(|c| format!("`{c}`")).collect(); + format!( + "Run {} to update the installed tree (vendoring rewires the lockfile \ + only; the current install keeps the unpatched bytes until reinstalled)", + cmds.join(" and ") + ) + }; + let mut extra = Vec::new(); + if wired_flavors.contains("bun") && common.cwd.join("bun.lockb").exists() { + extra.push( + "For binary Bun workspaces, also commit the workspace members' \ + .socket/vendor/ tarballs recorded in the vendor ledger." + .to_string(), ); } + for line in crate::ui::next_steps(commit, &reinstall, &extra) { + println!("{line}"); + } } } has_errors } -/// The committable-files hint of a vlt-wired run. -const VLT_COMMIT_HINT: &str = "Commit package.json (and workspace package.json files), \ +/// What a vlt-wired run commits (the "Commit …" next step). +const VLT_COMMIT_HINT: &str = "package.json (and workspace package.json files), \ vlt-lock.json and .socket/vendor/ (the .gitignore there re-includes the payload and keeps \ - vlt's node_modules links out of git); CI: `vlt ci`."; + vlt's node_modules links out of git); CI: `vlt ci`"; /// The install command that re-materializes the project tree from the wired /// lockfile, per npm-family flavor. Vendoring edits ONLY the lockfile/config @@ -3551,9 +3559,9 @@ pub(crate) async fn run_vendor_gc( /// Human-mode stderr line for a pass-level GC problem (the GC has no /// envelope of its own; JSON consumers see it as `scan --prune --json`'s /// `gc.skipped` / `gc.warnings`). Muted under `--json` and `--silent`. -fn gc_note(common: &GlobalArgs, code: &str, detail: &str) { +fn gc_note(common: &GlobalArgs, _code: &str, detail: &str) { if !common.json && !common.silent { - eprintln!("Warning ({code}): {detail}"); + eprintln!("Warning: {detail}"); } } @@ -5457,7 +5465,7 @@ mod ui_format_tests { ); assert_eq!( format_advisory("vendor_lock_entry_drifted", "drifted", false), - Some("Warning (vendor_lock_entry_drifted): drifted".to_string()) + Some("Warning: drifted".to_string()) ); } diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 88f93bb4..5fd4c00a 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -973,7 +973,7 @@ async fn generate_vex_from_manifest_path_inner( // overwritten") is written for the hosted `scan` writer, and // `vex` only reads the ledger. let message = format!( - "The redirect ledger {} is malformed ({}); cannot attest redirected patches. \ + "The hosted ledger {} is malformed ({}); cannot attest hosted patches. \ Repair its JSON or restore it from version control, then re-run.", corrupt.path.display(), corrupt.detail @@ -1247,7 +1247,7 @@ fn omission_phrase(reason: &str) -> &'static str { package any more" } REDIRECT_UNWIRED => { - "the redirect ledger records it, but no lockfile wires its hosted patch to this \ + "the hosted ledger records it, but no lockfile wires its hosted patch to this \ package any more" } WIRING_CONFLICT => { diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 5810b943..4adec636 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -364,7 +364,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S &cand.key, &[ (&entry.uuid, WiringMode::Vendored, "vendor ledger"), - (&hosted.uuid, WiringMode::Hosted, "redirect ledger"), + (&hosted.uuid, WiringMode::Hosted, "hosted ledger"), ], )); gated.push(failed(&cand.key, VENDOR_UNWIRED)); @@ -400,7 +400,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S notes.extend(dead_claim_notes( &discovery, &cand.key, - &[(&cand.uuid, WiringMode::Hosted, "redirect ledger")], + &[(&cand.uuid, WiringMode::Hosted, "hosted ledger")], )); gated.push(failed(&cand.key, REDIRECT_UNWIRED)); continue; @@ -1497,7 +1497,7 @@ mod tests { ); assert!( dead.notes.iter().any(|n| n.detail.contains("Cargo.lock") - && n.detail.contains("redirect ledger") + && n.detail.contains("hosted ledger") && n.detail.contains(U2)), "{:?}", dead.notes diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index b5f8b0f3..8afa6a4f 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -744,7 +744,7 @@ fn finish_root( _ => record_warnings.push(serde_json::json!({ "code": "record_fetch_failed", "detail": format!( - "{purl} redirected, but its patch record could not be fetched; \ + "{purl} was switched to hosted, but its patch record could not be fetched; \ it will be missing from VEX until `socket-patch scan --mode \ hosted` is re-run" ), diff --git a/crates/socket-patch-cli/src/hosted_memory/redirect.rs b/crates/socket-patch-cli/src/hosted_memory/redirect.rs index 7848425c..fe90fd7a 100644 --- a/crates/socket-patch-cli/src/hosted_memory/redirect.rs +++ b/crates/socket-patch-cli/src/hosted_memory/redirect.rs @@ -743,7 +743,7 @@ pub(crate) fn rewrite( "detail": "pnpm-lock.yaml was edited outside `rush update`; if \ preventManualShrinkwrapChanges is enabled, `rush install` fails until \ - `rush update` refreshes repo-state.json (the redirect survives `rush \ + `rush update` refreshes repo-state.json (the hosted wiring survives `rush \ update`)", })); } diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 51f400d3..223e8043 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -143,6 +143,86 @@ impl Commands { } } +/// Global options every subcommand's short help (`-h`) still lists; the +/// rest move to `--help` only. +const SHORT_HELP_GLOBALS: &[&str] = &["json", "dry_run", "verbose"]; + +/// Per-subcommand arguments shown in `-h` on top of its own (non-global) +/// ones: the commands that prompt keep `--yes`. +fn short_help_extra_globals(sub: &str) -> &'static [&'static str] { + match sub { + "get" | "rollback" | "remove" | "self-update" => &["yes"], + _ => &[], + } +} + +/// Command-specific arguments left out of a subcommand's `-h` (still in +/// `--help`), so each short help stays at about eight options. +fn short_help_hidden_own(sub: &str) -> &'static [&'static str] { + match sub { + "scan" => &[ + "batch_size", + "sync", + "all_releases", + "vex_product", + "vex_no_verify", + "vex_doc_id", + "vex_compact", + ], + "get" => &["id", "cve", "ghsa", "package", "save_only", "one_off", "all_releases"], + "vex" => &["doc_id", "compact"], + "apply" => &["vex_product", "vex_no_verify", "vex_doc_id", "vex_compact"], + "vendor" => &["vex_product", "vex_no_verify", "vex_doc_id", "vex_compact"], + _ => &[], + } +} + +/// The `socket-patch` command as it parses and renders: [`Cli`]'s derived +/// command with the short help (`-h`) trimmed to the common options. Every +/// argument stays in `--help` and parses exactly as before. +pub fn cli_command() -> clap::Command { + use clap::CommandFactory; + let mut cmd = Cli::command(); + let subs: Vec = cmd + .get_subcommands() + .map(|s| s.get_name().to_string()) + .collect(); + for name in subs { + cmd = cmd.mut_subcommand(&name, |mut sub| { + let hidden_own = short_help_hidden_own(&name); + let extra = short_help_extra_globals(&name); + let ids: Vec<(String, bool)> = sub + .get_arguments() + .map(|a| { + ( + a.get_id().to_string(), + a.get_help_heading() == Some(args::GLOBAL_OPTIONS), + ) + }) + .collect(); + for (id, global) in ids { + let keep = if global { + SHORT_HELP_GLOBALS.contains(&id.as_str()) || extra.contains(&id.as_str()) + } else { + !hidden_own.contains(&id.as_str()) + }; + if !keep { + sub = sub.mut_arg(&id, |a| a.hide_short_help(true)); + } + } + sub + }); + } + cmd +} + +/// Parse `argv` against [`cli_command`]. +pub fn try_parse_cli(argv: &[String]) -> Result { + use clap::FromArgMatches; + let mut matches = cli_command().try_get_matches_from(argv)?; + Cli::from_arg_matches_mut(&mut matches).map_err(|e| e.format(&mut cli_command())) +} + /// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). /// /// Used by [`parse_argv_with_shortcuts`] to detect the convenience form @@ -168,7 +248,7 @@ pub(crate) fn looks_like_uuid(s: &str) -> bool { /// /// Pulled out of `main.rs` so the fallback paths are unit-testable. pub fn parse_argv_with_shortcuts(argv: Vec) -> Result { - match Cli::try_parse_from(&argv) { + match try_parse_cli(&argv) { Ok(cli) => Ok(cli), Err(err) => { // Root `--update` never parses Ok on its own (the subcommand @@ -215,7 +295,7 @@ pub fn parse_argv_with_shortcuts(argv: Vec) -> Result new_args.push(version.to_string()); } new_args.extend_from_slice(&argv[pos + 1..]); - return match Cli::try_parse_from(&new_args) { + return match try_parse_cli(&new_args) { Ok(cli) => Ok(cli), Err(rewrite_err) if pos == 1 || !rewrite_err.use_stderr() => Err(rewrite_err), Err(_) => Err(err), @@ -224,7 +304,7 @@ pub fn parse_argv_with_shortcuts(argv: Vec) -> Result if argv.len() >= 2 && looks_like_uuid(&argv[1]) { let mut new_args = vec![argv[0].clone(), "get".into()]; new_args.extend_from_slice(&argv[1..]); - match Cli::try_parse_from(&new_args) { + match try_parse_cli(&new_args) { Ok(cli) => Ok(cli), // clap models `--help`/`--version` as `Err`, but they are // display requests, not parse failures. For those the diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index 0cffadf0..b66d9c0d 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -4,6 +4,7 @@ //! - [`confirm`], [`select_one`]: prompts. //! - [`print_json`]: the one `--json` document writer. //! - [`plural`], [`truncate`]: text shaping. +//! - [`next_steps`]: the one "Next steps:" block (hosted and vendored). //! - [`color_enabled`], [`paint`], [`severity`], [`pad`]: color policy and //! ANSI-aware column alignment. //! - [`init`] / [`quiet`]: the process-wide `--silent`/`--json` switch, @@ -23,7 +24,15 @@ use crate::args::GlobalArgs; pub(crate) use prompt::confirm; pub use prompt::{select_one, SelectError}; pub(crate) use status::StatusLine; -pub(crate) use text::{plural, truncate}; +pub(crate) use text::{next_steps, plural, truncate}; + +/// The one line every declined prompt prints (get, rollback, remove, +/// `--update`). +pub(crate) const CANCELLED: &str = "Cancelled; no changes made."; + +/// The one paid-plan upsell line (scan and get). +pub(crate) const PAID_UPGRADE: &str = + "Upgrade to a paid Socket plan to access all patches: https://socket.dev/pricing"; /// Call once after argument parsing. Core's informational advisories (and /// the prompts' non-interactive notes) go quiet under `--silent`/`--json`; diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index 78074958..11a05bb1 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -13,6 +13,25 @@ const ELLIPSIS: &str = "..."; /// over a hard mid-word cut. const WORD_BOUNDARY_WINDOW: usize = 15; +/// The "Next steps:" block printed after a hosted or vendored run that +/// changed the project: commit `commit`, then `reinstall` and verify with +/// `vex`, then any `extra` steps. Shared so the two modes read alike. +pub(crate) fn next_steps(commit: &str, reinstall: &str, extra: &[String]) -> Vec { + let mut steps = vec![ + format!("Commit {commit}."), + format!("{reinstall}, then run `socket-patch vex` to verify the installed patches."), + ]; + steps.extend(extra.iter().cloned()); + let mut out = vec!["Next steps:".to_string()]; + out.extend( + steps + .iter() + .enumerate() + .map(|(i, step)| format!(" {}. {step}", i + 1)), + ); + out +} + /// Fit `s` on one line of at most `max` characters. /// /// - Every whitespace run (including embedded newlines and tabs from API @@ -137,4 +156,17 @@ mod tests { assert!(truncate("some words here ok", max).chars().count() <= max); } } + + #[test] + fn next_steps_numbers_commit_reinstall_then_extras() { + assert_eq!( + next_steps("a and b", "Run `npm ci`", &["vlt: x".to_string()]), + vec![ + "Next steps:", + " 1. Commit a and b.", + " 2. Run `npm ci`, then run `socket-patch vex` to verify the installed patches.", + " 3. vlt: x", + ] + ); + } } diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 2c01ebb8..431c8da9 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -518,7 +518,7 @@ async fn apply_hash_mismatch_default_warns_and_applies_strict_fails() { let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!(out.status.code().unwrap_or(-1), 0, "stderr={stderr}"); assert!( - stderr.contains("content_mismatch_overwritten"), + stderr.contains("did not match the patch's expected original content"), "stderr warning present: {stderr}" ); diff --git a/crates/socket-patch-cli/tests/cli_gem_variant_mismatch_policy.rs b/crates/socket-patch-cli/tests/cli_gem_variant_mismatch_policy.rs index 94a4919d..39f9a92f 100644 --- a/crates/socket-patch-cli/tests/cli_gem_variant_mismatch_policy.rs +++ b/crates/socket-patch-cli/tests/cli_gem_variant_mismatch_policy.rs @@ -183,7 +183,7 @@ fn singleton_mismatch_default_warns_and_applies() { "the file must carry exactly the verified patched bytes" ); assert!( - stderr.contains("content_mismatch_overwritten"), + stderr.contains("did not match the patch's expected original content"), "the overwrite must be surfaced as the npm-family mismatch warning; stderr={stderr}" ); assert!( @@ -246,7 +246,7 @@ fn singleton_mismatch_strict_refuses() { generic no-matching-variant miss; stderr={stderr}" ); assert!( - !stderr.contains("content_mismatch_overwritten"), + !stderr.contains("did not match the patch's expected original content"), "--strict must not claim an overwrite happened; stderr={stderr}" ); } @@ -320,7 +320,7 @@ fn qualified_singleton_wrong_platform_fails_closed() { "the failure must stay the no-matching-variant error; stderr={stderr}" ); assert!( - !stderr.contains("content_mismatch_overwritten"), + !stderr.contains("did not match the patch's expected original content"), "a wrong-distribution record must never be surfaced as a \ local-modification overwrite; stderr={stderr}" ); @@ -395,7 +395,7 @@ fn multi_variant_mismatched_sibling_is_skipped_not_overwritten() { "the sibling distribution's bytes must never be written" ); assert!( - !stderr.contains("content_mismatch_overwritten"), + !stderr.contains("did not match the patch's expected original content"), "a sibling-variant mismatch is a skip, not an overwrite warning; stderr={stderr}" ); } diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 5e5dd991..d2f6f29e 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -561,7 +561,7 @@ fn empty_manifest_plain_says_no_patches_via_binary() { let stdout = String::from_utf8_lossy(&out.stdout); assert_eq!(out.status.code(), Some(0), "empty list must exit 0"); assert!( - stdout.contains("No patches found in manifest."), + stdout.contains("No patches in this project. Run `socket-patch scan`."), "empty manifest must report no patches, got: {stdout}" ); // Guard against a regression that prints a record anyway. @@ -948,8 +948,8 @@ fn silent_keeps_missing_manifest_error_on_stderr_via_binary() { let out = run_list_binary_scrubbed(tmp.path(), &["--silent"]); assert_eq!(out.status.code(), Some(1), "missing manifest must exit 1"); assert!( - String::from_utf8_lossy(&out.stderr).contains("Manifest not found"), - "error output must NOT be muted by --silent" + String::from_utf8_lossy(&out.stderr).contains("No patches in this project."), + "the exit-1 reason must NOT be muted by --silent" ); } @@ -1203,12 +1203,16 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina String::from_utf8_lossy(&out.stderr) ); - // Human mode: the warning still reaches stderr ahead of the error. + // Human mode: the warning still reaches stderr; the empty-project line + // is on stdout (v5.0). let out = run_list_binary(tmp.path(), &[]); let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!(out.status.code(), Some(1)); assert!(stderr.contains("Warning: "), "stderr={stderr}"); - assert!(stderr.contains("Error: Manifest not found at "), "stderr={stderr}"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("No patches in this project."), + "stderr={stderr}" + ); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_remove_silent.rs b/crates/socket-patch-cli/tests/cli_remove_silent.rs index fb8acebe..bab3ee49 100644 --- a/crates/socket-patch-cli/tests/cli_remove_silent.rs +++ b/crates/socket-patch-cli/tests/cli_remove_silent.rs @@ -357,7 +357,7 @@ fn remove_silent_suppresses_detached_revert_output() { } /// Backend revert warnings are chatter, not errors: `vendor --revert` -/// gates the identical "Warning (code): detail" stderr line on +/// gates the identical "Warning: detail" stderr line on /// `!silent && !json` (`record_warning`), but remove's vendor block /// printed it under `--silent` (gated on `!json` alone). #[test] @@ -384,7 +384,7 @@ fn remove_silent_suppresses_vendor_revert_warnings() { "the drift-kept error must print even under --silent; got {stderr:?}" ); assert!( - !stderr.contains("Warning ("), + !stderr.contains("Warning:"), "--silent must suppress backend revert warnings; got {stderr:?}" ); @@ -395,7 +395,7 @@ fn remove_silent_suppresses_vendor_revert_warnings() { let (loud_code, _loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); assert_eq!(loud_code, 1); assert!( - loud_stderr.contains("Warning (vendor_lock_entry_drifted)"), + loud_stderr.contains("Warning: "), "non-silent run must print the backend warning; got {loud_stderr:?}" ); // The drift-keep must leave BOTH stores intact: ledger entry and @@ -527,7 +527,7 @@ fn remove_silent_suppresses_detached_revert_warnings() { "an all-kept detached remove is a partial failure; stderr={stderr:?}" ); assert!( - !stderr.contains("Warning ("), + !stderr.contains("Warning:"), "--silent must suppress detached revert warnings; got {stderr:?}" ); assert!( @@ -549,7 +549,7 @@ fn remove_silent_suppresses_detached_revert_warnings() { let (loud_code, _loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); assert_eq!(loud_code, 1); assert!( - loud_stderr.contains("Warning (vendor_lock_entry_drifted)"), + loud_stderr.contains("Warning: "), "non-silent detached run must print the backend warning; got {loud_stderr:?}" ); assert!( diff --git a/crates/socket-patch-cli/tests/cli_sigpipe.rs b/crates/socket-patch-cli/tests/cli_sigpipe.rs index caf99ee2..cba356cd 100644 --- a/crates/socket-patch-cli/tests/cli_sigpipe.rs +++ b/crates/socket-patch-cli/tests/cli_sigpipe.rs @@ -23,7 +23,7 @@ const BINARY: &str = env!("CARGO_BIN_EXE_socket-patch"); const SIGPIPE: i32 = 13; /// `list` against an empty manifest is the cheapest command that writes -/// to stdout: offline, lock-free — prints "No patches found in manifest." +/// to stdout: offline, lock-free — prints "No patches in this project. Run `socket-patch scan`." /// and exits 0 when stdout is healthy. #[test] fn closed_stdout_pipe_is_not_a_panic() { diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs index 928010ff..0dabe3c5 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs @@ -489,7 +489,7 @@ fn scan_hosted_human(cwd: &Path, api_url: &str, dry_run: bool) -> (i32, String, fn summary_line(stdout: &str) -> &str { stdout .lines() - .find(|l| l.starts_with("Would redirect ") || l.starts_with("Redirected ")) + .find(|l| l.starts_with("Would switch ") || l.starts_with("Switched ")) .unwrap_or_default() } @@ -526,7 +526,7 @@ async fn human_takeover_prints_migration_lines_and_matching_file_counts() { ); assert_eq!( summary_line(&dry_out), - "Would redirect 1 package and rewrite 3 files (--dry-run: nothing was changed).", + "Would switch 1 package to hosted patches and rewrite 3 files (--dry-run: nothing was changed).", "stdout=\n{dry_out}" ); @@ -542,14 +542,14 @@ async fn human_takeover_prints_migration_lines_and_matching_file_counts() { ); assert_eq!( summary_line(&wet_out), - "Redirected 1 package; rewrote 3 files.", + "Switched 1 package to hosted patches; rewrote 3 files.", "the wet count must equal the dry-run preview; stdout=\n{wet_out}" ); assert!( wet_out.contains( - "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ + " 1. Commit .socket/vendor/ (the hosted ledger, plus the removed vendored ledger \ entries and artifacts), package.json, pnpm-lock.yaml, and pnpm-workspace.yaml \ - to keep the redirect." + to keep the hosted patches." ), "stdout=\n{wet_out}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_apply.rs b/crates/socket-patch-cli/tests/covgap_commands_apply.rs index edeb8f0a..28d0caa3 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_apply.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_apply.rs @@ -479,7 +479,7 @@ async fn online_mismatch_prefetch_prints_download_line_in_human_mode() { "the human progress line must print before the prefetch; stderr={stderr}" ); assert!( - stderr.contains("content_mismatch_overwritten"), + stderr.contains("did not match the patch's expected original content"), "the overwrite must be surfaced as the mismatch warning; stderr={stderr}" ); assert_eq!( @@ -988,7 +988,7 @@ mod gem_fallback_home_human { "a mismatched fallback-home copy must not fail the run; stderr={stderr}" ); assert!( - stderr.contains("Warning (gem_fallback_home_skipped):"), + stderr.contains("Warning: ") && stderr.contains("was not patched"), "the best-effort skip must print its human warning; stderr={stderr}" ); assert!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index ba006e94..97f3b634 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -487,7 +487,7 @@ async fn get_conflicting_type_flags_rejected_before_any_network() { args.cve = true; let code = run(args).await; - assert_eq!(code, 1, "conflicting --id/--cve must exit 1"); + assert_eq!(code, 2, "conflicting --id/--cve is a usage error (exit 2)"); assert_no_manifest(tmp.path()); assert!( received_paths(&server).await.is_empty(), @@ -1764,7 +1764,7 @@ async fn human_uuid_paid_via_proxy_prints_upgrade_message() { "paid_required is exit 0; stdout={stdout}\nstderr={stderr}" ); assert!( - stdout.contains("requires a paid subscription"), + stdout.contains("requires a paid Socket plan"), "stdout={stdout}" ); assert!( @@ -1905,7 +1905,7 @@ async fn human_paid_only_search_prints_subscription_message() { let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[cve, "--save-only"]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("All available patches require a paid subscription."), + stdout.contains("All available patches require a paid Socket plan."), "stdout={stdout}" ); assert!( @@ -2813,7 +2813,7 @@ async fn nested_apply_block_starts_stdout_without_a_blank_line() { } /// A forced `--id` / `--cve` / `--ghsa` identifier is shape-checked before -/// any network call: a readable error, exit 1, zero requests. +/// any network call: a readable error, exit 2 (usage), zero requests. #[tokio::test] async fn forced_identifier_type_is_validated_locally() { let server = MockServer::start().await; @@ -2824,13 +2824,13 @@ async fn forced_identifier_type_is_validated_locally() { ("--ghsa", "is not a valid GHSA ID"), ] { let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &["lodash", flag]); - assert_eq!(code, 1, "{flag}: stdout={stdout}\nstderr={stderr}"); + assert_eq!(code, 2, "{flag}: stdout={stdout}\nstderr={stderr}"); assert!( stderr.contains(&format!("Error: \"lodash\" {what} (expected ")), "{flag}: stderr={stderr}" ); let (code, stdout, _) = run_get_bin(tmp.path(), &server.uri(), &["lodash", flag, "--json"]); - assert_eq!(code, 1); + assert_eq!(code, 2); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "{v}"); assert!(v["error"].as_str().unwrap().contains(what), "{v}"); @@ -2878,8 +2878,8 @@ async fn proxy_403_on_uuid_is_paid_required() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( stdout.contains(&format!( - "This patch requires a paid subscription to download.\n Patch: {UUID}\n \ - Upgrade at: https://socket.dev/pricing" + "This patch requires a paid Socket plan.\n Patch: {UUID}\n\ + Upgrade to a paid Socket plan to access all patches: https://socket.dev/pricing" )), "stdout={stdout}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_remove.rs b/crates/socket-patch-cli/tests/covgap_commands_remove.rs index fa000a59..bf86e1dd 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_remove.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_remove.rs @@ -745,7 +745,7 @@ fn remove_hosted_preserve_state_notes_no_preservable_state() { ); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("hosted redirects have no preservable local state"), + stderr.contains("hosted patches have no preservable local state"), "the preserve-state hosted note must reach stderr; got:\n{stderr}" ); assert!( @@ -794,7 +794,7 @@ fn remove_corrupt_hosted_ledger_warns_and_continues_human() { "a corrupt hosted ledger must not block the removal; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("hosted redirects were not examined"), + stderr.contains("hosted patches were not examined"), "the warning must reach stderr; got:\n{stderr}" ); assert!( @@ -887,7 +887,7 @@ fn remove_hosted_only_human_lists_redirects_and_unwinds() { let (code, stdout, stderr) = run_remove(tmp.path(), &[NPM_PURL, "--yes", "--offline"], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("The following hosted redirect will be unwound and removed:"), + stderr.contains("The following hosted patch will be unwound and removed:"), "the hosted-only listing must reach stderr; got:\n{stderr}" ); assert!( @@ -937,7 +937,7 @@ fn remove_hosted_revert_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("could not unwind hosted redirect for pkg:npm/left-pad@1.3.0") + msg.contains("could not unwind the hosted patch for pkg:npm/left-pad@1.3.0") && msg.contains("The manifest was not modified."), "the error must name the purl and promise the manifest is intact; got: {msg}" ); @@ -968,7 +968,7 @@ fn remove_hosted_only_revert_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("could not unwind hosted redirect for pkg:npm/left-pad@1.3.0"), + msg.contains("could not unwind the hosted patch for pkg:npm/left-pad@1.3.0"), "the error must name the purl; got: {msg}" ); assert_eq!(read_bytes(&ledger_path), ledger_before, "ledger untouched"); @@ -1799,7 +1799,7 @@ mod pty { } /// Declining the hosted-only confirm prompt must cancel cleanly (exit - /// 0, "Removal cancelled.") with the lock and ledger byte-identical. + /// 0, "Cancelled; no changes made.") with the lock and ledger byte-identical. #[test] fn remove_hosted_only_interactive_n_cancels() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1821,7 +1821,7 @@ mod pty { ); // Vacuity guard: the hosted-only confirm prompt MUST have run. assert!( - output.contains("Remove 1 hosted redirect and unwind its lockfile wiring?"), + output.contains("Remove 1 hosted patch and unwind its lockfile wiring?"), "the hosted-only confirm prompt must have shown; got: {output}" ); assert!( @@ -1829,7 +1829,7 @@ mod pty { "must NOT have taken the non-interactive branch in a PTY; got: {output}" ); assert!( - output.contains("Removal cancelled"), + output.contains("Cancelled; no changes made."), "'n' must report cancellation; got: {output}" ); // Declined: nothing moved. diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs index 357c8e75..afbbd7f5 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs @@ -1879,7 +1879,7 @@ async fn repair_human_warns_wiring_unknown() { let (code, stdout, stderr) = run_cli_human(tmp.path(), &mock.uri(), &["repair"]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); assert!( - stderr.contains("Warning (vendor_wiring_unknown):"), + stderr.contains("Warning: "), "the run-level advisory is printed to stderr: {stderr}" ); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 082b15c4..f67a3290 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -1497,7 +1497,7 @@ fn write_two_record_fixture(root: &Path) { } /// Human wet run over a hosted-only (manifest-less) project: the wet -/// "Unwound hosted redirect for {purl}" line and the reinstall note — with +/// "Unwound the hosted patch for {purl}" line and the reinstall note — with /// the wiring actually unwound, the emptied ledger deleted and no /// `.socket/` residue. The unscoped "No patches found in manifest" line is /// reserved for a run with no work in ANY leg: a project whose patches are @@ -1520,7 +1520,7 @@ fn hosted_human_wet_announces_and_unwinds() { stdout=\n{stdout}" ); assert!( - stdout.contains(&format!("Unwound hosted redirect for {LP_PURL}")), + stdout.contains(&format!("Unwound the hosted patch for {LP_PURL}")), "the wet unwind line must print; stdout=\n{stdout}" ); assert!( @@ -1543,7 +1543,7 @@ fn hosted_human_wet_announces_and_unwinds() { ); } -/// Human dry-run twin: "Would unwind hosted redirect for {purl}", nothing +/// Human dry-run twin: "Would unwind the hosted patch for {purl}", nothing /// mutated. #[test] fn hosted_human_dry_run_previews() { @@ -1554,7 +1554,7 @@ fn hosted_human_dry_run_previews() { let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--dry-run"]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains(&format!("Would unwind hosted redirect for {LP_PURL}")), + stdout.contains(&format!("Would unwind the hosted patch for {LP_PURL}")), "the dry-run unwind preview must print; stdout=\n{stdout}" ); assert_eq!( @@ -1584,7 +1584,7 @@ fn scoped_unsupported_ecosystem_prints_human_notice() { "a scoped unsupported hosted purl fails closed; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains(&format!("Cannot unwind hosted redirect for {GEM_PURL}")) + stderr.contains(&format!("Cannot unwind the hosted patch for {GEM_PURL}")) && stderr.contains("no per-purl revert exists"), "the human guidance must print on stderr; stderr=\n{stderr}" ); @@ -1711,7 +1711,7 @@ fn replay_refusal_reports_group_failures_in_both_modes() { let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes"]); assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("Cannot unwind hosted redirect edits (yarn)"), + stderr.contains("Cannot unwind hosted wiring edits (yarn)"), "the human refusal line must print on stderr; stderr=\n{stderr}" ); } @@ -2001,7 +2001,7 @@ fn bun_deferred_purl_unwinds_via_replay() { "the bun-deferred unwind succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains(&format!("Unwound hosted redirect for {LP_PURL}")), + stdout.contains(&format!("Unwound the hosted patch for {LP_PURL}")), "the deferred purl's wet unwind line must print; stdout=\n{stdout}" ); assert_eq!( @@ -2245,7 +2245,7 @@ mod interactive { } /// Declining the rollback confirm prompt cancels cleanly: the composed - /// manifest clause renders with the `[Y/n]` hint, "Rollback cancelled." + /// manifest clause renders with the `[Y/n]` hint, "Cancelled; no changes made." /// prints, the run exits 0, and nothing is mutated. #[test] fn rollback_interactive_decline_cancels() { @@ -2280,7 +2280,7 @@ mod interactive { "the PTY run must take the interactive branch; got: {output}" ); assert!( - output.contains("Rollback cancelled."), + output.contains("Cancelled; no changes made."), "the decline must be acknowledged; got: {output}" ); assert_eq!( @@ -2364,7 +2364,7 @@ fn per_purl_revert_failure_prints_human_stderr_line() { "a failed per-purl revert must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains(&format!("Failed to unwind hosted redirect for {LP_PURL}:")), + stderr.contains(&format!("Failed to unwind the hosted patch for {LP_PURL}:")), "the human failure line must print on stderr; stderr=\n{stderr}" ); assert_eq!( @@ -2376,7 +2376,7 @@ fn per_purl_revert_failure_prints_human_stderr_line() { /// Dry-run twin of `bun_deferred_purl_unwinds_via_replay`: the deferred /// preview routes through the replay's dropped-records probe and prints -/// "Would unwind hosted redirect for {purl}" — with bun.lock and the +/// "Would unwind the hosted patch for {purl}" — with bun.lock and the /// ledger byte-identical afterwards. #[test] fn bun_deferred_purl_dry_run_previews_via_replay() { @@ -2413,7 +2413,7 @@ fn bun_deferred_purl_dry_run_previews_via_replay() { "the bun-deferred dry run succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains(&format!("Would unwind hosted redirect for {LP_PURL}")), + stdout.contains(&format!("Would unwind the hosted patch for {LP_PURL}")), "the deferred purl's dry-run preview line must print; stdout=\n{stdout}" ); assert_eq!( @@ -3440,7 +3440,7 @@ fn vlt_hosted_rollback_dry_run_keeps_the_store_and_wet_human_run_heals() { ); assert!( stderr.contains( - "Warning (redirect_vlt_reinstall_required): restored registry pins for 1 packages; \ + "Warning: restored registry pins for 1 packages; \ removed the patched installed copies" ), "{stderr}" diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 41433f64..6471d0fc 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -517,7 +517,7 @@ async fn wet_takeover_refuses_unrevertable_vendored_flavor_fail_closed() { assert_eq!(code, 0, "a refused takeover still exits 0: {doc:#}"); let detail = warning_detail(&doc, "redirect_vendored_revert_failed"); assert!( - detail.contains("NOT redirected") && detail.contains("vendor --revert"), + detail.contains("NOT switched to hosted") && detail.contains("vendor --revert"), "the refusal must name the fail-closed outcome and the manual path: {detail}" ); assert!( @@ -555,18 +555,18 @@ async fn wet_takeover_refuses_unrevertable_vendored_flavor_fail_closed() { let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); assert_eq!(code, 0, "human refusal run exits 0; stderr=\n{stderr}"); assert!( - stdout.contains("Redirected 0 packages; rewrote 0 files."), + stdout.contains("Switched 0 packages to hosted patches; rewrote 0 files."), "anchor: the human redirect branch ran; stdout=\n{stdout}" ); assert!( stderr.contains(&format!( - "No patches could be redirected:\n {PURL}: its vendored state could not be \ + "No patches could be switched to hosted:\n {PURL}: its vendored state could not be \ reverted (see the warning)" )), "the human skipped line must name purl + reason; stderr=\n{stderr}" ); assert!( - stderr.contains("Warning (redirect_vendored_revert_failed): ") + stderr.contains("Warning: ") && stderr.contains("could not be reverted"), "the takeover pre-warning must reach human stderr; stderr=\n{stderr}" ); @@ -972,7 +972,7 @@ async fn successful_wet_hosted_run_leaves_only_vendor_under_socket() { let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains("Redirected 1 package; rewrote"), + stdout.contains("Switched 1 package to hosted patches; rewrote"), "the run must have redirected (and therefore locked); stdout=\n{stdout}" ); assert!( @@ -1107,15 +1107,15 @@ async fn hosted_human_paid_only_discovery_stops_with_the_paid_hint() { let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains("No downloadable patches (paid subscription required)."), + stdout.contains("No downloadable patches: every patch found requires a paid Socket plan."), "stdout=\n{stdout}" ); assert!( - stdout.contains("1 additional patch is available with a paid subscription"), + stdout.contains("1 additional patch is available with a paid Socket plan"), "the table's paid nudge still prints; stdout=\n{stdout}" ); assert!( - !stdout.contains("Redirected"), + !stdout.contains("Switched"), "the engine is never entered; stdout=\n{stdout}" ); assert_eq!( @@ -1963,8 +1963,7 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { assert_eq!(code, 0, "human overlap run exits 0; stderr=\n{stderr}"); assert!( stderr.contains( - "Warning (redirect_supersedes_vendored): Hosted redirect superseded the vendored \ - ledger for:" + "Warning: Hosted wiring superseded the vendored ledger for:" ) && stderr.contains(XPURL), "the supersedes warning must reach human stderr; stderr=\n{stderr}" ); @@ -2004,16 +2003,16 @@ async fn human_dry_run_prints_would_rewrite_pnpm_guidance_and_vex_skip() { ); assert!( stdout.contains( - "Would redirect 1 package and rewrite 2 files (--dry-run: nothing was changed)." + "Would switch 1 package to hosted patches and rewrite 2 files (--dry-run: nothing was changed)." ), "the dry-run summary must use the preview verb; stdout=\n{stdout}" ); assert!( - stderr.contains("Skipping VEX generation (--dry-run: nothing was redirected)."), + stderr.contains("Skipping VEX generation (--dry-run: nothing was rewritten)."), "the requested-but-skipped VEX must be announced; stderr=\n{stderr}" ); assert!( - stderr.contains("Warning (redirect_pnpm_trust_lockfile): ") + stderr.contains("Warning: ") && stderr.contains("trustLockfile"), "the pnpm trust guidance must reach human stderr; stderr=\n{stderr}" ); @@ -2058,7 +2057,7 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { "scan --vex exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 1 package; rewrote"), + stdout.contains("Switched 1 package to hosted patches; rewrote"), "anchor: the wet-run summary verb; stdout=\n{stdout}" ); assert!( @@ -2153,12 +2152,12 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() { "rush run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 1 package; rewrote"), + stdout.contains("Switched 1 package to hosted patches; rewrote"), "anchor: the rush lock must be rewritten; stdout=\n{stdout}" ); assert!( stderr.contains( - "Warning (redirect_rush_repo_state_stale): pnpm-lock.yaml was edited outside \ + "Warning: pnpm-lock.yaml was edited outside \ `rush update`" ), "the rush repo-state warning must reach human stderr; stderr=\n{stderr}" @@ -2272,7 +2271,7 @@ async fn human_reference_failure_prints_an_error_line_and_exits_1() { && line.ends_with("(nothing was changed; re-run to retry)"), "{line}" ); - assert!(!stdout.contains("Redirected"), "stdout=\n{stdout}"); + assert!(!stdout.contains("Switched"), "stdout=\n{stdout}"); assert_eq!( std::fs::read(tmp.path().join("package-lock.json")).unwrap(), lock_before @@ -2363,11 +2362,13 @@ async fn human_rerun_says_already_redirected_and_first_run_prints_next_steps() { assert_eq!(code, 0, "stderr=\n{stderr}"); assert_eq!( engine_stdout(&stdout), - "Redirected 1 package; rewrote 2 files.\n\ - Commit .socket/vendor/redirect-state.json, .npmrc, and package-lock.json to keep \ - the redirect.\n\ - Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages pick \ - up the patched artifacts, then run `socket-patch vex` to verify them.\n", + "Switched 1 package to hosted patches; rewrote 2 files.\n\ + Next steps:\n \ + 1. Commit .socket/vendor/redirect-state.json, .npmrc, and package-lock.json to keep \ + the hosted patches.\n \ + 2. Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages pick \ + up the patched artifacts, then run `socket-patch vex` to verify the installed \ + patches.\n", "stderr=\n{stderr}" ); @@ -2375,14 +2376,14 @@ async fn human_rerun_says_already_redirected_and_first_run_prints_next_steps() { assert_eq!(code, 0, "stderr=\n{stderr}"); assert_eq!( engine_stdout(&stdout), - "1 package is already redirected; nothing to rewrite.\n", + "1 package is already on hosted patches; nothing to rewrite.\n", "stderr=\n{stderr}" ); let (code, stdout, _) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); assert_eq!(code, 0); assert_eq!( engine_stdout(&stdout), - "1 package is already redirected; nothing to rewrite.\n" + "1 package is already on hosted patches; nothing to rewrite.\n" ); } @@ -2409,13 +2410,13 @@ async fn human_unconfirmed_purl_is_listed_with_a_headline() { let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); assert_eq!(code, 0, "a no-op redirect still exits 0; stderr=\n{stderr}"); assert!( - stdout.contains("Would redirect 0 packages and rewrite 0 files"), + stdout.contains("Would switch 0 packages to hosted patches and rewrite 0 files"), "stdout=\n{stdout}" ); assert!( stderr.contains(&format!( - "No patches could be redirected:\n {PURL}: no lockfile entry pinning it could \ - be redirected" + "No patches could be switched to hosted:\n {PURL}: no lockfile entry pinning it \ + could be rewritten" )), "stderr=\n{stderr}" ); @@ -2437,22 +2438,22 @@ async fn human_pnpm_rerun_prints_only_the_reminder_and_heal_restores_guidance() let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_pnpm_project(root); - const REMINDER: &str = "Warning (redirect_pnpm_trust_lockfile): pnpm-lock.yaml is already \ - redirected and pnpm-workspace.yaml already sets `trustLockfile: true`; keep both \ + const REMINDER: &str = "Warning: pnpm-lock.yaml already uses \ + hosted patches and pnpm-workspace.yaml already sets `trustLockfile: true`; keep both \ committed, and never rebuild the lockfile (`pnpm clean --lockfile`), which discards \ - the redirect\n"; + the hosted patches\n"; let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - engine_stdout(&stdout).starts_with("Redirected 1 package; rewrote 2 files.\n"), + engine_stdout(&stdout).starts_with("Switched 1 package to hosted patches; rewrote 2 files.\n"), "{stdout}" ); // Everything from the pnpm warning on (the lines above it are the // token-format notice and discovery progress). let pnpm_part = |stderr: &str| -> String { stderr - .find("Warning (redirect_pnpm_trust_lockfile): ") + .find("Warning: pnpm-lock.yaml") .map(|i| stderr[i..].to_string()) .unwrap_or_default() }; @@ -2466,7 +2467,7 @@ async fn human_pnpm_rerun_prints_only_the_reminder_and_heal_restores_guidance() assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert_eq!( engine_stdout(&stdout), - "1 package is already redirected; nothing to rewrite.\n" + "1 package is already on hosted patches; nothing to rewrite.\n" ); assert_eq!( pnpm_part(&stderr), diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 57da62ea..d28b8f45 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -444,7 +444,7 @@ fn scan_hosted_prune_human_warns_prune_is_ignored() { "hosted --prune stays accepted (never a usage error)" ); assert!( - stderr.contains("Warning (redirect_prune_ignored):"), + stderr.contains("Warning: --prune has no effect with --mode hosted"), "the ignored-prune warning must reach stderr; got {stderr:?}" ); assert!( @@ -547,7 +547,7 @@ async fn scan_paid_patch_without_access_nudges_and_downloads_nothing() { "the no-access summary counts FREE patches only; got {stdout:?}" ); assert!( - stdout.contains("+ 1 additional patch is available with a paid subscription"), + stdout.contains("+ 1 additional patch is available with a paid Socket plan"), "the paid nudge must print; got {stdout:?}" ); assert!( @@ -555,7 +555,7 @@ async fn scan_paid_patch_without_access_nudges_and_downloads_nothing() { "the pricing URL must print; got {stdout:?}" ); assert!( - stdout.contains("No downloadable patches (paid subscription required)."), + stdout.contains("No downloadable patches: every patch found requires a paid Socket plan."), "the gated-catalog terminal must print; got {stdout:?}" ); @@ -1089,7 +1089,7 @@ async fn scan_human_apply_over_live_hosted_wiring_warns_retained() { let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains("Warning (hosted_wiring_retained):"), + stderr.contains("Warning: agent-mode scan left the hosted wiring live"), "the retained-wiring warning must reach stderr; got {stderr:?}" ); assert!( @@ -1215,7 +1215,7 @@ async fn scan_human_pnp_refusal_prints_alongside_other_ecosystems() { "the gem must be discovered (non-empty path); got {stderr:?}" ); assert!( - stderr.contains("Warning (yarn_pnp_unsupported):"), + stderr.contains("Warning: ") && stderr.contains("Plug'n'Play"), "the PnP refusal must print on the non-empty path; got {stderr:?}" ); assert!( @@ -1500,7 +1500,7 @@ async fn scan_hosted_paths_run_once_per_project_directory() { let header = format!("== {} ==", std::path::Path::new(app).display()); assert!(stdout.contains(&header), "missing {header:?}: {stdout}"); } - assert_eq!(stdout.matches("Redirected 0 packages").count(), 2, "{stdout}"); + assert_eq!(stdout.matches("Switched 0 packages to hosted patches").count(), 2, "{stdout}"); let reqs = recorded(&mock).await; assert_eq!(batch_bodies(&reqs).len(), 2, "one discovery per directory"); } @@ -1540,7 +1540,7 @@ async fn scan_hosted_human_prints_table_updates_and_redirects() { "scan never prompts; got {stderr:?}" ); assert!( - stdout.contains("Redirected 0 packages"), + stdout.contains("Switched 0 packages to hosted patches"), "the engine must run; got {stdout:?}" ); let reqs = recorded(&mock).await; @@ -1991,19 +1991,19 @@ fn scan_invalid_bun_lockb_warns_instead_of_silent_success() { "mode={mode:?}: the binary format error must name its file: {detail}" ); assert!( - !stdout.contains("Warning ("), + !stdout.contains("Warning:"), "mode={mode:?}: the human warning line must not leak into the JSON stream: {stdout}" ); } - // Human path: the same diagnosis as a stderr `Warning (code): detail` + // Human path: the same diagnosis as a stderr `Warning: detail` // line, exit 0, and the generic "No packages found" hint still prints. let tmp = tempfile::tempdir().unwrap(); write_invalid_bun_lockb_project(tmp.path()); let (code, stdout, stderr) = run_scan(tmp.path(), &[]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains("Warning (bun_lockb_invalid): cannot inventory bun.lockb"), + stderr.contains("Warning: cannot inventory bun.lockb"), "the human path must name the layout and the code; got {stderr:?}" ); assert!( @@ -2069,7 +2069,7 @@ async fn scan_nonempty_keeps_the_bun_lockb_discovery_warning_in_every_mode() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains("Warning (bun_lockb_invalid):"), + stderr.contains("Warning: cannot inventory bun.lockb"), "the human hosted path must keep the warning; got {stderr:?}" ); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_update.rs b/crates/socket-patch-cli/tests/covgap_commands_update.rs index 82aa57f2..809bb3c7 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_update.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_update.rs @@ -296,7 +296,7 @@ mod pty { } /// Declining the reinstall confirm must cancel with exit 1 and - /// "Reinstall cancelled.", leaving the installed binary byte-identical. + /// "Cancelled; no changes made.", leaving the installed binary byte-identical. /// The pin-to-current + `--force` combination reaches the confirm with /// ZERO network before the prompt (pinned skips latest-resolution, /// --force skips the already-there return), and the dead endpoint @@ -334,7 +334,7 @@ mod pty { "update must NOT have taken the non-TTY auto-proceed branch in a PTY; got: {output}" ); assert!( - output.contains("Reinstall cancelled."), + output.contains("Cancelled; no changes made."), "'n' must report cancellation, naming the reinstall; got: {output}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index 0c6399bd..7363165a 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -447,7 +447,7 @@ fn corrupt_redirect_ledger_fails_takeover_capable_purl_closed() { assert!( failed["error"] .as_str() - .is_some_and(|d| d.contains("cannot vendor over a possibly-live hosted redirect")), + .is_some_and(|d| d.contains("cannot vendor over a possibly-live hosted wiring")), "{env:#}" ); assert_eq!( @@ -531,7 +531,7 @@ fn unrevertable_redirect_claim_fails_closed() { assert!( failed["error"] .as_str() - .is_some_and(|d| d.contains("cannot vendor over the live hosted redirect")), + .is_some_and(|d| d.contains("cannot vendor over the live hosted wiring")), "{env:#}" ); assert_eq!( @@ -1382,7 +1382,7 @@ fn human_classic_migration_risk_prints_stderr_warning() { "the revert itself is the calm no-op: {stdout}" ); assert!( - stderr.contains("Warning (yarn_classic_berry_migration_risk)"), + stderr.contains("Warning: yarn.lock is yarn-classic"), "the run-level advisory prints for humans: {stderr}" ); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index de577d38..b2a62977 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -264,7 +264,7 @@ fn corrupt_redirect_ledger_hard_errors_in_human_mode() { assert!(out.stdout.is_empty(), "no document on a hard error"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("redirect ledger") && stderr.contains("malformed"), + stderr.contains("hosted ledger") && stderr.contains("malformed"), "the CorruptRedirectState message must reach stderr. got: {stderr}" ); // Ordering: the ledger error fires before the missing-manifest check — diff --git a/crates/socket-patch-cli/tests/covgap_output.rs b/crates/socket-patch-cli/tests/covgap_output.rs index 4a25175f..b629bb8e 100644 --- a/crates/socket-patch-cli/tests/covgap_output.rs +++ b/crates/socket-patch-cli/tests/covgap_output.rs @@ -528,7 +528,7 @@ fn get_yes_answers_the_menu_with_its_default_without_showing_it() { fn get_interactive_dialoguer_quit_cancels_with_exit_zero() { // Cancelling the dialoguer menu → `interact_opt()` returns `Ok(None)` → // `select_one` maps it to `SelectError::Cancelled` (ui/prompt.rs) → - // get prints "Selection cancelled." and exits 0 without downloading + // get prints "Cancelled; no changes made." and exits 0 without downloading // anything (get.rs, the SelectError::Cancelled arm after select_one). // // The keystroke is `q`, not ESC: dialoguer 0.11's Select treats @@ -577,7 +577,7 @@ fn get_interactive_dialoguer_quit_cancels_with_exit_zero() { "get must NOT have taken the non-TTY auto-select branch in a PTY; got: {output}" ); assert!( - output.contains("Selection cancelled."), + output.contains("Cancelled; no changes made."), "Esc must surface the Cancelled message; got: {output}" ); // dialoguer shows the cursor again itself on a clean q/Esc cancel, so @@ -705,7 +705,7 @@ fn get_interactive_dialoguer_ctrl_c_with_sigint_ignored_cancels_cleanly() { "an ignored SIGINT must not kill the process; got: {output:?}" ); assert!( - output.contains("Selection cancelled."), + output.contains("Cancelled; no changes made."), "Ctrl-C with SIGINT ignored must cancel the menu; got: {output}" ); assert_eq!(code, 0, "{output}"); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index b3bbb586..23277740 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs @@ -326,8 +326,8 @@ async fn gem_hosted_redirect_over_stale_install_warns_loudly() { ); assert_eq!(code, 0, "human re-scan must succeed:\n{stderr}"); assert!( - stderr.contains("redirect_gem_stale_install"), - "human mode must carry the greppable code tag on stderr:\n{stderr}" + stderr.contains("Warning: ") && stderr.contains("was switched to its hosted patch, but a stale"), + "human mode must print the stale-install warning on stderr:\n{stderr}" ); assert!( stderr.contains(&gem_dir.display().to_string()), 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 0b408476..63f2a3bb 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -352,7 +352,7 @@ fn npm_layout_does_not_trigger_yarn_pnp_refusal() { // Belt-and-braces: the marker string must be absent from both // streams entirely. assert!( - !stdout.contains("yarn_pnp_unsupported") && !stderr.contains("yarn_pnp_unsupported"), + !stdout.contains("yarn_pnp_unsupported") && !stderr.contains("Plug'n'Play"), "npm layout should not mention yarn-pnp anywhere.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); // Far stronger than pinning a no-match `partialFailure`: with a @@ -943,8 +943,8 @@ fn scan_human_mode_on_pnp_project_prints_refusal_to_stderr() { "human scan stays exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert!( - stderr.contains("yarn_pnp_unsupported"), - "human scan must print the stable refusal code to stderr, got:\n{stderr}" + stderr.contains("Warning: this project uses yarn Plug'n'Play"), + "human scan must print the refusal to stderr, got:\n{stderr}" ); assert!( stderr.contains("Plug'n'Play") && stderr.contains("yarn patch"), diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index 86b754a2..b1e54018 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -77,7 +77,7 @@ fn get_one_off_and_save_only_together_errors() { ORG_SLUG, ], ); - assert_eq!(code, 1); + assert_eq!(code, 2, "a usage error (v5.0)"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "error"); let err = v["error"].as_str().expect("error message"); diff --git a/crates/socket-patch-cli/tests/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get_modes_e2e.rs index a50a6bc0..39c3da93 100644 --- a/crates/socket-patch-cli/tests/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get_modes_e2e.rs @@ -462,7 +462,7 @@ async fn get_ghsa_all_uninstalled_emits_not_installed_envelope() { /// any network contact. Human mode names the conflict on stderr; `--json` /// mode emits get's `{status:"error", error:}` envelope on stdout. #[tokio::test] -async fn save_only_with_mode_conflicts_exit_one() { +async fn save_only_with_mode_conflicts_exit_two() { // No mocks mounted: the zero-received-requests assertion below is the // "before any network contact" oracle. let server = MockServer::start().await; @@ -475,7 +475,7 @@ async fn save_only_with_mode_conflicts_exit_one() { &server.uri(), &[UUID1, "--mode", mode, "--save-only"], ); - assert_eq!(code, 1, "--save-only + --mode {mode} must exit 1"); + assert_eq!(code, 2, "--save-only + --mode {mode} must exit 2 (usage)"); assert!( stdout.is_empty(), "conflict error is stderr-only in human mode; stdout: {stdout:?}" @@ -485,15 +485,15 @@ async fn save_only_with_mode_conflicts_exit_one() { "stderr must name the conflicting flags; got:\n{stderr}" ); - // JSON path: ONE {status:"error"} envelope on stdout, exit 1. + // JSON path: ONE {status:"error"} envelope on stdout, exit 2. let (code, stdout, stderr) = run_get( tmp.path(), &server.uri(), &[UUID1, "--mode", mode, "--save-only", "--json"], ); assert_eq!( - code, 1, - "--save-only + --mode {mode} --json must exit 1; stderr:\n{stderr}" + code, 2, + "--save-only + --mode {mode} --json must exit 2; stderr:\n{stderr}" ); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "envelope={v}"); @@ -568,7 +568,7 @@ async fn get_hosted_silent_prints_nothing_to_stdout() { // Two files: the lock, plus the project `.npmrc` the npm 12 // `allow-remote=all` auto-config creates. assert!( - loud_stdout.contains("Redirected 1 package; rewrote 2 files."), + loud_stdout.contains("Switched 1 package to hosted patches; rewrote 2 files."), "non-silent hosted run must print the redirect summary; got {loud_stdout:?}" ); } @@ -899,7 +899,7 @@ async fn get_pnp_only_narrowing_message_names_the_layout() { stdout:\n{stdout}" ); assert!( - stderr.contains("yarn_pnp_unsupported"), + stderr.contains("Warning: this project uses yarn Plug'n'Play"), "the layout refusal warning must be on stderr; stderr:\n{stderr}" ); } diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index a2e095e3..32ada166 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -228,7 +228,31 @@ fn lock_timeout_help_names_get_and_scan() { let text = long_help(&["list"]); let flat = text.split_whitespace().collect::>().join(" "); assert!( - flat.contains("`get` and `scan` when they record, apply, vendor or redirect patches"), + flat.contains("`get` and `scan` when they record, apply, vendor or host patches"), "{flat}" ); } + +/// `-h` stays short (about eight options per page); `--help` still lists +/// every option, and the deprecated `scan --apply`/`--vendor` spellings +/// are in neither. +#[test] +fn short_help_lists_about_eight_options_and_long_help_lists_all() { + let mut cmd = socket_patch_cli::cli_command(); + cmd.build(); + for sub in cmd.get_subcommands_mut() { + if sub.is_hide_set() || sub.get_name() == "help" { + continue; + } + let name = sub.get_name().to_string(); + let short = sub.render_help().to_string(); + let long = sub.render_long_help().to_string(); + let count = |t: &str| t.lines().filter(|l| l.trim_start().starts_with('-')).count(); + assert!(count(&short) <= 9, "{name} -h lists {} options:\n{short}", count(&short)); + assert!(count(&long) > count(&short), "{name} --help must list more than -h"); + assert!(short.contains("--json") && long.contains("--cwd"), "{name}"); + } + let scan = cmd.find_subcommand_mut("scan").expect("scan"); + let long = scan.render_long_help().to_string(); + assert!(!long.contains("--apply") && !long.contains("--vendor "), "{long}"); +} diff --git a/crates/socket-patch-cli/tests/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/in_process_gem_config_warning.rs index d8bcc5ae..6e849f90 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_config_warning.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_config_warning.rs @@ -197,11 +197,11 @@ fn apply_stderr_warning_gates_on_silent() { let (code, _stdout, stderr) = run(tmp.path(), &["apply", "--offline", "--ecosystems", "gem"]); assert_eq!(code, 0, "loud apply exits 0.\nstderr:\n{stderr}"); assert!( - stderr.contains(CODE), + stderr.contains("Warning: bundler app config BUNDLE_PATH"), "non-silent stderr must carry the {CODE} warning; got:\n{stderr}" ); assert_eq!( - stderr.matches(CODE).count(), + stderr.matches("Warning: bundler app config BUNDLE_PATH").count(), 1, "exactly ONE warning line (not one per discovery call); got:\n{stderr}" ); diff --git a/crates/socket-patch-cli/tests/in_process_get.rs b/crates/socket-patch-cli/tests/in_process_get.rs index 5e6915e9..9035e57f 100644 --- a/crates/socket-patch-cli/tests/in_process_get.rs +++ b/crates/socket-patch-cli/tests/in_process_get.rs @@ -572,7 +572,7 @@ async fn get_one_off_with_save_only_errors() { args.save_only = true; let code = run(args).await; - assert_eq!(code, 1, "conflicting flags must exit 1"); + assert_eq!(code, 2, "conflicting flags are a usage error (exit 2)"); // The conflict is rejected up front, before any fetch — nothing saved. assert_no_manifest(tmp.path()); assert_no_api_requests(&server).await; @@ -600,7 +600,7 @@ async fn get_one_off_is_an_honest_not_implemented_error() { args.save_only = false; let code = run(args).await; - assert_eq!(code, 1, "--one-off must fail as not-yet-implemented"); + assert_eq!(code, 2, "--one-off must fail as not-yet-implemented (usage, exit 2)"); assert_no_manifest(tmp.path()); assert_no_api_requests(&server).await; } diff --git a/crates/socket-patch-cli/tests/in_process_get_modes.rs b/crates/socket-patch-cli/tests/in_process_get_modes.rs index f2317fb0..ebacc301 100644 --- a/crates/socket-patch-cli/tests/in_process_get_modes.rs +++ b/crates/socket-patch-cli/tests/in_process_get_modes.rs @@ -655,7 +655,7 @@ async fn mode_with_save_only_conflicts_exit_one_before_network() { args.mode = Some(mode); args.save_only = true; let code = socket_patch_cli::commands::get::run(args).await; - assert_eq!(code, 1, "--save-only + --mode {mode:?} must be rejected"); + assert_eq!(code, 2, "--save-only + --mode {mode:?} must be rejected (usage, exit 2)"); } assert!( server diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 6e0141e0..1e6e5126 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -2176,12 +2176,12 @@ async fn redirect_human_mode_prints_rewriter_warnings() { "a no-op redirect still exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 0 packages; rewrote 0 files."), + stdout.contains("Switched 0 packages to hosted patches; rewrote 0 files."), "anchor: the run must have taken the human-mode redirect branch; \ stdout=\n{stdout}" ); assert!( - stderr.contains("Warning (redirect_npm_no_lockfile): No package-lock.json"), + stderr.contains("Warning: No package-lock.json"), "human mode must print the rewriter's no-lockfile warning (JSON mode \ already carries it); stderr=\n{stderr}" ); @@ -2261,13 +2261,13 @@ async fn redirect_human_mode_warnings_are_not_json_quoted() { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stdout.contains("Redirected 1 package; rewrote"), + stdout.contains("Switched 1 package to hosted patches; rewrote"), "anchor: the dep must have been redirected so the record fetch runs; \ stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( stderr.contains(&format!( - "Warning (record_fetch_failed): {PURL} redirected, but its patch record could not \ + "Warning: {PURL} was switched to hosted, but its patch record could not \ be fetched" )), "the record-fetch warning must print the bare detail string, not a \ diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index b26c7af3..234aa9b2 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -469,7 +469,7 @@ async fn stale_python_install_warns_and_cannot_attest_even_on_rescan() { "{}", String::from_utf8_lossy(&out.stderr) ); - assert!(String::from_utf8_lossy(&out.stderr).contains("redirect_pypi_stale_install")); + assert!(String::from_utf8_lossy(&out.stderr).contains("was switched to a hosted patch, but installed files")); assert_eq!( std::fs::read(installed).unwrap(), bytes, diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index f9b2898d..25da2f43 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -249,7 +249,7 @@ fn remove_interactive_n_cancels() { "remove must NOT have taken the non-interactive branch in a PTY; got: {output}" ); assert!( - output.contains("Removal cancelled"), + output.contains("Cancelled; no changes made."), "remove 'n' must report cancellation; got: {output}" ); assert!( @@ -315,7 +315,7 @@ fn remove_interactive_non_utf8_answer_declines_without_panic() { "remove must NOT have taken the non-interactive branch in a PTY; got: {output}" ); assert!( - output.contains("Removal cancelled"), + output.contains("Cancelled; no changes made."), "non-UTF-8 answer must be treated as 'no'; got: {output}" ); // Declined: the manifest entry must be intact. @@ -390,7 +390,7 @@ fn remove_detached_interactive_n_cancel_message_respects_silent() { "remove must NOT have taken the non-interactive branch in a PTY; got: {output}" ); assert!( - !output.contains("Removal cancelled"), + !output.contains("Cancelled; no changes made."), "--silent must suppress the cancellation chatter; got: {output}" ); // Declined: the detached ledger entry must be intact. @@ -415,7 +415,7 @@ fn remove_detached_interactive_n_cancel_message_respects_silent() { "declined detached remove must exit cleanly; got: {loud_output}" ); assert!( - loud_output.contains("Removal cancelled"), + loud_output.contains("Cancelled; no changes made."), "non-silent declined detached remove must report cancellation; got: {loud_output}" ); assert!( diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index 6d45eef5..82f66bf1 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -267,19 +267,19 @@ fn list_empty_manifest_non_json() { let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["list"], &[]); assert_eq!(code, 0); assert!( - stdout.contains("No patches found"), + stdout.contains("No patches in this project."), "empty manifest non-JSON message; got: {stdout}" ); } #[test] -fn list_no_manifest_non_json_prints_error_to_stderr() { +fn list_no_manifest_non_json_prints_the_empty_project_line() { let tmp = tempfile::tempdir().unwrap(); - let (code, _stdout, stderr) = common::run_with_env(tmp.path(), &["list"], &[]); + let (code, stdout, stderr) = common::run_with_env(tmp.path(), &["list"], &[]); assert_eq!(code, 1); assert!( - stderr.contains("Manifest not found") || stderr.contains("not found"), - "non-JSON list-without-manifest must print to stderr; got: {stderr}" + stdout.contains("No patches in this project. Run `socket-patch scan`."), + "non-JSON list-without-manifest names the next step; got: {stdout} / {stderr}" ); } diff --git a/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs b/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs index 4697de93..b93da800 100644 --- a/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs +++ b/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs @@ -311,13 +311,21 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { ); assert_eq!(npmrc_edits(tmp.path()).len(), 1, "no duplicate ledger edit"); - // Human output: the `Warning (): …` line on stderr; --silent mutes it. + // Human output: one line by default, the full text under --verbose; + // --silent mutes it. let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); assert_eq!(code, 0, "{stderr}"); assert!( - stderr.contains(&format!("Warning ({CODE}): ")) && stderr.contains("EALLOWREMOTE"), + stderr.contains("Note: .npmrc already sets `allow-remote=all`") + && !stderr.contains("EALLOWREMOTE"), "human stderr: {stderr}" ); + let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--verbose"]); + assert_eq!(code, 0, "{stderr}"); + assert!( + stderr.contains("Warning: ") && stderr.contains("EALLOWREMOTE"), + "verbose human stderr: {stderr}" + ); let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--silent"]); assert_eq!(code, 0, "{stderr}"); assert!(!stderr.contains(CODE), "--silent is errors only: {stderr}"); @@ -710,7 +718,7 @@ async fn outer_npm_config_layers_are_respected() { let (code, _, stderr) = scan_hosted_env( tmp.path(), &server.uri(), - &[], + &["--verbose"], &[("npm_config_allow_remote", "none")], ); assert_eq!(code, 0, "{stderr}"); @@ -719,7 +727,7 @@ async fn outer_npm_config_layers_are_respected() { // `NPM_CONFIG_ALLOW_REMOTE` makes the child see that spelling there, so // match the name case-insensitively. assert!( - stderr.contains(&format!("Warning ({CODE}): ")) + stderr.contains("Warning: ") && stderr .to_ascii_lowercase() .contains("npm_config_allow_remote=none") @@ -782,12 +790,12 @@ async fn remove_surfaces_the_npmrc_modified_warning() { "{doc:#}" ); assert!( - !stderr.contains("Warning ("), + !stderr.contains("Warning:"), "--json keeps stderr quiet: {stderr}" ); } else { assert!( - stderr.contains("Warning (redirect_npmrc_allow_remote_modified): "), + stderr.contains("Warning: "), "{stderr}" ); } diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index 3588eadd..1c291c85 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -197,7 +197,7 @@ fn repair_redirect_only_project_human_mode_prints_note() { assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("Hosted redirects need no local repair"), + stdout.contains("Hosted patches need no local repair"), "human mode must print the informational note; got stdout=\n{stdout}" ); } diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index 1cba3538..a447e068 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -134,8 +134,8 @@ fn rollback_one_off_without_identifier_errors() { let tmp = tempfile::tempdir().expect("tempdir"); let (code, stdout) = run(tmp.path(), &["--json", "--one-off"]); assert_eq!( - code, 1, - "--one-off w/o identifier must exit 1; stdout=\n{stdout}" + code, 2, + "--one-off w/o identifier is a usage error (exit 2); stdout=\n{stdout}" ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "error"); @@ -160,7 +160,7 @@ fn rollback_one_off_with_identifier_reports_not_implemented() { "33333333-3333-4333-8333-333333333333", ], ); - assert_eq!(code, 1, "one-off mode must exit 1 today; stdout=\n{stdout}"); + assert_eq!(code, 2, "one-off mode is a usage error (exit 2); stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "error"); let err = v["error"].as_str().expect("error message string"); @@ -187,8 +187,9 @@ fn truthy_one_off_env_var_sets_flag() { .expect("run socket-patch"); assert_eq!( out.status.code(), - Some(1), - "SOCKET_ONE_OFF=1 must parse, not abort with a usage error; stderr=\n{}", + Some(2), + "SOCKET_ONE_OFF=1 must parse and reach the one-off stub (its JSON envelope \ + below proves it was not a clap error); stderr=\n{}", String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); @@ -244,7 +245,7 @@ fn rollback_one_off_human_reports_not_implemented_error() { .args(["--one-off", "33333333-3333-4333-8333-333333333333"]) .output() .expect("run socket-patch"); - assert_eq!(out.status.code(), Some(1), "one-off mode must exit 1 today"); + assert_eq!(out.status.code(), Some(2), "one-off mode is a usage error (exit 2)"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("not yet implemented"), diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index d0b61f89..31efe699 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1478,7 +1478,7 @@ async fn scan_agent_over_vendored_purl_surfaces_run_level_warning() { ); // Mirrored to stderr (not silent). assert!( - stderr.contains("vendored_ownership_retained"), + stderr.contains("Warning: ") && stderr.contains("vendor --revert"), "warning must be mirrored to stderr when not silent: {stderr}" ); } @@ -1586,7 +1586,7 @@ async fn scan_agent_over_live_hosted_wiring_surfaces_run_level_warning() { revert originals): {detail}" ); assert!( - stderr.contains("hosted_wiring_retained"), + stderr.contains("Warning: agent-mode scan left the hosted wiring live"), "warning must be mirrored to stderr when not silent: {stderr}" ); } diff --git a/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs b/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs index 4ce81b0c..abb1df7d 100644 --- a/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs @@ -903,8 +903,8 @@ async fn hosted_record_fetch_failures_keep_order_and_ledger_bytes() { .map(|w| w["detail"].as_str().unwrap()) .collect(); assert_eq!(warnings.len(), 2, "{stdout}"); - assert!(warnings[0].starts_with(&format!("{} redirected", purl(NAMES[1])))); - assert!(warnings[1].starts_with(&format!("{} redirected", purl(NAMES[3])))); + assert!(warnings[0].starts_with(&format!("{} was switched to hosted", purl(NAMES[1])))); + assert!(warnings[1].starts_with(&format!("{} was switched to hosted", purl(NAMES[3])))); for idx in 0..NAMES.len() { assert!(lock.contains(&hosted_url(idx)), "{lock}"); let has_record = ledger.contains(&format!("GHSA-conc-{idx:04}-aaaa")); diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 2926892d..486b9657 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1222,7 +1222,7 @@ async fn scan_vendor_annotates_mismatched_baseline_and_vendors_anyway() { "the annotation names the purl; stdout={stdout}" ); assert!( - stderr.contains("vendor_content_mismatch_overwritten"), + stderr.contains("vendored the patched content anyway"), "overwrite warning surfaced; stderr={stderr}" ); // Vendored despite the mismatch. diff --git a/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs b/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs index c1303105..c82f240b 100644 --- a/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs +++ b/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs @@ -29,7 +29,7 @@ pub const OPTIONAL_KEPT: &str = "socket-patch does not remove them because `vlt project that declares only optional dependencies, so there both commands remove the \ installed copy: upgrade vlt to 1.0.5 or later first."; pub const VLT_UPDATE_NOTE: &str = - " Note: `vlt update` re-resolves from the registry and drops these redirects."; + " Note: `vlt update` re-resolves from the registry and drops these hosted patches."; pub fn invalidated_head(n: usize) -> String { format!( diff --git a/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs b/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs index 560bacb8..f8b25ba2 100644 --- a/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs +++ b/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs @@ -39,14 +39,14 @@ pub const UNVERIFIABLE: &str = "redirect_vlt_artifact_unverifiable"; pub const ADVISORY_NOTHING_STALE: &str = "vlt-lock.json pins Socket-patched packages; fresh \ checkouts install them with `vlt ci` or `vlt install --frozen-lockfile`. Note: `vlt update` \ - re-resolves from the registry and drops these redirects."; + re-resolves from the registry and drops these hosted patches."; pub fn advisory_invalidated(n: usize) -> String { format!( "vlt-lock.json pins Socket-patched packages; socket-patch removed {n} stale installed \ copies (node_modules/.vlt-lock.json and node_modules/.vlt entries), so node_modules is \ incomplete until you run `vlt install` (or `vlt ci`), which installs the patched \ - packages. Note: `vlt update` re-resolves from the registry and drops these redirects." + packages. Note: `vlt update` re-resolves from the registry and drops these hosted patches." ) } diff --git a/docs/design/v5-plan.md b/docs/design/v5-plan.md index 12ef9b56..98fe26ed 100644 --- a/docs/design/v5-plan.md +++ b/docs/design/v5-plan.md @@ -141,8 +141,24 @@ patch-UI review. manifest says "No patches in this project"; unify cancel/upsell strings; exit 2 for all usage errors; scan/get JSON onto `json_envelope`. - Full item list: 22 findings from the UI review (sizes S/M/L, contract flags). +- **Status (branch `v5/remove-setup-and-ui`):** done — short `-h` + (`cli_command()` hides the rest; `--help` unchanged), hidden + `scan --apply/--vendor`, one-line npm allow-remote note (`--verbose`/JSON + keep the detail), code-free `Warning:`/`GC: skipped:` lines (error lines keep their code), + "hosted" wording in human text, `ui::next_steps` shared by hosted and + vendored, prompt-free hosted/vendored `get` (JSON too), `list`'s + `No patches in this project.` line (exit codes unchanged: 1 missing, + 0 empty), `ui::CANCELLED` / `ui::PAID_UPGRADE`, exit 2 for `get` and + `rollback --one-off` usage errors. **Not done:** scan/get JSON onto + `json_envelope` (larger contract change; deferred). Per-row + `[error] ()` / `[would-refuse]` lines keep their codes + (grep-able under `--silent`). ## Remaining small follow-ups +*(All done on `v5/remove-setup-and-ui`: the vacuous rows are dropped, +GEM_PATCHES has both patches, `tool_command` and the deprecated aliases — +plus the CI grep that guarded them — are removed, and the backtest label is +retired.)* - ci.yml `e2e_cargo`/`e2e_golang` rows select `--ignored` but have no ignored tests (vacuous legs) → give them `--include-ignored` or drop the rows. - `e2e_vendored_production` GEM_PATCHES lacks merged `01019627` (v5 ranking From c3d4b38864264657f7c668bfd11b14f890556899 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:16:32 +0000 Subject: [PATCH 3/7] Pin the real-vlt get_and_remove leg to --mode agent `get ` defaults to hosted since v5, so the leg's in-place patched/pristine assertions need agent mode spelled out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- crates/socket-patch-cli/tests/e2e_vlt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/e2e_vlt.rs b/crates/socket-patch-cli/tests/e2e_vlt.rs index 9f02cca0..0df73f80 100644 --- a/crates/socket-patch-cli/tests/e2e_vlt.rs +++ b/crates/socket-patch-cli/tests/e2e_vlt.rs @@ -143,7 +143,7 @@ async fn vlt_pinned_matrix_agent_get_and_remove() { return; }; let fx = Fixture::build(leg, Shape::with_bystander().warm()).await; - let out = socket_api(&fx.proj, &fx.svc, &["get", UUID], &[]); + let out = socket_api(&fx.proj, &fx.svc, &["get", UUID, "--mode", "agent"], &[]); assert_eq!(out.code, 0, "{out}"); assert_eq!(state(&fx.proj, fx.t()), State::Patched); let purl = fx.t().purl(); From f200524f57dff6ec1271ae8a35425a9220e8931c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:28:43 +0000 Subject: [PATCH 4/7] Match the hosted-ledger persist-failure wording in two covgap tests These chmod-guarded tests skip under root, so the WS8 wording change ("hosted redirect ledger" -> "hosted ledger") only showed up in CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- crates/socket-patch-cli/tests/covgap_commands_remove.rs | 4 ++-- crates/socket-patch-cli/tests/covgap_commands_rollback.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_remove.rs b/crates/socket-patch-cli/tests/covgap_commands_remove.rs index bf86e1dd..c22e9600 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_remove.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_remove.rs @@ -1014,7 +1014,7 @@ fn remove_hosted_ledger_persist_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("failed to persist the hosted redirect ledger"), + msg.contains("failed to persist the hosted ledger"), "the error must name the persist failure; got: {msg}" ); assert_eq!( @@ -1061,7 +1061,7 @@ fn remove_hosted_only_ledger_persist_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("failed to persist the hosted redirect ledger"), + msg.contains("failed to persist the hosted ledger"), "the error must name the persist failure; got: {msg}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index f67a3290..c592e619 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -1949,7 +1949,7 @@ fn hosted_persist_failure_lands_in_hosted_failed() { failed.iter().any(|f| f["purl"] == "ledger" && f["error"] .as_str() - .is_some_and(|e| e.contains("failed to persist the hosted redirect ledger"))), + .is_some_and(|e| e.contains("failed to persist the hosted ledger"))), "the persist failure must be reported under the 'ledger' key; stdout=\n{stdout}" ); // The replay itself ran before the persist: the wired file is restored. @@ -2531,7 +2531,7 @@ fn hosted_persist_failure_prints_human_error_line() { "a ledger persist failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("Error: Failed to persist the hosted redirect ledger"), + stderr.contains("Error: Failed to persist the hosted ledger"), "the human persist-failure line must print on stderr; stderr=\n{stderr}" ); assert_eq!( From c4f3625d93b133338b3ac9ef93214da471ad2a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:14:55 +0000 Subject: [PATCH 5/7] Empty list exits 0; group help by task; document the setup upgrade Review follow-ups: - `list` on a project with no manifest and no ledger record is an empty list: exit 0, the empty-project line (human) or the success envelope with `events: []` (`--json`). Only an unreadable or invalid manifest fails. Hosted mode writes no manifest, so this is the normal case. - Root help groups the commands by task (patch, undo, ship, agent mode) instead of calling get/rollback/remove "older agent-mode commands"; the subcommand list follows the same order. `-h` keeps --cwd, --ecosystems and --offline, and moves `scan --prune` to --help. - README gains "Upgrading from `setup`": move to hosted or keep agent mode, and the exact hook to delete per ecosystem. The CHANGELOG and the frozen hook/plugin READMEs link it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- CHANGELOG.md | 21 +- README.md | 230 ++++++++++-------- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- crates/socket-patch-cli/src/commands/list.rs | 26 +- crates/socket-patch-cli/src/lib.rs | 55 +++-- .../tests/cli_env_deprecation.rs | 48 ++-- .../socket-patch-cli/tests/cli_global_args.rs | 8 +- .../socket-patch-cli/tests/cli_parse_list.rs | 111 ++++----- .../tests/help_text_hygiene.rs | 18 +- .../tests/output_modes_e2e.rs | 2 +- docs/design/v5-plan.md | 2 +- gem/socket-patch-bundler/README.md | 2 + pypi/socket-patch-hook/README.md | 2 + 13 files changed, 278 insertions(+), 253 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 836e222b..870c12d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,8 @@ into the new version's section — see docs/releasing.md. "socket-patch"` Gemfile block, and Composer `post-install-cmd` / `post-update-cmd` entries. Hooks already committed keep working, since they only call `socket-patch apply`, which stays; delete them by hand to - stop them. Agent mode is now `socket-patch scan --mode agent` once + stop them. The README's "Upgrading from `setup`" section lists each hook + and the commands to move to hosted mode or keep agent mode. Agent mode is now `socket-patch scan --mode agent` once (commit `.socket/`), then `socket-patch apply` in CI after every install. Hosted and vendored mode never needed a hook. - **The `socket-patch-hook` PyPI wheel and the `socket-patch-bundler` gem @@ -73,6 +74,15 @@ into the new version's section — see docs/releasing.md. ### Changed (BREAKING): patch UI streamlining +- **`list` on an empty project exits 0.** A project with no manifest and + no ledger record (normal for hosted mode) used to exit 1 with + `manifest_not_found`; it now prints `No patches in this project. Run + \`socket-patch scan\`.` (human) or the success envelope with + `events: []` (`--json`). Only an unreadable or invalid manifest fails. +- **Help is grouped by task**: patch (`scan`, `get`, `list`), undo + (`remove`, `rollback`), ship (`vex`, `vendor`), and agent mode + (`apply`, `repair`). `-h` keeps `--cwd`, `--ecosystems` and + `--offline`; `scan --prune` moves to `--help`. - **Hosted and vendored `get` never prompt.** Like `scan`, they take the top-ranked accessible patch per package with no picker and no confirmation, in `--json` too (no `selection_required` outside agent @@ -82,11 +92,6 @@ into the new version's section — see docs/releasing.md. `--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`, `--mode hosted|vendored --save-only`, `--one-off`, a malformed forced identifier, and `rollback --one-off`. Every usage error now exits 2. -- **`list` in a project with no patches** prints `No patches in this - project. Run \`socket-patch scan\`.` (stdout) instead of `Error: Manifest - not found at …` / `No patches found in manifest.`. Exit codes are - unchanged (1 with no manifest and no ledger record, 0 for an empty - manifest); `--json` keeps the `manifest_not_found` envelope. - **Human output:** warning lines no longer carry the `(code)` tag (`Warning: …`, `GC: skipped: …`); the codes stay in the JSON envelope. Error lines keep theirs (`Error (): …`). Hosted mode is called "hosted", not "redirect", in human @@ -96,8 +101,8 @@ into the new version's section — see docs/releasing.md. block. Every declined prompt prints `Cancelled; no changes made.`, and scan/get share one paid-plan upsell line. - **`-h` is short**: about eight options per command (`--json`, - `--verbose`, `--dry-run`, `--yes` where the command prompts, and the - command's main flags); `--help` still lists everything. The deprecated + `--dry-run`, `--cwd`, `--ecosystems`, `--offline`, `--yes` where the + command prompts, and the command's main flags); `--help` still lists everything. The deprecated `scan --apply` / `--vendor` spellings are hidden from both (still accepted). diff --git a/README.md b/README.md index c26d89d3..007d5984 100644 --- a/README.md +++ b/README.md @@ -472,11 +472,11 @@ need the network and refuse to run with `--offline`. | [`vex`](#vex) | Generate an OpenVEX document for the vulnerabilities the project's patches fix | | [`vendor`](#vendor) | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles to use them (`--revert` undoes it) | | [`list`](#list) | List the patches in this project: hosted and vendored records plus any agent-mode manifest entries | -| **Agent mode (older commands)** | | -| [`get`](#get) | Fetch and apply one patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | -| [`apply`](#apply) | Apply the patches in `.socket/manifest.json` in place | -| [`rollback`](#rollback) | Undo patches in every mode: restore original files and unwind hosted or vendored lockfile wiring | -| [`remove`](#remove) | Remove one patch by PURL or UUID (rolls back first) | +| [`get`](#get) | Patch one package, CVE, GHSA or patch UUID (hosted by default; alias: `download`) | +| [`remove`](#remove) | Unwind one patch by PURL or UUID, in any mode | +| [`rollback`](#rollback) | Unwind every patch, in any mode: restore original files and hosted or vendored lockfile wiring | +| **[Agent mode](#agent-mode)** | | +| [`apply`](#apply) | Apply the patches in `.socket/manifest.json` in place (run it after every install) | | [`repair`](#repair) | Download missing patch artifacts, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | `socket-patch --update` updates the CLI itself (see [Updating](#updating)). @@ -798,8 +798,7 @@ socket-patch vendor --json List the patches in this project: the hosted ledger's records (labeled `Mode: hosted`), the vendor ledger's (`Mode: vendored`), and any agent-mode entries in `.socket/manifest.json`. A project with none prints `No patches in this project. Run -\`socket-patch scan\`.` (exit 1 when there is no manifest or ledger record at all, 0 for -an empty manifest). +\`socket-patch scan\`.` and exits 0 (`--json`: the success envelope with no events). **Usage:** ```bash @@ -838,12 +837,6 @@ Package: pkg:npm/flatted@3.3.1 ... ``` -### Agent mode (older commands) - -Agent mode keeps patches in `.socket/manifest.json` + `.socket/blobs/` and edits the -installed files in place, so the CLI has to run again after every install. These -commands drive it. `rollback` also undoes hosted and vendored patches. - ### `get` Get one security patch from the Socket API and apply it. Accepts a UUID, CVE ID, GHSA @@ -911,91 +904,44 @@ socket-patch get lodash -g socket-patch get CVE-2024-12345 --json -y ``` -### `apply` +### `remove` -Apply the patches in `.socket/manifest.json` to the installed files in place. Idempotent — safe to run from install -hooks and CI on every build. +Remove a patch from the manifest (rolls back files first by default). If the package is +[vendored](#vendor), `remove` also **reverts the vendoring** — the lockfile is restored +byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone +in one command. Patches vendored by `scan --mode vendored` have no manifest entry and are +removable by PURL or UUID all the same (reverting the vendoring *is* the removal, so +`--skip-rollback` is refused for them). **Usage:** ```bash -socket-patch apply [options] +socket-patch remove [options] ``` +**Arguments:** +- `identifier` — package PURL (e.g. `pkg:npm/package@version`) or patch UUID. + **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| -| `-f, --force` | `SOCKET_FORCE` | Skip pre-application hash verification (apply even if package version differs). | -| `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (for CI / GitHub-App auditing) — Go only, since cargo patches in place and has no redirect to audit. Lock-free, crawl-free, and offline-safe: exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit. | -| `--vex ` | `SOCKET_VEX` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | -| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | +| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Restore the files and lockfiles but keep the patch's local state (manifest entry, vendored artifact + ledger entry) for a later re-apply, and skip blob cleanup — the single-patch twin of `rollback --preserve-state`. Conflicts with `--skip-rollback`. | +| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for a vendored package that still has a manifest entry this also leaves the vendor wiring + artifact in place; refused for manifest-less vendored patches, where the revert *is* the removal). | **Examples:** ```bash -# Apply patches -socket-patch apply - -# Dry run -socket-patch apply --dry-run - -# Apply only npm patches -socket-patch apply --ecosystems npm - -# Apply in offline mode -socket-patch apply --offline - -# JSON output for CI/CD -socket-patch apply --json - -# Apply and emit an OpenVEX attestation in one step -socket-patch apply --vex socket.vex.json -``` - -> Packages managed by [`vendor`](#vendor) are skipped (`skipped`/`vendored` in JSON): the -> committed vendored artifact is the patch, so there is nothing for `apply` to do — even -> when the installed tree (e.g. `node_modules/`) is absent. - -### Agent mode in CI +# Remove by PURL +socket-patch remove "pkg:npm/lodash@4.17.20" -Agent mode patches the installed files in place, so every fresh dependency install -reverts the patches until `socket-patch apply` runs again. (Hosted and vendored projects -need no such step: the lockfile already names the patched packages.) Commit -`.socket/manifest.json` and its blobs, then run `apply` in CI after every install: +# Remove by UUID +socket-patch remove 550e8400-e29b-41d4-a716-446655440000 -```bash -# once, locally: record the patches (commit .socket/) -socket-patch scan --mode agent +# Remove without rolling back files +socket-patch remove "pkg:npm/lodash@4.17.20" --skip-rollback -# in CI, after `npm ci` / `pip install` / `bundle install` / ... -socket-patch apply +# JSON output +socket-patch remove "pkg:npm/lodash@4.17.20" --json ``` -> **v5.0: `setup` was removed.** It used to wire install hooks (npm `postinstall` / -> `dependencies` scripts, the `socket-patch[hook]` Python `.pth` wheel, a Bundler plugin, -> Composer script events) that ran `apply` for you. Hooks an earlier release committed -> keep working — they call `socket-patch apply`, which still exists — until you delete -> them by hand: the `package.json` scripts, the `socket-patch[hook]` dependency (the -> `socket-patch-hook` wheel is no longer published; `pip uninstall socket-patch-hook`), -> the managed `plugin "socket-patch"` Gemfile block and `.socket/bundler-plugin/`, and -> the `composer.json` script entries. - -Per-ecosystem notes for in-place patching: - -- **Cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting - `.cargo-checksum.json` so `cargo build` accepts it) — note that a non-vendored crate - patches the **shared** `$CARGO_HOME/registry` cache, which affects every project on - the machine and is silently reset by `cargo clean` or a cache prune; vendor the - dependency (`--mode vendored`) for a project-local, committable patch. -- **Go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` - `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't - build); commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. -- **Maven / NuGet**: in-place patching leaves the caches' own checksum sidecars stale - (NuGet's fixup deletes `.nupkg.metadata` and raises an advisory for the signed-package - `.nupkg.sha512` marker; Maven's `.jar.sha1`/`.jar.md5` are left as-is) — the copy-out - modes, `scan --mode vendored` and `scan --mode hosted`, never touch the caches and - avoid the issue entirely. See - [ecosystems.md](docs/ecosystems.md#maven--nuget-caveats). -- **Deno** has no hosted or vendored mode, so agent mode is the only way to patch it. - ### `rollback` Roll back patches to restore the system to unpatched. If no target is given, everything @@ -1045,44 +991,124 @@ socket-patch rollback --dry-run socket-patch rollback --json ``` -### `remove` +### Agent mode -Remove a patch from the manifest (rolls back files first by default). If the package is -[vendored](#vendor), `remove` also **reverts the vendoring** — the lockfile is restored -byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone -in one command. Patches vendored by `scan --mode vendored` have no manifest entry and are -removable by PURL or UUID all the same (reverting the vendoring *is* the removal, so -`--skip-rollback` is refused for them). +Agent mode keeps patches in `.socket/manifest.json` + `.socket/blobs/` and edits the +installed files in place, so the CLI has to run again after every install. `apply` +and `repair` are agent-mode commands; `get`, `list`, `remove` and `rollback` work in +every mode. + +### `apply` + +Apply the patches in `.socket/manifest.json` to the installed files in place. Idempotent — safe to run from install +hooks and CI on every build. **Usage:** ```bash -socket-patch remove [options] +socket-patch apply [options] ``` -**Arguments:** -- `identifier` — package PURL (e.g. `pkg:npm/package@version`) or patch UUID. - **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| -| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Restore the files and lockfiles but keep the patch's local state (manifest entry, vendored artifact + ledger entry) for a later re-apply, and skip blob cleanup — the single-patch twin of `rollback --preserve-state`. Conflicts with `--skip-rollback`. | -| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for a vendored package that still has a manifest entry this also leaves the vendor wiring + artifact in place; refused for manifest-less vendored patches, where the revert *is* the removal). | +| `-f, --force` | `SOCKET_FORCE` | Skip pre-application hash verification (apply even if package version differs). | +| `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (for CI / GitHub-App auditing) — Go only, since cargo patches in place and has no redirect to audit. Lock-free, crawl-free, and offline-safe: exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit. | +| `--vex ` | `SOCKET_VEX` | On a successful apply, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | +| `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | **Examples:** ```bash -# Remove by PURL -socket-patch remove "pkg:npm/lodash@4.17.20" +# Apply patches +socket-patch apply -# Remove by UUID -socket-patch remove 550e8400-e29b-41d4-a716-446655440000 +# Dry run +socket-patch apply --dry-run -# Remove without rolling back files -socket-patch remove "pkg:npm/lodash@4.17.20" --skip-rollback +# Apply only npm patches +socket-patch apply --ecosystems npm -# JSON output -socket-patch remove "pkg:npm/lodash@4.17.20" --json +# Apply in offline mode +socket-patch apply --offline + +# JSON output for CI/CD +socket-patch apply --json + +# Apply and emit an OpenVEX attestation in one step +socket-patch apply --vex socket.vex.json +``` + +> Packages managed by [`vendor`](#vendor) are skipped (`skipped`/`vendored` in JSON): the +> committed vendored artifact is the patch, so there is nothing for `apply` to do — even +> when the installed tree (e.g. `node_modules/`) is absent. + +### Agent mode in CI + +Agent mode patches the installed files in place, so every fresh dependency install +reverts the patches until `socket-patch apply` runs again. (Hosted and vendored projects +need no such step: the lockfile already names the patched packages.) Commit +`.socket/manifest.json` and its blobs, then run `apply` in CI after every install: + +```bash +# once, locally: record the patches (commit .socket/) +socket-patch scan --mode agent + +# in CI, after `npm ci` / `pip install` / `bundle install` / ... +socket-patch apply ``` +> **v5.0: `setup` was removed.** See [Upgrading from `setup`](#upgrading-from-setup) to +> retire the install hooks it wrote. + +Per-ecosystem notes for in-place patching: + +- **Cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting + `.cargo-checksum.json` so `cargo build` accepts it) — note that a non-vendored crate + patches the **shared** `$CARGO_HOME/registry` cache, which affects every project on + the machine and is silently reset by `cargo clean` or a cache prune; vendor the + dependency (`--mode vendored`) for a project-local, committable patch. +- **Go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` + `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't + build); commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. +- **Maven / NuGet**: in-place patching leaves the caches' own checksum sidecars stale + (NuGet's fixup deletes `.nupkg.metadata` and raises an advisory for the signed-package + `.nupkg.sha512` marker; Maven's `.jar.sha1`/`.jar.md5` are left as-is) — the copy-out + modes, `scan --mode vendored` and `scan --mode hosted`, never touch the caches and + avoid the issue entirely. See + [ecosystems.md](docs/ecosystems.md#maven--nuget-caveats). +- **Deno** has no hosted or vendored mode, so agent mode is the only way to patch it. + +### Upgrading from `setup` + +v5 removes `socket-patch setup`. The hooks it wrote only ran `socket-patch apply`, so +they keep working until you delete them, but nothing maintains them any more. For each +project that ran `setup`: + +1. **Pick a mode.** + - *Move to hosted mode* (recommended; nothing runs after installs): run + `socket-patch rollback` (restores the patched files and empties the agent-mode + manifest), then `socket-patch scan`, and commit the lockfile changes and + `.socket/`. + - *Stay in agent mode*: keep `.socket/`, and add `socket-patch apply` to CI after + every install (see [Agent mode in CI](#agent-mode-in-ci)). +2. **Delete the hook for each ecosystem `setup` wired:** + - **npm / pnpm / yarn / bun**: in `package.json`, remove the + `npx @socketsecurity/socket-patch apply --silent --ecosystems npm` command (or its + `pnpm dlx` twin) from `scripts.postinstall`, and from `scripts.dependencies` if it + is there. Drop the script key if nothing else is left in it. + - **Composer**: in `composer.json`, remove + `socket-patch apply --offline --silent --ecosystems composer` from + `scripts.post-install-cmd` and `scripts.post-update-cmd`. + - **Python**: remove `socket-patch[hook]` from `requirements.txt`, or from + `[project].dependencies` / `[tool.poetry.dependencies]` in `pyproject.toml`, then + `pip uninstall socket-patch-hook` in each environment that has it (the wheel is no + longer published, so a fresh install fails while the dependency is still listed). + - **Bundler**: delete the managed `plugin "socket-patch", path: ...` block from the + `Gemfile`, then `bundle plugin uninstall socket-patch`, and delete + `.socket/bundler-plugin/`, `.socket/gem-plugin-stamp` and the `/gem-plugin-stamp` + line in `.socket/.gitignore`. +3. **Check:** `socket-patch list` shows what remains. In agent mode, run + `socket-patch apply` once to confirm the manifest still applies. + ### `repair` Download missing blobs, rebuild missing or corrupt vendored artifacts, and clean up unused diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 2e9aee43..ea6feb03 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -11,7 +11,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages); `--mode vendored` / `--mode agent` pick the other modes. Never prompts. See [scan modes](#scan-modes-v50) | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the `.socket/vendor` ledgers, and the hosted / vendored patch references the project's lockfiles wire (no manifest required) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | -| `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | +| `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the action matrix; an empty project exits 0) | | `get` | `download` | Agent mode by default (`--mode` selects hosted/vendored): fetch + apply a patch; requires positional `identifier` | | `apply` | — | Agent mode: apply patches from the local manifest | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | @@ -1190,7 +1190,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | list, remove, repair, rollback, vex | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no redirect-ledger record, no vendor-ledger entry, no lockfile reference — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | +| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no redirect-ledger record, no vendor-ledger entry, no lockfile reference — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | | `manifest_invalid` | list, remove | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove, vex | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | | `no_patches` | vex | The manifest file exists but is empty AND no ledger record or lockfile reference names a patch (exit 1). | @@ -1446,7 +1446,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | | `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`, `--mode hosted\|vendored --save-only`, the unimplemented `--one-off`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`), as does `rollback --one-off` — previously `1` (MAJOR). | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (v5.0: both print `No patches in this project. Run \`socket-patch scan\`.` on stdout in human mode, the missing-manifest case no longer as an `Error:` line; `--json` keeps the `manifest_not_found` envelope) (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 1db07c44..28db3ef0 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -380,28 +380,10 @@ pub async fn run(args: ListArgs) -> i32 { redirect_state.as_ref(), vendor_state.as_ref().map(|s| &s.entries), ); - if manifest.is_none() && entries.is_empty() { - // No manifest AND no ledger records: nothing is listable anywhere. - // Exit 1 (unchanged), so scripts can tell "nothing here" from a - // listing; humans get the plain empty-project line, JSON keeps the - // `manifest_not_found` envelope. - if args.common.json { - emit_error( - &args, - "manifest_not_found", - format!("Manifest not found at {}", manifest_path.display()), - warnings, - ); - } else if args.common.silent { - // `--silent` is "errors only", and this run exits 1: say why. - eprintln!("{NO_PATCHES}"); - } else { - println!("{NO_PATCHES}"); - } - return 1; - } - - // Records found (any store) ⇒ a successful list, exit 0. + // A successful list, exit 0, with or without records. No manifest + // and no ledger record is just an empty project (normal for hosted + // mode, which writes no manifest); only an unreadable or invalid + // manifest fails. // // Telemetry: `patch_listed`'s `patches_count` means "manifest patches" // to its consumers, so it counts the manifest ONLY (0 on a ledger-only diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 223e8043..7bc0e4d1 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -26,12 +26,19 @@ use clap::{Parser, Subcommand}; about = "Patch vulnerable dependencies with Socket's security patches", version, propagate_version = true, - after_help = "Typical workflow:\n \ - socket-patch scan Patch dependencies (rewrites lockfiles to Socket-hosted patched packages)\n \ - socket-patch vex Emit an OpenVEX document for your vulnerability scanner\n \ - socket-patch vendor Eject the patches into .socket/vendor/ for offline installs\n \ - socket-patch list Show the patches in this project\n\n\ - get, apply, rollback, remove and repair are the older agent-mode commands." + after_help = "Patch a project:\n \ + socket-patch scan Patch every dependency with a patch (hosted: rewrites lockfiles)\n \ + socket-patch get Patch one package, CVE, GHSA or patch UUID\n \ + socket-patch list Show the patches in this project\n\n\ + Undo:\n \ + socket-patch remove Unwind one patch (by PURL or UUID)\n \ + socket-patch rollback Unwind every patch\n\n\ + Ship:\n \ + socket-patch vex Emit an OpenVEX document for your vulnerability scanner\n \ + socket-patch vendor Eject the patches into .socket/vendor/ for offline installs\n\n\ + Agent mode (`scan --mode agent` edits installed files in place):\n \ + socket-patch apply Re-apply .socket/manifest.json after each install (e.g. in CI)\n \ + socket-patch repair Restore missing patch artifacts" )] pub struct Cli { #[command(subcommand)] @@ -60,6 +67,23 @@ pub enum Commands { /// lockfiles to Socket-hosted patched packages Scan(commands::scan::ScanArgs), + /// Patch one package, CVE, GHSA or patch UUID (hosted mode by default) + #[command(visible_alias = "download")] + Get(commands::get::GetArgs), + + /// List the patches in this project: hosted and vendored lockfile + /// references plus any agent-mode manifest entries + List(commands::list::ListArgs), + + /// Remove one patch by PURL or UUID: unwind its hosted or vendored + /// wiring, or roll back its agent-mode files and drop it from the + /// manifest + Remove(commands::remove::RemoveArgs), + + /// Undo patches: restore original files and unwind hosted or vendored + /// lockfile wiring + Rollback(commands::rollback::RollbackArgs), + /// Generate an OpenVEX 0.2.0 document for the vulnerabilities the /// project's patches fix Vex(commands::vex::VexArgs), @@ -71,25 +95,9 @@ pub enum Commands { /// Socket API needed. Vendor(commands::vendor::VendorArgs), - /// List the patches in this project: hosted and vendored lockfile - /// references plus any agent-mode manifest entries - List(commands::list::ListArgs), - - /// Patch one package, CVE, GHSA or patch UUID (hosted mode by default) - #[command(visible_alias = "download")] - Get(commands::get::GetArgs), - /// Agent mode: apply the patches in `.socket/manifest.json` in place Apply(commands::apply::ApplyArgs), - /// Undo patches: restore original files and unwind hosted or vendored - /// lockfile wiring - Rollback(commands::rollback::RollbackArgs), - - /// Agent mode: remove a patch from the manifest by PURL or UUID (rolls - /// back files first) - Remove(commands::remove::RemoveArgs), - /// Agent mode: download missing patch artifacts and clean up unused ones /// /// Restores missing blobs and diff/package archives, rebuilds missing @@ -145,7 +153,7 @@ impl Commands { /// Global options every subcommand's short help (`-h`) still lists; the /// rest move to `--help` only. -const SHORT_HELP_GLOBALS: &[&str] = &["json", "dry_run", "verbose"]; +const SHORT_HELP_GLOBALS: &[&str] = &["json", "dry_run", "cwd", "ecosystems", "offline"]; /// Per-subcommand arguments shown in `-h` on top of its own (non-global) /// ones: the commands that prompt keep `--yes`. @@ -162,6 +170,7 @@ fn short_help_hidden_own(sub: &str) -> &'static [&'static str] { match sub { "scan" => &[ "batch_size", + "prune", "sync", "all_releases", "vex_product", diff --git a/crates/socket-patch-cli/tests/cli_env_deprecation.rs b/crates/socket-patch-cli/tests/cli_env_deprecation.rs index d09daac2..5e4d9bc5 100644 --- a/crates/socket-patch-cli/tests/cli_env_deprecation.rs +++ b/crates/socket-patch-cli/tests/cli_env_deprecation.rs @@ -145,13 +145,13 @@ fn legacy_proxy_url_warns() { out.stdout ); // The warning must fire on the *real* code path: `list` against an empty - // tempdir runs to its normal "manifest not found" error (exit 1). Pinning - // this rejects a child that crashed (signal → `None`) after emitting the - // line, and proves the shim ran inside an actual command invocation. + // tempdir runs to its normal empty-project result (exit 0). Pinning this + // rejects a child that crashed (signal → `None`) after emitting the line, + // and proves the shim ran inside an actual command invocation. assert_eq!( out.code, - Some(1), - "expected the manifest-not-found error exit; stderr was:\n{}", + Some(0), + "expected the empty-project list exit; stderr was:\n{}", out.stderr ); } @@ -167,8 +167,8 @@ fn legacy_debug_warns() { ); assert_eq!( out.code, - Some(1), - "expected the manifest-not-found error exit; stderr was:\n{}", + Some(0), + "expected the empty-project list exit; stderr was:\n{}", out.stderr ); } @@ -188,8 +188,8 @@ fn legacy_telemetry_disabled_warns() { ); assert_eq!( out.code, - Some(1), - "expected the manifest-not-found error exit; stderr was:\n{}", + Some(0), + "expected the empty-project list exit; stderr was:\n{}", out.stderr ); } @@ -209,11 +209,11 @@ fn legacy_warning_fires_under_silent() { // `--silent` it must be byte-for-byte the same line emitted without it. assert_deprecation_warning(&out.stderr, "SOCKET_PATCH_PROXY_URL", "SOCKET_PROXY_URL"); // `--silent` is parsed and accepted (no clap usage error, which would be - // exit 2); the command still runs to its normal manifest-not-found error. + // exit 2); the command still runs to its normal empty-project result. assert_eq!( out.code, - Some(1), - "--silent should be accepted and the command reach its normal error exit; stderr was:\n{}", + Some(0), + "--silent should be accepted and the command reach its normal exit; stderr was:\n{}", out.stderr ); // The warning is diagnostic output: it must stay on stderr and never bleed @@ -263,19 +263,19 @@ fn legacy_warning_fires_under_json() { "JSON payload should be the structured `list` command result; got:\n{}", out.stdout ); - // The run errors (no manifest in the fresh tempdir), so the structured - // result must say so — and exit non-zero — proving the JSON path itself - // ran rather than some short-circuited stub. + // An empty tempdir lists as an empty project, so the structured result + // must be a success with no events — proving the JSON path itself ran + // rather than some short-circuited stub. assert_eq!( parsed.get("status").and_then(|v| v.as_str()), - Some("error"), - "JSON payload should report the manifest-not-found error; got:\n{}", + Some("success"), + "JSON payload should report the empty-project success; got:\n{}", out.stdout ); assert_eq!( out.code, - Some(1), - "expected the manifest-not-found error exit under --json; stderr was:\n{}", + Some(0), + "expected the empty-project list exit under --json; stderr was:\n{}", out.stderr ); } @@ -294,12 +294,12 @@ fn new_var_takes_precedence_and_silences_warning() { let stderr = String::from_utf8_lossy(&out.stderr); // Guard against a vacuous pass: if the binary never launched (or crashed // before promoting env vars) stderr would also lack "deprecated". Require - // the real manifest-not-found error exit so "no warning" means the shim + // the real empty-project list exit so "no warning" means the shim // ran and chose to stay quiet — not that nothing ran at all. assert_eq!( out.status.code(), - Some(1), - "expected the binary to run to its manifest-not-found error; stderr was:\n{stderr}" + Some(0), + "expected the binary to run to its empty-project list result; stderr was:\n{stderr}" ); assert!( !stderr.to_lowercase().contains("deprecated"), @@ -325,8 +325,8 @@ fn no_warning_when_no_legacy_var_set() { // result of the binary failing to start. assert_eq!( out.status.code(), - Some(1), - "expected the binary to run to its manifest-not-found error; stderr was:\n{stderr}" + Some(0), + "expected the binary to run to its empty-project list result; stderr was:\n{stderr}" ); assert!( !stderr.to_lowercase().contains("deprecated"), diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index c4bbc574..cb1bc8fd 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -758,16 +758,16 @@ fn empty_nonbool_env_vars_do_not_crash_the_binary() { "blank env vars must not abort the clap parse.\nstderr: {stderr}", ); // The command must reach normal execution: with the blanks treated as - // unset, `list --json` in an empty temp dir resolves the default manifest - // path and emits the manifest_not_found envelope (exit 1). + // unset, `list --json` in an empty temp dir lists an empty project + // (success envelope, exit 0). let envelope: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { panic!("expected a JSON envelope on stdout, got {e}.\nstdout: {stdout}\nstderr: {stderr}") }); assert_eq!( - envelope["error"]["code"], "manifest_not_found", + envelope["status"], "success", "blank env vars must fall back to defaults: {envelope}", ); - assert_eq!(out.status.code(), Some(1), "manifest_not_found exits 1"); + assert_eq!(out.status.code(), Some(0), "an empty project lists with exit 0"); } /// `save_and_clear_global_env` must clear **every** env var `GlobalArgs` diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index d2f6f29e..c856da53 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -125,7 +125,7 @@ fn populated_manifest() -> PatchManifest { } #[tokio::test] -async fn missing_manifest_returns_1_plain() { +async fn missing_manifest_returns_0_plain() { let tmp = tempfile::tempdir().unwrap(); let args = ListArgs { common: socket_patch_cli::args::GlobalArgs { @@ -135,11 +135,11 @@ async fn missing_manifest_returns_1_plain() { ..socket_patch_cli::args::GlobalArgs::default() }, }; - assert_eq!(run(args).await, 1); + assert_eq!(run(args).await, 0); } #[tokio::test] -async fn missing_manifest_returns_1_json() { +async fn missing_manifest_returns_0_json() { let tmp = tempfile::tempdir().unwrap(); let args = ListArgs { common: socket_patch_cli::args::GlobalArgs { @@ -149,7 +149,7 @@ async fn missing_manifest_returns_1_json() { ..socket_patch_cli::args::GlobalArgs::default() }, }; - assert_eq!(run(args).await, 1); + assert_eq!(run(args).await, 0); } #[tokio::test] @@ -269,10 +269,9 @@ async fn absolute_manifest_path_wins_over_cwd() { // --------------------------------------------------------------------------- #[test] -fn missing_manifest_json_status_is_error_via_binary() { - // Pins the new unified envelope shape for `list --json` when the - // manifest doesn't exist. Top-level keys: command, status, error - // (object with code + message), plus the usual envelope fields. +fn missing_manifest_json_is_an_empty_success_via_binary() { + // No manifest and no ledger is an empty project (normal for hosted + // mode): `list --json` emits the success envelope with no events. let tmp = tempfile::tempdir().unwrap(); let out = Command::new(env!("CARGO_BIN_EXE_socket-patch")) .args(["list", "--cwd", tmp.path().to_str().unwrap(), "--json"]) @@ -281,8 +280,8 @@ fn missing_manifest_json_status_is_error_via_binary() { assert_eq!( out.status.code(), - Some(1), - "missing manifest must exit 1, stderr={}", + Some(0), + "an empty project must exit 0, stderr={}", String::from_utf8_lossy(&out.stderr) ); @@ -290,13 +289,10 @@ fn missing_manifest_json_status_is_error_via_binary() { let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON"); assert_eq!(parsed["command"], "list"); - assert_eq!(parsed["status"], "error"); - assert_eq!(parsed["error"]["code"], "manifest_not_found"); - let msg = parsed["error"]["message"].as_str().expect("error message"); - assert!( - msg.contains("Manifest not found"), - "error.message must include 'Manifest not found', got: {msg}" - ); + assert_eq!(parsed["status"], "success"); + assert_eq!(parsed["summary"]["discovered"], 0); + assert_eq!(parsed["events"], serde_json::json!([])); + assert!(parsed.get("error").is_none(), "{parsed}"); } // --------------------------------------------------------------------------- @@ -365,26 +361,18 @@ fn empty_file_manifest_reports_manifest_invalid_via_binary() { } #[test] -fn missing_manifest_under_valid_cwd_reports_manifest_not_found_via_binary() { +fn missing_manifest_under_valid_cwd_is_not_an_error_via_binary() { // The common missing-manifest case: cwd exists, but `.socket/manifest.json` - // does not. `read_manifest` returns `Ok(None)` here, which must surface as - // `manifest_not_found` — NOT `manifest_invalid`, which would tell - // consumers a missing file was corrupt. + // does not. `read_manifest` returns `Ok(None)` here, which is an empty + // project — NOT `manifest_invalid`, which would tell consumers a missing + // file was corrupt. let tmp = tempfile::tempdir().unwrap(); let out = run_list_binary(tmp.path(), &["--json"]); let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) .expect("stdout must be valid JSON envelope"); - assert_eq!(out.status.code(), Some(1), "missing manifest must exit 1"); - assert_eq!(v["status"], "error"); - assert_eq!( - v["error"]["code"], "manifest_not_found", - "missing manifest must be manifest_not_found, got envelope: {v}" - ); - let msg = v["error"]["message"].as_str().expect("error message"); - assert!( - msg.contains("Manifest not found"), - "message must name the missing manifest, got: {msg}" - ); + assert_eq!(out.status.code(), Some(0), "missing manifest is an empty list"); + assert_eq!(v["status"], "success", "envelope: {v}"); + assert_eq!(v["summary"]["discovered"], 0, "envelope: {v}"); } #[test] @@ -941,16 +929,14 @@ fn silent_does_not_mute_json_envelope_via_binary() { } #[test] -fn silent_keeps_missing_manifest_error_on_stderr_via_binary() { - // "Errors only": the missing-manifest diagnostic must survive --silent. +fn silent_empty_project_prints_nothing_via_binary() { + // "Errors only": an empty project is not an error, so --silent prints + // nothing and exits 0. let tmp = tempfile::tempdir().unwrap(); let out = run_list_binary_scrubbed(tmp.path(), &["--silent"]); - assert_eq!(out.status.code(), Some(1), "missing manifest must exit 1"); - assert!( - String::from_utf8_lossy(&out.stderr).contains("No patches in this project."), - "the exit-1 reason must NOT be muted by --silent" - ); + assert_eq!(out.status.code(), Some(0), "an empty project exits 0"); + assert!(out.stdout.is_empty() && out.stderr.is_empty(), "{out:?}"); } // --------------------------------------------------------------------------- @@ -1143,7 +1129,7 @@ fn manifest_and_hosted_ledger_coexist_via_binary() { } #[test] -fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { +fn edits_only_ledger_without_manifest_lists_nothing_via_binary() { // A ledger with recorded edits but NO records (the post-takeover / // degraded shape) asserts no patches, so a manifest-less project stays // on the manifest_not_found path. @@ -1172,18 +1158,19 @@ fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { .expect("stdout must be valid JSON"); assert_eq!( out.status.code(), - Some(1), - "no records anywhere must exit 1" + Some(0), + "no records anywhere is an empty list" ); - assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!(v["summary"]["discovered"], 0, "envelope={v}"); } #[test] -fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_binary() { - // No manifest and a corrupt ledger: the run takes the - // manifest_not_found exit, but the ledger corruption must still reach - // a JSON consumer via the error envelope's `warnings[]` (stderr is not - // the machine channel), and nothing may leak onto stderr. +fn missing_manifest_with_corrupt_ledger_keeps_warning_in_the_envelope_via_binary() { + // No manifest and a corrupt ledger: the run lists nothing, but the + // ledger corruption must still reach a JSON consumer via the + // envelope's `warnings[]` (stderr is not the machine channel), and + // nothing may leak onto stderr. let tmp = tempfile::tempdir().unwrap(); let vendor_dir = tmp.path().join(".socket/vendor"); std::fs::create_dir_all(&vendor_dir).unwrap(); @@ -1192,8 +1179,8 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina let out = run_list_binary(tmp.path(), &["--json"]); let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) .expect("stdout must be valid JSON"); - assert_eq!(out.status.code(), Some(1)); - assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); + assert_eq!(out.status.code(), Some(0)); + assert_eq!(v["status"], "success", "envelope={v}"); let warnings = v["warnings"].as_array().expect("warnings[] present"); assert_eq!(warnings.len(), 1, "envelope={v}"); assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}"); @@ -1207,7 +1194,7 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina // is on stdout (v5.0). let out = run_list_binary(tmp.path(), &[]); let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!(out.status.code(), Some(1)); + assert_eq!(out.status.code(), Some(0)); assert!(stderr.contains("Warning: "), "stderr={stderr}"); assert!( String::from_utf8_lossy(&out.stdout).contains("No patches in this project."), @@ -1335,9 +1322,9 @@ fn manifest_path_scopes_ledger_to_target_project_via_binary() { } #[test] -fn local_ledger_never_suppresses_flagged_manifest_not_found_via_binary() { +fn local_ledger_never_leaks_into_the_flagged_project_via_binary() { // --manifest-path points at a project with NO manifest and NO ledger; - // the cwd's local ledger records must not turn that into a success. + // the cwd's local ledger records must not be listed for it. let cwd = tempfile::tempdir().unwrap(); common::write_redirect_ledger(cwd.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); let target = tempfile::tempdir().unwrap(); @@ -1351,10 +1338,11 @@ fn local_ledger_never_suppresses_flagged_manifest_not_found_via_binary() { .expect("stdout must be valid JSON"); assert_eq!( out.status.code(), - Some(1), + Some(0), "the flagged project has no stores at all; envelope={v}" ); - assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); + assert_eq!(v["summary"]["discovered"], 0, "envelope={v}"); + assert_eq!(v["events"], serde_json::json!([]), "envelope={v}"); } // --------------------------------------------------------------------------- @@ -1516,10 +1504,10 @@ fn manifest_hosted_and_vendored_ledgers_coexist_via_binary() { } #[test] -fn record_less_vendor_entry_without_manifest_still_manifest_not_found_via_binary() { +fn record_less_vendor_entry_without_manifest_lists_nothing_via_binary() { // A legacy manifest-tracked entry asserts no patch of its own: with no - // manifest and no other store, `list` stays on the manifest_not_found - // path (mirrors the edits-only redirect ledger). + // manifest and no other store, `list` is an empty list (mirrors the + // edits-only redirect ledger). let tmp = tempfile::tempdir().unwrap(); write_vendor_ledger(tmp.path(), &[(VENDORED_PURL, None)]); @@ -1528,10 +1516,11 @@ fn record_less_vendor_entry_without_manifest_still_manifest_not_found_via_binary .expect("stdout must be valid JSON"); assert_eq!( out.status.code(), - Some(1), - "no records anywhere must exit 1" + Some(0), + "no records anywhere is an empty list" ); - assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!(v["summary"]["discovered"], 0, "envelope={v}"); } #[test] diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 32ada166..9b3280e8 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -176,10 +176,14 @@ fn root_command_list_leads_with_the_v5_workflow() { .collect(); assert_eq!( &order[..9], - ["scan", "vex", "vendor", "list", "get", "apply", "rollback", "remove", "repair"], + ["scan", "get", "list", "remove", "rollback", "vex", "vendor", "apply", "repair"], "{text}" ); - assert!(text.contains("Typical workflow:"), "{text}"); + assert!( + text.contains("Patch a project:") && text.contains("Agent mode ("), + "{text}" + ); + assert!(!text.contains("older agent-mode"), "{text}"); } #[test] @@ -247,10 +251,16 @@ fn short_help_lists_about_eight_options_and_long_help_lists_all() { let name = sub.get_name().to_string(); let short = sub.render_help().to_string(); let long = sub.render_long_help().to_string(); - let count = |t: &str| t.lines().filter(|l| l.trim_start().starts_with('-')).count(); + // Options only: `-h`/`-V` are on every command. + let count = |t: &str| { + t.lines() + .map(str::trim_start) + .filter(|l| l.starts_with('-') && !l.starts_with("-h,") && !l.starts_with("-V,")) + .count() + }; assert!(count(&short) <= 9, "{name} -h lists {} options:\n{short}", count(&short)); assert!(count(&long) > count(&short), "{name} --help must list more than -h"); - assert!(short.contains("--json") && long.contains("--cwd"), "{name}"); + assert!(short.contains("--json") && short.contains("--cwd"), "{name}"); } let scan = cmd.find_subcommand_mut("scan").expect("scan"); let long = scan.render_long_help().to_string(); diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index 82f66bf1..aab9ef8b 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -276,7 +276,7 @@ fn list_empty_manifest_non_json() { fn list_no_manifest_non_json_prints_the_empty_project_line() { let tmp = tempfile::tempdir().unwrap(); let (code, stdout, stderr) = common::run_with_env(tmp.path(), &["list"], &[]); - assert_eq!(code, 1); + assert_eq!(code, 0, "an empty project is a successful empty list"); assert!( stdout.contains("No patches in this project. Run `socket-patch scan`."), "non-JSON list-without-manifest names the next step; got: {stdout} / {stderr}" diff --git a/docs/design/v5-plan.md b/docs/design/v5-plan.md index 98fe26ed..9d67346b 100644 --- a/docs/design/v5-plan.md +++ b/docs/design/v5-plan.md @@ -147,7 +147,7 @@ patch-UI review. keep the detail), code-free `Warning:`/`GC: skipped:` lines (error lines keep their code), "hosted" wording in human text, `ui::next_steps` shared by hosted and vendored, prompt-free hosted/vendored `get` (JSON too), `list`'s - `No patches in this project.` line (exit codes unchanged: 1 missing, + `No patches in this project.` line (exit 0, empty success envelope in JSON; was 1 missing, 0 empty), `ui::CANCELLED` / `ui::PAID_UPGRADE`, exit 2 for `get` and `rollback --one-off` usage errors. **Not done:** scan/get JSON onto `json_envelope` (larger contract change; deferred). Per-row diff --git a/gem/socket-patch-bundler/README.md b/gem/socket-patch-bundler/README.md index c95528a3..5e49e857 100644 --- a/gem/socket-patch-bundler/README.md +++ b/gem/socket-patch-bundler/README.md @@ -4,6 +4,8 @@ > plugin) was removed in socket-patch v5, and this gem is no longer built or > published. In agent mode, run `socket-patch apply` in CI after > `bundle install` instead. The source is kept for reference only. +> To remove the hook from a project, see +> [Upgrading from `setup`](https://github.com/SocketDev/socket-patch#upgrading-from-setup). A [Bundler plugin](https://bundler.io/guides/bundler_plugins.html) that keeps the gem patches recorded in your project's `.socket/manifest.json` applied on every diff --git a/pypi/socket-patch-hook/README.md b/pypi/socket-patch-hook/README.md index e1efc4fa..88517036 100644 --- a/pypi/socket-patch-hook/README.md +++ b/pypi/socket-patch-hook/README.md @@ -5,6 +5,8 @@ > wheel is no longer built or published; the `socket-patch[hook]` extra is > gone too. In agent mode, run `socket-patch apply` in CI after install > instead. The source is kept for reference only. +> To remove the hook from a project, see +> [Upgrading from `setup`](https://github.com/SocketDev/socket-patch#upgrading-from-setup). A tiny, package-manager-agnostic **post-install hook** for [`socket-patch`](https://pypi.org/project/socket-patch/). From 2374af105e2e2a96a36228e6e298218737498058 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:19:16 +0000 Subject: [PATCH 6/7] Carry the hosted wording and code-free warnings onto #280's new tests and docs #280 added tests and contract lines with the pre-WS8 human strings ("Would redirect", " redirected, but its patch record ...", `Warning (): ...`). Switch them to this branch's conventions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- crates/socket-patch-cli/CLI_CONTRACT.md | 12 ++++++------ .../tests/e2e_redirect_gem_stale_install.rs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 2b84f9ae..e1379cb2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -107,7 +107,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the npm registry, following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning (vendor_takeover_reverted_redirect): …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -117,7 +117,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result), and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. `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) is 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 ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. @@ -125,7 +125,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. -**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (): …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. **Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger — a vlt directory artifact against its file inventory, which leaves out the links vlt creates inside it; offline-safe) when the ledger entry is at the manifest record's patch uuid; a superseding uuid fetches the pristine package instead, since the older artifact holds the older patch's bytes. Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment; for vlt the registry node's slot [2]), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`, `GONOPROXY` and `GOPRIVATE` the way go reads them — see the env table); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched. **Deferred fetch (v5.0):** a purl the vendor ledger already covers (its entry records the record's patch uuid and the committed artifact is on disk — a file artifact only while it hashes to the ledger's `sha256`; not under `--force`), and a lockfile-only cargo crate the registry could fetch and verify (a crates.io `Cargo.lock` entry with a checksum, or the pre-vendor resolution the ledger recovers) while the patch service is enabled, are NOT downloaded up front: the fetch runs only if the backend reaches a branch that reads the pristine tree (a drifted committed copy rebuilt locally, a service miss). An in-sync re-run therefore makes no registry request, reports no `vendor_fetched_missing`, and succeeds with no network or under `--offline`. A deferred fetch that does run records its `vendor_fetched_missing` just ahead of the package's own event; one that fails, is unverifiable, or is refused by `--offline` reports the same events the up-front fetch would have. A git, path or custom-registry cargo crate is never deferred, so it keeps `vendor_fetch_unverifiable` + `package_not_installed` and is never vendored from the service's crates.io build. **Gem, local build only** (`--vendor-source build`, or no service config): a not-installed gem the lock can verify (bundler >= 2.6 `CHECKSUMS`) and no ledger entry covers is refused `gem_spec_missing` (`failed`, the backend's own detail) BEFORE any download — a downloaded `.gem` carries no eval-able stub gemspec, so a local build can never vendor it; no `vendor_fetched_missing` precedes it, and a refusal the backend would have reached first on the fetched copy reports as `gem_spec_missing` too. Not under `--dry-run`, which still fetches and previews the gem (`vendor_fetched_missing` + `verified`). @@ -356,7 +356,7 @@ the model is **not uniform** today: path, as bundler itself ignores it). The skip is surfaced per the run-warning conventions: a `gem_bundle_config_path_ignored` entry in the run-level `warnings[]` of `scan`/`apply` `--json` envelopes (detail names the config value and the env-`BUNDLE_PATH` remedy), and one stderr - `Warning (gem_bundle_config_path_ignored): …` line on the human path, gated on `!--silent` + `Warning: …` line on the human path, gated on `!--silent` (`--silent` = errors only). Explicit env/config roots only count when `--cwd` holds a Bundler manifest/lockfile. When the default `vendor/bundle` root holds no store, the gem homes `gem env` reports are appended (default gems like rexml/json only ever live there). When several roots hold @@ -1131,7 +1131,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_bun_lockb_invalid` | `failed` | vendor / scan / get `--mode vendored`: the binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. The detail names the parser, hash or filesystem error. Refused before patch downloads and before hosted takeover; `patches[]` / `download.patches[]` carry `errorCode` and `error`, while `get ` also carries top-level `error.code`. Dry-run predicts the same refusal. | | `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_invalid` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` rebuilds. | | `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_invalid`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | -| `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning (bun_lockb_invalid): …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | +| `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning: …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | | `would_refuse` | dry-run preview action (`vendor.patches[]`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl; the record carries `errorCode` (one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger) + `error`. Exit 0 / `status: "success"`, nothing written. | | `cargo_wiring_migrated` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a pre-v5 `.cargo/config.toml` / `.cargo/config` vendored `[patch.crates-io]` entry was moved into the workspace-root `Cargo.toml` (dry run: "would move"); the ledger entry is rewritten to name `Cargo.toml` (lock originals kept). A vendor re-run that migrates reports the package `applied`, not `already_vendored`. | | `cargo_legacy_wiring_kept` | vendor: `failed`; repair: `skipped` (warning) | vendor / scan / get `--mode vendored` (v5.0): the pre-v5 config entry could not be removed after the manifest took the wiring — the run is unwound (manifest, lock and copy as before) and the package fails, since a kept entry would double-wire the crate and, on a uuid bump, point at a copy the stale sweep deletes; the code prefixes the error detail. repair: the move was refused (e.g. an unparseable `Cargo.toml`, a user entry for the crate, or an unremovable legacy entry — the manifest edit is unwound); left in place. | @@ -1162,7 +1162,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_vlt_lock_out_of_sync` | `failed` | vendor (vlt): an importer's `package.json` is missing, unparseable, or declares a spec for the dependency that differs from the lock's importer edge. Remedy: `vlt install` first. Refused before any write. | | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | -| `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning (vendor_vlt_reinstall_required): …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a rebuild of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the rebuilt payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | +| `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a rebuild of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the rebuilt payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index ce54459d..4c2aa532 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs @@ -458,7 +458,7 @@ async fn gem_hosted_rescan_with_failing_record_fetch_reports_it_and_keeps_the_wi assert_eq!( failed["detail"], format!( - "{PURL} redirected, but its patch record could not be fetched; this run's VEX \ + "{PURL} was switched to hosted, but its patch record could not be fetched; this run's VEX \ attestation omits it (`socket-patch vex` fetches it again once the API answers)" ), "{env}" From e824129e181ba15e8a3d753ef155de28356320e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:24:09 +0000 Subject: [PATCH 7/7] Give the cargo safety VEX baseline a vulnerability to attest With setup's install-hook filter gone, the manifest-backed agent-mode cargo patch attests, but the staged minimal manifest carries no vulnerabilities, so vex ended no_applicable_patches (exit 1). Add one vulnerability to the entry before the baseline run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQaKzoW5dSw9u5pgAAvVRj --- .../tests/e2e_safety_cargo_build.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index ae4f7b57..62e9ef05 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -406,7 +406,20 @@ fn manifestless_agent_patch_is_not_attested(consumer: &Path, cargo_home: &Path) let run = VexRun::online(&api).env("CARGO_HOME", cargo_home); // Baseline, manifest present: the applied, verified agent-mode patch - // attests. + // attests. The staged manifest carries no vulnerabilities (which alone + // would end `no_applicable_patches`), so give the entry one first. + let manifest_path = consumer.join(".socket/manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + manifest["patches"][FIXTURE_PURL]["vulnerabilities"] = serde_json::json!({ + "GHSA-cccc-cccc-cccc": { + "cves": ["CVE-2099-0001"], + "summary": "cargo safety vex vuln", + "severity": "high", + "description": "d" + } + }); + std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); let out = run_vex(&bin, consumer, &run); assert_eq!(out.code, Some(0), "manifest-backed vex:\n{out}"); assert!(