diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 30a88bce..73af2f3c 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -35,6 +35,11 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[ "packages.lock.json", "Gemfile", "Gemfile.lock", + // Bundler's modern manifest spelling — preferred over Gemfile when both + // exist (the gem rewriter picks the pair bundler reads and fails closed + // on diverging spellings). + "gems.rb", + "gems.locked", "pom.xml", // Maven Trusted Checksums files the fail-closed maven rewriter merges into // (read so an existing user config / checksum set is preserved, not diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs index 9b99ee11..c5a6cad7 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs @@ -29,13 +29,17 @@ //! Gemfile and Gemfile.lock and removes `.socket/vendor` entirely → //! re-vendor succeeds again. //! -//! This suite deliberately runs against a lock WITHOUT a `CHECKSUMS` section -//! (bundler keeps `lockfile_checksums` opt-in, and CHECKSUMS-aware vendoring -//! is a parallel workstream) — stage 1 hard-asserts that precondition. -//! TODO(v2 gem CHECKSUMS): add the lockfile_checksums variant (fixture with -//! `bundle config set --local lockfile_checksums true` before the first -//! lock; expect the vendored entry rewritten to bundler's bare path-gem -//! CHECKSUMS form per spikes/gem-checksums/). +//! The first test runs against a lock WITHOUT a `CHECKSUMS` section (bundler +//! 2.7 keeps `lockfile_checksums` opt-in) — its stage 1 hard-asserts that +//! precondition. The `lockfile_checksums` twin covers the opt-in flavor: the +//! fixture lock gains `CHECKSUMS` via `bundle lock --add-checksums` +//! (supported by the image's bundler 2.7), vendor must rewrite the gem's +//! registry `sha256=` line to bundler's bare path-gem form (per +//! spikes/gem-checksums/ a leftover registry line on a path-sourced gem +//! makes `bundle install` fail — exit 16 under frozen mode), the frozen +//! offline install must accept the rewritten lock byte-stably, and revert +//! must restore the registry `sha256=` line VERBATIM (the recorded original +//! is the only offline path back). #![cfg(feature = "docker-e2e")] @@ -56,8 +60,13 @@ const UUID: &str = "32323232-3232-4232-8232-323232323232"; /// host capstones). const GHSA: &str = "GHSA-vend-gem-real"; -/// Glue the shared bash helpers onto a stage body and pin the uuid + ghsa. -fn render(stage_body: &str) -> String { +/// The lockfile_checksums twin's identifiers — distinct so a leaked path or +/// vulnerability id from one flavor can never satisfy the other's asserts. +const CK_UUID: &str = "34343434-3434-4434-8434-343434343434"; +const CK_GHSA: &str = "GHSA-vend-gem-ck"; + +/// Glue the shared bash helpers onto a stage body and pin a uuid + ghsa. +fn render_with(stage_body: &str, uuid: &str, ghsa: &str) -> String { format!( "{}{}{}{}", bash_prelude(), @@ -65,8 +74,12 @@ fn render(stage_body: &str) -> String { json_assert_fns(), stage_body ) - .replace("__UUID__", UUID) - .replace("__GHSA__", GHSA) + .replace("__UUID__", uuid) + .replace("__GHSA__", ghsa) +} + +fn render(stage_body: &str) -> String { + render_with(stage_body, UUID, GHSA) } /// Stage 1: real bundler fixture (network OK) + staged marker patch + @@ -98,10 +111,10 @@ RACK_VER=$(sed -n 's/^ rack (\([0-9][0-9.]*\))$/\1/p' Gemfile.lock | head -1) [ -n "$RACK_VER" ] || { cat Gemfile.lock >&2; fail "could not read the resolved rack version from Gemfile.lock"; } echo "resolved rack version: $RACK_VER" >&2 -# Precondition this suite is scoped to: NO CHECKSUMS section (bundler >= 2.6 -# keeps lockfile_checksums opt-in; CHECKSUMS-aware vendoring is a parallel -# workstream — see the module doc TODO). -grep -q '^CHECKSUMS' Gemfile.lock && fail "Gemfile.lock unexpectedly has a CHECKSUMS section — this suite requires the default (no-CHECKSUMS) lock" +# Precondition this test is scoped to: NO CHECKSUMS section (bundler 2.7 +# keeps lockfile_checksums opt-in; the CHECKSUMS flavor is the +# lockfile_checksums twin below). +grep -q '^CHECKSUMS' Gemfile.lock && fail "Gemfile.lock unexpectedly has a CHECKSUMS section — this test requires the default (no-CHECKSUMS) lock" RUBY_API=$(ruby -e 'puts Gem.ruby_api_version') || fail "ruby api version probe" GEM_DIR="vendor/bundle/ruby/$RUBY_API/gems/rack-$RACK_VER" @@ -349,6 +362,227 @@ fn assert_vex_attested_from_host(host_dir: &std::path::Path) { ); } +/// Stage 1 of the lockfile_checksums twin: the fixture lock GAINS a +/// CHECKSUMS section (`bundle lock --add-checksums`, real bundler 2.7), the +/// upstream registry `sha256=` line is captured verbatim for the revert +/// oracle, and vendor must rewrite that line to bundler's bare path-gem form +/// while landing the same pair edit as the no-CHECKSUMS flavor. +const STAGE1_CK: &str = r#" +mkdir -p /workspace/proj && cd /workspace/proj +export SOCKET_OFFLINE=1 +export BUNDLE_APP_CONFIG="$PWD/.bundle" + +cat > Gemfile <<'EOF' +source "https://rubygems.org" + +gem "rack", "~> 3.1" +EOF + +bundle config set --local path vendor/bundle || fail "bundle config set --local path" + +# 1. REAL fixture: bundle install + an opt-in CHECKSUMS lock (bundler 2.7 +# does not write one by default — the twin suite pins that default). +bundle install > /tmp/install.log 2>&1 || { cat /tmp/install.log >&2; fail "bundle install (fixture) failed"; } +bundle lock --add-checksums > /tmp/lock.log 2>&1 || { cat /tmp/lock.log >&2; fail "bundle lock --add-checksums failed"; } + +RACK_VER=$(sed -n 's/^ rack (\([0-9][0-9.]*\))$/\1/p' Gemfile.lock | head -1) +[ -n "$RACK_VER" ] || { cat Gemfile.lock >&2; fail "could not read the resolved rack version from Gemfile.lock"; } +echo "resolved rack version: $RACK_VER" >&2 + +grep -q '^CHECKSUMS$' Gemfile.lock || { cat Gemfile.lock >&2; fail "bundle lock --add-checksums did not add a CHECKSUMS section"; } +UPSTREAM_LINE=$(grep -E "^ rack \($RACK_VER\) sha256=[0-9a-f]{64}$" Gemfile.lock) +[ "$(echo "$UPSTREAM_LINE" | wc -l)" -eq 1 ] && [ -n "$UPSTREAM_LINE" ] \ + || { cat Gemfile.lock >&2; fail "expected exactly one registry sha256 CHECKSUMS line for rack"; } + +RUBY_API=$(ruby -e 'puts Gem.ruby_api_version') || fail "ruby api version probe" +GEM_DIR="vendor/bundle/ruby/$RUBY_API/gems/rack-$RACK_VER" +ORIG="$GEM_DIR/lib/rack.rb" +[ -f "$ORIG" ] || { ls -R vendor/bundle/ruby >&2 || true; fail "$ORIG missing after bundle install"; } +grep -q 'SOCKET_PATCH_VENDOR_E2E' "$ORIG" && fail "probe constant already in $ORIG — fixture not pristine" + +# 2. Marker patch on the ACTUAL installed bytes. +cp "$ORIG" /tmp/patched.rb +cat >> /tmp/patched.rb <<'EOF' + +# SOCKET-PATCH-VENDOR-E2E-MARKER +module Rack + SOCKET_PATCH_VENDOR_E2E = "__UUID__" +end +EOF +PURL="pkg:gem/rack@$RACK_VER" +stage_patch "$PURL" "__UUID__" "lib/rack.rb" "$ORIG" /tmp/patched.rb \ + "__GHSA__" "CVE-2024-88888" + +mkdir -p /workspace/snap +cp Gemfile /workspace/snap/Gemfile.prevendor +cp Gemfile.lock /workspace/snap/Gemfile.lock.prevendor +printf '%s\n' "$UPSTREAM_LINE" > /workspace/snap/upstream-checksum-line +echo "$RACK_VER" > /workspace/snap/rack-ver + +# 3. Vendor (fully offline). +socket-patch vendor --json --offline > /tmp/vendor.json 2>/tmp/vendor.err +RC=$?; cat /tmp/vendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/vendor.json >&2; fail "vendor exited $RC (expected 0)"; } +assert_json_field /tmp/vendor.json '"status": "success"' +assert_summary /tmp/vendor.json applied 1 +assert_summary /tmp/vendor.json failed 0 +echo "===VENDOR RUN VERIFIED===" + +# 4. The CHECKSUMS rewrite: the registry sha256= line becomes bundler's bare +# path-gem entry (a leftover registry line on a path-sourced gem fails +# the next install — spikes/gem-checksums/). +grep -qxF " rack ($RACK_VER)" Gemfile.lock \ + || { cat Gemfile.lock >&2; fail "CHECKSUMS entry not rewritten to the bare path-gem form"; } +grep -q "^ rack ($RACK_VER) sha256=" Gemfile.lock \ + && { cat Gemfile.lock >&2; fail "registry sha256 CHECKSUMS line still present after vendor"; } +grep -q '^CHECKSUMS$' Gemfile.lock || { cat Gemfile.lock >&2; fail "CHECKSUMS section lost by the vendor edit"; } +echo "===CHECKSUMS REWRITE VERIFIED===" + +# 5. Same mandatory pair edit as the no-CHECKSUMS flavor. +COPY_REL=".socket/vendor/gem/__UUID__/rack-$RACK_VER" +[ -d "$COPY_REL" ] || fail "vendored gem dir missing at $COPY_REL" +grep -qF "gem \"rack\", \"$RACK_VER\", path: \"$COPY_REL\"" Gemfile \ + || { cat Gemfile >&2; fail "Gemfile line not rewritten to the exact-pin + path: form"; } +grep -qF " remote: $COPY_REL" Gemfile.lock || { cat Gemfile.lock >&2; fail "PATH remote is not the relative vendored path"; } +grep -qF " rack (= $RACK_VER)!" Gemfile.lock || { cat Gemfile.lock >&2; fail "DEPENDENCIES pin ' rack (= $RACK_VER)!' missing"; } +awk '/^PATH$/{p=NR} /^GEM$/{g=NR} END{exit !(p && g && p&2; fail "PATH section must precede GEM"; } +echo "===LOCK WIRING VERIFIED===" + +# 6. Fresh-checkout staging: ONLY the committable files. +rm -rf /workspace/fresh && mkdir -p /workspace/fresh +cp Gemfile Gemfile.lock /workspace/fresh/ +cp -R .socket /workspace/fresh/.socket +cp -R .bundle /workspace/fresh/.bundle +echo "===STAGE1 VERIFIED===" +exit 0 +"#; + +/// Stage 2 of the twin (`--network none` + `BUNDLE_FROZEN=true`): the +/// exit-16 hazard proof — a FROZEN cold-cache offline install must accept +/// the CHECKSUMS lock whose entry for the vendored gem is the bare path-gem +/// form, byte-stably, and the probe constant must load from the vendored +/// path. +const STAGE2_CK: &str = r#" +cd /workspace/fresh +export BUNDLE_APP_CONFIG="$PWD/.bundle" +export BUNDLE_FROZEN=true +RACK_VER=$(cat /workspace/snap/rack-ver) + +[ ! -e vendor ] || fail "fresh checkout already has vendor/ (test bug: uncommittable file copied)" +gem list -i '^rack$' > /dev/null && fail "rack pre-installed in the image gem home — cold-cache premise broken" +grep -q '^CHECKSUMS$' Gemfile.lock || fail "fresh checkout lost the CHECKSUMS section (test bug)" + +LOCK_SHA_BEFORE=$(sha256sum Gemfile.lock | cut -d' ' -f1) +bundle install > /tmp/install.log 2>&1 || { cat /tmp/install.log >&2; fail "frozen cold-cache offline bundle install failed on the CHECKSUMS lock"; } +cat /tmp/install.log >&2 +[ "$LOCK_SHA_BEFORE" = "$(sha256sum Gemfile.lock | cut -d' ' -f1)" ] \ + || fail "bundle install churned the committed CHECKSUMS Gemfile.lock" +echo "===FRESH INSTALL VERIFIED===" + +OUT=$(bundle exec ruby -e ' + require "rack" + abort "probe constant missing after require" unless defined?(Rack::SOCKET_PATCH_VENDOR_E2E) + puts Rack::SOCKET_PATCH_VENDOR_E2E + puts $LOADED_FEATURES.grep(%r{/rack\.rb\z}) +' 2>&1) || { echo "$OUT" >&2; fail "bundle exec runtime probe failed"; } +echo "$OUT" >&2 +echo "$OUT" | grep -qF "__UUID__" || fail "probe constant does not carry the patch uuid" +echo "$OUT" | grep -qF ".socket/vendor/gem/__UUID__/rack-$RACK_VER/lib/rack.rb" \ + || fail "rack was not loaded from the vendored path" +echo "===RUNTIME MARKER VERIFIED===" +exit 0 +"#; + +/// Stage 3 of the twin (`--network none`): idempotent re-vendor → revert +/// restores the registry `sha256=` CHECKSUMS line VERBATIM (byte-identical +/// files) → re-vendor rewrites it back to the bare form. +const STAGE3_CK: &str = r#" +cd /workspace/proj +export SOCKET_OFFLINE=1 +export BUNDLE_APP_CONFIG="$PWD/.bundle" +RACK_VER=$(cat /workspace/snap/rack-ver) +UPSTREAM_LINE=$(cat /workspace/snap/upstream-checksum-line) + +# 1. Idempotency: re-run reports already_vendored, both files byte-stable. +GEMFILE_SHA=$(sha256sum Gemfile | cut -d' ' -f1) +LOCK_SHA=$(sha256sum Gemfile.lock | cut -d' ' -f1) +socket-patch vendor --json --offline > /tmp/revendor.json 2>/tmp/revendor.err +RC=$?; cat /tmp/revendor.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor.json >&2; fail "re-vendor exited $RC"; } +assert_summary /tmp/revendor.json failed 0 +assert_json_field /tmp/revendor.json '"already_vendored"' +[ "$LOCK_SHA" = "$(sha256sum Gemfile.lock | cut -d' ' -f1)" ] || fail "re-vendor churned Gemfile.lock" +[ "$GEMFILE_SHA" = "$(sha256sum Gemfile | cut -d' ' -f1)" ] || fail "re-vendor churned Gemfile" +echo "===IDEMPOTENT VERIFIED===" + +# 2. Revert: byte-restore, INCLUDING the registry sha256= line verbatim +# (the explicit grep keeps the exit-16 hazard documented even if the +# byte-identity assert is ever loosened). +socket-patch vendor --revert --json --offline > /tmp/revert.json 2>/tmp/revert.err +RC=$?; cat /tmp/revert.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revert.json >&2; fail "revert exited $RC"; } +assert_summary /tmp/revert.json removed 1 +cmp -s Gemfile /workspace/snap/Gemfile.prevendor \ + || { diff /workspace/snap/Gemfile.prevendor Gemfile >&2 || true; fail "revert did not byte-restore the Gemfile"; } +cmp -s Gemfile.lock /workspace/snap/Gemfile.lock.prevendor \ + || { diff /workspace/snap/Gemfile.lock.prevendor Gemfile.lock >&2 || true; fail "revert did not byte-restore Gemfile.lock"; } +grep -qxF "$UPSTREAM_LINE" Gemfile.lock \ + || { cat Gemfile.lock >&2; fail "revert did not restore the registry sha256= CHECKSUMS line verbatim"; } +[ ! -e .socket/vendor ] || fail ".socket/vendor must be fully removed after revert" +echo "===REVERT VERIFIED===" + +# 3. Re-vendor after revert: the CHECKSUMS entry goes bare again. +socket-patch vendor --json --offline > /tmp/revendor2.json 2>/tmp/revendor2.err +RC=$?; cat /tmp/revendor2.err >&2 +[ "$RC" -eq 0 ] || { cat /tmp/revendor2.json >&2; fail "post-revert re-vendor exited $RC"; } +assert_summary /tmp/revendor2.json applied 1 +grep -qxF " rack ($RACK_VER)" Gemfile.lock || { cat Gemfile.lock >&2; fail "re-vendor did not re-bare the CHECKSUMS entry"; } +grep -q "^ rack ($RACK_VER) sha256=" Gemfile.lock && { cat Gemfile.lock >&2; fail "registry sha256 line back after re-vendor"; } +echo "===REVENDOR VERIFIED===" +exit 0 +"#; + +/// Host-side oracle for the lockfile_checksums twin: the pair edit is wired +/// AND the CHECKSUMS section holds bundler's bare path-gem entry for rack +/// (no registry `sha256=` remnant) — asserted from the mounted files without +/// trusting the in-container greps. +fn assert_ck_pair_wired_from_host(host_dir: &std::path::Path) { + let rack_ver = std::fs::read_to_string(host_dir.join("snap/rack-ver")) + .expect("snap/rack-ver") + .trim() + .to_string(); + let copy_rel = format!(".socket/vendor/gem/{CK_UUID}/rack-{rack_ver}"); + + let gemfile = + std::fs::read_to_string(host_dir.join("proj/Gemfile")).expect("read mounted Gemfile"); + assert!( + gemfile.contains(&format!( + "gem \"rack\", \"{rack_ver}\", path: \"{copy_rel}\"" + )), + "host oracle: Gemfile not in the exact-pin + path: form:\n{gemfile}" + ); + + let lock = std::fs::read_to_string(host_dir.join("proj/Gemfile.lock")) + .expect("read mounted Gemfile.lock"); + assert!( + lock.contains("\nCHECKSUMS\n"), + "host oracle: this twin must run against a CHECKSUMS lock:\n{lock}" + ); + assert!( + lock.contains(&format!("\n rack ({rack_ver})\n")), + "host oracle: bare path-gem CHECKSUMS entry missing:\n{lock}" + ); + assert!( + !lock.contains(&format!(" rack ({rack_ver}) sha256=")), + "host oracle: registry sha256 CHECKSUMS line survived the vendor edit:\n{lock}" + ); + assert!( + lock.contains(&format!("\n rack (= {rack_ver})!")), + "host oracle: DEPENDENCIES pin missing:\n{lock}" + ); +} + #[test] fn gem_vendor_fresh_checkout_bundle_install_and_revert() { if skip_if_no_image(IMAGE) { @@ -388,3 +622,47 @@ fn gem_vendor_fresh_checkout_bundle_install_and_revert() { // Suite leaves the project re-vendored; the host oracle must hold again. assert_pair_wired_from_host(&host_dir); } + +/// The `lockfile_checksums` twin (see the module doc): same lifecycle against +/// a lock WITH a CHECKSUMS section — the vendor edit must swap the registry +/// `sha256=` line for bundler's bare path-gem entry, the frozen offline +/// install must accept it byte-stably, and revert must restore the registry +/// line verbatim. +#[test] +fn gem_vendor_lockfile_checksums_fresh_checkout_and_revert() { + if skip_if_no_image(IMAGE) { + return; + } + let tmp = tempfile::tempdir().expect("tempdir"); + let host_dir = tmp.path().canonicalize().expect("canonicalize tempdir"); + + // Stage 1 — networked fixture install + --add-checksums + offline vendor + // + CHECKSUMS-rewrite + pair-edit asserts. + let out = run_in_image(IMAGE, &host_dir, &render_with(STAGE1_CK, CK_UUID, CK_GHSA)); + assert_stage_markers( + "gem ck stage 1 (install+add-checksums+vendor)", + &out, + &["VENDOR RUN", "CHECKSUMS REWRITE", "LOCK WIRING", "STAGE1"], + ); + assert_ck_pair_wired_from_host(&host_dir); + + // Stage 2 — fresh checkout, frozen + cold caches + network cut: the + // exit-16 hazard proof on the CHECKSUMS lock. + let out = + run_in_image_network_none(IMAGE, &host_dir, &render_with(STAGE2_CK, CK_UUID, CK_GHSA)); + assert_stage_markers( + "gem ck stage 2 (fresh checkout, --network none, BUNDLE_FROZEN)", + &out, + &["FRESH INSTALL", "RUNTIME MARKER"], + ); + + // Stage 3 — idempotency, revert (verbatim sha256= restore), re-vendor. + let out = + run_in_image_network_none(IMAGE, &host_dir, &render_with(STAGE3_CK, CK_UUID, CK_GHSA)); + assert_stage_markers( + "gem ck stage 3 (idempotent+revert+re-vendor)", + &out, + &["IDEMPOTENT", "REVERT", "REVENDOR"], + ); + assert_ck_pair_wired_from_host(&host_dir); +} diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs new file mode 100644 index 00000000..b017f277 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -0,0 +1,1032 @@ +//! Real-bundler hosted-mode capstone e2e for gem — the full-chain proof for +//! `scan --mode hosted` on the rubygems-compact-index override, and the +//! executable pin on the compact-index DEPENDENCY contract the production +//! server currently violates (its `/info` answers `{"error":"not_built"}` and +//! the `/api/v1/dependencies` fallback returns a zero-byte body — see +//! `e2e_hosted_production.rs`'s `is_known_defect` tolerance). +//! +//! Unlike the npm/cargo siblings, this suite is FULLY hermetic: the fixture +//! gems are authored here and built with the real `gem build`, and ONE +//! wiremock plays every server in the chain — +//! +//! * the UPSTREAM rubygems registry (compact index `/versions`, +//! `/info/`, `/names`, `/gems/-.gem`) serving +//! `vuln-gem` 1.0.0 (which `require`s its runtime dependency `tiny-dep`) +//! and `tiny-dep` 1.0.0, +//! * the Socket PATCH REGISTRY compact index (same protocol, production's +//! `/patch-registry/gem///` base) serving the PATCHED +//! `vuln-gem` — with `/info` correctly declaring the `tiny-dep` runtime +//! dependency and the patched `.gem`'s sha256 checksum, +//! * the Socket patches API (batch / by-package / package-reference / view). +//! +//! The chain proven against the REAL host bundler: +//! +//! 1. `bundle install` the fixture project from the mock upstream into a +//! project-local `vendor/bundle` (no rubygems.org, no network beyond +//! loopback). +//! 2. `scan --mode hosted --json --vex …` (the real binary): the Gemfile +//! gains the `source "" do … end` block, the ledger embeds +//! the patch record, the in-run VEX is the unverified `(redirected)` +//! attestation. +//! 3. FRESH-CHECKOUT PROOF: only the committable files travel; an UNFROZEN +//! `bundle install` (the flow the rewriter's `redirect_gem_frozen_install` +//! warning prescribes) resolves the patched gem from the mock patch +//! registry: installed bytes byte-match the patch blob, the runtime dep +//! installs BECAUSE the registry `/info` declares it, and a require +//! probe loads the patched code. +//! 4. POST-INSTALL VERIFIED VEX: `socket-patch vex` hash-verifies the +//! installed tree against the ledger record. +//! +//! The `gems.rb` twin drives the same chain through bundler's modern +//! `gems.rb`/`gems.locked` spelling (which bundler prefers over `Gemfile` +//! when both exist — this pins the candidate-list + rewriter support). +//! +//! The deps red-arm serves a PRODUCTION-LIKE `/info` (checksum but NO +//! dependencies): the fresh install must fail with bundler's +//! `APIResponseMismatchError … revealed dependencies not in the API` — the +//! exact live-CI signature — so any server or fixture that stops declaring +//! runtime deps turns this suite red. +//! +//! KNOWN LIMITATION, pinned as a canary: on a lock that carries a CHECKSUMS +//! section (bundler >= 4 writes one by default), today's rewrite (Gemfile +//! block + CHECKSUMS pin, GEM section left on the upstream remote) makes the +//! prescribed unfrozen install fail with "Bundler found mismatched checksums" +//! — bundler still attributes the gem to the upstream source and refuses the +//! lockfile-vs-API disagreement (exit 37, verified on bundler 4.0.15). The +//! canary test pins that reality; the verified fix shape is the fully +//! converged lock (patched-registry GEM section + ` (= )!` +//! DEPENDENCIES pin + patched CHECKSUMS sha — a frozen install of that shape +//! passes), which must land in the TS twin + golden fixtures together. +//! +//! Skips (with a println) when `ruby`/`gem`/`bundle` are missing or the host +//! bundler predates 2.6 (the CHECKSUMS-aware floor); everything after that is +//! hard — no live network is involved at all. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha256}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/cache_env.rs"] +mod cache_env; + +const ORG: &str = "test-org"; +const DEP: &str = "vuln-gem"; +const DEP_VERSION: &str = "1.0.0"; +const TRANSITIVE: &str = "tiny-dep"; +/// Canonical lowercase patch uuid — a path level of both the hosted artifact +/// URL and the patch-registry index URL (production shape). +const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; +/// Access-token uuid segment of the hosted URLs (opaque to the CLI — it +/// writes what the reference endpoint hands back). +const TOKEN: &str = "44444444-4444-4444-8444-444444444444"; +const GHSA: &str = "GHSA-redirect-gem-real"; +const PRODUCT: &str = "pkg:gem/app@1.0.0"; +const PURL: &str = "pkg:gem/vuln-gem@1.0.0"; + +/// The runtime probe constant baked into the PATCHED lib — observable at +/// `require` time, carries the patch uuid so the assert can't pass on any +/// other content. +fn patched_marker() -> String { + format!("PATCHED-{UUID}") +} + +/// The pristine gem sources. `vuln-gem` REQUIRES its runtime dependency at +/// load time, so a resolution that drops `tiny-dep` (what a deps-less +/// registry `/info` produces) cannot pass the require probe. +fn orig_lib() -> String { + "require \"tiny_dep\"\n\nmodule VulnGem\n def self.status\n \"VULNERABLE\"\n end\n\n DEP = TinyDep::VALUE\nend\n".to_string() +} + +fn patched_lib() -> String { + orig_lib().replace("\"VULNERABLE\"", &format!("\"{}\"", patched_marker())) +} + +const TINY_LIB: &str = "module TinyDep\n VALUE = \"tiny-ok\"\nend\n"; + +// ── self-contained helpers ──────────────────────────────────────────── + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn has_command(cmd: &str) -> bool { + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +/// `bundle --version` → `(major, minor)`; `None` = no usable bundler. +fn bundler_version() -> Option<(u32, u32)> { + let mut probe = Command::new("bundle"); + probe.arg("--version"); + cache_env::isolate(&mut probe); + let out = probe.output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let ver = text.split_whitespace().last()?.to_string(); + let mut it = ver.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + Some((major, minor)) +} + +/// Run the socket-patch binary with the ambient `SOCKET_*` surface scrubbed +/// (a developer's `SOCKET_DRY_RUN=1` must not steer the assertions) and +/// `VIRTUAL_ENV` (crawler discovery input) removed. +fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { + cmd.env_remove(&k); + } + } + cmd.env_remove("VIRTUAL_ENV"); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// Run `bundle ` in `cwd`: ambient `BUNDLE_*`/`GEM_*` scrubbed, caches +/// isolated, `BUNDLE_APP_CONFIG` pinned to the project's own `.bundle/`, and +/// a PER-PROJECT `BUNDLE_USER_HOME` so each stage's compact-index cache is +/// cold (the fresh-checkout install must be forced through the wiremock +/// registry, never satisfied from the scan project's cache). +fn bundle(cwd: &Path, args: &[&str]) -> Output { + let mut cmd = Command::new("bundle"); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + let key = k.to_string_lossy().into_owned(); + if key.starts_with("BUNDLE_") || key.starts_with("GEM_") { + cmd.env_remove(&k); + } + } + cache_env::isolate(&mut cmd); + cmd.env("BUNDLE_APP_CONFIG", cwd.join(".bundle")); + cmd.env("BUNDLE_USER_HOME", cwd.join(".bundle-user-home")); + cmd.output().expect("failed to run bundle") +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// MD5 hex digest via the host ruby (`Digest::MD5`) — the compact-index +/// `/versions` line carries the md5 of each `/info/` body and bundler +/// validates it; ruby is already a suite prerequisite, so no md5 dev-dep. +fn md5_hex(bytes: &[u8]) -> String { + let mut child = Command::new("ruby") + .args(["-rdigest", "-e", "print Digest::MD5.hexdigest(STDIN.read)"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to run ruby for md5"); + child + .stdin + .take() + .expect("ruby stdin") + .write_all(bytes) + .expect("write md5 input"); + let out = child.wait_with_output().expect("ruby md5 output"); + assert!(out.status.success(), "ruby md5 helper failed"); + let hexstr = String::from_utf8(out.stdout).expect("md5 hex is ascii"); + assert_eq!( + hexstr.len(), + 32, + "md5 hex digest must be 32 chars: {hexstr}" + ); + hexstr +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir_recursive(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// Author a gem (gemspec + one lib file) and build it with the REAL +/// `gem build`; returns the `.gem` bytes. +fn build_gem( + stage: &Path, + name: &str, + version: &str, + lib_file: &str, + lib_content: &str, + runtime_deps: &[&str], +) -> Vec { + let dir = stage.join(format!("{name}-src")); + std::fs::create_dir_all(dir.join("lib")).unwrap(); + std::fs::write(dir.join("lib").join(lib_file), lib_content).unwrap(); + let deps: String = runtime_deps + .iter() + .map(|d| format!(" s.add_dependency \"{d}\", \">= 0\"\n")) + .collect(); + std::fs::write( + dir.join(format!("{name}.gemspec")), + format!( + "Gem::Specification.new do |s|\n s.name = \"{name}\"\n s.version = \"{version}\"\n s.summary = \"socket-patch hosted-gem capstone fixture\"\n s.authors = [\"socket-patch e2e\"]\n s.files = [\"lib/{lib_file}\"]\n s.require_paths = [\"lib\"]\n{deps}end\n" + ), + ) + .unwrap(); + let mut cmd = Command::new("gem"); + cmd.args(["build", &format!("{name}.gemspec")]) + .current_dir(&dir); + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run gem build"); + assert!( + out.status.success(), + "gem build {name} failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + std::fs::read(dir.join(format!("{name}-{version}.gem"))).expect("built .gem present") +} + +/// One gem a compact index serves: coordinates, runtime deps (compact-index +/// `name:constraint` tokens), and the `.gem` bytes the download route returns. +struct IndexGem { + name: &'static str, + version: &'static str, + deps: Vec, + gem: Vec, +} + +/// Mount a complete rubygems compact index under `base` (no trailing slash): +/// `/versions` (with real per-info md5 digests — bundler validates them), +/// `/info/` (deps + `checksum:`), `/names`, and the +/// `/gems/-.gem` download routes. +async fn mount_compact_index(server: &MockServer, base: &str, gems: &[IndexGem]) { + let mut versions_body = String::from("created_at: 2026-01-01T00:00:00Z\n---\n"); + let mut names_body = String::from("---\n"); + for g in gems { + let deps = g.deps.join(","); + let info_body = format!( + "---\n{} {deps}|checksum:{}\n", + g.version, + sha256_hex(&g.gem) + ); + versions_body.push_str(&format!( + "{} {} {}\n", + g.name, + g.version, + md5_hex(info_body.as_bytes()) + )); + names_body.push_str(&format!("{}\n", g.name)); + Mock::given(method("GET")) + .and(path(format!("{base}/info/{}", g.name))) + .respond_with(ResponseTemplate::new(200).set_body_raw(info_body, "text/plain")) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("{base}/gems/{}-{}.gem", g.name, g.version))) + .respond_with( + ResponseTemplate::new(200).set_body_raw(g.gem.clone(), "application/octet-stream"), + ) + .mount(server) + .await; + } + Mock::given(method("GET")) + .and(path(format!("{base}/versions"))) + .respond_with(ResponseTemplate::new(200).set_body_raw(versions_body, "text/plain")) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("{base}/names"))) + .respond_with(ResponseTemplate::new(200).set_body_raw(names_body, "text/plain")) + .mount(server) + .await; +} + +/// Everything the post-redirect legs need. `_server` keeps every registry and +/// API route alive through the fresh `bundle install`. +struct RedirectFixture { + tmp: tempfile::TempDir, + proj: PathBuf, + index_url: String, + gemfile_name: &'static str, + lock_name: &'static str, + patched: Vec, + _server: MockServer, +} + +/// Which manifest spelling the fixture project uses. +#[derive(Clone, Copy, PartialEq)] +enum Spelling { + Gemfile, + GemsRb, +} + +impl Spelling { + fn pair(self) -> (&'static str, &'static str) { + match self { + Spelling::Gemfile => ("Gemfile", "Gemfile.lock"), + Spelling::GemsRb => ("gems.rb", "gems.locked"), + } + } +} + +/// Build the hermetic fixture and run `scan --mode hosted` through the real +/// binary: author + `gem build` the three gems, mount both compact indexes +/// and the patches API, `bundle install` from the mock upstream, scan, and +/// assert the redirect envelope + Gemfile rewrite. `checksums_lock` opts the +/// fixture lock into a CHECKSUMS section (`bundle lock --add-checksums`); +/// `registry_declares_deps` toggles the patch registry's `/info` between the +/// CORRECT contract (runtime deps declared) and today's production-like +/// deps-less answer. `None` = skip (message already printed). +async fn redirect_scanned_project( + tag: &str, + spelling: Spelling, + checksums_lock: bool, + registry_declares_deps: bool, +) -> Option { + for cmd in ["ruby", "gem", "bundle"] { + if !has_command(cmd) { + println!("SKIP e2e_redirect_gem_build ({tag}): `{cmd}` not installed"); + return None; + } + } + let Some((major, minor)) = bundler_version() else { + println!("SKIP e2e_redirect_gem_build ({tag}): `bundle --version` unparseable"); + return None; + }; + // 2.6 floor: the suite exercises CHECKSUMS-aware behavior (lock pins, + // `bundle lock --add-checksums`, `lockfile_checksums` config) that + // predates nothing older. + if major < 2 || (major == 2 && minor < 6) { + println!( + "SKIP e2e_redirect_gem_build ({tag}): host bundler {major}.{minor} predates the \ + CHECKSUMS-aware 2.6 floor" + ); + return None; + } + + let tmp = tempfile::tempdir().unwrap(); + let (gemfile_name, lock_name) = spelling.pair(); + + // 1. Author + build the fixture gems with the real toolchain. + let stage = tmp.path().join("gem-stage"); + let tiny_gem = build_gem(&stage, TRANSITIVE, "1.0.0", "tiny_dep.rb", TINY_LIB, &[]); + let vuln_gem = build_gem( + &stage, + DEP, + DEP_VERSION, + "vuln_gem.rb", + &orig_lib(), + &[TRANSITIVE], + ); + let patched_gem = build_gem( + &stage, + DEP, + DEP_VERSION, + "vuln_gem.rb", + &patched_lib(), + &[TRANSITIVE], + ); + let patched_sha = sha256_hex(&patched_gem); + + // 2. One wiremock plays upstream registry, patch registry, and the API. + let server = MockServer::start().await; + mount_compact_index( + &server, + "/upstream", + &[ + IndexGem { + name: TRANSITIVE, + version: "1.0.0", + deps: vec![], + gem: tiny_gem, + }, + IndexGem { + name: DEP, + version: DEP_VERSION, + deps: vec![format!("{TRANSITIVE}:>= 0")], + gem: vuln_gem, + }, + ], + ) + .await; + // The patch registry: production's `/patch-registry/gem///` + // base. The deps red-arm serves the checksum but NO runtime deps — the + // shape a server that ignores the gem's own gemspec dependencies emits. + let registry_base = format!("/patch-registry/gem/{TOKEN}/{UUID}"); + let index_url = format!("{}{registry_base}/", server.uri()); + mount_compact_index( + &server, + ®istry_base, + &[IndexGem { + name: DEP, + version: DEP_VERSION, + deps: if registry_declares_deps { + vec![format!("{TRANSITIVE}:>= 0")] + } else { + vec![] + }, + gem: patched_gem, + }], + ) + .await; + + let orig = orig_lib().into_bytes(); + let patched = patched_lib().into_bytes(); + let hosted_url = format!( + "{}/patch/gem/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.gem", + server.uri() + ); + // Batch discovery: the crawled gem has one free patch. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "gem redirect capstone fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + // Per-package search used by the redirect selection. + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + // Reference endpoint: granted, carrying the rubygems-compact-index + // registry override (the identifier shape the TS reference builder + // emits — name / version / gemChecksumSha256). + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": hosted_url, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url, + "integrity": { "sha256": patched_sha } + }], + "registryOverride": { + "kind": "rubygems-compact-index", + "indexUrl": index_url, + "identifiers": { + "name": DEP, + "version": DEP_VERSION, + "gemChecksumSha256": patched_sha, + } + } + } + } + }))) + .mount(&server) + .await; + // View endpoint: the patch record (REAL before/after hashes of the + // authored vs patched lib) the redirect run persists for VEX. + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "lib/vuln_gem.rb": { + "beforeHash": compute_git_sha256_from_bytes(&orig), + "afterHash": compute_git_sha256_from_bytes(&patched), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-3333"], + "summary": "gem redirect capstone vuln", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + + // 3. The fixture project, installed from the MOCK upstream (hermetic). + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join(gemfile_name), + format!("source \"{}/upstream\"\n\ngem \"{DEP}\"\n", server.uri()), + ) + .unwrap(); + let config = bundle( + &proj, + &["config", "set", "--local", "path", "vendor/bundle"], + ); + assert!( + config.status.success(), + "bundle config set --local path failed:\n{}", + String::from_utf8_lossy(&config.stderr) + ); + if !checksums_lock { + // Pin the bundler-2.x/3.x lock shape (no CHECKSUMS section) even on a + // bundler >= 4 host, which writes CHECKSUMS into fresh locks by default. + let cfg = bundle( + &proj, + &["config", "set", "--local", "lockfile_checksums", "false"], + ); + assert!(cfg.status.success(), "bundle config lockfile_checksums"); + } + let install = bundle(&proj, &["install"]); + assert!( + install.status.success(), + "fixture `bundle install` against the mock upstream failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr), + ); + if checksums_lock { + // Idempotent on bundler >= 4 (already written), materializes the + // section on 2.6–3.x hosts. + let add = bundle(&proj, &["lock", "--add-checksums"]); + assert!( + add.status.success(), + "bundle lock --add-checksums failed:\n{}", + String::from_utf8_lossy(&add.stderr) + ); + } + let lock_before = std::fs::read_to_string(proj.join(lock_name)) + .unwrap_or_else(|e| panic!("{lock_name} after fixture install: {e}")); + assert_eq!( + lock_before.contains("\nCHECKSUMS\n"), + checksums_lock, + "fixture lock CHECKSUMS presence must match the arm: {lock_before}" + ); + + // Pristine pre-checks (file AND absence of the marker): the post-install + // byte asserts are circular otherwise. + let mut ruby = Command::new("ruby"); + ruby.args(["-e", "puts Gem.ruby_api_version"]); + cache_env::isolate(&mut ruby); + let api = ruby.output().expect("failed to run ruby"); + assert!(api.status.success(), "ruby api version probe failed"); + let api = String::from_utf8_lossy(&api.stdout).trim().to_string(); + let installed_lib = proj + .join("vendor/bundle/ruby") + .join(&api) + .join("gems") + .join(format!("{DEP}-{DEP_VERSION}")) + .join("lib/vuln_gem.rb"); + assert_eq!( + std::fs::read(&installed_lib).expect("installed lib/vuln_gem.rb"), + orig, + "fixture install must extract the authored pristine bytes" + ); + + // 4. scan --mode hosted --vex: the Gemfile rewrite + the in-run + // (unverified) attestation. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + "--vex", + "out.vex.json", + "--vex-product", + PRODUCT, + ], + ); + assert_eq!( + code, 0, + "scan --mode hosted failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("scan --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") + }); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!(env["redirect"]["mode"], "hosted", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "exactly one dep redirected: {env}" + ); + let rewritten: Vec<&str> = env["redirect"]["rewrittenFiles"] + .as_array() + .expect("rewrittenFiles") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!( + rewritten.contains(&gemfile_name), + "the {gemfile_name} rewrite must be reported: {env}" + ); + let warning_codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .expect("warnings") + .iter() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert!( + warning_codes.contains(&"redirect_gem_frozen_install"), + "the frozen-install caveat must be surfaced: {env}" + ); + if checksums_lock { + assert!( + rewritten.contains(&lock_name), + "the CHECKSUMS pin must land in {lock_name}: {env}" + ); + } else { + assert!( + warning_codes.contains(&"redirect_gem_no_checksums_section"), + "a no-CHECKSUMS lock cannot be pinned and must say so: {env}" + ); + assert_eq!( + std::fs::read_to_string(proj.join(lock_name)).unwrap(), + lock_before, + "a no-CHECKSUMS lock must be byte-untouched" + ); + } + assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); + assert_eq!( + env["vex"]["verified"], false, + "in-run hosted VEX is attested from the ledger, not hash-verified: {env}" + ); + + // The Gemfile rewrite: the declaration moved into the source block whose + // URL is the patch-registry compact index. + let gemfile = std::fs::read_to_string(proj.join(gemfile_name)).unwrap(); + assert!( + gemfile.contains(&format!( + "source \"{index_url}\" do\n gem \"{DEP}\", \"{DEP_VERSION}\"\nend" + )), + "{gemfile_name} must gain the patch-registry source block:\n{gemfile}" + ); + if checksums_lock { + let lock = std::fs::read_to_string(proj.join(lock_name)).unwrap(); + assert!( + lock.contains(&format!(" {DEP} ({DEP_VERSION}) sha256={patched_sha}")), + "the lock CHECKSUMS must pin the PATCHED .gem's sha256:\n{lock}" + ); + } + + // Ledger embeds the patch record so a post-install `vex` can verify. + let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger.contains("\"records\"") && ledger.contains(GHSA), + "redirect ledger must embed the patch record + vulnerability: {ledger}" + ); + + Some(RedirectFixture { + tmp, + proj, + index_url, + gemfile_name, + lock_name, + patched, + _server: server, + }) +} + +/// New dir holding ONLY what a git checkout would carry — the manifest pair, +/// `.socket/`, `.bundle/` — then the UNFROZEN `bundle install` the rewriter's +/// `redirect_gem_frozen_install` warning prescribes, with a cold per-dir +/// bundler home. Returns the fresh dir and the install output. +fn fresh_checkout_bundle_install(fx: &RedirectFixture) -> (PathBuf, Output) { + let fresh = fx.tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(fx.proj.join(fx.gemfile_name), fresh.join(fx.gemfile_name)).unwrap(); + std::fs::copy(fx.proj.join(fx.lock_name), fresh.join(fx.lock_name)).unwrap(); + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + copy_dir_recursive(&fx.proj.join(".bundle"), &fresh.join(".bundle")); + assert!( + !fresh.join("vendor").exists(), + "fresh checkout must not carry an installed tree (test bug)" + ); + let install = bundle(&fresh, &["install"]); + (fresh, install) +} + +/// The installed gem's lib file under the fresh checkout's vendor/bundle. +fn fresh_installed_lib(fresh: &Path, gem_leaf: &str, lib: &str) -> PathBuf { + let mut ruby = Command::new("ruby"); + ruby.args(["-e", "puts Gem.ruby_api_version"]); + cache_env::isolate(&mut ruby); + let api = ruby.output().expect("failed to run ruby"); + let api = String::from_utf8_lossy(&api.stdout).trim().to_string(); + fresh + .join("vendor/bundle/ruby") + .join(api) + .join("gems") + .join(gem_leaf) + .join("lib") + .join(lib) +} + +/// Assert the full post-install proof: patched bytes on disk, the runtime +/// dependency present (the compact-index deps contract), and the require +/// probe resolving the patched code + the dep from the fresh vendor path. +fn assert_patched_install(fx: &RedirectFixture, fresh: &Path) { + let installed = std::fs::read(fresh_installed_lib( + fresh, + &format!("{DEP}-{DEP_VERSION}"), + "vuln_gem.rb", + )) + .expect("fresh install must land lib/vuln_gem.rb"); + assert_eq!( + installed, fx.patched, + "fresh install must hold the PATCHED bytes, byte-identical to the hosted .gem's lib" + ); + assert_eq!( + compute_git_sha256_from_bytes(&installed), + compute_git_sha256_from_bytes(&fx.patched), + "installed bytes must hash to the patch record's afterHash" + ); + // The deps contract: `tiny-dep` reaches the install ONLY through the + // patch registry's `/info` declaring it (the fresh resolution re-derives + // vuln-gem's dependencies from that answer — production's deps-less + // answer drops it, see the red-arm twin). + assert!( + fresh_installed_lib(fresh, &format!("{TRANSITIVE}-1.0.0"), "tiny_dep.rb").is_file(), + "the runtime dependency must install alongside the patched gem" + ); + let probe = bundle( + fresh, + &[ + "exec", + "ruby", + "-e", + "require \"vuln_gem\"\nputs VulnGem.status\nputs TinyDep::VALUE\nputs $LOADED_FEATURES.grep(%r{/vuln_gem\\.rb\\z})", + ], + ); + assert!( + probe.status.success(), + "bundle exec require probe failed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&probe.stdout), + String::from_utf8_lossy(&probe.stderr), + ); + let out = String::from_utf8_lossy(&probe.stdout).into_owned(); + assert!( + out.contains(&patched_marker()), + "the patched status marker must be live at require time:\n{out}" + ); + assert!( + out.contains("tiny-ok"), + "the runtime dep's constant must resolve (deps contract):\n{out}" + ); + assert!( + out.contains("/vendor/bundle/"), + "vuln_gem.rb must load from the fresh project-local install:\n{out}" + ); +} + +// ── the capstones ───────────────────────────────────────────────────── + +// multi_thread: the CLI/gem/bundle subprocesses block a worker thread while +// wiremock keeps serving the API + both compact indexes on the others. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler >= 2.6; the unpinned `test` \ + job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_fresh_checkout_bundle_install_installs_patched_bytes_and_vex_verifies() { + let Some(fx) = redirect_scanned_project("main", Spelling::Gemfile, false, true).await else { + return; + }; + + // FRESH-CHECKOUT PROOF: the unfrozen install the redirect prescribes + // pulls the patched .gem from the hosted compact index. + let (fresh, install) = fresh_checkout_bundle_install(&fx); + assert!( + install.status.success(), + "fresh-checkout `bundle install` must succeed from the patch registry.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr), + ); + assert_patched_install(&fx, &fresh); + + // The converged lock records the patch registry as the gem's source and + // bundler's own `!` pin — the state a subsequent frozen install accepts. + let lock = std::fs::read_to_string(fresh.join(fx.lock_name)).unwrap(); + assert!( + lock.contains(&format!("remote: {}", fx.index_url)), + "post-install lock must record the patch-registry source:\n{lock}" + ); + assert!( + lock.contains(&format!("{DEP} (= {DEP_VERSION})!")), + "post-install lock must carry bundler's source-pinned dependency:\n{lock}" + ); + + // POST-INSTALL VERIFIED VEX: default verify mode hash-verifies the + // installed tree against the ledger's patch record. + let doc_path = fresh.join("doc.json"); + let (code, stdout, stderr) = run_socket( + &fresh, + &[ + "vex", + "--output", + doc_path.to_str().unwrap(), + "--product", + PRODUCT, + "--cwd", + fresh.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "post-install vex failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!( + stmts.len(), + 1, + "exactly the redirected patch must be attested: {doc}" + ); + assert_eq!(stmts[0]["vulnerability"]["name"], GHSA); + assert_eq!(stmts[0]["status"], "not_affected"); + assert_eq!(stmts[0]["products"][0]["subcomponents"][0]["@id"], PURL); + assert_eq!( + stmts[0]["impact_statement"].as_str().unwrap(), + format!("Patched via Socket patch {UUID} (redirected)"), + "the post-install (hash-verified) attestation must carry the (redirected) marker" + ); +} + +/// Bundler's modern `gems.rb`/`gems.locked` spelling, end to end: the +/// candidate list must read the pair, the rewriter must key its edits to it, +/// and the real bundler must install the patched gem from the redirected +/// gems.rb. Fails without the gems.rb support in either layer. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler >= 2.6; the unpinned `test` \ + job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_gems_rb_spelling_redirects_and_installs() { + let Some(fx) = redirect_scanned_project("gems.rb", Spelling::GemsRb, false, true).await else { + return; + }; + assert!( + !fx.proj.join("Gemfile").exists() && !fx.proj.join("Gemfile.lock").exists(), + "fixture must exercise the modern spelling exclusively (test bug)" + ); + + let (fresh, install) = fresh_checkout_bundle_install(&fx); + assert!( + install.status.success(), + "fresh-checkout `bundle install` from gems.rb must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr), + ); + assert_patched_install(&fx, &fresh); + let lock = std::fs::read_to_string(fresh.join("gems.locked")).unwrap(); + assert!( + lock.contains(&format!("remote: {}", fx.index_url)), + "gems.locked must converge on the patch-registry source:\n{lock}" + ); +} + +/// The compact-index DEPENDENCY contract, pinned from the red side: a patch +/// registry whose `/info` omits the gem's runtime deps (today's production +/// behavior — its sidecar index answers `not_built` and the dependency-API +/// fallback is a zero-byte body) BREAKS the prescribed install with +/// bundler's `APIResponseMismatchError`. If the CLI or fixture ever starts +/// tolerating that silently, this turns red. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler >= 2.6; the unpinned `test` \ + job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_registry_info_without_deps_breaks_install_like_production() { + let Some(fx) = redirect_scanned_project("nodeps", Spelling::Gemfile, false, false).await else { + return; + }; + + let (_fresh, install) = fresh_checkout_bundle_install(&fx); + assert!( + !install.status.success(), + "a deps-less registry /info MUST break the fresh install — a quiet success here means \ + the dependency contract stopped being load-bearing.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr), + ); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + assert!( + chatter.contains("APIResponseMismatchError") + && chatter.contains("dependencies not in the API"), + "the failure must be bundler's API-mismatch check (the live production signature), \ + not something incidental:\n{chatter}" + ); + // Anti-vacuity: the .gem itself declares the dep, so the mismatch can + // only come from the registry's deps-less /info. + assert!( + chatter.contains(TRANSITIVE), + "the mismatch must name the dropped runtime dep:\n{chatter}" + ); +} + +/// KNOWN-LIMITATION CANARY — CHECKSUMS locks (bundler >= 4 default): the +/// current rewrite (source block + CHECKSUMS pin, GEM section left on the +/// upstream remote) makes the prescribed unfrozen install FAIL: bundler +/// still attributes the gem to the upstream source and refuses the +/// lockfile-vs-upstream-API checksum disagreement ("Bundler found mismatched +/// checksums", exit 37 — verified on bundler 4.0.15). This test pins the +/// rewrite half (the pin lands, its ledger edit records the upstream sha for +/// revert) AND the current install failure. When the rewriter learns the +/// verified fix — the fully converged lock: patched-registry GEM section, +/// ` (= )!` DEPENDENCIES pin, patched CHECKSUMS sha, which a +/// FROZEN install accepts — this canary must flip to asserting success. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler >= 2.6; the unpinned `test` \ + job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_checksums_lock_pins_patched_sha_but_bundler_refuses_mixed_state() { + let Some(fx) = redirect_scanned_project("checksums", Spelling::Gemfile, true, true).await + else { + return; + }; + + // The rewrite half: the ledger's CHECKSUMS edit must carry the UPSTREAM + // sha as `original` (the only revert path back to the registry line). + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fx.proj.join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + let edit = ledger["edits"] + .as_array() + .expect("ledger edits") + .iter() + .find(|e| e["kind"] == "redirect_gemfile_lock_checksum") + .expect("CHECKSUMS pin edit recorded in the ledger"); + assert_eq!(edit["path"], "Gemfile.lock", "edit path: {edit}"); + let original = edit["original"].as_str().expect("original recorded"); + assert!( + original.starts_with(&format!("{DEP} ({DEP_VERSION}) sha256=")), + "original must be the pre-edit registry line: {original}" + ); + assert!( + !std::fs::read_to_string(fx.proj.join("Gemfile.lock")) + .unwrap() + .contains(original), + "the upstream sha line must actually have been replaced (else the pin is vacuous)" + ); + + // The install half — today's reality on a CHECKSUMS lock. + let (_fresh, install) = fresh_checkout_bundle_install(&fx); + assert!( + !install.status.success(), + "KNOWN LIMITATION pinned: if this fresh install now SUCCEEDS, the mixed-state lock \ + handling was fixed — flip this canary to assert success + patched bytes (see the \ + test doc for the verified converged-lock shape).\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr), + ); + let chatter = format!( + "{}\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + assert!( + chatter.to_lowercase().contains("mismatched checksums"), + "the refusal must be bundler's checksum-conflict check, not something incidental:\n{chatter}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index ad8df879..882d206f 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -543,3 +543,309 @@ fn gem_vendor_fresh_checkout_bundle_install_and_revert() { ".socket/vendor must be fully removed after revert" ); } + +/// TRANSITIVE-dep capstone: vendoring a gem the Gemfile never declares +/// (`rack`, pulled in by `rack-test`) appends the managed block + the sorted +/// `rack (= )!` DEPENDENCIES pin — a wiring shape the direct-dep +/// capstone never produces — and a REAL frozen `bundle install` of a fresh +/// checkout must accept that pair byte-stably, load the patched bytes from +/// the vendored path through the rack-test require chain, and revert must +/// byte-restore both files (managed block gone, DEPENDENCIES entry deleted). +#[test] +#[ignore = "host capstone: shells out to a real bundler >= 2.5; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn gem_vendor_transitive_dep_fresh_checkout_and_revert() { + if !has_command("ruby") { + println!("SKIP e2e_vendor_gem_build (transitive): `ruby` not installed"); + return; + } + let Some((major, minor)) = bundler_version() else { + println!( + "SKIP e2e_vendor_gem_build (transitive): `bundle` not installed (or version \ + unparseable)" + ); + return; + }; + if major < 2 || (major == 2 && minor < 5) { + println!( + "SKIP e2e_vendor_gem_build (transitive): host bundler {major}.{minor} predates \ + the spike-verified 2.5 floor" + ); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("Gemfile"), + "source \"https://rubygems.org\"\n\ngem \"rack-test\", \"~> 2.1\"\n", + ) + .unwrap(); + + let config = bundle( + &proj, + &["config", "set", "--local", "path", "vendor/bundle"], + false, + ); + if !config.status.success() { + println!( + "SKIP e2e_vendor_gem_build (transitive): `bundle config set --local path` failed:\n{}", + String::from_utf8_lossy(&config.stderr) + ); + return; + } + // Pin the no-CHECKSUMS lock shape on every host (bundler >= 4 writes a + // CHECKSUMS section by default; 2.5–3.x never do) — the CHECKSUMS-lock + // vendoring flavor is covered by docker_e2e_vendor_gem's twin. + let no_ck = bundle( + &proj, + &["config", "set", "--local", "lockfile_checksums", "false"], + false, + ); + assert!( + no_ck.status.success(), + "bundle config set --local lockfile_checksums failed:\n{}", + String::from_utf8_lossy(&no_ck.stderr) + ); + let install = bundle(&proj, &["install"], false); + if !install.status.success() { + println!( + "SKIP e2e_vendor_gem_build (transitive): `bundle install` failed (registry \ + unreachable, or host ruby too old for rack-test ~> 2.1?):\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + + let lock_path = proj.join("Gemfile.lock"); + let lock_before = std::fs::read(&lock_path).expect("Gemfile.lock after bundle install"); + let lock_before_text = String::from_utf8_lossy(&lock_before).into_owned(); + let version = locked_gem_version(&lock_before_text, DEP) + .unwrap_or_else(|| panic!("rack-test must resolve rack into Gemfile.lock")); + + // Anti-vacuity: rack really is transitive — undeclared in the Gemfile + // and absent from the lock's DEPENDENCIES section (which does list + // ` rack-test (~> 2.1)`, so the probe pins the exact token). + let gemfile_path = proj.join("Gemfile"); + let gemfile_before = std::fs::read(&gemfile_path).unwrap(); + assert!( + !String::from_utf8_lossy(&gemfile_before).contains("\"rack\""), + "fixture bug: rack must not be Gemfile-declared" + ); + assert!( + !lock_before_text.contains("\nCHECKSUMS\n"), + "fixture bug: this capstone pins the no-CHECKSUMS lock shape: {lock_before_text}" + ); + let deps_section = lock_before_text + .split("DEPENDENCIES\n") + .nth(1) + .and_then(|rest| rest.split("\n\n").next()) + .expect("lock has a DEPENDENCIES section"); + assert!( + !deps_section + .lines() + .any(|l| l == " rack" || l.starts_with(" rack (") || l.starts_with(" rack!")), + "fixture bug: rack must not appear in DEPENDENCIES: {deps_section}" + ); + + // The installed transitive gem, marker patch on its ACTUAL bytes. + let mut ruby = Command::new("ruby"); + ruby.args(["-e", "puts Gem.ruby_api_version"]); + cache_env::isolate(&mut ruby); + let api = ruby.output().expect("failed to run ruby"); + assert!(api.status.success(), "ruby api version probe failed"); + let api = String::from_utf8_lossy(&api.stdout).trim().to_string(); + let installed_rb = proj + .join("vendor/bundle/ruby") + .join(&api) + .join("gems") + .join(format!("{DEP}-{version}")) + .join("lib/rack.rb"); + let orig = std::fs::read(&installed_rb).expect("installed lib/rack.rb"); + assert!( + !String::from_utf8_lossy(&orig).contains("SOCKET_PATCH_VENDOR_E2E"), + "pristine install must not carry the probe constant" + ); + let marker = format!( + "\n# SOCKET-PATCH-VENDOR-E2E-MARKER\nmodule Rack\n SOCKET_PATCH_VENDOR_E2E = \"{UUID}\"\nend\n" + ); + let patched: Vec = [orig.as_slice(), marker.as_bytes()].concat(); + let purl = format!("pkg:gem/{DEP}@{version}"); + stage_patch_with_vuln(&proj, &purl, "lib/rack.rb", &orig, &patched); + + // Vendor (offline). The transitive branch appends the managed block. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); + + // The appended managed block, byte-exact (hand-pinned marker lines — the + // block delimits what revert may delete, so its shape is contract). + let copy_rel = format!(".socket/vendor/gem/{UUID}/{DEP}-{version}"); + let gemfile = std::fs::read_to_string(&gemfile_path).unwrap(); + let expected_gemfile = format!( + "{}# >>> socket-patch vendor (managed) >>>\ngem \"{DEP}\", \"{version}\", \ + path: \"{copy_rel}\"\n# <<< socket-patch vendor (managed) <<<\n", + String::from_utf8_lossy(&gemfile_before) + ); + assert_eq!( + gemfile, expected_gemfile, + "transitive vendor must append exactly the managed block" + ); + + // The lock pair: canonical PATH section before GEM, and the DEPENDENCIES + // pin inserted at bundler's sorted position (rack before rack-test). + let lock = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock.contains(&format!( + "PATH\n remote: {copy_rel}\n specs:\n {DEP} ({version})" + )), + "canonical PATH section missing:\n{lock}" + ); + assert!( + lock.contains(&format!( + "DEPENDENCIES\n {DEP} (= {version})!\n rack-test (~> 2.1)\n" + )), + "DEPENDENCIES pin must insert at bundler's sorted position:\n{lock}" + ); + + // FRESH-CHECKOUT PROOF: committable files only, frozen install (bundler + // validates the Gemfile↔lock dependency sets — an unsorted or malformed + // insert fails here), byte-stable lock, patched bytes reached THROUGH the + // rack-test require chain. + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&gemfile_path, fresh.join("Gemfile")).unwrap(); + std::fs::copy(&lock_path, fresh.join("Gemfile.lock")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + copy_dir_recursive(&proj.join(".bundle"), &fresh.join(".bundle")); + assert!( + !fresh.join("vendor").exists(), + "fresh checkout must not carry an installed tree (test bug)" + ); + + let lock_wired = std::fs::read(&lock_path).unwrap(); + let ci = bundle(&fresh, &["install"], true); + assert!( + ci.status.success(), + "fresh-checkout frozen `bundle install` must accept the managed-block pair.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + assert_eq!( + std::fs::read(fresh.join("Gemfile.lock")).unwrap(), + lock_wired, + "frozen install must leave the committed Gemfile.lock byte-identical" + ); + + // `require "rack/test"` proves the direct dep still resolves alongside + // the vendored transitive; it does not itself load `lib/rack.rb`, so the + // marker is probed through an explicit `require "rack"` in the same VM. + let probe = bundle( + &fresh, + &[ + "exec", + "ruby", + "-e", + "require \"rack/test\"\n\ + require \"rack\"\n\ + abort \"probe constant missing after require\" unless defined?(Rack::SOCKET_PATCH_VENDOR_E2E)\n\ + puts Rack::SOCKET_PATCH_VENDOR_E2E\n\ + puts $LOADED_FEATURES.grep(%r{/rack\\.rb\\z})", + ], + false, + ); + assert!( + probe.status.success(), + "bundle exec runtime probe failed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&probe.stdout), + String::from_utf8_lossy(&probe.stderr), + ); + let probe_out = String::from_utf8_lossy(&probe.stdout).into_owned(); + assert!( + probe_out.contains(UUID), + "probe constant must carry the patch uuid:\n{probe_out}" + ); + assert!( + probe_out.contains(&format!("{copy_rel}/lib/rack.rb")), + "rack must be loaded from the vendored path via rack-test:\n{probe_out}" + ); + + // Idempotency: a re-run leaves both files byte-identical (a second + // managed block or a duplicated DEPENDENCIES pin breaks bundler). + let gemfile_wired = std::fs::read(&gemfile_path).unwrap(); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env2 = parse_envelope(&stdout); + assert_eq!(env2["summary"]["failed"], 0, "re-run must not fail: {env2}"); + assert_eq!( + std::fs::read(&gemfile_path).unwrap(), + gemfile_wired, + "re-vendor must leave the Gemfile byte-identical" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "re-vendor must leave Gemfile.lock byte-identical" + ); + + // REVERT PROOF: the managed block and the DEPENDENCIES pin are deletions + // (no pre-vendor original exists for either) — both files must come back + // byte-identical to the pre-vendor snapshots. + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["summary"]["removed"], 1, "one entry reverted: {renv}"); + assert_eq!( + std::fs::read(&gemfile_path).unwrap(), + gemfile_before, + "revert must restore the Gemfile byte-identical (managed block gone)" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_before, + "revert must restore Gemfile.lock byte-identical (PATH + pin gone)" + ); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 2695dc8b..3b8286ea 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1,5 +1,7 @@ //! In-process + envelope contract tests for `socket-patch vendor` (npm -//! backend, plus the golang apply-yields-to-vendor handshake). +//! backend, plus the golang apply-yields-to-vendor handshake, plus the gem +//! backend's `scan --vendor` arm — the one route into the vendor engine no +//! gem project had ever been driven through). //! //! The lifecycle tests call `socket_patch_cli::commands::vendor::run(args)` //! directly (the in-process convention of `in_process_cargo_apply.rs` / @@ -1620,3 +1622,336 @@ async fn offline_service_mode_refuses_instead_of_building() { ); assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); } + +// ───────────────────────────────────────────────────────────────────── +// 13. gem through `scan --vendor` (mock-proxy API, hermetic bundler layout) +// ───────────────────────────────────────────────────────────────────── + +const GEM_UUID: &str = "35353535-3535-4335-8335-353535353535"; +const GEM_PURL: &str = "pkg:gem/demo-gem@1.0.0"; +const GEM_ORIG: &[u8] = b"module DemoGem\n STATUS = \"orig\"\nend\n"; +const GEM_PATCHED: &[u8] = b"module DemoGem\n STATUS = \"patched\"\nend\n"; +const GEM_GEMSPEC: &str = "Gem::Specification.new do |s|\n s.name = \"demo-gem\"\n s.version = \"1.0.0\"\n s.summary = \"in-process scan --vendor fixture\"\n s.require_paths = [\"lib\"]\nend\n"; +const GEM_GEMFILE: &str = "source \"https://rubygems.org\"\n\ngem \"demo-gem\", \"~> 1.0\"\n"; +/// Hand-pinned bundler lock grammar (no CHECKSUMS — the 2.x/3.x default). +const GEM_LOCK: &str = "GEM\n remote: https://rubygems.org/\n specs:\n demo-gem (1.0.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n demo-gem (~> 1.0)\n\nBUNDLED WITH\n 2.6.2\n"; + +/// A vendorable gem project in bundler's deployment layout — no real ruby +/// needed: the crawler discovers `vendor/bundle///gems/` under a +/// project with a Gemfile, and the vendor backend reads the stub gemspec from +/// the sibling `specifications/` dir. +struct GemFixture { + tmp: tempfile::TempDir, +} + +impl GemFixture { + fn root(&self) -> &Path { + self.tmp.path() + } + fn gemfile_path(&self) -> PathBuf { + self.root().join("Gemfile") + } + fn lock_path(&self) -> PathBuf { + self.root().join("Gemfile.lock") + } + fn installed_lib(&self) -> PathBuf { + self.root() + .join("vendor/bundle/ruby/3.4.0/gems/demo-gem-1.0.0/lib/demo_gem.rb") + } + fn copy_rel() -> String { + format!(".socket/vendor/gem/{GEM_UUID}/demo-gem-1.0.0") + } + fn vendored_lib(&self) -> PathBuf { + self.root().join(Self::copy_rel()).join("lib/demo_gem.rb") + } + fn state_path(&self) -> PathBuf { + self.root().join(".socket/vendor/state.json") + } +} + +fn gem_fixture() -> GemFixture { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + std::fs::write(root.join("Gemfile"), GEM_GEMFILE).unwrap(); + std::fs::write(root.join("Gemfile.lock"), GEM_LOCK).unwrap(); + let home = root.join("vendor/bundle/ruby/3.4.0"); + std::fs::create_dir_all(home.join("gems/demo-gem-1.0.0/lib")).unwrap(); + std::fs::write(home.join("gems/demo-gem-1.0.0/lib/demo_gem.rb"), GEM_ORIG).unwrap(); + std::fs::create_dir_all(home.join("specifications")).unwrap(); + std::fs::write( + home.join("specifications/demo-gem-1.0.0.gemspec"), + GEM_GEMSPEC, + ) + .unwrap(); + GemFixture { tmp } +} + +/// Mount discovery (batch), per-package search, and the full view (inline +/// `blobContent`, so `scan --vendor` runs against the mock alone) for the +/// demo gem — the gem mirror of `scan_vendor_e2e::mount_patch_api`. +async fn mount_gem_patch_api(mock: &wiremock::MockServer) { + use base64::Engine as _; + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, ResponseTemplate}; + + const ORG_SLUG: &str = "test-org"; + let before_hash = compute_git_sha256_from_bytes(GEM_ORIG); + let after_hash = compute_git_sha256_from_bytes(GEM_PATCHED); + let blob_b64 = base64::engine::general_purpose::STANDARD.encode(GEM_PATCHED); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": GEM_PURL, + "patches": [{ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "tier": "free", + "cveIds": ["CVE-2026-0002"], + "ghsaIds": [], + "severity": "high", + "title": "gem vendor target" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG_SLUG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "gem vendor patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{GEM_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "lib/demo_gem.rb": { + "beforeHash": before_hash, + "afterHash": after_hash, + "blobContent": blob_b64, + } + }, + "vulnerabilities": { + "GHSA-gem-vendor-test": { + "cves": ["CVE-2026-0002"], + "summary": "gem vendor vuln", + "severity": "high", + "description": "details" + } + }, + "description": "gem vendor patch", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, Value) { + let mut argv = vec![ + "scan", + "--json", + "--vendor", + "--yes", + "--api-url", + mock_uri, + "--api-token", + "fake-token", + "--org", + "test-org", + "--cwd", + root.to_str().unwrap(), + ]; + argv.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli(root, &argv, &[]); + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("scan --vendor --json must emit JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +/// `scan --vendor` end to end on a gem project: discover → download +/// (manifest written) → vendor lands the gem pair edit (Gemfile pin + +/// `path:`, lock PATH section + `(= …)!` DEPENDENCIES pin) and the patched +/// artifact dir — then reconcile auto-reverts once the manifest drops the +/// patch, byte-restoring both halves. +#[tokio::test] +async fn scan_vendor_gem_end_to_end_and_reconcile() { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock).await; + let fx = gem_fixture(); + + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(code, 0, "scan --vendor must succeed: {env:#}"); + assert_eq!(env["status"], "success", "envelope: {env:#}"); + assert_eq!(env["download"]["downloaded"], 1, "envelope: {env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); + assert_eq!(env["vendor"]["summary"]["failed"], 0, "envelope: {env:#}"); + + // Manifest written by the download phase, keyed by the gem purl. + let manifest: Value = + serde_json::from_slice(&std::fs::read(fx.root().join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert_eq!(manifest["patches"][GEM_PURL]["uuid"], GEM_UUID); + + // Artifact: patched bytes + the stub gemspec a path source needs. + assert_eq!( + std::fs::read(fx.vendored_lib()).unwrap(), + GEM_PATCHED, + "vendored lib must hold the patched bytes" + ); + assert_eq!( + std::fs::read_to_string( + fx.root() + .join(GemFixture::copy_rel()) + .join("demo-gem.gemspec") + ) + .unwrap(), + GEM_GEMSPEC, + "stub gemspec materialized from specifications/" + ); + + // The MANDATORY pair edit. + let gemfile = std::fs::read_to_string(fx.gemfile_path()).unwrap(); + assert!( + gemfile.contains(&format!( + "gem \"demo-gem\", \"1.0.0\", path: \"{}\"", + GemFixture::copy_rel() + )), + "Gemfile line not rewritten to the exact-pin + path: form:\n{gemfile}" + ); + let lock = std::fs::read_to_string(fx.lock_path()).unwrap(); + assert!( + lock.contains(&format!( + "PATH\n remote: {}\n specs:\n demo-gem (1.0.0)", + GemFixture::copy_rel() + )), + "canonical PATH section missing:\n{lock}" + ); + assert!( + lock.contains("\n demo-gem (= 1.0.0)!"), + "DEPENDENCIES pin missing:\n{lock}" + ); + + // The installed tree stays pristine (vendoring is not an in-place apply) + // and the ledger entry is manifest-tracked (not detached). + assert_eq!(std::fs::read(fx.installed_lib()).unwrap(), GEM_ORIG); + let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); + assert_eq!(state["entries"][GEM_PURL]["ecosystem"], "gem"); + assert_eq!(state["entries"][GEM_PURL]["uuid"], GEM_UUID); + assert!( + state["entries"][GEM_PURL]["detached"].is_null(), + "manifest-mode entries are not detached: {state:#}" + ); + + // Idempotent re-run through the same JSON arm. + let gemfile_wired = std::fs::read(fx.gemfile_path()).unwrap(); + let lock_wired = std::fs::read(fx.lock_path()).unwrap(); + let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(code, 0, "re-run must succeed: {env2:#}"); + assert_eq!(env2["vendor"]["summary"]["applied"], 0, "{env2:#}"); + assert!( + env2["vendor"]["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "already_vendored"), + "re-run must be an already_vendored skip: {env2:#}" + ); + assert_eq!(std::fs::read(fx.gemfile_path()).unwrap(), gemfile_wired); + assert_eq!(std::fs::read(fx.lock_path()).unwrap(), lock_wired); + + // Reconcile: the patch dropped from the manifest is auto-reverted by the + // next plain vendor run — BOTH pair-edit halves byte-restored. + std::fs::write( + fx.root().join(".socket/manifest.json"), + b"{\"patches\": {}}\n", + ) + .unwrap(); + let (code, renv) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "reconcile-only run must exit 0: {renv:#}"); + let removed = find_event(&renv, "removed", Some("vendor_reconciled")); + assert_eq!(removed["purl"], GEM_PURL); + assert_eq!( + std::fs::read(fx.gemfile_path()).unwrap(), + GEM_GEMFILE.as_bytes(), + "reconcile must byte-restore the Gemfile" + ); + assert_eq!( + std::fs::read(fx.lock_path()).unwrap(), + GEM_LOCK.as_bytes(), + "reconcile must byte-restore Gemfile.lock" + ); + assert!( + !fx.root().join(".socket/vendor").exists(), + "the reconciled vendor tree must be fully pruned" + ); +} + +/// `scan --vendor --detached` on the gem project: no manifest is written, +/// the ledger entry is detached with the patch record embedded, the pair +/// edit still lands — and `vendor --revert` (the detached entry's only exit +/// path) byte-restores both files. +#[tokio::test] +async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock).await; + let fx = gem_fixture(); + + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &["--detached"]); + assert_eq!(code, 0, "scan --vendor --detached must succeed: {env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); + + assert!( + !fx.root().join(".socket/manifest.json").exists(), + "detached mode must not write a manifest" + ); + assert!( + !fx.root().join(".socket/blobs").exists(), + "detached vendoring holds content in memory, never .socket/blobs" + ); + let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); + assert_eq!(state["entries"][GEM_PURL]["detached"], json!(true)); + assert!( + state["entries"][GEM_PURL]["record"].is_object(), + "detached entries embed the patch record: {state:#}" + ); + assert_eq!(std::fs::read(fx.vendored_lib()).unwrap(), GEM_PATCHED); + let lock = std::fs::read_to_string(fx.lock_path()).unwrap(); + assert!( + lock.contains("\n demo-gem (= 1.0.0)!"), + "detached vendoring still lands the pair edit:\n{lock}" + ); + + // `--revert` is the detached entry's exit path: byte-restoration. + let (code, renv) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "revert must undo the detached entry: {renv:#}"); + assert_eq!( + std::fs::read(fx.gemfile_path()).unwrap(), + GEM_GEMFILE.as_bytes(), + "revert must byte-restore the Gemfile" + ); + assert_eq!( + std::fs::read(fx.lock_path()).unwrap(), + GEM_LOCK.as_bytes(), + "revert must byte-restore Gemfile.lock" + ); + assert!(!fx.root().join(".socket/vendor").exists()); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ec6a9c42..461eaae3 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1923,6 +1923,61 @@ fn gem_tail_source_option(tail: &str) -> Option<&'static str> { .find(|tok| code.contains(tok)) } +/// A dep's Socket index URL as a regex source with the per-request rotating +/// segments (grant token, patch uuid) wildcarded — an exact-URL pattern +/// misses the URL a previous run wrote under an older grant. +fn gem_index_url_pattern(dep: &DepOverride, index_url: &str) -> String { + let mut url_pat = regex::escape(index_url); + for rotating in [&dep.token, &dep.patch_uuid] { + if !rotating.is_empty() { + url_pat = url_pat.replace(®ex::escape(&format!("/{rotating}/")), "/[^/\"]+/"); + } + } + url_pat +} + +/// A gemfile spelling with the redirect's own footprint erased: every managed +/// Socket `source "…" do … end` block for a redirected dep (rotating grant +/// segments wildcarded) and the dep's own `gem` declaration line. The +/// gems.rb/Gemfile divergence guard compares these residues rather than raw +/// bytes: run 1 on byte-identical twins edits only gems.rb (the file bundler +/// reads), so a raw comparison would trap every later run — the rotated-grant +/// URL refresh included — behind `redirect_gem_gemfile_spellings_diverge`, a +/// divergence the rewriter itself created. Trailing whitespace is trimmed (a +/// block appended to a newline-less file adds a final newline the other +/// spelling never had). `\r?` mirrors the block recognizer in `rewrite_gem`: +/// a `core.autocrlf` checkout rewrites run 1's LF block to CRLF, and a block +/// the recognizer accepts must also be erased here or the re-run is trapped +/// behind the divergence warning before it can reach the recognizer. +fn gem_spelling_residue(content: &str, deps: &[&DepOverride]) -> String { + let mut residue = content.to_string(); + for dep in deps { + let Some(ov) = &dep.registry_override else { + continue; + }; + if ov.kind != "rubygems-compact-index" { + continue; + } + let block_re = Regex::new( + &(String::from(r#"(?m)^source ""#) + + &gem_index_url_pattern(dep, &ov.index_url) + + r#"" do\r?\n gem ["']"# + + ®ex::escape(&dep.name) + + r#"["'][^\n]*\nend\r?\n?"#), + ) + .unwrap(); + residue = block_re.replace_all(&residue, "").into_owned(); + let decl_re = Regex::new( + &(String::from(r#"(?m)^[ \t]*gem\b[^\n]*["']"#) + + ®ex::escape(&dep.name) + + r#"["'][^\n]*\n?"#), + ) + .unwrap(); + residue = decl_re.replace_all(&residue, "").into_owned(); + } + residue.trim_end().to_string() +} + fn rewrite_gem( files: &BTreeMap, overrides: &[DepOverride], @@ -1932,12 +1987,49 @@ fn rewrite_gem( if gem.is_empty() { return; } - let mut gemfile = files.get("Gemfile").cloned(); + // Bundler's modern manifest spelling: `gems.rb`/`gems.locked` wins over + // `Gemfile`/`Gemfile.lock` when both sit in one directory (bundler's + // `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 + // 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 + // residue (`gem_spelling_residue`), NOT raw bytes: run 1 on identical + // twins edits only gems.rb (following bundler), so a raw comparison would + // trap every later run behind the divergence the rewriter itself created. + // Identical spellings follow bundler: edit gems.rb. + let modern = files.contains_key("gems.rb"); + if modern + && files.get("Gemfile").is_some_and(|c| { + gem_spelling_residue(&files["gems.rb"], &gem) != gem_spelling_residue(c, &gem) + }) + { + result.warnings.push(RewriteWarning { + code: "redirect_gem_gemfile_spellings_diverge".into(), + detail: "both gems.rb and Gemfile are present with different contents; bundler \ + reads gems.rb but the redirect cannot safely pick one — reconcile the \ + two spellings and re-run" + .into(), + }); + return; + } + let (gemfile_name, lock_name) = if modern { + ("gems.rb", "gems.locked") + } else { + ("Gemfile", "Gemfile.lock") + }; + let mut gemfile = files.get(gemfile_name).cloned(); let mut gemfile_changed = false; - let mut lock = files.get("Gemfile.lock").cloned(); + let mut lock = files.get(lock_name).cloned(); let mut lock_changed = false; // Static regex — compile once, not per-dependency (clippy: regex-in-loop). - let checksums_re = Regex::new(r"(?m)^CHECKSUMS$").unwrap(); + // `\r?` throughout the lock handling: a CRLF Gemfile.lock is legal to + // bundler (verified: `bundle check`/frozen install both accept one on + // 4.0.15), and without the tolerance the CHECKSUMS header never matched, + // misdiagnosing the lock as bundler <2.6. + let checksums_re = Regex::new(r"(?m)^CHECKSUMS(\r?)$").unwrap(); for dep in &gem { let Some(ov) = &dep.registry_override else { @@ -1982,7 +2074,7 @@ fn rewrite_gem( result.warnings.push(RewriteWarning { code: "redirect_gem_platform_unsupported".into(), detail: format!( - "Gemfile.lock CHECKSUMS carries platform-specific entries for {} {} — \ + "{lock_name} CHECKSUMS carries platform-specific entries for {} {} — \ the patch registry serves only the ruby platform gem; redirect skipped", dep.name, dep.version ), @@ -2001,13 +2093,7 @@ fn rewrite_gem( // run would wrap the gem line inside it — nesting source blocks. // Wildcard the rotating segments instead (mirrors the CHECKSUMS // at-target guard below). - let mut url_pat = regex::escape(&ov.index_url); - for rotating in [&dep.token, &dep.patch_uuid] { - if !rotating.is_empty() { - url_pat = - url_pat.replace(®ex::escape(&format!("/{rotating}/")), "/[^/\"]+/"); - } - } + let url_pat = gem_index_url_pattern(dep, &ov.index_url); // `\r?\n`: the rewriter emits LF, but a `core.autocrlf` checkout // rewrites the working tree to CRLF — the guard must still // recognize the block there, or the indented `gem` line inside @@ -2030,7 +2116,7 @@ fn rewrite_gem( gf.replace_range(range, &ov.index_url); gemfile_changed = true; result.edits.push(FileEdit { - path: "Gemfile".into(), + path: gemfile_name.into(), kind: "redirect_gemfile_source_url".into(), action: "rewritten".into(), key: Some(dep.name.clone()), @@ -2122,7 +2208,7 @@ fn rewrite_gem( gf.replace_range(range, &block); gemfile_changed = true; result.edits.push(FileEdit { - path: "Gemfile".into(), + path: gemfile_name.into(), kind: "redirect_gemfile_source_block".into(), action: "rewritten".into(), key: Some(dep.name.clone()), @@ -2150,7 +2236,7 @@ fn rewrite_gem( *gf = format!("{gf}{sep}{block}\n"); gemfile_changed = true; result.edits.push(FileEdit { - path: "Gemfile".into(), + path: gemfile_name.into(), kind: "redirect_gemfile_source_block".into(), action: "added".into(), key: Some(dep.name.clone()), @@ -2171,7 +2257,8 @@ fn rewrite_gem( result.warnings.push(RewriteWarning { code: "redirect_gem_lock_without_source".into(), detail: format!( - "no Gemfile source redirect is in place for {} — CHECKSUMS pin skipped", + "no {gemfile_name} source redirect is in place for {} — CHECKSUMS pin \ + skipped", dep.name ), }); @@ -2182,13 +2269,16 @@ fn rewrite_gem( + ®ex::escape(&dep.name) + r" \(" + ®ex::escape(&dep.version) - + r"\)) sha256=([0-9a-f]+)$"), + + r"\)) sha256=([0-9a-f]+)(\r?)$"), ) .unwrap(); let new_val = format!("{} ({}) sha256={sha256}", dep.name, dep.version); // Already redirected (re-run): the CHECKSUMS line is at the // target value; recording an edit would grow the ledger forever. - if lk.contains(&format!("\n {new_val}\n")) || lk.ends_with(&format!("\n {new_val}")) { + let already_re = + Regex::new(&(String::from(r"(?m)^ ") + ®ex::escape(&new_val) + r"\r?$")) + .unwrap(); + if already_re.is_match(lk) { // no-op } else if let Some(m) = sum_line_re.captures(lk) { // The pre-edit line goes into the ledger as `original` so a @@ -2200,11 +2290,11 @@ fn rewrite_gem( m.get(2).unwrap().as_str() ); *lk = sum_line_re - .replace(lk, format!("${{1}} sha256={sha256}").as_str()) + .replace(lk, format!("${{1}} sha256={sha256}${{3}}").as_str()) .to_string(); lock_changed = true; result.edits.push(FileEdit { - path: "Gemfile.lock".into(), + path: lock_name.into(), kind: "redirect_gemfile_lock_checksum".into(), action: "rewritten".into(), key: Some(dep.name.clone()), @@ -2216,7 +2306,7 @@ fn rewrite_gem( .replace( lk, format!( - "CHECKSUMS\n {} ({}) sha256={sha256}", + "CHECKSUMS${{1}}\n {} ({}) sha256={sha256}${{1}}", dep.name, dep.version ) .as_str(), @@ -2224,7 +2314,7 @@ fn rewrite_gem( .to_string(); lock_changed = true; result.edits.push(FileEdit { - path: "Gemfile.lock".into(), + path: lock_name.into(), kind: "redirect_gemfile_lock_checksum".into(), action: "added".into(), key: Some(dep.name.clone()), @@ -2235,7 +2325,7 @@ fn rewrite_gem( result.warnings.push(RewriteWarning { code: "redirect_gem_no_checksums_section".into(), detail: format!( - "Gemfile.lock has no CHECKSUMS section (bundler <2.6) — cannot pin {}", + "{lock_name} has no CHECKSUMS section (bundler <2.6) — cannot pin {}", dep.name ), }); @@ -2250,22 +2340,23 @@ fn rewrite_gem( if gemfile_changed || lock_changed { result.warnings.push(RewriteWarning { code: "redirect_gem_frozen_install".into(), - detail: "Gemfile was repointed at the Socket patch registry but Gemfile.lock's \ - GEM section still records the upstream source; bundler rejects the pair \ - under frozen/deployment mode — run `bundle install` (unfrozen) once to \ - record the new source in Gemfile.lock" - .into(), + detail: format!( + "{gemfile_name} was repointed at the Socket patch registry but {lock_name}'s \ + GEM section still records the upstream source; bundler rejects the pair \ + under frozen/deployment mode — run `bundle install` (unfrozen) once to \ + record the new source in {lock_name}" + ), }); } if gemfile_changed { if let Some(gf) = gemfile { - result.files.insert("Gemfile".into(), gf); + result.files.insert(gemfile_name.into(), gf); } } if lock_changed { if let Some(lk) = lock { - result.files.insert("Gemfile.lock".into(), lk); + result.files.insert(lock_name.into(), lk); } } } @@ -4824,6 +4915,439 @@ mod tests { ); } + /// Bundler's modern `gems.rb`/`gems.locked` spelling must be redirected + /// exactly like the classic pair — before this, a gems.rb project was a + /// silent no-op (the rewriter keyed on the literal "Gemfile" names). + #[test] + fn gems_rb_pair_is_rewritten_with_modern_paths() { + let mut files = BTreeMap::new(); + files.insert( + "gems.rb".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(), + ); + files.insert( + "gems.locked".to_string(), + gem_lock(&format!(" rails (7.0.0) sha256={}", "2".repeat(64))), + ); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + let gf = r.files.get("gems.rb").expect("gems.rb rewritten"); + assert!( + gf.contains( + "source \"https://patch.test/gem/tok/uuid/\" do\n gem \"rails\", \"7.0.0\"\nend" + ), + "source block lands in gems.rb: {gf}" + ); + let lk = r.files.get("gems.locked").expect("gems.locked rewritten"); + assert!( + lk.contains(&format!(" rails (7.0.0) sha256={}", "f".repeat(64))), + "CHECKSUMS pin lands in gems.locked: {lk}" + ); + assert!( + !r.files.contains_key("Gemfile") && !r.files.contains_key("Gemfile.lock"), + "classic spellings must not be invented: {:?}", + r.files.keys() + ); + // The ledger edits must name the files actually written, or a future + // revert restores the wrong pair. + assert!( + r.edits + .iter() + .any(|e| e.kind == "redirect_gemfile_source_block" && e.path == "gems.rb"), + "source-block edit keyed to gems.rb: {:?}", + r.edits + ); + assert!( + r.edits + .iter() + .any(|e| e.kind == "redirect_gemfile_lock_checksum" && e.path == "gems.locked"), + "lock edit keyed to gems.locked: {:?}", + r.edits + ); + } + + /// Both spellings present and byte-identical: follow bundler (which reads + /// gems.rb and ignores the Gemfile) — edit gems.rb, leave Gemfile alone. + #[test] + fn gems_rb_beats_identical_gemfile() { + let gemfile = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(); + let mut files = BTreeMap::new(); + files.insert("gems.rb".to_string(), gemfile.clone()); + files.insert("Gemfile".to_string(), gemfile); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + assert!( + r.files.contains_key("gems.rb") && !r.files.contains_key("Gemfile"), + "bundler reads gems.rb, so only gems.rb may be edited: {:?}", + r.files.keys() + ); + } + + /// Both spellings present and DIVERGING outside the redirect's own + /// footprint (an unrelated gem only one file declares): editing either is + /// a guess (the redirect could land in the file bundler ignores, or + /// tooling pinned to the classic name keeps resolving upstream). Fail + /// closed with a warning. + #[test] + fn gems_rb_and_gemfile_diverging_fail_closed() { + let mut files = BTreeMap::new(); + files.insert( + "gems.rb".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\ngem \"puma\", \"6.0.0\"\n" + .to_string(), + ); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "diverging spellings must not be edited: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "fail-closed skip must warn: {:?}", + r.warnings + ); + } + + /// Divergence confined to the redirected dep's OWN declaration line is + /// tolerated: the rewriter canonicalizes that line into the managed block + /// either way, and bundler reads gems.rb regardless (verified on 4.0.15, + /// which warns it is ignoring the Gemfile). Only divergence outside the + /// redirect's footprint is ambiguous enough to fail closed on. + #[test] + fn gems_rb_divergence_only_in_redirected_dep_line_proceeds() { + let mut files = BTreeMap::new(); + files.insert( + "gems.rb".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(), + ); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"6.1.0\"\n".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + assert!( + !r.warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "the redirected dep's own line is not ambient divergence: {:?}", + r.warnings + ); + assert!( + r.files.contains_key("gems.rb") && !r.files.contains_key("Gemfile"), + "redirect proceeds on the file bundler reads: {:?}", + r.files.keys() + ); + } + + /// Run 1 on byte-identical twins edits only gems.rb (bundler's file), + /// which makes the pair diverge on raw bytes. The divergence guard judges + /// the redirect-footprint residue instead: feeding run 1's output back + /// must be a plain no-op re-run, not a + /// `redirect_gem_gemfile_spellings_diverge` trap that blocks every later + /// run against the state run 1 itself created. + #[test] + fn gems_rb_identical_twins_rerun_is_a_no_op_not_a_diverge_trap() { + let gemfile = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(); + let mut files = BTreeMap::new(); + files.insert("gems.rb".to_string(), gemfile.clone()); + files.insert("Gemfile".to_string(), gemfile); + files.insert( + "gems.locked".to_string(), + gem_lock(&format!(" rails (7.0.0) sha256={}", "2".repeat(64))), + ); + let ovr = gem_override("rails", "7.0.0"); + let first = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + first.files.contains_key("gems.rb") && first.files.contains_key("gems.locked"), + "run 1 lands on the modern pair: files={:?} warnings={:?}", + first.files.keys(), + first.warnings + ); + for (name, content) in first.files { + files.insert(name, content); + } + let second = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + !second + .warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "the divergence run 1 itself created must not trap run 2: {:?}", + second.warnings + ); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "same-grant re-run is a no-op: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + } + + /// The identical-twins re-run with a ROTATED grant (the token/uuid URL + /// segments rotate per request) must still reach the in-place URL + /// refresh — with a raw-byte divergence guard, run 1's edit tripped the + /// trap and the redirect went permanently stale under the old grant. + #[test] + fn gems_rb_identical_twins_rerun_refreshes_rotated_grant_url() { + fn ov(token: &str) -> DepOverride { + let mut o = gem_override("rails", "7.0.0"); + o.token = token.into(); + if let Some(r) = o.registry_override.as_mut() { + r.index_url = format!("https://patch.test/gem/{token}/uuid/"); + } + o + } + let gemfile = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(); + let mut files = BTreeMap::new(); + files.insert("gems.rb".to_string(), gemfile.clone()); + files.insert("Gemfile".to_string(), gemfile); + let first = rewrite_registry_redirect(&files, &[ov("tok-one")]); + for (name, content) in first.files { + files.insert(name, content); + } + let second = rewrite_registry_redirect(&files, &[ov("tok-two")]); + assert!( + !second + .warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "run 1's own edit must not read as divergence: {:?}", + second.warnings + ); + let out = second + .files + .get("gems.rb") + .expect("rotated grant refreshes gems.rb"); + assert!( + out.contains( + "source \"https://patch.test/gem/tok-two/uuid/\" do\n gem \"rails\", \"7.0.0\"\nend" + ), + "URL refreshed in place: {out}" + ); + assert!(!out.contains("tok-one"), "old grant token gone: {out}"); + assert!( + second + .edits + .iter() + .any(|e| e.kind == "redirect_gemfile_source_url" && e.path == "gems.rb"), + "refresh recorded against gems.rb: {:?}", + second.edits + ); + } + + /// Twins where the redirected dep is TRANSITIVE (undeclared): run 1 + /// appends a source block to gems.rb — a footprint shape the residue + /// comparison must also erase, including the final newline the append + /// adds to a newline-less file. + #[test] + fn gems_rb_identical_twins_rerun_after_appended_block_is_no_op() { + let gemfile = "source \"https://rubygems.org\"\n\ngem \"rack\", \"3.0.0\"".to_string(); + let mut files = BTreeMap::new(); + files.insert("gems.rb".to_string(), gemfile.clone()); + files.insert("Gemfile".to_string(), gemfile); + let ovr = gem_override("rails", "7.0.0"); + let first = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + first + .files + .get("gems.rb") + .is_some_and(|gf| gf.contains("source \"https://patch.test/gem/tok/uuid/\" do")), + "run 1 appends the block for the undeclared dep: {:?}", + first.files + ); + for (name, content) in first.files { + files.insert(name, content); + } + let second = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + assert!( + !second + .warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "an appended block is the redirect's own footprint, not divergence: {:?}", + second.warnings + ); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "re-run is a no-op: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + } + + /// The block recognizer accepts a CRLF Socket source block (a + /// `core.autocrlf` checkout rewrites run 1's LF output), so the residue + /// comparison must erase that CRLF spelling too: after the checkout + /// rewrites BOTH twins to CRLF, only gems.rb carries the block — if the + /// residue regex stays LF-only the block survives into gems.rb's residue + /// and every later run (the rotated-grant URL refresh included) is + /// trapped behind `redirect_gem_gemfile_spellings_diverge`. + #[test] + fn gems_rb_crlf_twins_rerun_is_no_op_and_rotated_grant_refreshes() { + fn ov(token: &str) -> DepOverride { + let mut o = gem_override("rails", "7.0.0"); + o.token = token.into(); + if let Some(r) = o.registry_override.as_mut() { + r.index_url = format!("https://patch.test/gem/{token}/uuid/"); + } + o + } + // gems.rb exactly as run 1 wrote it, after a CRLF checkout; the + // Gemfile twin got the same CRLF treatment but never had the block. + let mut files = BTreeMap::new(); + files.insert( + "gems.rb".to_string(), + "source \"https://rubygems.org\"\r\n\r\n\ + source \"https://patch.test/gem/tok-one/uuid/\" do\r\n \ + gem \"rails\", \"7.0.0\"\r\nend\r\n" + .to_string(), + ); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\r\n\r\ngem \"rails\", \"7.0.0\"\r\n".to_string(), + ); + + // Same grant: recognized in place, a true no-op — not a diverge trap. + let same = rewrite_registry_redirect(&files, &[ov("tok-one")]); + assert!( + !same + .warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "the CRLF block is the redirect's own footprint, not divergence: {:?}", + same.warnings + ); + assert!( + same.files.is_empty() && same.edits.is_empty(), + "same-grant re-run on CRLF twins is a no-op: files={:?} edits={:?}", + same.files.keys(), + same.edits + ); + + // Rotated grant: URL refreshed in place inside gems.rb, never nested. + let rotated = rewrite_registry_redirect(&files, &[ov("tok-two")]); + assert!( + !rotated + .warnings + .iter() + .any(|w| w.code == "redirect_gem_gemfile_spellings_diverge"), + "rotated grant must reach the refresh, not the diverge trap: {:?}", + rotated.warnings + ); + let out = rotated + .files + .get("gems.rb") + .expect("rotated grant refreshes gems.rb on a CRLF checkout"); + assert_eq!( + out.matches("source \"https://patch.test/gem/").count(), + 1, + "exactly one Socket source block, never nested: {out}" + ); + assert!(!out.contains("tok-one"), "old grant token gone: {out}"); + assert!( + out.contains("source \"https://patch.test/gem/tok-two/uuid/\" do\r\n"), + "existing CRLF block body left intact: {out}" + ); + assert!( + !rotated.files.contains_key("Gemfile"), + "bundler reads gems.rb; the Gemfile twin stays untouched: {:?}", + rotated.files.keys() + ); + } + + /// A CRLF Gemfile.lock is legal to bundler (`bundle check` and a frozen + /// install both accept one — verified on 4.0.15). The CHECKSUMS pin must + /// land in place, byte-preserving the `\r\n` endings — before this, the + /// `(?m)^…$` matchers never saw the `\r`-terminated lines and the lock + /// was misdiagnosed as bundler <2.6 (`redirect_gem_no_checksums_section`). + #[test] + fn gem_crlf_lock_checksum_pinned_preserving_crlf() { + let mut files = BTreeMap::new(); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(), + ); + files.insert( + "Gemfile.lock".to_string(), + gem_lock(&format!(" rails (7.0.0) sha256={}", "2".repeat(64))).replace('\n', "\r\n"), + ); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + assert!( + !r.warnings + .iter() + .any(|w| w.code == "redirect_gem_no_checksums_section"), + "a CRLF CHECKSUMS section must be recognized: {:?}", + r.warnings + ); + let expected = + gem_lock(&format!(" rails (7.0.0) sha256={}", "f".repeat(64))).replace('\n', "\r\n"); + assert_eq!( + r.files.get("Gemfile.lock"), + Some(&expected), + "pin rewritten in place with every \\r\\n preserved" + ); + let edit = r + .edits + .iter() + .find(|e| e.kind == "redirect_gemfile_lock_checksum") + .expect("lock checksum edit recorded"); + assert_eq!( + edit.original, + Some(Value::String(format!( + "rails (7.0.0) sha256={}", + "2".repeat(64) + ))), + "recorded original carries no line-ending bytes" + ); + } + + /// CRLF lock whose CHECKSUMS section has no entry for the gem yet: the + /// added pin line must use the file's `\r\n` endings, not introduce a + /// lone `\n` into an otherwise-CRLF file. + #[test] + fn gem_crlf_lock_checksums_header_gains_crlf_entry() { + let mut files = BTreeMap::new(); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(), + ); + files.insert( + "Gemfile.lock".to_string(), + gem_lock(&format!(" nokogiri (1.16.0) sha256={}", "4".repeat(64))) + .replace('\n', "\r\n"), + ); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + let lk = r.files.get("Gemfile.lock").expect("lock rewritten"); + assert!( + lk.contains(&format!( + "CHECKSUMS\r\n rails (7.0.0) sha256={}\r\n", + "f".repeat(64) + )), + "added pin keeps the CRLF endings: {lk:?}" + ); + + // Re-run on the rewritten pair: recognizing the at-target CRLF line + // must be a no-op (the ledger would otherwise grow forever). + files.insert("Gemfile.lock".to_string(), lk.clone()); + files.insert( + "Gemfile".to_string(), + r.files.get("Gemfile").expect("Gemfile rewritten").clone(), + ); + let second = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "CRLF re-run must be a no-op: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + } + /// An unparseable package-lock.json must surface a warning, not silently /// skip the npm redirect entirely (missing-lockfile already warns; a /// corrupt lockfile is strictly worse and was silent). diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b4c3d4d1..85f2d0d3 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ five lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml, yarn classic, yarn berry, bun — berry and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | | 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 | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | -| RubyGems (`gem`) | ✅ Bundler plugin via `setup` | ✅ Gemfile + Gemfile.lock path pair | ✅ per-dep `source` block; the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | +| RubyGems (`gem`) | ✅ Bundler plugin via `setup` | ✅ 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) | | 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 | ❌ **not possible** — sumdb, module-path identity, and default-GOPROXY leakage each rule it out; see [golang-hosted-no-go.md](design/golang-hosted-no-go.md). **Use vendored** (`redirect_golang_unsupported` names the 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) |