@@ -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.\n stdout:\n {stdout}\n stderr:\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.\n stdout:\n {}\n stderr:\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