@@ -1320,3 +1320,134 @@ async fn vendor_over_unrestorable_hosted_pin_is_refused() {
13201320 assert_eq ! ( read( & proj, "Cargo.lock" ) , pristine_lock) ;
13211321 assert ! ( !vendor_ledger_claims( & proj, & purl) ) ;
13221322}
1323+
1324+ // ── #1020: a takeover the hosted rewriter refuses keeps the vendored patch ──
1325+ // Vendored mode accepts every Cargo.toml spelling of the dependency (it never
1326+ // edits the dependency line); the hosted cargo rewriter refuses some
1327+ // (`redirect_cargo_toml_dep_unrewritable`). A vendored → hosted takeover over
1328+ // such a spelling must leave the purl VENDORED, byte for byte (wiring, ledger
1329+ // entry and committed copy), and the dry run must preview exactly that, not
1330+ // `redirect_would_revert_vendored` + `redirected: 1`.
1331+ #[ tokio:: test( flavor = "multi_thread" ) ]
1332+ async fn takeover_over_unrewritable_spelling_keeps_vendored ( ) {
1333+ for ( tag, spelling) in [
1334+ ( "dotted" , format ! ( "{DEP}.version = \" 1.0\" \n " ) ) ,
1335+ ( "literal" , format ! ( "{DEP} = '1.0'\n " ) ) ,
1336+ (
1337+ "crates-io" ,
1338+ format ! ( "{DEP} = {{ version = \" 1.0\" , registry = \" crates-io\" }}\n " ) ,
1339+ ) ,
1340+ ] {
1341+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1342+ let Some ( ( proj, cargo_home, version, crate_dir) ) = stage_fixture ( tmp. path ( ) ) else {
1343+ return ;
1344+ } ;
1345+ let toml = read ( & proj, "Cargo.toml" ) . replace ( & format ! ( "{DEP} = \" 1.0\" \n " ) , & spelling) ;
1346+ assert ! ( toml. contains( & spelling) , "{tag}: {toml}" ) ;
1347+ std:: fs:: write ( proj. join ( "Cargo.toml" ) , toml) . unwrap ( ) ;
1348+ std:: fs:: write (
1349+ proj. join ( "src/main.rs" ) ,
1350+ format ! (
1351+ "fn main() {{ println!(\" M:{{}}\" , {}::socket_patched()); }}\n " ,
1352+ DEP . replace( '-' , "_" )
1353+ ) ,
1354+ )
1355+ . unwrap ( ) ;
1356+ let purl = format ! ( "pkg:cargo/{DEP}@{version}" ) ;
1357+ let orig = std:: fs:: read ( crate_dir. join ( "src/lib.rs" ) ) . unwrap ( ) ;
1358+ let patched: Vec < u8 > = [ orig. as_slice ( ) , PATCH_SUFFIX . as_bytes ( ) ] . concat ( ) ;
1359+ stage_patch ( & proj, & purl, & orig, & patched) ;
1360+
1361+ let ( code, stdout, stderr) = run_socket (
1362+ & proj,
1363+ & [
1364+ "vendor" ,
1365+ "--json" ,
1366+ "--offline" ,
1367+ "--cwd" ,
1368+ proj. to_str ( ) . unwrap ( ) ,
1369+ ] ,
1370+ & cargo_home,
1371+ ) ;
1372+ assert_eq ! ( code, 0 , "{tag}: vendor failed: {stdout}\n {stderr}" ) ;
1373+ assert ! ( vendor_ledger_claims( & proj, & purl) , "{tag}: vendored" ) ;
1374+ assert_build_ok (
1375+ & format ! ( "{tag}: vendored cargo build --locked --offline" ) ,
1376+ & cargo (
1377+ & proj,
1378+ & [ "build" , "--locked" , "--offline" , "-q" ] ,
1379+ & cargo_home,
1380+ ) ,
1381+ ) ;
1382+
1383+ let server = MockServer :: start ( ) . await ;
1384+ let crate_bytes =
1385+ build_patched_crate ( & tmp. path ( ) . join ( "stage" ) , & crate_dir, & version, & patched) ;
1386+ mount_hosted_mocks ( & server, & purl, & version, & crate_bytes, & orig, & patched) . await ;
1387+ let uri = server. uri ( ) ;
1388+ let hosted_args = [
1389+ "scan" ,
1390+ "--mode" ,
1391+ "hosted" ,
1392+ "--json" ,
1393+ "--yes" ,
1394+ "--cwd" ,
1395+ proj. to_str ( ) . unwrap ( ) ,
1396+ "--api-url" ,
1397+ & uri,
1398+ "--org" ,
1399+ ORG ,
1400+ "--api-token" ,
1401+ "fake" ,
1402+ ] ;
1403+ let before = project_snapshot ( & proj) ;
1404+ let dry: Vec < & str > = hosted_args. iter ( ) . copied ( ) . chain ( [ "--dry-run" ] ) . collect ( ) ;
1405+ let ( dry_code, dry_out, stderr) = run_socket ( & proj, & dry, & cargo_home) ;
1406+ let dry_env: serde_json:: Value = serde_json:: from_str ( & dry_out)
1407+ . unwrap_or_else ( |e| panic ! ( "{tag}: dry-run json ({e}): {dry_out}\n {stderr}" ) ) ;
1408+ assert_eq ! ( dry_env[ "redirect" ] [ "redirected" ] , 0 , "{tag}: {dry_out}" ) ;
1409+ assert ! (
1410+ !dry_out. contains( "redirect_would_revert_vendored" ) ,
1411+ "{tag}: the dry run must not preview a takeover the wet run refuses: {dry_out}"
1412+ ) ;
1413+ assert ! (
1414+ dry_out. contains( "redirect_takeover_kept_vendored" )
1415+ && dry_out. contains( "redirect_cargo_toml_dep_unrewritable" ) ,
1416+ "{tag}: the dry run previews the refusal: {dry_out}"
1417+ ) ;
1418+ assert ! ( project_snapshot( & proj) == before, "{tag}: dry run wrote" ) ;
1419+
1420+ let ( code, stdout, stderr) = run_socket ( & proj, & hosted_args, & cargo_home) ;
1421+ let env: serde_json:: Value = serde_json:: from_str ( & stdout)
1422+ . unwrap_or_else ( |e| panic ! ( "{tag}: wet json ({e}): {stdout}\n {stderr}" ) ) ;
1423+ assert_eq ! ( code, dry_code, "{tag}: dry run and wet run agree: {stdout}" ) ;
1424+ assert_eq ! ( env[ "status" ] , dry_env[ "status" ] , "{tag}: {stdout}" ) ;
1425+ assert_eq ! ( env[ "redirect" ] [ "redirected" ] , 0 , "{tag}: {stdout}" ) ;
1426+ assert ! (
1427+ !stdout. contains( "redirect_takeover_unpatched" )
1428+ && !stdout. contains( "redirect_takeover_reverted_vendored" ) ,
1429+ "{tag}: the vendored patch must not be reverted: {stdout}"
1430+ ) ;
1431+ assert ! (
1432+ stdout. contains( "redirect_takeover_kept_vendored" )
1433+ && stdout. contains( "redirect_cargo_toml_dep_unrewritable" ) ,
1434+ "{tag}: the refusal is reported: {stdout}"
1435+ ) ;
1436+ let after = project_snapshot ( & proj) ;
1437+ let changed: Vec < & String > = before
1438+ . keys ( )
1439+ . chain ( after. keys ( ) )
1440+ . filter ( |k| before. get ( * k) != after. get ( * k) )
1441+ . collect ( ) ;
1442+ assert ! (
1443+ changed. is_empty( ) ,
1444+ "{tag}: a refused takeover leaves the vendored project byte for byte: {changed:?}\n {stdout}"
1445+ ) ;
1446+ // A fresh checkout still builds the PATCHED crate.
1447+ let ( fresh, home) = fresh_checkout ( & proj, tmp. path ( ) , tag) ;
1448+ assert_build_ok (
1449+ & format ! ( "{tag}: fresh cargo build --locked" ) ,
1450+ & cargo ( & fresh, & [ "build" , "--locked" , "-q" ] , & home) ,
1451+ ) ;
1452+ }
1453+ }
0 commit comments