@@ -358,6 +358,15 @@ fn fresh_checkout_install(tmp: &Path, proj: &Path, yarnrc: &str) -> (PathBuf, Ou
358358 fresh. join ( "packages/app/package.json" ) ,
359359 )
360360 . unwrap ( ) ;
361+ // The member a test adds after pinning (#1082).
362+ if proj. join ( "packages/b" ) . is_dir ( ) {
363+ std:: fs:: create_dir_all ( fresh. join ( "packages/b" ) ) . unwrap ( ) ;
364+ std:: fs:: copy (
365+ proj. join ( "packages/b/package.json" ) ,
366+ fresh. join ( "packages/b/package.json" ) ,
367+ )
368+ . unwrap ( ) ;
369+ }
361370 std:: fs:: copy ( proj. join ( "yarn.lock" ) , fresh. join ( "yarn.lock" ) ) . unwrap ( ) ;
362371 std:: fs:: write ( fresh. join ( ".yarnrc.yml" ) , yarnrc) . unwrap ( ) ;
363372 // v5 hosted mode may leave no `.socket/` at all (no ledger, no manifest).
@@ -374,42 +383,15 @@ fn fresh_checkout_install(tmp: &Path, proj: &Path, yarnrc: &str) -> (PathBuf, Ou
374383 ( fresh, ci)
375384}
376385
377- // ── hosted capstone ───────────────────────────────────────────────────
378-
379- #[ tokio:: test( flavor = "multi_thread" ) ]
380- async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan ( ) {
381- if !has_corepack_pm ( yarn_berry ( ) ) {
382- skip ! (
383- "SKIP e2e_yarn4_workspaces_build (hosted): `corepack {}` unavailable" ,
384- yarn_berry( )
385- ) ;
386- return ;
387- }
388- if !has_command ( "tar" ) {
389- skip ! ( "SKIP e2e_yarn4_workspaces_build (hosted): `tar` not installed" ) ;
390- return ;
391- }
392-
393- let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
394- let proj = tmp. path ( ) . join ( "proj" ) ;
395- std:: fs:: create_dir_all ( & proj) . unwrap ( ) ;
396- let Some ( orig) = install_workspace_fixture ( "hosted" , tmp. path ( ) , & proj) else {
397- return ;
398- } ;
399- let patched: Vec < u8 > = [ MARKER . as_bytes ( ) , orig. as_slice ( ) ] . concat ( ) ;
400- let registry_lock = std:: fs:: read ( proj. join ( "yarn.lock" ) ) . unwrap ( ) ;
401-
402- // Patched tarball + the exact `10c0` checksum yarn computes for it.
403- let tgz_path = tmp. path ( ) . join ( format ! ( "{DEP}-{DEP_VERSION}.tgz" ) ) ;
404- build_patched_tgz ( & proj. join ( "node_modules" ) . join ( DEP ) , & patched, & tgz_path) ;
405- let tgz = std:: fs:: read ( & tgz_path) . unwrap ( ) ;
406- let Some ( checksum) = bootstrap_berry_checksum ( tmp. path ( ) , & tgz_path) else {
407- return ;
408- } ;
409-
410- // API mocks + the hosted tarball route yarn will hit at install time.
411- let server = MockServer :: start ( ) . await ;
412- let host = server. uri ( ) . replace ( "http://" , "" ) . replace ( "https://" , "" ) ;
386+ /// The patch API mocks and the hosted tarball route yarn hits at install
387+ /// time; returns the hosted tarball URL.
388+ async fn mount_hosted_api (
389+ server : & MockServer ,
390+ checksum : & str ,
391+ orig : & [ u8 ] ,
392+ patched : & [ u8 ] ,
393+ tgz : & [ u8 ] ,
394+ ) -> String {
413395 let hosted_url = format ! (
414396 "{}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz" ,
415397 server. uri( )
@@ -427,7 +409,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
427409 } ] ,
428410 "canAccessPaidPatches" : false ,
429411 } ) ) )
430- . mount ( & server)
412+ . mount ( server)
431413 . await ;
432414 Mock :: given ( method ( "GET" ) )
433415 . and ( path_regex ( format ! (
@@ -442,7 +424,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
442424 } ] ,
443425 "canAccessPaidPatches" : false ,
444426 } ) ) )
445- . mount ( & server)
427+ . mount ( server)
446428 . await ;
447429 Mock :: given ( method ( "POST" ) )
448430 . and ( path ( format ! ( "/v0/orgs/{ORG}/patches/package" ) ) )
@@ -462,7 +444,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
462444 }
463445 }
464446 } ) ) )
465- . mount ( & server)
447+ . mount ( server)
466448 . await ;
467449 Mock :: given ( method ( "GET" ) )
468450 . and ( path ( format ! ( "/v0/orgs/{ORG}/patches/view/{UUID}" ) ) )
@@ -472,8 +454,8 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
472454 "publishedAt" : "2026-01-01T00:00:00Z" ,
473455 "files" : {
474456 "package/index.js" : {
475- "beforeHash" : compute_git_sha256_from_bytes( & orig) ,
476- "afterHash" : compute_git_sha256_from_bytes( & patched) ,
457+ "beforeHash" : compute_git_sha256_from_bytes( orig) ,
458+ "afterHash" : compute_git_sha256_from_bytes( patched) ,
477459 }
478460 } ,
479461 "vulnerabilities" : {
@@ -484,18 +466,59 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
484466 } ,
485467 "description" : "x" , "license" : "MIT" , "tier" : "free"
486468 } ) ) )
487- . mount ( & server)
469+ . mount ( server)
488470 . await ;
489471 Mock :: given ( method ( "GET" ) )
490472 . and ( path ( format ! (
491473 "/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz"
492474 ) ) )
493475 . respond_with (
494- ResponseTemplate :: new ( 200 ) . set_body_raw ( tgz. clone ( ) , "application/octet-stream" ) ,
476+ ResponseTemplate :: new ( 200 ) . set_body_raw ( tgz. to_vec ( ) , "application/octet-stream" ) ,
495477 )
496- . mount ( & server)
478+ . mount ( server)
497479 . await ;
498480
481+ hosted_url
482+ }
483+
484+ // ── hosted capstone ───────────────────────────────────────────────────
485+
486+ #[ tokio:: test( flavor = "multi_thread" ) ]
487+ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan ( ) {
488+ if !has_corepack_pm ( yarn_berry ( ) ) {
489+ skip ! (
490+ "SKIP e2e_yarn4_workspaces_build (hosted): `corepack {}` unavailable" ,
491+ yarn_berry( )
492+ ) ;
493+ return ;
494+ }
495+ if !has_command ( "tar" ) {
496+ skip ! ( "SKIP e2e_yarn4_workspaces_build (hosted): `tar` not installed" ) ;
497+ return ;
498+ }
499+
500+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
501+ let proj = tmp. path ( ) . join ( "proj" ) ;
502+ std:: fs:: create_dir_all ( & proj) . unwrap ( ) ;
503+ let Some ( orig) = install_workspace_fixture ( "hosted" , tmp. path ( ) , & proj) else {
504+ return ;
505+ } ;
506+ let patched: Vec < u8 > = [ MARKER . as_bytes ( ) , orig. as_slice ( ) ] . concat ( ) ;
507+ let registry_lock = std:: fs:: read ( proj. join ( "yarn.lock" ) ) . unwrap ( ) ;
508+
509+ // Patched tarball + the exact `10c0` checksum yarn computes for it.
510+ let tgz_path = tmp. path ( ) . join ( format ! ( "{DEP}-{DEP_VERSION}.tgz" ) ) ;
511+ build_patched_tgz ( & proj. join ( "node_modules" ) . join ( DEP ) , & patched, & tgz_path) ;
512+ let tgz = std:: fs:: read ( & tgz_path) . unwrap ( ) ;
513+ let Some ( checksum) = bootstrap_berry_checksum ( tmp. path ( ) , & tgz_path) else {
514+ return ;
515+ } ;
516+
517+ // API mocks + the hosted tarball route yarn will hit at install time.
518+ let server = MockServer :: start ( ) . await ;
519+ let host = server. uri ( ) . replace ( "http://" , "" ) . replace ( "https://" , "" ) ;
520+ let hosted_url = mount_hosted_api ( & server, & checksum, & orig, & patched, & tgz) . await ;
521+
499522 let root_pkg_before = std:: fs:: read ( proj. join ( "package.json" ) ) . unwrap ( ) ;
500523 let member_pkg_before = std:: fs:: read ( proj. join ( "packages/app/package.json" ) ) . unwrap ( ) ;
501524
@@ -650,6 +673,229 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
650673 yarn_berry_common:: off_runtime ( || yarn_berry_common:: run_manifestless_vex_matrix ( & flow) ) ;
651674}
652675
676+ /// #1082 with a real yarn: a member added after the hosted pin depends on
677+ /// `left-pad@^1.3.0`, which no selector routes, so yarn locks a separate
678+ /// registry entry and installs that copy unpatched. A re-scan folds it
679+ /// into the pin (a fresh `--immutable` install then gets the patched bytes
680+ /// only), and `rollback` leaves the one merged registry entry yarn writes,
681+ /// so a fresh `--immutable` install of the restored lock passes (two
682+ /// entries sharing a resolution fail it with YN0028).
683+ #[ tokio:: test( flavor = "multi_thread" ) ]
684+ async fn yarn4_hosted_pin_folds_a_member_added_later_and_rolls_back ( ) {
685+ if !has_corepack_pm ( yarn_berry ( ) ) {
686+ skip ! (
687+ "SKIP e2e_yarn4_workspaces_build (hosted, #1082): `corepack {}` unavailable" ,
688+ yarn_berry( )
689+ ) ;
690+ return ;
691+ }
692+ if !has_command ( "tar" ) {
693+ skip ! ( "SKIP e2e_yarn4_workspaces_build (hosted, #1082): `tar` not installed" ) ;
694+ return ;
695+ }
696+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
697+ let proj = tmp. path ( ) . join ( "proj" ) ;
698+ std:: fs:: create_dir_all ( & proj) . unwrap ( ) ;
699+ let Some ( orig) = install_workspace_fixture ( "hosted-1082" , tmp. path ( ) , & proj) else {
700+ return ;
701+ } ;
702+ let patched: Vec < u8 > = [ MARKER . as_bytes ( ) , orig. as_slice ( ) ] . concat ( ) ;
703+ let registry_lock = std:: fs:: read_to_string ( proj. join ( "yarn.lock" ) ) . unwrap ( ) ;
704+ let upstream_checksum = yarn_berry_common:: yarn_written_checksum ( & registry_lock)
705+ . expect ( "the registry lock carries left-pad's checksum" ) ;
706+ let global = tmp. path ( ) . join ( "yarn-global" ) ;
707+ let global_env = [ ( "YARN_GLOBAL_FOLDER" , global. to_str ( ) . unwrap ( ) ) ] ;
708+ let info = corepack (
709+ & proj,
710+ yarn_berry ( ) ,
711+ & [
712+ "npm" ,
713+ "info" ,
714+ & format ! ( "{DEP}@{DEP_VERSION}" ) ,
715+ "--fields" ,
716+ "dist" ,
717+ "--json" ,
718+ ] ,
719+ & global_env,
720+ ) ;
721+ if !info. status . success ( ) {
722+ skip ! (
723+ "SKIP e2e_yarn4_workspaces_build (hosted, #1082): `yarn npm info` failed:\n {}" ,
724+ yarn_berry_common:: yarn_output( & info)
725+ ) ;
726+ return ;
727+ }
728+ let info: serde_json:: Value = serde_json:: from_slice ( & info. stdout ) . unwrap ( ) ;
729+ let integrity = info[ "dist" ] [ "integrity" ] . as_str ( ) . unwrap ( ) . to_string ( ) ;
730+
731+ let tgz_path = tmp. path ( ) . join ( format ! ( "{DEP}-{DEP_VERSION}.tgz" ) ) ;
732+ build_patched_tgz ( & proj. join ( "node_modules" ) . join ( DEP ) , & patched, & tgz_path) ;
733+ let tgz = std:: fs:: read ( & tgz_path) . unwrap ( ) ;
734+ let Some ( checksum) = bootstrap_berry_checksum ( tmp. path ( ) , & tgz_path) else {
735+ return ;
736+ } ;
737+ let server = MockServer :: start ( ) . await ;
738+ let host = server. uri ( ) . replace ( "http://" , "" ) . replace ( "https://" , "" ) ;
739+ let host = host. split ( ':' ) . next ( ) . unwrap_or ( "127.0.0.1" ) . to_string ( ) ;
740+ let hosted_url = mount_hosted_api ( & server, & checksum, & orig, & patched, & tgz) . await ;
741+ // What `rollback` restores the registry entry's checksum from.
742+ Mock :: given ( method ( "GET" ) )
743+ . and ( path ( format ! ( "/upstream/npm/{UUID}.json" ) ) )
744+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_json ( serde_json:: json!( {
745+ "name" : DEP , "version" : DEP_VERSION ,
746+ "integrity" : integrity, "yarnBerry10c0" : upstream_checksum,
747+ } ) ) )
748+ . mount ( & server)
749+ . await ;
750+ let scan = |label : & str | {
751+ let ( code, stdout, stderr) = run_socket (
752+ & proj,
753+ & [
754+ "scan" ,
755+ "--mode" ,
756+ "hosted" ,
757+ "--json" ,
758+ "--yes" ,
759+ "--cwd" ,
760+ proj. to_str ( ) . unwrap ( ) ,
761+ "--api-url" ,
762+ & server. uri ( ) ,
763+ "--org" ,
764+ ORG ,
765+ "--api-token" ,
766+ "fake" ,
767+ ] ,
768+ ) ;
769+ assert_eq ! ( code, 0 , "{label}: stdout:\n {stdout}\n stderr:\n {stderr}" ) ;
770+ let env: serde_json:: Value = serde_json:: from_str ( & stdout) . unwrap ( ) ;
771+ assert_eq ! ( env[ "summary" ] [ "applied" ] , 1 , "{label}: {env}" ) ;
772+ env
773+ } ;
774+ scan ( "first scan" ) ;
775+
776+ // A second member, added after pinning, with a range no selector routes.
777+ std:: fs:: create_dir_all ( proj. join ( "packages/b" ) ) . unwrap ( ) ;
778+ std:: fs:: write (
779+ proj. join ( "packages/b/package.json" ) ,
780+ format ! ( r#"{{"name":"b","version":"1.0.0","dependencies":{{"{DEP}":"^{DEP_VERSION}"}}}}"# ) ,
781+ )
782+ . unwrap ( ) ;
783+ let mut root: serde_json:: Value =
784+ serde_json:: from_slice ( & std:: fs:: read ( proj. join ( "package.json" ) ) . unwrap ( ) ) . unwrap ( ) ;
785+ root[ "workspaces" ] = serde_json:: json!( [ "packages/app" , "packages/b" ] ) ;
786+ std:: fs:: write (
787+ proj. join ( "package.json" ) ,
788+ serde_json:: to_string_pretty ( & root) . unwrap ( ) + "\n " ,
789+ )
790+ . unwrap ( ) ;
791+ std:: fs:: write (
792+ proj. join ( ".yarnrc.yml" ) ,
793+ format ! (
794+ "nodeLinker: node-modules\n enableGlobalCache: false\n \
795+ unsafeHttpWhitelist:\n - \" {host}\" \n "
796+ ) ,
797+ )
798+ . unwrap ( ) ;
799+ let install = corepack ( & proj, yarn_berry ( ) , & [ "install" ] , & global_env) ;
800+ assert ! (
801+ install. status. success( ) ,
802+ "{}" ,
803+ yarn_berry_common:: yarn_output( & install)
804+ ) ;
805+ let drifted = std:: fs:: read_to_string ( proj. join ( "yarn.lock" ) ) . unwrap ( ) ;
806+ assert ! (
807+ drifted. contains( & format!( "\" {DEP}@npm:^{DEP_VERSION}\" :" ) ) ,
808+ "yarn locks the new range as its own registry entry:\n {drifted}"
809+ ) ;
810+
811+ let rollback = || {
812+ let ( code, stdout, stderr) = run_socket (
813+ & proj,
814+ & [
815+ "rollback" ,
816+ "--json" ,
817+ "--yes" ,
818+ "--cwd" ,
819+ proj. to_str ( ) . unwrap ( ) ,
820+ "--patch-server-url" ,
821+ & server. uri ( ) ,
822+ ] ,
823+ ) ;
824+ assert_eq ! ( code, 0 , "rollback: stdout:\n {stdout}\n stderr:\n {stderr}" ) ;
825+ let restored = std:: fs:: read_to_string ( proj. join ( "yarn.lock" ) ) . unwrap ( ) ;
826+ assert ! (
827+ restored. contains( & format!(
828+ "\" {DEP}@npm:{DEP_VERSION}, {DEP}@npm:^{DEP_VERSION}\" :"
829+ ) ) ,
830+ "{restored}"
831+ ) ;
832+ assert ! ( !restored. contains( & hosted_url) , "{restored}" ) ;
833+ let ( fresh, ci) = fresh_checkout_install (
834+ tmp. path ( ) ,
835+ & proj,
836+ "nodeLinker: node-modules\n enableGlobalCache: false\n " ,
837+ ) ;
838+ assert ! (
839+ ci. status. success( ) ,
840+ "a fresh `--immutable` install of the rolled-back lock (YN0028 = two \
841+ entries with one resolution):\n {}",
842+ yarn_berry_common:: yarn_output( & ci)
843+ ) ;
844+ assert_eq ! (
845+ std:: fs:: read( fresh. join( "node_modules" ) . join( DEP ) . join( "index.js" ) ) . unwrap( ) ,
846+ orig
847+ ) ;
848+ std:: fs:: remove_dir_all ( & fresh) . unwrap ( ) ;
849+ } ;
850+
851+ // Rolled back as it stands: the restored entry merges into the new one.
852+ let drifted_pkg = std:: fs:: read ( proj. join ( "package.json" ) ) . unwrap ( ) ;
853+ rollback ( ) ;
854+ eprintln ! ( "#1082 ROLLBACK OF THE DRIFTED PIN OK" ) ;
855+ std:: fs:: write ( proj. join ( "package.json" ) , drifted_pkg) . unwrap ( ) ;
856+ std:: fs:: write ( proj. join ( "yarn.lock" ) , & drifted) . unwrap ( ) ;
857+
858+ // The re-scan folds it into the pin.
859+ scan ( "re-scan" ) ;
860+ let folded = std:: fs:: read_to_string ( proj. join ( "yarn.lock" ) ) . unwrap ( ) ;
861+ assert ! ( !folded. contains( & format!( "{DEP}@npm:" ) ) , "{folded}" ) ;
862+ assert_eq ! (
863+ folded. matches( & format!( "resolution: \" {DEP}@" ) ) . count( ) ,
864+ 1 ,
865+ "{folded}"
866+ ) ;
867+ let root: serde_json:: Value =
868+ serde_json:: from_slice ( & std:: fs:: read ( proj. join ( "package.json" ) ) . unwrap ( ) ) . unwrap ( ) ;
869+ assert_eq ! (
870+ root[ "resolutions" ] ,
871+ serde_json:: json!( {
872+ format!( "{DEP}@npm:{DEP_VERSION}" ) : hosted_url,
873+ format!( "{DEP}@npm:^{DEP_VERSION}" ) : hosted_url,
874+ } )
875+ ) ;
876+ let offline = format ! (
877+ "nodeLinker: node-modules\n enableGlobalCache: false\n \
878+ unsafeHttpWhitelist:\n - \" {host}\" \n \
879+ npmRegistryServer: \" http://127.0.0.1:1\" \n "
880+ ) ;
881+ let ( fresh, ci) = fresh_checkout_install ( tmp. path ( ) , & proj, & offline) ;
882+ assert ! (
883+ ci. status. success( ) ,
884+ "a fresh `--immutable` install of the folded pin:\n {}" ,
885+ yarn_berry_common:: yarn_output( & ci)
886+ ) ;
887+ assert_eq ! (
888+ std:: fs:: read( fresh. join( "node_modules" ) . join( DEP ) . join( "index.js" ) ) . unwrap( ) ,
889+ patched
890+ ) ;
891+ assert ! ( !fresh. join( "packages/b/node_modules" ) . join( DEP ) . exists( ) ) ;
892+ std:: fs:: remove_dir_all ( & fresh) . unwrap ( ) ;
893+ eprintln ! ( "#1082 RE-SCAN FOLD OK" ) ;
894+
895+ rollback ( ) ;
896+ eprintln ! ( "#1082 ROLLBACK OK" ) ;
897+ }
898+
653899// ── vendored capstone ─────────────────────────────────────────────────
654900
655901#[ test]
0 commit comments