Skip to content

Commit b76d7ab

Browse files
Fix gem lock section order after a hosted re-scan (#1186) (#1190)
* Start fix for #1186 Assisted-by: Claude Code:claude-opus-5-5 * Keep gem lock sections sorted after a re-scan A hosted re-scan that gives an already-redirected gem a new index URL (a superseding patch, or a rotated grant) rewrote that GEM section's remote: line where it stood. Bundler writes GEM sections sorted by their remote URL, so with two or more patched gems the new URL could land out of order, and every frozen or deployment install on Bundler 4.0.19+ then failed with exit 16 ("Your lockfile needs to be updated"). The refreshed section now moves to Bundler's sorted position, the same rule a fresh insert already follows. A lock an earlier run left out of order is healed on the next scan. Fixes #1186 Assisted-by: Claude Code:claude-opus-5-5 * Test superseded gem patches with real Bundler Adds a hosted gem e2e that patches two gems into their own registry sections, then supersedes one with a patch whose URL sorts after its sibling. The committed lock must keep Bundler's section order: bundle lock leaves it byte-identical and a cold frozen install accepts it. Refs #1186 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb3fcc4 commit b76d7ab

3 files changed

Lines changed: 523 additions & 12 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs‎

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,6 +1912,294 @@ async fn gem_get_uuid_hosted_fresh_checkout_bundle_install() {
19121912
manifestless_vex_matrix(&fx, &fresh).await;
19131913
}
19141914

1915+
/// One patched gem a later patch generation serves: its uuid, the patched
1916+
/// `.gem`, and the lib file's before/after bytes for the view record.
1917+
struct GenerationGem {
1918+
name: &'static str,
1919+
uuid: &'static str,
1920+
deps: Vec<String>,
1921+
lib_file: &'static str,
1922+
orig: String,
1923+
patched: String,
1924+
gem: Vec<u8>,
1925+
}
1926+
1927+
/// Layer a new patch generation over the fixture's API mocks (wiremock
1928+
/// serves the lowest `priority` first): the batch, by-package, reference and
1929+
/// view routes answer with `gems`, and each uuid gets its own patch-registry
1930+
/// compact index under the fixture's grant token.
1931+
async fn mount_patch_generation(server: &MockServer, priority: u8, gems: &[GenerationGem]) {
1932+
let purl = |g: &GenerationGem| format!("pkg:gem/{}@{DEP_VERSION}", g.name);
1933+
for g in gems {
1934+
mount_compact_index(
1935+
server,
1936+
&format!("/patch-registry/gem/{TOKEN}/{}", g.uuid),
1937+
&[IndexGem {
1938+
name: g.name,
1939+
version: DEP_VERSION,
1940+
deps: g.deps.clone(),
1941+
gem: g.gem.clone(),
1942+
}],
1943+
)
1944+
.await;
1945+
Mock::given(method("GET"))
1946+
.and(path(format!("/v0/orgs/{ORG}/patches/view/{}", g.uuid)))
1947+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1948+
"uuid": g.uuid,
1949+
"purl": purl(g),
1950+
"publishedAt": "2026-02-01T00:00:00Z",
1951+
"files": {
1952+
format!("lib/{}", g.lib_file): {
1953+
"beforeHash": compute_git_sha256_from_bytes(g.orig.as_bytes()),
1954+
"afterHash": compute_git_sha256_from_bytes(g.patched.as_bytes()),
1955+
}
1956+
},
1957+
"vulnerabilities": {
1958+
GHSA: {
1959+
"cves": ["CVE-2026-3333"],
1960+
"summary": "gem redirect capstone vuln",
1961+
"severity": "high",
1962+
"description": "d"
1963+
}
1964+
},
1965+
"description": "x", "license": "MIT", "tier": "free"
1966+
})))
1967+
.with_priority(priority)
1968+
.mount(server)
1969+
.await;
1970+
Mock::given(method("GET"))
1971+
.and(path_regex(format!(
1972+
"^/v0/orgs/{ORG}/patches/by-package/.*{}.*$",
1973+
g.name
1974+
)))
1975+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1976+
"patches": [{
1977+
"uuid": g.uuid, "purl": purl(g),
1978+
"publishedAt": "2026-02-01T00:00:00Z",
1979+
"description": "x", "license": "MIT", "tier": "free",
1980+
"vulnerabilities": {}
1981+
}],
1982+
"canAccessPaidPatches": false,
1983+
})))
1984+
.with_priority(priority)
1985+
.mount(server)
1986+
.await;
1987+
}
1988+
let packages: Vec<serde_json::Value> = gems
1989+
.iter()
1990+
.map(|g| {
1991+
serde_json::json!({
1992+
"purl": purl(g),
1993+
"patches": [{
1994+
"uuid": g.uuid, "purl": purl(g), "tier": "free",
1995+
"cveIds": [], "ghsaIds": [], "severity": "high",
1996+
"title": "gem redirect capstone fixture"
1997+
}]
1998+
})
1999+
})
2000+
.collect();
2001+
Mock::given(method("POST"))
2002+
.and(path(format!("/v0/orgs/{ORG}/patches/batch")))
2003+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2004+
"packages": packages,
2005+
"canAccessPaidPatches": false,
2006+
})))
2007+
.with_priority(priority)
2008+
.mount(server)
2009+
.await;
2010+
let results: serde_json::Map<String, serde_json::Value> = gems
2011+
.iter()
2012+
.map(|g| {
2013+
let sha = sha256_hex(&g.gem);
2014+
let hosted_url = format!(
2015+
"{}/patch/gem/{}/{DEP_VERSION}/{TOKEN}/{}/{}-{DEP_VERSION}.gem",
2016+
server.uri(),
2017+
g.name,
2018+
g.uuid,
2019+
g.name
2020+
);
2021+
let index_url = format!("{}/patch-registry/gem/{TOKEN}/{}/", server.uri(), g.uuid);
2022+
(
2023+
g.uuid.to_string(),
2024+
serde_json::json!({
2025+
"status": "granted",
2026+
"url": hosted_url,
2027+
"purl": purl(g),
2028+
"artifacts": [{
2029+
"kind": "tarball",
2030+
"url": hosted_url,
2031+
"integrity": { "sha256": sha }
2032+
}],
2033+
"registryOverride": {
2034+
"kind": "rubygems-compact-index",
2035+
"indexUrl": index_url,
2036+
"identifiers": {
2037+
"name": g.name,
2038+
"version": DEP_VERSION,
2039+
"gemChecksumSha256": sha,
2040+
}
2041+
}
2042+
}),
2043+
)
2044+
})
2045+
.collect();
2046+
Mock::given(method("POST"))
2047+
.and(path(format!("/v0/orgs/{ORG}/patches/package")))
2048+
.respond_with(
2049+
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": results })),
2050+
)
2051+
.with_priority(priority)
2052+
.mount(server)
2053+
.await;
2054+
}
2055+
2056+
/// The `remote:` URLs of a lock's `GEM` sections, in file order.
2057+
fn gem_remotes(lock: &str) -> Vec<&str> {
2058+
let mut out = Vec::new();
2059+
let mut in_gem = false;
2060+
for line in lock.lines() {
2061+
if !line.starts_with(' ') && !line.is_empty() {
2062+
in_gem = line == "GEM";
2063+
} else if let Some(url) = line.strip_prefix(" remote: ").filter(|_| in_gem) {
2064+
out.push(url);
2065+
}
2066+
}
2067+
out
2068+
}
2069+
2070+
/// #1186: two gems hosted in their own patch-registry `GEM` sections, then a
2071+
/// superseding patch for one of them whose new uuid sorts AFTER its
2072+
/// sibling. The re-scan must move the refreshed section to where bundler
2073+
/// writes it (sections sorted by remote URL): a converged CHECKSUMS lock
2074+
/// that `bundle lock` leaves byte-identical and a cold frozen install
2075+
/// accepts. An in-place refresh left the sections out of order, and every
2076+
/// frozen install on bundler 4.0.19+ exited 16 ("Your lockfile needs to be
2077+
/// updated, but it can't be because frozen mode is set").
2078+
#[tokio::test(flavor = "multi_thread")]
2079+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 2.6 for the CHECKSUMS lock); \
2080+
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
2081+
async fn gem_hosted_superseding_patch_keeps_gem_sections_in_bundler_order() {
2082+
let Some(fx) = redirect_scanned_project(
2083+
"supersede-section-order",
2084+
Spelling::Gemfile,
2085+
true,
2086+
true,
2087+
None,
2088+
Driver::ScanVex,
2089+
)
2090+
.await
2091+
else {
2092+
return;
2093+
};
2094+
const VULN_GEN2: &str = "10000000-1a2b-4a1b-8c2d-3e4f5a6b7c8d";
2095+
const TINY_GEN2: &str = "80000000-1a2b-4a1b-8c2d-3e4f5a6b7c8d";
2096+
const VULN_GEN3: &str = "9a9a9a9a-1a2b-4a1b-8c2d-3e4f5a6b7c8d";
2097+
let stage = fx.tmp.path().join("generation-stage");
2098+
let vuln = |uuid: &'static str, lib: String| {
2099+
let gem = build_gem(
2100+
&stage.join(uuid),
2101+
DEP,
2102+
DEP_VERSION,
2103+
"vuln_gem.rb",
2104+
&lib,
2105+
&[TRANSITIVE],
2106+
);
2107+
GenerationGem {
2108+
name: DEP,
2109+
uuid,
2110+
deps: vec![format!("{TRANSITIVE}:>= 0")],
2111+
lib_file: "vuln_gem.rb",
2112+
orig: orig_lib(),
2113+
patched: lib,
2114+
gem,
2115+
}
2116+
};
2117+
let tiny_patched = TINY_LIB.replace("tiny-ok", "tiny-patched");
2118+
let tiny = GenerationGem {
2119+
name: TRANSITIVE,
2120+
uuid: TINY_GEN2,
2121+
deps: vec![],
2122+
lib_file: "tiny_dep.rb",
2123+
orig: TINY_LIB.to_string(),
2124+
patched: tiny_patched.clone(),
2125+
gem: build_gem(
2126+
&stage.join(TINY_GEN2),
2127+
TRANSITIVE,
2128+
"1.0.0",
2129+
"tiny_dep.rb",
2130+
&tiny_patched,
2131+
&[],
2132+
),
2133+
};
2134+
let server = fx._server.uri();
2135+
let registry = |uuid: &str| format!("{server}/patch-registry/gem/{TOKEN}/{uuid}/");
2136+
let upstream = format!("{server}/upstream/");
2137+
let lock_path = fx.proj.join(fx.lock_name);
2138+
let scan = |what: &str| {
2139+
let (code, stdout, stderr) = run_hosted_scan(&fx.proj, &server);
2140+
assert_eq!(
2141+
code, 0,
2142+
"{what} failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
2143+
);
2144+
std::fs::read_to_string(&lock_path).unwrap()
2145+
};
2146+
2147+
// Generation 2: both gems hosted, each in its own section, inserted
2148+
// sorted (the path that already worked).
2149+
let tiny_gem = tiny.gem.clone();
2150+
mount_patch_generation(&fx._server, 2, &[vuln(VULN_GEN2, patched_lib()), tiny]).await;
2151+
let lock = scan("generation-2 scan");
2152+
assert_eq!(
2153+
gem_remotes(&lock),
2154+
[registry(VULN_GEN2), registry(TINY_GEN2), upstream.clone()],
2155+
"generation 2 sections:\n{lock}"
2156+
);
2157+
2158+
// Generation 3: a superseding patch for vuln-gem only (same version,
2159+
// new bytes) whose uuid sorts after tiny-dep's section.
2160+
let gen3_lib = patched_lib().replace("PATCHED", "PATCHED-GEN3");
2161+
assert_ne!(gen3_lib, patched_lib(), "generation 3 must change bytes");
2162+
let tiny = GenerationGem {
2163+
name: TRANSITIVE,
2164+
uuid: TINY_GEN2,
2165+
deps: vec![],
2166+
lib_file: "tiny_dep.rb",
2167+
orig: TINY_LIB.to_string(),
2168+
patched: tiny_patched,
2169+
gem: tiny_gem,
2170+
};
2171+
mount_patch_generation(&fx._server, 1, &[vuln(VULN_GEN3, gen3_lib), tiny]).await;
2172+
let lock = scan("generation-3 scan");
2173+
assert_eq!(
2174+
gem_remotes(&lock),
2175+
[registry(TINY_GEN2), registry(VULN_GEN3), upstream.clone()],
2176+
"the superseded section must move to bundler's sorted position:\n{lock}"
2177+
);
2178+
2179+
// Bundler agrees: `bundle lock` re-renders the committed lock
2180+
// byte-identically, and a cold frozen install accepts it.
2181+
let relock = stage_fresh_checkout(&fx, "fresh-relock");
2182+
let out = bundle(&relock, &["lock"]);
2183+
assert!(
2184+
out.status.success(),
2185+
"bundle lock failed:\n{}",
2186+
String::from_utf8_lossy(&out.stderr)
2187+
);
2188+
assert_eq!(
2189+
std::fs::read_to_string(relock.join(fx.lock_name)).unwrap(),
2190+
lock,
2191+
"bundle lock must leave the converged lock byte-identical"
2192+
);
2193+
let fresh = stage_fresh_checkout(&fx, "fresh-frozen");
2194+
let install = bundle_env(&fresh, &["install"], &[("BUNDLE_FROZEN", "true")]);
2195+
let stderr = String::from_utf8_lossy(&install.stderr);
2196+
assert!(
2197+
install.status.success() && !stderr.contains("Cannot write a changed lockfile"),
2198+
"cold frozen install must accept the lock unchanged.\nstdout:\n{}\nstderr:\n{stderr}",
2199+
String::from_utf8_lossy(&install.stdout),
2200+
);
2201+
}
2202+
19152203
/// Bundler's modern `gems.rb`/`gems.locked` spelling, end to end: the
19162204
/// candidate list must read the pair, the rewriter must key its edits to it,
19172205
/// and the real bundler must install the patched gem from the redirected

0 commit comments

Comments
 (0)