Skip to content

Commit 91668a3

Browse files
committed
Patch and check every installed build of a gem
A gem home can hold both builds of one gem version, such as ffi-1.17.2/ and ffi-1.17.2-x86_64-linux-gnu/. RubyGems and Bundler load the platform build, but agent-mode apply, rollback, apply --check and vex only ever looked at the plain dir. So apply patched a copy nothing loads, and vex attested not_affected while the loaded copy stayed vulnerable. Global mode (-g) had the same gap. The gem crawler now returns every verifying build of a version in a gem home (plain dir first, then each platform dir in name order), and the dispatch maps feed all of them to the commands that already act on every copy. A platform dir must parse back to the same gem name and version, so foo-1.0-2.0/ (the gem foo-1.0 at 2.0) is never taken as a build of foo 1.0. When builds sit side by side, a build whose bytes match none of the patch's variants is a different distribution rather than a locally edited copy. Apply leaves it untouched and fails, instead of overwriting it with another build's bytes. Fixes #1092 Assisted-by: Claude Code:claude-opus-5-5
1 parent 1dbd37c commit 91668a3

5 files changed

Lines changed: 411 additions & 57 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,13 @@ the model is **not uniform** today:
642642
**coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's
643643
flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy,
644644
mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`,
645-
`vex`) use the highest-precedence copy. PyPI follows the same rule: when the crawler resolves
645+
`vex`) use the highest-precedence copy. One gem home holding **several builds** of a version
646+
(the ruby-platform `x-1.0/` beside `x-1.0-x86_64-linux-gnu/`, after `gem install --platform ruby`
647+
or a `force_ruby_platform` toggle) counts as several copies too: the plain dir first, then each
648+
platform dir, so `apply`, `rollback`, `apply --check` and `vex` cover the platform build RubyGems
649+
loads (#1092). Such a build is not the only candidate, so the default mismatch policy never
650+
overwrites it: a build whose bytes match no manifest variant is left untouched and fails the run
651+
(`no matching variant found`). PyPI follows the same rule: when the crawler resolves
646652
one release in several site-packages dirs (a Pipenv WORKON_HOME venv beside an auto-detected
647653
`./.venv`, or the user site beside a system dir in global scope), agent `apply` patches **every**
648654
copy, one summary event per copy, because any of them may be the one the interpreter imports.

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ fn mismatch_event_detail(file: &str, dry_run: bool) -> String {
9292
)
9393
}
9494

95+
/// Whether another of `copies` sits in the same gem home as `copy`: two
96+
/// builds of one gem version (`x-1.0/` and `x-1.0-<platform>/`) installed
97+
/// side by side.
98+
fn has_build_sibling(copy: &Path, copies: &[PathBuf]) -> bool {
99+
copies
100+
.iter()
101+
.any(|other| other != copy && other.parent() == copy.parent())
102+
}
103+
95104
/// One `content_mismatch_overwritten` run warning per mismatch-overwritten
96105
/// file across `results`, in the event detail's words prefixed with the
97106
/// package purl — what a nested apply hands back to `get` / `scan --mode
@@ -2333,8 +2342,17 @@ async fn apply_patches_inner(
23332342
// let a lone x86_64-linux record silently overwrite a
23342343
// darwin install in the Bundler plugin's `--silent`
23352344
// auto-apply, where the warn half of warn-and-apply is
2336-
// invisible.
2337-
if !args.force && (variants.len() > 1 || variants[0] != base_purl) {
2345+
// invisible. The same holds for a copy with a BUILD
2346+
// SIBLING (another copy in the same gem home: the
2347+
// ruby-platform `x-1.0/` beside `x-1.0-x86_64-linux/`,
2348+
// #1092): it is no longer the only candidate, so a
2349+
// mismatch there means another distribution, and the
2350+
// copy stays gated rather than overwritten.
2351+
if !args.force
2352+
&& (variants.len() > 1
2353+
|| variants[0] != base_purl
2354+
|| has_build_sibling(pkg_path, copy_paths))
2355+
{
23382356
let first_status = match representative_file(&patch.files) {
23392357
Some((file_name, file_info)) => Some(
23402358
verify_file_patch(pkg_path, file_name, file_info)

‎crates/socket-patch-cli/src/ecosystem_dispatch.rs‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,15 +439,21 @@ async fn dispatch_find(
439439
eco = Ecosystem::Gem,
440440
options = options,
441441
silent = silent,
442-
crawler = RubyCrawler,
442+
crawler = GemCopiesCrawler,
443443
get_paths = get_gem_paths,
444444
using_label = "ruby gem paths",
445445
err_label = "Ruby gems",
446446
// RubyGems has per-platform release variants (`?platform=`); the
447447
// crawler emits the base PURL and the platform is resolved by
448448
// hashing the installed files, same as PyPI.
449449
purls_override = dedup_qualified_purls,
450-
on_match = variant_merge,
450+
// One gem home can hold a version's ruby-platform AND platform
451+
// builds; fold every copy in (#1092).
452+
on_match = |out: &mut HashMap<String, Vec<PathBuf>>,
453+
purls: &[String],
454+
packages: HashMap<String, Vec<CrawledPackage>>| {
455+
merge_each_copy(variant_merge, out, purls, packages)
456+
},
451457
);
452458

453459
scan_ecosystem!(
@@ -669,6 +675,43 @@ impl NpmCrawlSnapshot {
669675
}
670676
}
671677

678+
/// [`RubyCrawler`] for [`dispatch_find`], returning every installed copy of
679+
/// each gem in a gem home (the ruby-platform dir and each platform dir).
680+
struct GemCopiesCrawler;
681+
682+
impl GemCopiesCrawler {
683+
async fn get_gem_paths(
684+
&self,
685+
options: &CrawlerOptions,
686+
) -> Result<Vec<PathBuf>, std::io::Error> {
687+
RubyCrawler.get_gem_paths(options).await
688+
}
689+
690+
async fn find_by_purls(
691+
&self,
692+
gem_path: &std::path::Path,
693+
purls: &[String],
694+
) -> Result<HashMap<String, Vec<CrawledPackage>>, std::io::Error> {
695+
RubyCrawler.find_all_by_purls(gem_path, purls).await
696+
}
697+
}
698+
699+
/// Feed every copy in `packages` through `merge`, one copy at a time and in
700+
/// order, so a crawler that returns several copies per PURL reaches the
701+
/// one-copy [`MergeFn`]s unchanged.
702+
fn merge_each_copy(
703+
merge: MergeFn,
704+
out: &mut HashMap<String, Vec<PathBuf>>,
705+
purls: &[String],
706+
packages: HashMap<String, Vec<CrawledPackage>>,
707+
) {
708+
for (purl, copies) in packages {
709+
for pkg in copies {
710+
merge(out, purls, HashMap::from([(purl.clone(), pkg)]));
711+
}
712+
}
713+
}
714+
672715
/// [`NpmCrawler`] for [`dispatch_find`], answering its root discovery from
673716
/// an earlier crawl's roots when it has them.
674717
struct NpmRootsCrawler<'a> {
@@ -831,8 +874,8 @@ pub async fn find_manifest_package_copies_reusing(
831874
.await;
832875
let bases = dedup_qualified_purls(gem_purls);
833876
for store in &stores {
834-
if let Ok(found) = RubyCrawler.find_by_purls(store, &bases).await {
835-
merge_qualified(&mut copies, gem_purls, found);
877+
if let Ok(found) = RubyCrawler.find_all_by_purls(store, &bases).await {
878+
merge_each_copy(merge_qualified, &mut copies, gem_purls, found);
836879
}
837880
}
838881
}

‎crates/socket-patch-cli/tests/apply/in_process_gem_multicopy.rs‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,195 @@ fn rollback_restores_every_coexisting_gem_store_copy() {
217217
}
218218
assert_ne!(before_hash, after_hash);
219219
}
220+
221+
/// Lay down a project whose one scoped store holds BOTH the ruby-platform
222+
/// build `rack-3.1.0/` and the platform build `rack-3.1.0-x86_64-linux/` of
223+
/// `rack@3.1.0` (#1092: `gem install --platform ruby` beside a platform
224+
/// install, or a project toggling `force_ruby_platform`). RubyGems loads
225+
/// the platform build. Returns
226+
/// `(root, plain_file, platform_file, original, patched)`.
227+
fn build_platform_variant_project(tmp: &Path) -> (PathBuf, PathBuf, PathBuf, Vec<u8>, Vec<u8>) {
228+
let original = b"module Rack\n VERSION = 'VULNERABLE'\nend\n".to_vec();
229+
let mut patched = original.clone();
230+
patched.extend_from_slice(b"# SOCKET-PATCHED-GEM-PLATFORM\n");
231+
let before_hash = git_sha256(&original);
232+
let after_hash = git_sha256(&patched);
233+
234+
let gems = tmp
235+
.join("vendor")
236+
.join("bundle")
237+
.join("ruby")
238+
.join("3.2.0")
239+
.join("gems");
240+
let plain_file = write_copy(&gems.join("rack-3.1.0"), &original);
241+
let platform_file = write_copy(&gems.join("rack-3.1.0-x86_64-linux"), &original);
242+
243+
stage_manifest_and_blob(tmp, &before_hash, &after_hash, &patched);
244+
// A vulnerability, so `vex` has a statement to attest.
245+
let manifest = tmp.join(".socket").join("manifest.json");
246+
let text = std::fs::read_to_string(&manifest).unwrap().replace(
247+
r#""vulnerabilities": {}"#,
248+
r#""vulnerabilities": { "GHSA-gplt-gplt-gplt": {
249+
"cves": ["CVE-2026-1092"], "summary": "synthetic",
250+
"severity": "high", "description": "synthetic"
251+
}}"#,
252+
);
253+
assert!(text.contains("GHSA-gplt-gplt-gplt"));
254+
std::fs::write(&manifest, text).unwrap();
255+
std::fs::write(
256+
tmp.join(".socket").join("blobs").join(&before_hash),
257+
&original,
258+
)
259+
.unwrap();
260+
261+
(
262+
tmp.to_path_buf(),
263+
plain_file,
264+
platform_file,
265+
original,
266+
patched,
267+
)
268+
}
269+
270+
/// `run_json` without `--json`, for `vex`, whose stdout is the document.
271+
fn run_raw(root: &Path, args: &[&str]) -> (i32, String, String) {
272+
let mut cmd = Command::new(binary());
273+
cmd.args(args)
274+
.args(["--offline", "--ecosystems", "gem", "--cwd"])
275+
.arg(root);
276+
for (key, _) in std::env::vars_os() {
277+
let k = key.to_string_lossy();
278+
if (k.starts_with("SOCKET_") && k != "SOCKET_NO_CONFIG") || k.starts_with("BUNDLE_") {
279+
cmd.env_remove(&key);
280+
}
281+
}
282+
cmd.env("SOCKET_TELEMETRY_DISABLED", "1");
283+
let out = cmd.output().expect("run socket-patch");
284+
(
285+
out.status.code().unwrap_or(-1),
286+
String::from_utf8_lossy(&out.stdout).to_string(),
287+
String::from_utf8_lossy(&out.stderr).to_string(),
288+
)
289+
}
290+
291+
/// #1092: `apply` patches the platform build beside the ruby-platform one,
292+
/// and `rollback` restores both.
293+
#[test]
294+
fn apply_and_rollback_cover_every_platform_variant_copy() {
295+
let tmp = tempfile::tempdir().unwrap();
296+
let (root, plain_file, platform_file, original, patched) =
297+
build_platform_variant_project(tmp.path());
298+
299+
let (code, v) = run_json(&root, &["apply"]);
300+
assert_eq!(code, 0, "apply must succeed; envelope={v}");
301+
for (label, f) in [("plain", &plain_file), ("platform", &platform_file)] {
302+
assert_eq!(
303+
std::fs::read(f).unwrap(),
304+
patched,
305+
"{label} copy at {f:?} was NOT patched; envelope={v}"
306+
);
307+
}
308+
assert_eq!(
309+
v["summary"]["applied"], 2,
310+
"summary must count both copies; envelope={v}"
311+
);
312+
313+
let (code, v) = run_json(&root, &["rollback", "--yes"]);
314+
assert_eq!(code, 0, "rollback must succeed; envelope={v}");
315+
for (label, f) in [("plain", &plain_file), ("platform", &platform_file)] {
316+
assert_eq!(
317+
std::fs::read(f).unwrap(),
318+
original,
319+
"{label} copy at {f:?} was NOT restored; envelope={v}"
320+
);
321+
}
322+
}
323+
324+
/// #1092: with only the ruby-platform copy patched, `vex` must not attest
325+
/// `not_affected` and `apply --check` must not report the patch in sync,
326+
/// because the platform copy RubyGems loads is still vulnerable.
327+
#[test]
328+
fn vex_and_check_refuse_an_unpatched_platform_variant_copy() {
329+
let tmp = tempfile::tempdir().unwrap();
330+
let (root, plain_file, platform_file, original, patched) =
331+
build_platform_variant_project(tmp.path());
332+
std::fs::write(&plain_file, &patched).unwrap();
333+
assert_eq!(std::fs::read(&platform_file).unwrap(), original);
334+
335+
let (code, stdout, stderr) = run_raw(&root, &["vex", "--product", "pkg:gem/app@1.0.0"]);
336+
assert!(
337+
!stdout.contains("not_affected"),
338+
"vex must not attest while the platform copy is unpatched (exit {code})\
339+
\nstdout:\n{stdout}\nstderr:\n{stderr}"
340+
);
341+
342+
let (code, stdout, stderr) = run_raw(&root, &["apply", "--check"]);
343+
assert_ne!(
344+
code, 0,
345+
"apply --check must flag the unpatched platform copy\
346+
\nstdout:\n{stdout}\nstderr:\n{stderr}"
347+
);
348+
349+
// Once both copies are patched, both gates pass.
350+
std::fs::write(&platform_file, &patched).unwrap();
351+
let (code, stdout, stderr) = run_raw(&root, &["vex", "--product", "pkg:gem/app@1.0.0"]);
352+
assert_eq!(code, 0, "vex\nstdout:\n{stdout}\nstderr:\n{stderr}");
353+
assert!(stdout.contains("not_affected"), "vex doc:\n{stdout}");
354+
let (code, stdout, stderr) = run_raw(&root, &["apply", "--check"]);
355+
assert_eq!(
356+
code, 0,
357+
"apply --check\nstdout:\n{stdout}\nstderr:\n{stderr}"
358+
);
359+
}
360+
361+
/// #1092, global mode: `apply -g --global-prefix <gem home>/gems` patches
362+
/// the platform build too (plain RubyGems activates it, no Bundler needed).
363+
#[test]
364+
fn global_apply_covers_every_platform_variant_copy() {
365+
let tmp = tempfile::tempdir().unwrap();
366+
let (root, plain_file, platform_file, _original, patched) =
367+
build_platform_variant_project(tmp.path());
368+
let gems = plain_file
369+
.parent()
370+
.unwrap()
371+
.parent()
372+
.unwrap()
373+
.parent()
374+
.unwrap();
375+
376+
let (code, v) = run_json(
377+
&root,
378+
&["apply", "-g", "--global-prefix", gems.to_str().unwrap()],
379+
);
380+
assert_eq!(code, 0, "apply -g must succeed; envelope={v}");
381+
for (label, f) in [("plain", &plain_file), ("platform", &platform_file)] {
382+
assert_eq!(
383+
std::fs::read(f).unwrap(),
384+
patched,
385+
"{label} copy at {f:?} was NOT patched; envelope={v}"
386+
);
387+
}
388+
}
389+
390+
/// A platform build whose bytes are not the patch's distribution (a native
391+
/// build that ships a different file) is another distribution, not a
392+
/// locally modified copy: apply patches the plain copy, never overwrites
393+
/// the platform one with the plain build's bytes, and fails loudly because
394+
/// the copy RubyGems loads stays unpatched.
395+
#[test]
396+
fn apply_refuses_a_platform_copy_holding_another_distribution() {
397+
let tmp = tempfile::tempdir().unwrap();
398+
let (root, plain_file, platform_file, _original, patched) =
399+
build_platform_variant_project(tmp.path());
400+
let native = b"module Rack\n VERSION = 'NATIVE BUILD'\nend\n".to_vec();
401+
std::fs::write(&platform_file, &native).unwrap();
402+
403+
let (code, v) = run_json(&root, &["apply"]);
404+
assert_ne!(code, 0, "apply must flag the unpatched copy; envelope={v}");
405+
assert_eq!(std::fs::read(&plain_file).unwrap(), patched, "envelope={v}");
406+
assert_eq!(
407+
std::fs::read(&platform_file).unwrap(),
408+
native,
409+
"envelope={v}"
410+
);
411+
}

0 commit comments

Comments
 (0)