From 1e33f9996b2a450b31a142e51dc6d3861bc30d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 05:22:31 +0000 Subject: [PATCH 1/5] Start fix for unlocked gem vendor failure Refs #2075. Assisted-by: Claude Code:claude-fable-5-2 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x From c5aac7910206b81cc6d669426338b3cfb74d7b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 05:29:29 +0000 Subject: [PATCH 2/5] Test vendoring a gem the lock doesn't resolve Three failing tests for #2075: scan --mode vendored (wet and dry run) and vendor on a project whose gem home holds a patchable gem version that Gemfile.lock doesn't resolve. Today the lock edit fails with "GEM specs has no entry" and the run exits 1. Assisted-by: Claude Code:claude-fable-5-2 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x --- .../tests/in_process_vendor.rs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 08e1c1773..4471bf0ea 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3812,6 +3812,176 @@ async fn scan_vendor_gem_unserved_first_vendor_still_fails() { assert_eq!(std::fs::read_to_string(fx.lock_path()).unwrap(), GEM_LOCK); } +/// The locks of #2075: the gem home holds demo-gem 1.0.0 (Ruby ships it, +/// or another project installed it), and this project's lock doesn't +/// resolve that version. Each is `(label, Gemfile, Gemfile.lock)`. +fn gem_unlocked_projects() -> Vec<(&'static str, String, String)> { + let other = "GEM\n remote: https://rubygems.org/\n specs:\n other-gem (2.0.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n other-gem\n\nBUNDLED WITH\n 2.6.2\n"; + let newer = GEM_LOCK.replace("demo-gem (1.0.0)", "demo-gem (1.2.0)"); + vec![ + ( + "the project doesn't use the gem", + "source \"https://rubygems.org\"\n\ngem \"other-gem\"\n".to_string(), + other.to_string(), + ), + ( + "the lock resolves a newer version", + GEM_GEMFILE.to_string(), + newer.clone(), + ), + ( + "a CRLF lock that resolves a newer version", + GEM_GEMFILE.to_string(), + newer.replace('\n', "\r\n"), + ), + ] +} + +/// A [`gem_fixture`] whose Gemfile pair is one of [`gem_unlocked_projects`]. +fn gem_unlocked_fixture(gemfile: &str, lock: &str) -> GemFixture { + let fx = gem_fixture(); + std::fs::write(fx.gemfile_path(), gemfile).unwrap(); + std::fs::write(fx.lock_path(), lock).unwrap(); + fx +} + +/// The paths of the requests `mock` received. +async fn requested_paths(mock: &wiremock::MockServer) -> Vec { + mock.received_requests() + .await + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect() +} + +/// #2075: `scan --mode vendored` on a project whose gem home holds a +/// patchable gem version the lock doesn't resolve. The gem is skipped as +/// `vendor_gem_version_not_locked` (exit 0, nothing failed), its patch is +/// never downloaded, and the project is left byte-identical. It used to +/// fail the run: "Gemfile.lock GEM specs has no entry", exit 1. +#[tokio::test] +async fn scan_vendor_gem_skips_a_version_the_lock_does_not_resolve() { + for (label, gemfile, lock) in gem_unlocked_projects() { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL).await; + let fx = gem_unlocked_fixture(&gemfile, &lock); + + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(code, 0, "{label}: {env:#}"); + assert_eq!(env["status"], "success", "{label}: {env:#}"); + assert_eq!(env["summary"]["failed"], 0, "{label}: {env:#}"); + let skipped = find_event(&env, "skipped", Some("vendor_gem_version_not_locked")); + assert_eq!(skipped["purl"], GEM_PURL, "{label}: {skipped}"); + assert_eq!(skipped["details"]["mode"], "vendored", "{label}: {skipped}"); + let detail = skipped["reason"].as_str().unwrap_or_default(); + assert!( + detail.contains("Gemfile.lock does not resolve demo-gem 1.0.0") + && detail.contains("this project doesn't lock it"), + "{label}: the skip says why: {skipped}" + ); + assert!( + events(&env).iter().all(|e| e["action"] != "failed"), + "{label}: {env:#}" + ); + + assert_eq!( + std::fs::read_to_string(fx.gemfile_path()).unwrap(), + gemfile, + "{label}: Gemfile untouched" + ); + assert_eq!( + std::fs::read_to_string(fx.lock_path()).unwrap(), + lock, + "{label}: Gemfile.lock untouched" + ); + assert!( + !fx.root().join(".socket").exists(), + "{label}: nothing is written for a gem the project doesn't lock" + ); + let downloads: Vec = requested_paths(&mock) + .await + .into_iter() + .filter(|p| p.contains("/patches/view/") || p.ends_with("/package")) + .collect(); + assert!( + downloads.is_empty(), + "{label}: the skipped patch is never downloaded: {downloads:?}" + ); + } +} + +/// #2075: the dry run reports what the wet run does. It used to say it +/// would vendor the gem the wet run then failed on. +#[tokio::test] +async fn scan_vendor_gem_dry_run_previews_the_unlocked_version_skip() { + for (label, gemfile, lock) in gem_unlocked_projects() { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL).await; + let fx = gem_unlocked_fixture(&gemfile, &lock); + + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &["--dry-run"]); + assert_eq!(code, 0, "{label}: {env:#}"); + let skipped = find_event(&env, "skipped", Some("vendor_gem_version_not_locked")); + assert_eq!(skipped["purl"], GEM_PURL, "{label}: {skipped}"); + assert!( + events(&env) + .iter() + .all(|e| e["action"] != "verified" && e["action"] != "failed"), + "{label}: the dry run must not say it would vendor the gem: {env:#}" + ); + assert!(!fx.root().join(".socket").exists(), "{label}"); + } +} + +/// #2075 through `vendor`: a manifest record (an agent-mode patch of the +/// shared gem home, say) for a version the lock doesn't resolve is the +/// same skip, exit 0, and the Gemfile pair stays byte-identical. +#[tokio::test] +async fn vendor_gem_skips_a_version_the_lock_does_not_resolve() { + for (label, gemfile, lock) in gem_unlocked_projects() { + let fx = gem_unlocked_fixture(&gemfile, &lock); + let after = compute_git_sha256_from_bytes(GEM_PATCHED); + std::fs::create_dir_all(fx.root().join(".socket/blobs")).unwrap(); + std::fs::write(fx.root().join(".socket/blobs").join(&after), GEM_PATCHED).unwrap(); + std::fs::write( + fx.root().join(".socket/manifest.json"), + serde_json::to_vec_pretty(&json!({ "patches": { GEM_PURL: { + "uuid": GEM_UUID, "exportedAt": "2026-01-01T00:00:00Z", + "files": { "lib/demo_gem.rb": { + "beforeHash": compute_git_sha256_from_bytes(GEM_ORIG), "afterHash": after, + } }, + "vulnerabilities": {}, "description": "gem vendor patch", "license": "MIT", + "tier": "free", + } } })) + .unwrap(), + ) + .unwrap(); + + for extra in [&["--dry-run"][..], &[]] { + let (code, env) = vendor_cli(fx.root(), extra); + assert_eq!(code, 0, "{label} {extra:?}: {env:#}"); + let skipped = find_event(&env, "skipped", Some("vendor_gem_version_not_locked")); + assert_eq!(skipped["purl"], GEM_PURL, "{label} {extra:?}: {skipped}"); + assert_eq!(env["summary"]["failed"], 0, "{label} {extra:?}: {env:#}"); + assert_eq!( + std::fs::read_to_string(fx.gemfile_path()).unwrap(), + gemfile, + "{label} {extra:?}: Gemfile untouched" + ); + assert_eq!( + std::fs::read_to_string(fx.lock_path()).unwrap(), + lock, + "{label} {extra:?}: Gemfile.lock untouched" + ); + assert!( + !fx.root().join(".socket/vendor").exists(), + "{label} {extra:?}: nothing vendored" + ); + } + } +} + /// `scan --mode vendored` end to end on a gem project: discover → download → /// vendor lands the gem pair edit (Gemfile pin + `path:`, lock PATH section /// + `(= …)!` DEPENDENCIES pin) and the patched artifact dir, keyed in the From 355d9955ad864df8f478e96427d5e8ce48cf16ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 05:43:49 +0000 Subject: [PATCH 3/5] Skip a gem version the lock doesn't resolve A project that installs into a shared gem home is crawled together with the gems Ruby ships and the versions other projects installed. Vendored mode handed each of them to the gem backend, whose lock edit failed with "Gemfile.lock GEM specs has no entry", so the run exited 1 although nothing in the project could be vendored. Hosted mode has skipped such a version since #1055. The gem backend now refuses a version no section of Gemfile.lock lists as vendor_gem_version_not_locked, and the vendored refusals raise the same refusal for an installed gem before any download. vendor, scan and get report it as a skip and exit 0; the dry run previews it. get still reports it as its error. The unwind test that used a version skew to make the lock edit fail now uses a spec listed under two sources, which still fails there. Fixes #2075 Assisted-by: Claude Code:claude-fable-5-2 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x --- crates/socket-patch-cli/CLI_CONTRACT.md | 1 + .../src/commands/agent_download.rs | 16 ++ .../socket-patch-cli/src/commands/vendor.rs | 17 +- .../tests/in_process_vendor.rs | 49 +++++ crates/socket-patch-core/src/vendor/gem.rs | 204 +++++++++++++++++- .../socket-patch-core/src/vendor/refusals.rs | 93 +++++++- docs/ecosystems.md | 6 + 7 files changed, 379 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1755cd6fb..002ffcb39 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1997,6 +1997,7 @@ command's integration tests check its output. | `vendor_revert_dependency_moved_on` | `skipped` (advisory; human: `Warning: …`) | `vendor --revert` / rollback / remove / `scan --prune` (pnpm, yarn classic, yarn berry): a lock entry vendoring wired was re-resolved since (the dependency was upgraded or re-locked) and no wired file references the vendored artifact any more, so the revert removed the artifact and the ledger entry with nothing to restore. The re-resolved fragments are named by their own `vendor_lock_entry_drifted` warnings and are left as they are. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | +| `vendor_gem_version_not_locked` | `skipped` (benign; exit 0) | vendor / scan / get `--mode vendored` (gem): the gem version is installed on the machine but no section of the project's `Gemfile.lock` resolves it (a gem Ruby ships in the gem home, or another project's copy in a shared gem home). Decided before any download: the patch is not fetched, the Gemfile and lock stay byte-identical, and the dry run previews the same skip. Human: `[skip] (vendor_gem_version_not_locked): …` in the download phase, `Skipping : …` in `vendor`. The one exception is `get --mode vendored`, which names that patch alone and so reports the code as its error (exit 1), like every other pre-download refusal. The vendored twin of `redirect_gem_version_not_locked`. | | `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` | `skipped` | vendor / scan / get `--mode vendored` (every ecosystem, #954, #1235): the package is already vendored at another patch uuid, and the patch service has no prebuilt artifact for the selected (superseding) uuid — still building (`pending_build`) / not served (`build_failed`, `not_found`, `withdrawn`, no usable artifact). Nothing is touched: the older patch's artifact, wiring and ledger entry stay in force, the detail names both uuids (`kept the vendored patch : prebuilt is still building for patch `, where `` is the backend's artifact kind: `artifact` for the npm family, `.gem`, `crate`, `module zip`, `dist zip`, `wheel`, `.jar`, `.nupkg`, …), and the run does not fail (exit 0), the way hosted mode keeps its pin and lists the upgrade in `redirect.skipped[]`. A re-run picks the new patch up once the service serves it. A package with no vendored patch to keep still fails (`failed`, error `prebuilt is still building` / `prebuilt unavailable: `; outside the npm family under `vendor_prebuilt_required`), as does a request/transport failure. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index dab40dc10..b7e83acd1 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -951,6 +951,22 @@ pub(crate) async fn fetch_selected_patches( // fetched (nor, downstream, its pristine source). Code-tagged so a // `--silent` operator can grep the stable code. if let Some(VendorRefusal { code, detail }) = refusals.get(purl) { + // An expected skip (a gem version this project doesn't lock) + // is the package's whole account: no view, no record, and no + // failure, as in the vendor loop. + if crate::commands::vendor::refusal_is_benign(code) { + if !quiet { + eprintln!(" [skip] {} ({code}): {detail}", normalize_purl(purl)); + } + batch.events.push(tag_mode( + PatchEvent::new(EventAction::Skipped, purl) + .with_uuid(uuid) + .with_reason(*code, detail.as_str()), + batch.mode, + )); + batch.skipped += 1; + continue; + } batch.fail( params.json, Some(format!("[error] {purl} ({code}): {detail}")), diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0d4f131ab..de63db9b1 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -115,8 +115,11 @@ pub struct VendorArgs { /// Refusal codes that are expected skips, not command failures: the user's /// request is still fully satisfied when these are the only non-successes. -fn refusal_is_benign(code: &str) -> bool { +pub(crate) fn refusal_is_benign(code: &str) -> bool { matches!(code, "vendor_unsupported_ecosystem" | "already_vendored") + // A gem version another project (or Ruby) put in the shared gem + // home: this project doesn't lock it, so there is nothing to wire. + || code == vendor::gem::GEM_VERSION_NOT_LOCKED // An older vendored patch kept in force (see [`keep_older_vendored_patch`]). || vendor::is_unserved(code) || socket_patch_core::vendor::jvm::sbt_gate::SKIP_CODES.contains(&code) @@ -3555,6 +3558,18 @@ pub(crate) async fn vendor_records_reusing( // revert a live hosted redirect. CandidateVerdict::Refuse { code, detail } => { matched.insert(candidate.clone()); + if refusal_is_benign(code) { + // An expected skip, as for the backend's own + // benign refusals below. + if !common.silent && !common.json { + eprintln!("Skipping {}: {detail}", normalize_purl(candidate)); + } + env.record( + PatchEvent::new(PatchAction::Skipped, candidate.clone()) + .with_reason(code, detail), + ); + continue; + } has_errors = true; env.record( PatchEvent::new(PatchAction::Failed, candidate.clone()) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 4471bf0ea..5fc2ba61e 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3911,6 +3911,55 @@ async fn scan_vendor_gem_skips_a_version_the_lock_does_not_resolve() { } } +/// #2075 in human mode: one `[skip]` line that says why, no `Error:` line +/// and exit 0, wet run and dry run alike. +#[tokio::test] +async fn scan_vendor_gem_unlocked_version_skip_reads_as_a_skip() { + let (_, gemfile, lock) = gem_unlocked_projects().remove(0); + for (extra, marker) in [(&[][..], "[skip]"), (&["--dry-run"][..], "[would-refuse]")] { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL).await; + let fx = gem_unlocked_fixture(&gemfile, &lock); + let uri = mock.uri(); + let mut argv = vec![ + "scan", + "--mode", + "vendored", + "--yes", + "--api-url", + uri.as_str(), + "--api-token", + "fake-token", + "--org", + "test-org", + "--cwd", + fx.root().to_str().unwrap(), + ]; + argv.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli(fx.root(), &argv, &[]); + let out = format!("{stdout}\n{stderr}"); + assert_eq!(code, 0, "{extra:?}:\n{out}"); + let line = out + .lines() + .find(|l| { + l.contains(&format!( + "{marker} {GEM_PURL} (vendor_gem_version_not_locked): " + )) + }) + .unwrap_or_else(|| panic!("{extra:?}: no {marker} line for the gem:\n{out}")); + assert!( + line.contains("Gemfile.lock does not resolve demo-gem 1.0.0") + && line.contains("this project doesn't lock it"), + "{extra:?}: {line}" + ); + assert!( + !out.contains("Error") && !out.contains("failed") && !out.contains("has no entry"), + "{extra:?}: a skip is not an error:\n{out}" + ); + assert!(!fx.root().join(".socket").exists(), "{extra:?}"); + } +} + /// #2075: the dry run reports what the wet run does. It used to say it /// would vendor the gem the wet run then failed on. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 5b8937fc4..436adfd5f 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -188,6 +188,74 @@ pub async fn gem_manifest_refusal(project_root: &Path) -> Option<(&'static str, } } +/// Refusal code of a gem version the project's `Gemfile.lock` doesn't +/// resolve. An expected skip, not a failure: the gem belongs to another +/// project (or to Ruby itself), and there is nothing in this one to wire. +pub const GEM_VERSION_NOT_LOCKED: &str = "vendor_gem_version_not_locked"; + +/// Whether any section of `lock_text` (`GEM`, `PATH`, `GIT`, `PLUGIN +/// SOURCE`) has a spec entry for `name` at `version`, as the portable gem +/// or as one of its platform builds (`name (version-platform)`). CRLF +/// tolerant. +fn lock_lists_gem_version(lock_text: &str, name: &str, version: &str) -> bool { + lock_text.lines().any(|line| { + spec_entry(line.trim_end_matches('\r')).is_some_and(|(n, token)| { + n == name + && token + .strip_prefix(version) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('-')) + }) + }) +} + +fn version_not_locked_detail(name: &str, version: &str) -> String { + format!( + "{GEMFILE_LOCK} does not resolve {name} {version} (it is installed on this machine \ + but this project doesn't lock it); nothing to vendor" + ) +} + +/// The gem purls of `purls` whose version the project's `Gemfile.lock` +/// doesn't resolve, each with the [`GEM_VERSION_NOT_LOCKED`] refusal the +/// backend raises for it. A project that installs to a shared gem home +/// (the Bundler default) is crawled together with the versions Ruby ships +/// and the ones other projects installed; vendoring is a `Gemfile.lock` +/// edit, and for those versions there is no entry to edit (#2075, the +/// vendored twin of hosted mode's `redirect_gem_version_not_locked`). +/// +/// Read-only. Nothing is returned when bundler loads another manifest or +/// the lock can't be read: the backend reports those itself. +pub async fn gem_version_not_locked_refusals( + project_root: &Path, + purls: &[&str], +) -> Vec<(String, (&'static str, String))> { + let gems: Vec<(&str, String, String)> = purls + .iter() + .filter_map(|purl| { + let (name, version) = parse_gem_purl(purl)?; + Some((*purl, name.to_string(), version.to_string())) + }) + .collect(); + if gems.is_empty() || gem_manifest_refusal(project_root).await.is_some() { + return Vec::new(); + } + let Ok(lock_text) = read_regular_to_string(&project_root.join(GEMFILE_LOCK)).await else { + return Vec::new(); + }; + gems.into_iter() + .filter(|(_, name, version)| !lock_lists_gem_version(&lock_text, name, version)) + .map(|(purl, name, version)| { + ( + purl.to_string(), + ( + GEM_VERSION_NOT_LOCKED, + version_not_locked_detail(&name, &version), + ), + ) + }) + .collect() +} + /// The per-gem twin of [`gem_manifest_refusal`] for the hosted→vendored /// takeover: the backend's Gemfile declaration gate ([`plan_gemfile_edit`] /// and [`refuse_append_of_direct_dependency`], refused as @@ -392,6 +460,15 @@ async fn gem_prelude( } }; let (lock_eol, lock_text) = lock_lf_form(lock_text); + // A version the lock doesn't resolve has no entry to move into a PATH + // section: an expected skip (see [`gem_version_not_locked_refusals`]), + // decided before the patch service is asked for anything. + if !lock_lists_gem_version(&lock_text, name, version) { + return Err(refused( + GEM_VERSION_NOT_LOCKED, + version_not_locked_detail(name, version), + )); + } let local_stub = { let spec_src = installed_path @@ -4120,10 +4197,13 @@ mod tests { #[tokio::test] async fn test_unwind_on_lock_edit_failure() { - // The lock has no GEM spec entry for rack@3.2.6 (version skew): the - // lock edit fails AFTER the Gemfile was rewritten, so vendor must - // unwind the Gemfile to its original bytes and drop the copy. - let lock = LOCK_DIRECT.replace(" rack (3.2.6)", " rack (3.1.0)"); + // The lock lists rack@3.2.6 under two sources, which the lock edit + // refuses to guess between: it fails AFTER the Gemfile was + // rewritten, so vendor must unwind the Gemfile to its original + // bytes and drop the copy. + let lock = format!( + "GEM\n remote: https://gems.example.com/\n specs:\n rack (3.2.6)\n\n{LOCK_DIRECT}" + ); let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, &lock).await; let (result, entry, _w) = @@ -4153,6 +4233,41 @@ mod tests { ); } + /// #2075: the installed rack 3.2.6 is a version the lock doesn't + /// resolve (it resolves 3.1.0; another project put 3.2.6 in the shared + /// gem home). There is no entry to move into a PATH section, so the + /// gem is refused as the skip `vendor_gem_version_not_locked`, dry run + /// or not, with nothing written. It used to fail the lock edit. + #[tokio::test] + async fn version_the_lock_does_not_resolve_is_refused_untouched() { + let lock = LOCK_DIRECT.replace(" rack (3.2.6)", " rack (3.1.0)"); + for (lock, dry_run) in [ + (lock.clone(), false), + (lock.clone(), true), + (lock.replace('\n', "\r\n"), false), + ] { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, &lock).await; + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, dry_run).await); + assert_eq!(code, GEM_VERSION_NOT_LOCKED); + assert!( + detail.contains("Gemfile.lock does not resolve rack 3.2.6"), + "{detail}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + lock + ); + assert!(!root.join(".socket/vendor").exists()); + } + } + #[tokio::test] async fn test_revert_round_trip_direct() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; @@ -9467,6 +9582,87 @@ mod tests { ); } + /// #2075: what counts as "the lock resolves this version". A spec + /// entry in any section does, the portable gem or a platform build of + /// it; a dependency subline, a DEPENDENCIES or CHECKSUMS entry, another + /// version or a gem whose name only starts the same does not. + #[test] + fn lock_lists_gem_version_reads_spec_entries_of_every_section() { + assert!(lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2.6")); + assert!(lock_lists_gem_version(LOCK_TWO_SOURCES, "rack", "3.2.6")); + let crlf = LOCK_DIRECT.replace('\n', "\r\n"); + assert!(lock_lists_gem_version(&crlf, "rack", "3.2.6")); + // Our own PATH section, a user's PATH or GIT section. + assert!(lock_lists_gem_version( + &expected_lock_two_sources(), + "rack", + "3.2.6" + )); + let git = "GIT\n remote: https://example.com/rack.git\n revision: abc\n specs:\n rack (3.2.6)\n\nGEM\n specs:\n\nDEPENDENCIES\n rack!\n"; + assert!(lock_lists_gem_version(git, "rack", "3.2.6")); + // A platform build of the version is still that version. + let platform = "GEM\n remote: https://rubygems.org/\n specs:\n ffi (1.17.2-x86_64-linux-gnu)\n\nCHECKSUMS\n rake (13.0.6) sha256=00\n"; + assert!(lock_lists_gem_version(platform, "ffi", "1.17.2")); + + assert!(!lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2.5")); + assert!(!lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2")); + assert!(!lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2.60")); + assert!(!lock_lists_gem_version(LOCK_DIRECT, "rac", "3.2.6")); + // `nio4r (~> 2.0)` and `base64 (>= 0.1.0)` are dependency sublines. + assert!(!lock_lists_gem_version(LOCK_DIRECT, "base64", ">= 0.1.0")); + assert!(!lock_lists_gem_version(platform, "ffi", "1.17")); + assert!(!lock_lists_gem_version(platform, "rake", "13.0.6")); + assert!(!lock_lists_gem_version("", "rack", "3.2.6")); + } + + /// #2075: the refusals name exactly the gem purls the lock doesn't + /// resolve, qualified or not, and leave every other ecosystem alone. + #[tokio::test] + async fn version_not_locked_refusals_name_only_unlocked_gems() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(GEMFILE), GEMFILE_DIRECT).unwrap(); + std::fs::write(tmp.path().join(GEMFILE_LOCK), LOCK_DIRECT).unwrap(); + let purls = [ + "pkg:gem/rack@3.2.6", + "pkg:gem/rack@2.2.8", + "pkg:gem/rake@13.0.6?platform=ruby", + "pkg:gem/puma@6.4.2?platform=ruby", + "pkg:npm/rake@13.0.6", + ]; + let refused = gem_version_not_locked_refusals(tmp.path(), &purls).await; + let names: Vec<&str> = refused.iter().map(|(purl, _)| purl.as_str()).collect(); + assert_eq!( + names, + ["pkg:gem/rack@2.2.8", "pkg:gem/rake@13.0.6?platform=ruby"] + ); + for (_, (code, detail)) in &refused { + assert_eq!(*code, GEM_VERSION_NOT_LOCKED); + assert!(detail.contains("this project doesn't lock it"), "{detail}"); + } + assert!(refused[0].1 .1.contains("does not resolve rack 2.2.8")); + } + + /// With no readable lock, or a manifest bundler doesn't load, nothing + /// is judged here: the backend's own refusal names the real problem. + #[tokio::test] + async fn version_not_locked_refusals_leave_other_problems_to_the_backend() { + let purls = ["pkg:gem/rake@13.0.6"]; + let no_lock = tempfile::tempdir().unwrap(); + std::fs::write(no_lock.path().join(GEMFILE), GEMFILE_DIRECT).unwrap(); + assert!(gem_version_not_locked_refusals(no_lock.path(), &purls) + .await + .is_empty()); + + let gems_rb = tempfile::tempdir().unwrap(); + std::fs::write(gems_rb.path().join(GEMFILE), GEMFILE_DIRECT).unwrap(); + std::fs::write(gems_rb.path().join(GEMFILE_LOCK), LOCK_DIRECT).unwrap(); + std::fs::write(gems_rb.path().join("gems.rb"), GEMFILE_DIRECT).unwrap(); + assert!(gem_manifest_refusal(gems_rb.path()).await.is_some()); + assert!(gem_version_not_locked_refusals(gems_rb.path(), &purls) + .await + .is_empty()); + } + /// The first-section case keeps its ledger shape: no source remote is /// recorded, so ledgers written before #779 and after it agree. #[test] diff --git a/crates/socket-patch-core/src/vendor/refusals.rs b/crates/socket-patch-core/src/vendor/refusals.rs index 508485d12..507ff2eb5 100644 --- a/crates/socket-patch-core/src/vendor/refusals.rs +++ b/crates/socket-patch-core/src/vendor/refusals.rs @@ -17,7 +17,10 @@ //! 2. the npm package-lock project refusal (a lock that is not v2/v3); //! 3. the lock-text refusals a hosted pnpm pin's takeover meets (#853); //! 4. the lock-text refusals of every other npm and cargo candidate, the -//! ones the backend raises on the lock and manifest text alone. +//! ones the backend raises on the lock and manifest text alone; +//! 5. an installed gem version the project's `Gemfile.lock` doesn't +//! resolve (`vendor_gem_version_not_locked`, which the callers report +//! as a skip). //! //! Each check lives in its backend's module; this one only dispatches and //! scopes them. @@ -31,7 +34,7 @@ use crate::crawlers::Ecosystem; use crate::patch::redirect::upstream::HostedPin; use crate::utils::purl_key::PurlKey; use crate::vendor::state::{lookup_entry, VendorEntry}; -use crate::vendor::{bun_lock, lock_inventory, npm_flavor, npm_lock, vlt_lock}; +use crate::vendor::{bun_lock, gem, lock_inventory, npm_flavor, npm_lock, vlt_lock}; /// The vendor ledger as the refusals consume it: the caller's own load /// outcome, so an UNREADABLE ledger is a refusal the run can report @@ -144,9 +147,39 @@ pub async fn vendor_refusals( for (purl, (code, detail)) in takeover_refusals.into_iter().chain(lock_text) { refuse(&mut out, &purl, code, &detail); } + for (purl, (code, detail)) in gem_refusals(project_root, candidates, scope.installed).await { + refuse(&mut out, &purl, code, &detail); + } out } +/// The gem candidates whose version `Gemfile.lock` doesn't resolve +/// ([`gem::gem_version_not_locked_refusals`]), less the ones that are not +/// installed: like [`reaching_backend`], a package with no copy on disk +/// never reaches its backend, and the vendor loop reports it +/// `package_not_installed`. +async fn gem_refusals( + project_root: &Path, + candidates: &[(&str, &str)], + installed: &InstalledLookup<'_>, +) -> Vec<(String, (&'static str, String))> { + let gems: Vec<&str> = candidates + .iter() + .map(|(purl, _)| *purl) + .filter(|purl| Ecosystem::from_purl(purl) == Some(Ecosystem::Gem)) + .collect(); + if gems.is_empty() { + return Vec::new(); + } + let mut refused = gem::gem_version_not_locked_refusals(project_root, &gems).await; + if refused.is_empty() { + return refused; + } + let installed = installed(refused.iter().map(|(purl, _)| purl.clone()).collect()).await; + refused.retain(|(purl, _)| installed.contains(purl)); + refused +} + fn refuse(out: &mut VendorRefusals, purl: &str, code: &'static str, detail: &str) { out.entry(purl.to_string()) .or_insert_with(|| VendorRefusal { @@ -351,6 +384,62 @@ mod tests { serde_json::from_value(json).unwrap() } + mod gem { + use super::*; + + const LOCKED: &str = "pkg:gem/rack@2.2.8"; + const UNLOCKED: &str = "pkg:gem/rake@13.0.6"; + + fn project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("Gemfile"), + "source \"https://rubygems.org\"\n\ngem \"rack\", \"2.2.8\"\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://rubygems.org/\n specs:\n rack (2.2.8)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack (= 2.2.8)\n\nBUNDLED WITH\n 2.6.9\n", + ) + .unwrap(); + tmp + } + + fn everything_installed(purls: Vec) -> BoxFuture<'static, HashSet> { + Box::pin(async move { purls.into_iter().collect() }) + } + + /// #2075: a gem version in the gem home that the lock doesn't + /// resolve is refused before any download; the gem the project + /// locks is left to the engine. + #[tokio::test] + async fn installed_version_the_lock_does_not_resolve_is_refused() { + let tmp = project(); + let pairs = [(LOCKED, UUID), (UNLOCKED, OTHER_UUID)]; + let state = HashMap::new(); + let got = run_scoped(tmp.path(), &pairs, Ok(&state), &[], &everything_installed).await; + assert_eq!(got.keys().collect::>(), [UNLOCKED]); + assert_eq!(got[UNLOCKED].code, "vendor_gem_version_not_locked"); + assert!( + got[UNLOCKED] + .detail + .contains("does not resolve rake 13.0.6"), + "{}", + got[UNLOCKED].detail + ); + } + + /// A gem that is neither locked nor installed never reaches the + /// backend: the vendor loop still reports it `package_not_installed`. + #[tokio::test] + async fn uninstalled_version_is_left_to_the_vendor_loop() { + let tmp = project(); + let state = HashMap::new(); + let got = run(tmp.path(), &[(UNLOCKED, UUID)], Ok(&state)).await; + assert!(got.is_empty(), "{got:?}"); + } + } + mod bun { use super::*; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 141c2541d..634663286 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1950,6 +1950,12 @@ the shared gem home. Another project that pins the upstream build of the same ge loads the patched code too, and its `bundle pristine` fails on the checksum until it is patched as well. +A shared gem home also holds gem versions this project doesn't use: the gems Ruby ships (`rake`, +`rexml`, ...) and the versions other projects installed. Hosted and vendored mode patch what +`Gemfile.lock` resolves, so a version the lock doesn't list is skipped, never a failure: hosted +mode warns `redirect_gem_version_not_locked`, vendored mode reports a `skipped` event with +`vendor_gem_version_not_locked` and doesn't download its patch. The run exits 0. + ## Ruby gems: native extensions Some gem fixes change only a C extension's sources (`ext/**/*.c`, `*.h`, `extconf.rb`). Ruby From e5372852c1e357844022c9958de67f338e7c2aad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 06:03:33 +0000 Subject: [PATCH 4/5] Report the unlocked gem skip the same everywhere Follow-up to the review of the previous commit. The backend refuses an unlocked gem whether or not it is installed, so the pre-download refusal no longer filters on the installed copy (which also cost a second crawl of the gem home), and the detail no longer claims the gem is installed. The lock is read with the shared Bundler lock parser (listed_specs, beside hosted mode's locked_specs) instead of a second line scan. get --mode vendored reports the skip as a skip (exit 0), like the search path, scan and vendor. The dry run prints the wet run's [skip] line and counts it as "would be skipped", not refused. Refs #2075 Assisted-by: Claude Code:claude-fable-5-2 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/agent_download.rs | 8 +- crates/socket-patch-cli/src/commands/get.rs | 24 ++++++ .../src/commands/scan/render.rs | 35 +++++++- .../src/commands/scan/vendor_flow.rs | 43 +++++++++- .../tests/in_process_vendor.rs | 79 +++++++++++++++++-- .../socket-patch-core/src/formats/gem/mod.rs | 14 ++++ crates/socket-patch-core/src/vendor/gem.rs | 69 +++++++--------- .../socket-patch-core/src/vendor/refusals.rs | 44 +++-------- 9 files changed, 229 insertions(+), 91 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 002ffcb39..f7458bb52 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -394,7 +394,7 @@ Pinned by `tests/scan/scan_proxy_fallback_e2e.rs`, `tests/cli/api_client_errors_ * **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`; `--cwd` itself is headed `== . ==`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. * **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: any package link in a `node_modules` root, such as a pnpm workspace member's link into the root `.pnpm` store (or npm's linked `.store`), alias links included (`packages/a/node_modules/lp` for `"lp": "npm:left-pad@1.3.0"`, matched by its target's own `package.json` name@version), and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON envelope past the argument checks (success, zero-package, all-batches-failed) carries a `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. -`scan --mode vendored` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): the package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure, in every ecosystem (#954, #1235); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads ` (replacing )`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation"), or for a PyPI purl the PyPI vendored backend's wiring guards would refuse before building anything (the project routing and lock guards a standalone `vendor --dry-run` reports, such as `pypi_no_requirements` or `pypi_uv_no_sources`, on the project lane and the PEP 723 script lane alike, #891; a PyPI purl with a live hosted pin is not judged, since the wet run restores its upstream entry before those guards run) — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints the headline `[dry-run] Would download and vendor N patches. No changes made.`, or, when some patches are in sync or would be refused, `[dry-run] Would download and vendor K of N patches (A already vendored, R would be refused). No changes made.` with only the non-zero clauses (#1850), then a `[skip] (already vendored)` line on stderr for each in-sync patch — the wet run's line — and `[would-refuse] (): ` lines, all behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). +`scan --mode vendored` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): the package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure, in every ecosystem (#954, #1235); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads ` (replacing )`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation"), or for a PyPI purl the PyPI vendored backend's wiring guards would refuse before building anything (the project routing and lock guards a standalone `vendor --dry-run` reports, such as `pypi_no_requirements` or `pypi_uv_no_sources`, on the project lane and the PEP 723 script lane alike, #891; a PyPI purl with a live hosted pin is not judged, since the wet run restores its upstream entry before those guards run) — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints the headline `[dry-run] Would download and vendor N patches. No changes made.`, or, when some patches are in sync or would be refused, `[dry-run] Would download and vendor K of N patches (A already vendored, S would be skipped, R would be refused). No changes made.` with only the non-zero clauses (#1850), then a `[skip] (already vendored)` line on stderr for each in-sync patch — the wet run's line —, a `[skip] (): ` line on stderr for each patch the wet run skips under a benign refusal code (`vendor_gem_version_not_locked`, counted as "would be skipped", never as refused) and `[would-refuse] (): ` lines, all behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). @@ -1997,7 +1997,7 @@ command's integration tests check its output. | `vendor_revert_dependency_moved_on` | `skipped` (advisory; human: `Warning: …`) | `vendor --revert` / rollback / remove / `scan --prune` (pnpm, yarn classic, yarn berry): a lock entry vendoring wired was re-resolved since (the dependency was upgraded or re-locked) and no wired file references the vendored artifact any more, so the revert removed the artifact and the ledger entry with nothing to restore. The re-resolved fragments are named by their own `vendor_lock_entry_drifted` warnings and are left as they are. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | -| `vendor_gem_version_not_locked` | `skipped` (benign; exit 0) | vendor / scan / get `--mode vendored` (gem): the gem version is installed on the machine but no section of the project's `Gemfile.lock` resolves it (a gem Ruby ships in the gem home, or another project's copy in a shared gem home). Decided before any download: the patch is not fetched, the Gemfile and lock stay byte-identical, and the dry run previews the same skip. Human: `[skip] (vendor_gem_version_not_locked): …` in the download phase, `Skipping : …` in `vendor`. The one exception is `get --mode vendored`, which names that patch alone and so reports the code as its error (exit 1), like every other pre-download refusal. The vendored twin of `redirect_gem_version_not_locked`. | +| `vendor_gem_version_not_locked` | `skipped` (benign; exit 0) | vendor / scan / get `--mode vendored` (gem): no section of the project's `Gemfile.lock` resolves the gem at this version (typically a gem Ruby ships in the gem home, or another project's copy in a shared gem home, which the crawl of that gem home returns). Decided before any download: the patch is not fetched, the Gemfile and lock stay byte-identical, and nothing is written. Human: `[skip] (vendor_gem_version_not_locked): …` in the download phase of `scan` / `get` (both identifier paths) and in their dry run, `Skipping : …` in `vendor`. The vendored twin of `redirect_gem_version_not_locked`. | | `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` | `skipped` | vendor / scan / get `--mode vendored` (every ecosystem, #954, #1235): the package is already vendored at another patch uuid, and the patch service has no prebuilt artifact for the selected (superseding) uuid — still building (`pending_build`) / not served (`build_failed`, `not_found`, `withdrawn`, no usable artifact). Nothing is touched: the older patch's artifact, wiring and ledger entry stay in force, the detail names both uuids (`kept the vendored patch : prebuilt is still building for patch `, where `` is the backend's artifact kind: `artifact` for the npm family, `.gem`, `crate`, `module zip`, `dist zip`, `wheel`, `.jar`, `.nupkg`, …), and the run does not fail (exit 0), the way hosted mode keeps its pin and lists the upgrade in `redirect.skipped[]`. A re-run picks the new patch up once the service serves it. A package with no vendored patch to keep still fails (`failed`, error `prebuilt is still building` / `prebuilt unavailable: `; outside the npm family under `vendor_prebuilt_required`), as does a request/transport failure. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index b7e83acd1..6a4846f21 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -342,6 +342,12 @@ pub(crate) fn format_record_skip(purl: &str, why: &str) -> String { format!(" [skip] {} ({why})", normalize_purl(purl)) } +/// `[skip]` line for a patch a vendored run doesn't download because its +/// refusal is an expected skip (`code`, see `vendor::refusal_is_benign`). +pub(crate) fn format_refusal_skip(purl: &str, code: &str, detail: &str) -> String { + format!(" [skip] {} ({code}): {detail}", normalize_purl(purl)) +} + /// Download parameters shared between get and scan commands. pub struct DownloadParams { pub cwd: PathBuf, @@ -956,7 +962,7 @@ pub(crate) async fn fetch_selected_patches( // failure, as in the vendor loop. if crate::commands::vendor::refusal_is_benign(code) { if !quiet { - eprintln!(" [skip] {} ({code}): {detail}", normalize_purl(purl)); + eprintln!("{}", format_refusal_skip(purl, code, detail)); } batch.events.push(tag_mode( PatchEvent::new(EventAction::Skipped, purl) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e0902f63a..a4b231067 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2353,6 +2353,30 @@ async fn run_get_vendored( )) .await; if let Some(VendorRefusal { code, detail }) = refusals.get(&patch.purl) { + // An expected skip (a gem version this project doesn't lock) + // is not an error: the same `skipped` event and `[skip]` line + // as the search path and `scan`, exit 0, nothing written. + if crate::commands::vendor::refusal_is_benign(code) { + if args.common.json { + env.record(tag_mode( + PatchEvent::new(EventAction::Skipped, patch.purl.as_str()) + .with_uuid(patch.uuid.as_str()) + .with_reason(*code, detail.as_str()), + Some("vendored"), + )); + emit(&env); + } else if !args.common.silent { + eprintln!( + "{}", + crate::commands::agent_download::format_refusal_skip( + &patch.purl, + code, + detail + ) + ); + } + return 0; + } // Same failure telemetry as the vendor-step Err arm below: this // run exits 1 without vendoring anything. socket_patch_core::telemetry::track_patch_vendor_failed( diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index c51db1c93..5fb3cd8b3 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -445,12 +445,14 @@ pub(super) enum Plan { /// Vendored mode: of `total` selected patches, `refused` would be /// refused by the vendored preflight (listed as `[would-refuse]` /// lines) and `in_sync` are held by the ledger at this uuid (listed as - /// `[skip] … (already vendored)` lines); the rest are downloaded and - /// vendored. + /// `[skip] … (already vendored)` lines); `skipped` are expected skips + /// (a gem version the project doesn't lock, listed as `[skip]` lines + /// with their code); the rest are downloaded and vendored. Vendor { total: usize, refused: usize, in_sync: usize, + skipped: usize, }, } @@ -464,17 +466,21 @@ pub(super) fn dry_run_line(plan: Plan) -> String { total, refused, in_sync, - } if refused > 0 || in_sync > 0 => { + skipped, + } if refused > 0 || in_sync > 0 || skipped > 0 => { let mut why = Vec::new(); if in_sync > 0 { why.push(format!("{in_sync} already vendored")); } + if skipped > 0 { + why.push(format!("{skipped} would be skipped")); + } if refused > 0 { why.push(format!("{refused} would be refused")); } format!( "download and vendor {} of {} ({})", - total.saturating_sub(refused + in_sync), + total.saturating_sub(refused + in_sync + skipped), plural(total, "patch", "patches"), why.join(", ") ) @@ -995,9 +1001,30 @@ mod tests { total, refused, in_sync, + skipped: 0, } } + /// #2075: an expected skip is not a refusal, and not work either. + #[test] + fn dry_run_line_counts_expected_skips() { + let plan = |total, refused, skipped| Plan::Vendor { + total, + refused, + in_sync: 0, + skipped, + }; + assert_eq!( + dry_run_line(plan(1, 0, 1)), + "[dry-run] Would download and vendor 0 of 1 patch (1 would be skipped). No changes made." + ); + assert_eq!( + dry_run_line(plan(4, 1, 2)), + "[dry-run] Would download and vendor 1 of 4 patches (2 would be skipped, \ + 1 would be refused). No changes made." + ); + } + #[test] fn dry_run_line_counts_refusals() { assert_eq!( diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 3c85c233a..1612ce37f 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -85,6 +85,19 @@ pub(crate) enum PreviewVerdict { WouldVendor, } +impl PreviewVerdict { + /// For a row the wet run's preflight stops: whether it is an expected + /// skip (`[skip]`, exit 0) rather than a failure. `None` for the rest. + fn would_skip(&self) -> Option { + match self { + Self::WouldRefuse { code, .. } => { + Some(crate::commands::vendor::refusal_is_benign(code)) + } + _ => None, + } + } +} + /// One row of [`VendorPreview`]. #[derive(Debug, Clone)] pub(crate) struct PreviewRow { @@ -103,11 +116,20 @@ pub(crate) struct VendorPreview { } impl VendorPreview { - /// How many rows the wet run would refuse. + /// How many rows the wet run would refuse as failures. pub(crate) fn refused_count(&self) -> usize { self.rows .iter() - .filter(|r| matches!(r.verdict, PreviewVerdict::WouldRefuse { .. })) + .filter(|r| r.verdict.would_skip() == Some(false)) + .count() + } + + /// How many rows the wet run would skip without failing: a refusal + /// under a benign code. + pub(crate) fn skipped_count(&self) -> usize { + self.rows + .iter() + .filter(|r| r.verdict.would_skip() == Some(true)) .count() } @@ -199,13 +221,16 @@ impl VendorPreview { total: self.rows.len(), refused: self.refused_count(), in_sync: self.in_sync_count(), + skipped: self.skipped_count(), }) } /// Human rendering of the preview: the headline, then a `[skip] … /// (already vendored)` line for each in-sync row (stderr, the wet /// run's line and stream), a `[would-refuse]` line naming what the wet - /// run would refuse and why, and the symlink advisories. Shared by + /// run would refuse and why (a `[skip]` line on stderr, as the wet run + /// prints it, when the refusal is an expected skip), and the symlink + /// advisories. Shared by /// `scan --mode vendored --dry-run` and `get … --mode vendored /// --dry-run` so the two cannot drift. Callers gate it on `--silent`. pub(crate) fn print_human(&self) { @@ -223,7 +248,17 @@ impl VendorPreview { } for row in &self.rows { if let PreviewVerdict::WouldRefuse { code, detail } = &row.verdict { - println!(" [would-refuse] {} ({code}): {detail}", row.purl); + if row.verdict.would_skip() == Some(true) { + // The wet run's line and stream. + eprintln!( + "{}", + crate::commands::agent_download::format_refusal_skip( + &row.purl, code, detail + ) + ); + } else { + println!(" [would-refuse] {} ({code}): {detail}", row.purl); + } } } for row in &self.rows { diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 5fc2ba61e..8d5070b77 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3877,7 +3877,7 @@ async fn scan_vendor_gem_skips_a_version_the_lock_does_not_resolve() { let detail = skipped["reason"].as_str().unwrap_or_default(); assert!( detail.contains("Gemfile.lock does not resolve demo-gem 1.0.0") - && detail.contains("this project doesn't lock it"), + && detail.contains("this project doesn't lock that version"), "{label}: the skip says why: {skipped}" ); assert!( @@ -3916,7 +3916,13 @@ async fn scan_vendor_gem_skips_a_version_the_lock_does_not_resolve() { #[tokio::test] async fn scan_vendor_gem_unlocked_version_skip_reads_as_a_skip() { let (_, gemfile, lock) = gem_unlocked_projects().remove(0); - for (extra, marker) in [(&[][..], "[skip]"), (&["--dry-run"][..], "[would-refuse]")] { + for (extra, headline) in [ + (&[][..], "Downloading 1 patch"), + ( + &["--dry-run"][..], + "Would download and vendor 0 of 1 patch (1 would be skipped)", + ), + ] { let mock = wiremock::MockServer::start().await; mount_gem_patch_api(&mock, GEM_PURL).await; let fx = gem_unlocked_fixture(&gemfile, &lock); @@ -3939,27 +3945,86 @@ async fn scan_vendor_gem_unlocked_version_skip_reads_as_a_skip() { let (code, stdout, stderr) = run_cli(fx.root(), &argv, &[]); let out = format!("{stdout}\n{stderr}"); assert_eq!(code, 0, "{extra:?}:\n{out}"); - let line = out + assert!(out.contains(headline), "{extra:?}:\n{out}"); + let line = stderr .lines() .find(|l| { l.contains(&format!( - "{marker} {GEM_PURL} (vendor_gem_version_not_locked): " + "[skip] {GEM_PURL} (vendor_gem_version_not_locked): " )) }) - .unwrap_or_else(|| panic!("{extra:?}: no {marker} line for the gem:\n{out}")); + .unwrap_or_else(|| panic!("{extra:?}: no [skip] line for the gem:\n{out}")); assert!( line.contains("Gemfile.lock does not resolve demo-gem 1.0.0") - && line.contains("this project doesn't lock it"), + && line.contains("this project doesn't lock that version"), "{extra:?}: {line}" ); assert!( - !out.contains("Error") && !out.contains("failed") && !out.contains("has no entry"), + !["Error", "failed", "refuse", "has no entry"] + .iter() + .any(|bad| out.contains(bad)), "{extra:?}: a skip is not an error:\n{out}" ); assert!(!fx.root().join(".socket").exists(), "{extra:?}"); } } +/// #2075 through `get --mode vendored`, which judges the one patch +/// it names before its download phase: the same skip, exit 0 and nothing +/// written, in JSON and in human mode. +#[tokio::test] +async fn get_uuid_vendored_gem_skips_a_version_the_lock_does_not_resolve() { + for (label, gemfile, lock) in gem_unlocked_projects() { + for json_mode in [true, false] { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL).await; + let fx = gem_unlocked_fixture(&gemfile, &lock); + let uri = mock.uri(); + let mut argv = vec![ + "get", + GEM_UUID, + "--mode", + "vendored", + "--yes", + "--api-url", + uri.as_str(), + "--api-token", + "fake-token", + "--org", + "test-org", + "--cwd", + fx.root().to_str().unwrap(), + ]; + if json_mode { + argv.push("--json"); + } + let (code, stdout, stderr) = run_cli(fx.root(), &argv, &[]); + assert_eq!(code, 0, "{label}:\n{stdout}\n{stderr}"); + if json_mode { + let env: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(env["status"], "success", "{label}: {env:#}"); + let skipped = find_event(&env, "skipped", Some("vendor_gem_version_not_locked")); + assert_eq!(skipped["purl"], GEM_PURL, "{label}: {skipped}"); + assert_eq!(skipped["details"]["mode"], "vendored", "{label}"); + assert_eq!(env["summary"]["failed"], 0, "{label}: {env:#}"); + } else { + assert!( + stderr.contains(&format!( + "[skip] {GEM_PURL} (vendor_gem_version_not_locked): " + )) && !stderr.contains("Error"), + "{label}:\n{stdout}\n{stderr}" + ); + } + assert_eq!( + std::fs::read_to_string(fx.lock_path()).unwrap(), + lock, + "{label}: Gemfile.lock untouched" + ); + assert!(!fx.root().join(".socket").exists(), "{label}"); + } + } +} + /// #2075: the dry run reports what the wet run does. It used to say it /// would vendor the gem the wet run then failed on. #[tokio::test] diff --git a/crates/socket-patch-core/src/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index b844db4b3..0723cbbca 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -477,6 +477,20 @@ pub(crate) fn locked_specs(lock: &str) -> HashSet<(&str, &str)> { .collect() } +/// Every `name (version)` spec ANY source section of the Bundler lock +/// `lock` lists (`GEM`, `PATH`, `GIT`, `PLUGIN SOURCE`; any platform). +/// Vendored mode only wires what the lock resolves: its own `PATH` +/// sections count, and so does a section it cannot edit, which the backend +/// reports for what it is (#2075). +pub(crate) fn listed_specs(lock: &str) -> HashSet<(&str, &str)> { + parse(lock) + .sections + .iter() + .flat_map(|s| s.specs.iter().filter_map(|l| l.parsed)) + .map(|spec| (spec.name, spec.version)) + .collect() +} + /// The plain gem-token charset (letters, digits, `.`, `_`, `-`). The vendor /// backend applies it before embedding coordinates into Ruby source and lock /// line grammar (see the SECURITY note in `crate::vendor::gem`'s diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 436adfd5f..73a86229f 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -82,7 +82,7 @@ use super::state::{ write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; -use crate::formats::gem::{is_plain_gem_token, split_checksum_entry, split_entry}; +use crate::formats::gem::{is_plain_gem_token, listed_specs, split_checksum_entry, split_entry}; const GEMFILE: &str = "Gemfile"; const GEMFILE_LOCK: &str = "Gemfile.lock"; @@ -193,25 +193,10 @@ pub async fn gem_manifest_refusal(project_root: &Path) -> Option<(&'static str, /// project (or to Ruby itself), and there is nothing in this one to wire. pub const GEM_VERSION_NOT_LOCKED: &str = "vendor_gem_version_not_locked"; -/// Whether any section of `lock_text` (`GEM`, `PATH`, `GIT`, `PLUGIN -/// SOURCE`) has a spec entry for `name` at `version`, as the portable gem -/// or as one of its platform builds (`name (version-platform)`). CRLF -/// tolerant. -fn lock_lists_gem_version(lock_text: &str, name: &str, version: &str) -> bool { - lock_text.lines().any(|line| { - spec_entry(line.trim_end_matches('\r')).is_some_and(|(n, token)| { - n == name - && token - .strip_prefix(version) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('-')) - }) - }) -} - fn version_not_locked_detail(name: &str, version: &str) -> String { format!( - "{GEMFILE_LOCK} does not resolve {name} {version} (it is installed on this machine \ - but this project doesn't lock it); nothing to vendor" + "{GEMFILE_LOCK} does not resolve {name} {version}: this project doesn't lock that \ + version, so there is nothing to vendor" ) } @@ -222,6 +207,8 @@ fn version_not_locked_detail(name: &str, version: &str) -> String { /// and the ones other projects installed; vendoring is a `Gemfile.lock` /// edit, and for those versions there is no entry to edit (#2075, the /// vendored twin of hosted mode's `redirect_gem_version_not_locked`). +/// A spec entry in any source section counts as resolved +/// ([`listed_specs`]). /// /// Read-only. Nothing is returned when bundler loads another manifest or /// the lock can't be read: the backend reports those itself. @@ -242,8 +229,9 @@ pub async fn gem_version_not_locked_refusals( let Ok(lock_text) = read_regular_to_string(&project_root.join(GEMFILE_LOCK)).await else { return Vec::new(); }; + let listed = listed_specs(&lock_text); gems.into_iter() - .filter(|(_, name, version)| !lock_lists_gem_version(&lock_text, name, version)) + .filter(|(_, name, version)| !listed.contains(&(name.as_str(), version.as_str()))) .map(|(purl, name, version)| { ( purl.to_string(), @@ -463,7 +451,7 @@ async fn gem_prelude( // A version the lock doesn't resolve has no entry to move into a PATH // section: an expected skip (see [`gem_version_not_locked_refusals`]), // decided before the patch service is asked for anything. - if !lock_lists_gem_version(&lock_text, name, version) { + if !listed_specs(&lock_text).contains(&(name, version)) { return Err(refused( GEM_VERSION_NOT_LOCKED, version_not_locked_detail(name, version), @@ -9587,32 +9575,30 @@ mod tests { /// it; a dependency subline, a DEPENDENCIES or CHECKSUMS entry, another /// version or a gem whose name only starts the same does not. #[test] - fn lock_lists_gem_version_reads_spec_entries_of_every_section() { - assert!(lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2.6")); - assert!(lock_lists_gem_version(LOCK_TWO_SOURCES, "rack", "3.2.6")); + fn listed_specs_reads_spec_entries_of_every_section() { + let lists = + |lock: &str, name: &str, version: &str| listed_specs(lock).contains(&(name, version)); + assert!(lists(LOCK_DIRECT, "rack", "3.2.6")); + assert!(lists(LOCK_TWO_SOURCES, "rack", "3.2.6")); let crlf = LOCK_DIRECT.replace('\n', "\r\n"); - assert!(lock_lists_gem_version(&crlf, "rack", "3.2.6")); + assert!(lists(&crlf, "rack", "3.2.6")); // Our own PATH section, a user's PATH or GIT section. - assert!(lock_lists_gem_version( - &expected_lock_two_sources(), - "rack", - "3.2.6" - )); + assert!(lists(&expected_lock_two_sources(), "rack", "3.2.6")); let git = "GIT\n remote: https://example.com/rack.git\n revision: abc\n specs:\n rack (3.2.6)\n\nGEM\n specs:\n\nDEPENDENCIES\n rack!\n"; - assert!(lock_lists_gem_version(git, "rack", "3.2.6")); + assert!(lists(git, "rack", "3.2.6")); // A platform build of the version is still that version. let platform = "GEM\n remote: https://rubygems.org/\n specs:\n ffi (1.17.2-x86_64-linux-gnu)\n\nCHECKSUMS\n rake (13.0.6) sha256=00\n"; - assert!(lock_lists_gem_version(platform, "ffi", "1.17.2")); + assert!(lists(platform, "ffi", "1.17.2")); - assert!(!lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2.5")); - assert!(!lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2")); - assert!(!lock_lists_gem_version(LOCK_DIRECT, "rack", "3.2.60")); - assert!(!lock_lists_gem_version(LOCK_DIRECT, "rac", "3.2.6")); + assert!(!lists(LOCK_DIRECT, "rack", "3.2.5")); + assert!(!lists(LOCK_DIRECT, "rack", "3.2")); + assert!(!lists(LOCK_DIRECT, "rack", "3.2.60")); + assert!(!lists(LOCK_DIRECT, "rac", "3.2.6")); // `nio4r (~> 2.0)` and `base64 (>= 0.1.0)` are dependency sublines. - assert!(!lock_lists_gem_version(LOCK_DIRECT, "base64", ">= 0.1.0")); - assert!(!lock_lists_gem_version(platform, "ffi", "1.17")); - assert!(!lock_lists_gem_version(platform, "rake", "13.0.6")); - assert!(!lock_lists_gem_version("", "rack", "3.2.6")); + assert!(!lists(LOCK_DIRECT, "base64", ">= 0.1.0")); + assert!(!lists(platform, "ffi", "1.17")); + assert!(!lists(platform, "rake", "13.0.6")); + assert!(!lists("", "rack", "3.2.6")); } /// #2075: the refusals name exactly the gem purls the lock doesn't @@ -9637,7 +9623,10 @@ mod tests { ); for (_, (code, detail)) in &refused { assert_eq!(*code, GEM_VERSION_NOT_LOCKED); - assert!(detail.contains("this project doesn't lock it"), "{detail}"); + assert!( + detail.contains("this project doesn't lock that version"), + "{detail}" + ); } assert!(refused[0].1 .1.contains("does not resolve rack 2.2.8")); } diff --git a/crates/socket-patch-core/src/vendor/refusals.rs b/crates/socket-patch-core/src/vendor/refusals.rs index 507ff2eb5..0cbae8309 100644 --- a/crates/socket-patch-core/src/vendor/refusals.rs +++ b/crates/socket-patch-core/src/vendor/refusals.rs @@ -18,7 +18,7 @@ //! 3. the lock-text refusals a hosted pnpm pin's takeover meets (#853); //! 4. the lock-text refusals of every other npm and cargo candidate, the //! ones the backend raises on the lock and manifest text alone; -//! 5. an installed gem version the project's `Gemfile.lock` doesn't +//! 5. a gem version the project's `Gemfile.lock` doesn't //! resolve (`vendor_gem_version_not_locked`, which the callers report //! as a skip). //! @@ -147,21 +147,18 @@ pub async fn vendor_refusals( for (purl, (code, detail)) in takeover_refusals.into_iter().chain(lock_text) { refuse(&mut out, &purl, code, &detail); } - for (purl, (code, detail)) in gem_refusals(project_root, candidates, scope.installed).await { + for (purl, (code, detail)) in gem_refusals(project_root, candidates).await { refuse(&mut out, &purl, code, &detail); } out } /// The gem candidates whose version `Gemfile.lock` doesn't resolve -/// ([`gem::gem_version_not_locked_refusals`]), less the ones that are not -/// installed: like [`reaching_backend`], a package with no copy on disk -/// never reaches its backend, and the vendor loop reports it -/// `package_not_installed`. +/// ([`gem::gem_version_not_locked_refusals`]), installed or not: the +/// backend refuses either before it asks the patch service for anything. async fn gem_refusals( project_root: &Path, candidates: &[(&str, &str)], - installed: &InstalledLookup<'_>, ) -> Vec<(String, (&'static str, String))> { let gems: Vec<&str> = candidates .iter() @@ -171,13 +168,7 @@ async fn gem_refusals( if gems.is_empty() { return Vec::new(); } - let mut refused = gem::gem_version_not_locked_refusals(project_root, &gems).await; - if refused.is_empty() { - return refused; - } - let installed = installed(refused.iter().map(|(purl, _)| purl.clone()).collect()).await; - refused.retain(|(purl, _)| installed.contains(purl)); - refused + gem::gem_version_not_locked_refusals(project_root, &gems).await } fn refuse(out: &mut VendorRefusals, purl: &str, code: &'static str, detail: &str) { @@ -405,19 +396,16 @@ mod tests { tmp } - fn everything_installed(purls: Vec) -> BoxFuture<'static, HashSet> { - Box::pin(async move { purls.into_iter().collect() }) - } - - /// #2075: a gem version in the gem home that the lock doesn't - /// resolve is refused before any download; the gem the project - /// locks is left to the engine. + /// #2075: a gem version the lock doesn't resolve (one the gem + /// home holds for Ruby or another project, or one that is not + /// installed at all) is refused before any download; the gem the + /// project locks is left to the engine. #[tokio::test] - async fn installed_version_the_lock_does_not_resolve_is_refused() { + async fn version_the_lock_does_not_resolve_is_refused() { let tmp = project(); let pairs = [(LOCKED, UUID), (UNLOCKED, OTHER_UUID)]; let state = HashMap::new(); - let got = run_scoped(tmp.path(), &pairs, Ok(&state), &[], &everything_installed).await; + let got = run(tmp.path(), &pairs, Ok(&state)).await; assert_eq!(got.keys().collect::>(), [UNLOCKED]); assert_eq!(got[UNLOCKED].code, "vendor_gem_version_not_locked"); assert!( @@ -428,16 +416,6 @@ mod tests { got[UNLOCKED].detail ); } - - /// A gem that is neither locked nor installed never reaches the - /// backend: the vendor loop still reports it `package_not_installed`. - #[tokio::test] - async fn uninstalled_version_is_left_to_the_vendor_loop() { - let tmp = project(); - let state = HashMap::new(); - let got = run(tmp.path(), &[(UNLOCKED, UUID)], Ok(&state)).await; - assert!(got.is_empty(), "{got:?}"); - } } mod bun { From c622a25b70a6c601f795ae96e7a353cedea49eb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 06:09:47 +0000 Subject: [PATCH 5/5] Move the benign refusal list into core get.rs called vendor::refusal_is_benign, an import from one command module into another that command_module_layering forbids (coverage job). Every input of the predicate is a core constant, so it now lives beside the refusals in socket-patch-core, and vendor, scan, get and the download phase all read it there. Refs #2075 Assisted-by: Claude Code:claude-fable-5-2 Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011f9aePg6MaoEvntLaEAf2x --- .../src/commands/agent_download.rs | 4 ++-- crates/socket-patch-cli/src/commands/get.rs | 2 +- .../src/commands/scan/vendor_flow.rs | 2 +- crates/socket-patch-cli/src/commands/vendor.rs | 12 +----------- crates/socket-patch-core/src/vendor/refusals.rs | 15 +++++++++++++++ 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 6a4846f21..7dd966c92 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -343,7 +343,7 @@ pub(crate) fn format_record_skip(purl: &str, why: &str) -> String { } /// `[skip]` line for a patch a vendored run doesn't download because its -/// refusal is an expected skip (`code`, see `vendor::refusal_is_benign`). +/// refusal is an expected skip (`code`, see `socket_patch_core::vendor::refusals::refusal_is_benign`). pub(crate) fn format_refusal_skip(purl: &str, code: &str, detail: &str) -> String { format!(" [skip] {} ({code}): {detail}", normalize_purl(purl)) } @@ -960,7 +960,7 @@ pub(crate) async fn fetch_selected_patches( // An expected skip (a gem version this project doesn't lock) // is the package's whole account: no view, no record, and no // failure, as in the vendor loop. - if crate::commands::vendor::refusal_is_benign(code) { + if socket_patch_core::vendor::refusals::refusal_is_benign(code) { if !quiet { eprintln!("{}", format_refusal_skip(purl, code, detail)); } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index a4b231067..b758ccd7b 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2356,7 +2356,7 @@ async fn run_get_vendored( // An expected skip (a gem version this project doesn't lock) // is not an error: the same `skipped` event and `[skip]` line // as the search path and `scan`, exit 0, nothing written. - if crate::commands::vendor::refusal_is_benign(code) { + if socket_patch_core::vendor::refusals::refusal_is_benign(code) { if args.common.json { env.record(tag_mode( PatchEvent::new(EventAction::Skipped, patch.purl.as_str()) diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 1612ce37f..59ff28fc7 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -91,7 +91,7 @@ impl PreviewVerdict { fn would_skip(&self) -> Option { match self { Self::WouldRefuse { code, .. } => { - Some(crate::commands::vendor::refusal_is_benign(code)) + Some(socket_patch_core::vendor::refusals::refusal_is_benign(code)) } _ => None, } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index de63db9b1..459663d5d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -113,17 +113,7 @@ pub struct VendorArgs { pub vex: VexEmbedArgs, } -/// Refusal codes that are expected skips, not command failures: the user's -/// request is still fully satisfied when these are the only non-successes. -pub(crate) fn refusal_is_benign(code: &str) -> bool { - matches!(code, "vendor_unsupported_ecosystem" | "already_vendored") - // A gem version another project (or Ruby) put in the shared gem - // home: this project doesn't lock it, so there is nothing to wire. - || code == vendor::gem::GEM_VERSION_NOT_LOCKED - // An older vendored patch kept in force (see [`keep_older_vendored_patch`]). - || vendor::is_unserved(code) - || socket_patch_core::vendor::jvm::sbt_gate::SKIP_CODES.contains(&code) -} +use socket_patch_core::vendor::refusals::refusal_is_benign; /// #954, #1235: the patch service has no artifact for `uuid` yet (still /// building) or at all (`build_failed`, `not_found`, …) — the backend's diff --git a/crates/socket-patch-core/src/vendor/refusals.rs b/crates/socket-patch-core/src/vendor/refusals.rs index 0cbae8309..bec256a6c 100644 --- a/crates/socket-patch-core/src/vendor/refusals.rs +++ b/crates/socket-patch-core/src/vendor/refusals.rs @@ -41,6 +41,21 @@ use crate::vendor::{bun_lock, gem, lock_inventory, npm_flavor, npm_lock, vlt_loc /// instead of a silently empty ledger. pub type LedgerLoad<'a> = Result<&'a HashMap, &'a std::io::Error>; +/// Refusal codes that are expected skips, not command failures: the user's +/// request is still fully satisfied when these are the only non-successes. +/// Every vendored entry point (`vendor`, `scan`, `get`, their dry runs) +/// reads this one answer. +pub fn refusal_is_benign(code: &str) -> bool { + matches!(code, "vendor_unsupported_ecosystem" | "already_vendored") + // A gem version another project (or Ruby) put in the shared gem + // home: this project doesn't lock it, so there is nothing to wire. + || code == gem::GEM_VERSION_NOT_LOCKED + // An older vendored patch kept in force while the newer one is + // unserved. + || super::is_unserved(code) + || super::jvm::sbt_gate::SKIP_CODES.contains(&code) +} + /// A purl the engine would refuse: its stable code and detail. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VendorRefusal {