@@ -794,6 +794,7 @@ pub(crate) async fn run_redirect_selected(
794794 pre_warnings : takeover_pre_warnings,
795795 dry_run : dry_run_takeover,
796796 migrated : takeover_migrated,
797+ unrecorded : takeover_unrecorded,
797798 files : takeover_files,
798799 previews : dry_run_takeover_urls,
799800 } = match vendored_takeover ( common, & mut candidates, & mut vendor_state, & mut skipped) . await {
@@ -1015,6 +1016,15 @@ pub(crate) async fn run_redirect_selected(
10151016 // report that outcome. Populated only under --dry-run.
10161017 let mut confirmed = done. confirmed . clone ( ) ;
10171018 confirmed. extend ( dry_run_takeover) ;
1019+ // The takeover already reverted these purls' vendored wiring; one the
1020+ // rewrite then did not pin (a refused lock, unavailable wheel
1021+ // metadata) is left on the unpatched registry release in BOTH modes.
1022+ // That must never pass as success.
1023+ let mut stranded = stranded_takeovers ( & takeover_migrated, & confirmed, common. dry_run ) ;
1024+ // A takeover whose revert succeeded but whose ledger update failed is
1025+ // refused (never redirected), yet its vendored wiring and artifact are
1026+ // already gone: it is unpatched in both modes all the same.
1027+ stranded. extend ( takeover_unrecorded) ;
10181028
10191029 // Fetch the full patch view (file hashes + vulnerabilities) for each
10201030 // CONFIRMED redirect and persist it so a post-install `socket-patch vex`
@@ -1295,6 +1305,18 @@ pub(crate) async fn run_redirect_selected(
12951305 warnings. extend ( python_stale. warnings . iter ( ) . cloned ( ) ) ;
12961306 warnings. extend ( vlt_stale. warnings . iter ( ) . cloned ( ) ) ;
12971307 warnings. extend ( takeover_pre_warnings. iter ( ) . cloned ( ) ) ;
1308+ warnings. extend ( stranded. iter ( ) . map ( |purl| {
1309+ serde_json:: json!( {
1310+ "code" : "redirect_takeover_unpatched" ,
1311+ "detail" : format!(
1312+ "{purl} was vendored and its vendored wiring was reverted, but it \
1313+ was not pinned to hosted (see the warnings above), so the \
1314+ project now installs the UNPATCHED registry release — fix the \
1315+ reported cause and re-run `scan --mode hosted`, or run `scan \
1316+ --mode vendored` to vendor it again"
1317+ ) ,
1318+ } )
1319+ } ) ) ;
12981320 warnings. extend ( takeover_warnings. iter ( ) . cloned ( ) ) ;
12991321 warnings. extend ( prune_warnings. iter ( ) . cloned ( ) ) ;
13001322
@@ -1314,6 +1336,9 @@ pub(crate) async fn run_redirect_selected(
13141336 common. dry_run ,
13151337 ) ;
13161338 let mut result = build_redirect_json_envelope ( scan_result. take ( ) , redirect) ;
1339+ if !stranded. is_empty ( ) {
1340+ result[ "status" ] = serde_json:: json!( "partial_failure" ) ;
1341+ }
13171342 if let Some ( gate) = & rollout {
13181343 super :: finish_rollout_json ( gate. stage , & mut result) ;
13191344 }
@@ -1345,7 +1370,9 @@ pub(crate) async fn run_redirect_selected(
13451370 // line per sentence so CI can grep them.
13461371 let width =
13471372 std:: io:: IsTerminal :: is_terminal ( & std:: io:: stderr ( ) ) . then ( crate :: ui:: stderr_width) ;
1348- for purl in & takeover_migrated {
1373+ // A stranded takeover was NOT migrated to hosted: its
1374+ // `redirect_takeover_unpatched` warning below says so instead.
1375+ for purl in takeover_migrated. iter ( ) . filter ( |p| !stranded. contains ( p) ) {
13491376 eprintln ! ( "{}" , format_takeover_line( purl, common. dry_run) ) ;
13501377 }
13511378 // The files a takeover's revert touched (or, on --dry-run,
@@ -1455,7 +1482,9 @@ pub(crate) async fn run_redirect_selected(
14551482 if let Some ( line) = rollout_line {
14561483 println ! ( "{line}" ) ;
14571484 }
1458- let mut next_steps = if common. dry_run {
1485+ // "Commit … to keep the hosted patches" / "reinstall" would be
1486+ // wrong for a stranded takeover, whose warning names the remedy.
1487+ let mut next_steps = if common. dry_run || !stranded. is_empty ( ) {
14591488 Vec :: new ( )
14601489 } else {
14611490 format_next_steps ( & human_files, & rewrite. edits , !takeover_migrated. is_empty ( ) )
@@ -1470,10 +1499,50 @@ pub(crate) async fn run_redirect_selected(
14701499 if let Some ( e) = & vex_error {
14711500 e. print_embedded ( common) ;
14721501 }
1502+ if common. silent {
1503+ for w in warnings
1504+ . iter ( )
1505+ . filter ( |w| w[ "code" ] == "redirect_takeover_unpatched" )
1506+ {
1507+ eprintln ! (
1508+ "{}" ,
1509+ format_warning(
1510+ "redirect_takeover_unpatched" ,
1511+ w[ "detail" ] . as_str( ) . unwrap_or_default( ) ,
1512+ None
1513+ )
1514+ ) ;
1515+ }
1516+ }
1517+ }
1518+ if vex_code == 0 && !stranded. is_empty ( ) {
1519+ return 1 ;
14731520 }
14741521 vex_code
14751522}
14761523
1524+ /// The purls a WET takeover migrated (vendored wiring reverted) that the
1525+ /// rewrite did not confirm as pinned. Empty under `--dry-run`, whose
1526+ /// takeover previews are counted as confirmed without a rewrite.
1527+ fn stranded_takeovers (
1528+ migrated : & [ String ] ,
1529+ confirmed : & [ ( String , String ) ] ,
1530+ dry_run : bool ,
1531+ ) -> Vec < String > {
1532+ use socket_patch_core:: utils:: purl:: { canonical_purl, strip_purl_qualifiers} ;
1533+ if dry_run {
1534+ return Vec :: new ( ) ;
1535+ }
1536+ let key = |purl : & str | canonical_purl ( strip_purl_qualifiers ( purl) ) ;
1537+ let pinned: std:: collections:: HashSet < String > =
1538+ confirmed. iter ( ) . map ( |( purl, _) | key ( purl) ) . collect ( ) ;
1539+ migrated
1540+ . iter ( )
1541+ . filter ( |purl| !pinned. contains ( & key ( purl) ) )
1542+ . cloned ( )
1543+ . collect ( )
1544+ }
1545+
14771546/// Cross-mode takeover: a purl this run is about to redirect may still be
14781547/// VENDORED — for cargo a committed `[patch.crates-io]` path entry, a
14791548/// detached Cargo.lock entry, a committed copy, and a vendored ledger
@@ -1510,8 +1579,15 @@ async fn vendored_takeover(
15101579 // for those locks even though the rewriters never see these purls.
15111580 let mut dry_run_locks: std:: collections:: HashMap < String , Vec < String > > =
15121581 std:: collections:: HashMap :: new ( ) ;
1582+ // PyPI: every Python rewriter (requirements.txt, Poetry, Pipenv, uv,
1583+ // Hatch, PDM, pylock) refuses a non-registry source as user-authored,
1584+ // including the vendored one socket-patch wrote itself, so a vendored
1585+ // purl must be reverted to its registry entry first (#328).
15131586 let takeover_capable = |p : & str | {
1514- p. starts_with ( "pkg:cargo/" ) || p. starts_with ( "pkg:npm/" ) || p. starts_with ( "pkg:golang/" )
1587+ p. starts_with ( "pkg:cargo/" )
1588+ || p. starts_with ( "pkg:npm/" )
1589+ || p. starts_with ( "pkg:golang/" )
1590+ || p. starts_with ( "pkg:pypi/" )
15151591 } ;
15161592 if !candidates. iter ( ) . any ( |c| takeover_capable ( & c. purl ) ) {
15171593 // No takeover-capable candidates — nothing to reconcile.
@@ -1708,6 +1784,11 @@ async fn vendored_takeover(
17081784 // would refuse the still-vendored wiring.
17091785 let outcome =
17101786 crate :: commands:: vendor:: dispatch_revert_one ( entry, & common. cwd , true ) . await ;
1787+ if outcome. success && revert_keeps_wiring ( & outcome) {
1788+ refused. push ( purl. clone ( ) ) ;
1789+ out. pre_warnings . push ( drifted_takeover_warning ( purl) ) ;
1790+ continue ;
1791+ }
17111792 if !outcome. success {
17121793 refused. push ( purl. clone ( ) ) ;
17131794 out. pre_warnings . push ( serde_json:: json!( {
@@ -1754,6 +1835,16 @@ async fn vendored_takeover(
17541835 } ) ) ;
17551836 continue ;
17561837 }
1838+ if revert_keeps_wiring ( & outcome) {
1839+ // A wiring record drifted and was left in place, so the
1840+ // project may still resolve through the vendored artifact
1841+ // and the ledger entry holds the only recorded originals
1842+ // (the RevertOutcome contract): keep both and refuse,
1843+ // exactly as `vendor --revert` reports it skipped.
1844+ refused. push ( purl. clone ( ) ) ;
1845+ out. pre_warnings . push ( drifted_takeover_warning ( purl) ) ;
1846+ continue ;
1847+ }
17571848 // Drop the reverted entry from the in-memory ledger and
17581849 // persist per purl so a crash mid-run leaves a ledger
17591850 // matching the on-disk wiring. The entry stays dropped even
@@ -1768,8 +1859,10 @@ async fn vendored_takeover(
17681859 if let Err ( e) = socket_patch_core:: vendor:: save_state ( & common. cwd , state) . await {
17691860 // The wiring is reverted but the ledger still claims it;
17701861 // redirecting now would leave a ledger asserting wiring
1771- // that is gone. Fail closed for this purl.
1862+ // that is gone. Fail closed for this purl — and since its
1863+ // vendored wiring is already gone, report it as stranded.
17721864 refused. push ( purl. clone ( ) ) ;
1865+ out. unrecorded . push ( purl. clone ( ) ) ;
17731866 out. pre_warnings . push ( serde_json:: json!( {
17741867 "code" : "redirect_vendored_revert_failed" ,
17751868 "detail" : format!(
@@ -1866,6 +1959,32 @@ async fn vendored_takeover(
18661959 Ok ( out)
18671960}
18681961
1962+ /// Whether a takeover revert left (or, on `--dry-run`, would leave) vendored
1963+ /// wiring in place: a drift-skipped record, or a reverted file that still
1964+ /// references the artifact dir. The backends compute both signals on dry
1965+ /// runs too, while `kept_artifact` itself is set only on wet runs.
1966+ fn revert_keeps_wiring ( outcome : & socket_patch_core:: vendor:: RevertOutcome ) -> bool {
1967+ outcome. kept_artifact
1968+ || outcome. drift_skipped ( )
1969+ || outcome
1970+ . warnings
1971+ . iter ( )
1972+ . any ( |w| w. code == "vendor_revert_residual_reference" )
1973+ }
1974+
1975+ /// The refusal for a takeover whose vendored wiring drifted since vendoring.
1976+ fn drifted_takeover_warning ( purl : & str ) -> serde_json:: Value {
1977+ serde_json:: json!( {
1978+ "code" : "redirect_vendored_revert_failed" ,
1979+ "detail" : format!(
1980+ "{purl} is vendored and part of its vendored wiring was edited since \
1981+ vendoring, so it is left in place; NOT switched to hosted — restore or \
1982+ remove that wiring (`socket-patch vendor --revert` lists it), then re-run \
1983+ `scan --mode hosted`"
1984+ ) ,
1985+ } )
1986+ }
1987+
18691988/// What [`vendored_takeover`] did (or, on `--dry-run`, would do).
18701989#[ derive( Default ) ]
18711990struct Takeover {
@@ -1880,6 +1999,10 @@ struct Takeover {
18801999 /// Human output: the purls migrated (or, on --dry-run, to be migrated)
18812000 /// from vendored to hosted.
18822001 migrated : Vec < String > ,
2002+ /// Wet takeovers whose vendored wiring was reverted but whose ledger
2003+ /// update then failed: refused (never redirected), so unpatched in
2004+ /// both modes.
2005+ unrecorded : Vec < String > ,
18832006 /// The files their revert touches (or would touch). Both modes count
18842007 /// `rewritten ∪ files`, so the preview's file count matches the wet
18852008 /// run's even for wiring files the hosted rewriter does not also
0 commit comments