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