Skip to content

Commit edc7c8b

Browse files
committed
Merge remote-tracking branch 'origin/main' into agent/fix-npm-same-lock-unwired-copy
2 parents a0a210c + 045d7ec commit edc7c8b

22 files changed

Lines changed: 2442 additions & 1016 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ limits, and required install commands.
237237
project and rewired its lockfile, and `vendor --revert -g` unwound the
238238
project's vendoring, so its next frozen install was silently unpatched.
239239
Global installs have no project lockfile to vendor into (#498).
240+
- Patch API requests (`scan`, `get`, `apply` and `vex` lookups, and blob and
241+
diff downloads) no longer hang forever on a stalled proxy, load balancer or
242+
half-open connection. A connect now fails after 10 s, and a connection that
243+
sends nothing for 60 s fails as a network error. Downloads that keep
244+
streaming are not cut off (#570).
240245

241246
### Maintenance
242247

‎crates/socket-patch-bench/src/fixtures/npm.rs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,13 @@ pub fn build_yarn_berry(t: &mut Tree, size: Size) -> std::io::Result<Fixture> {
623623
t.write("project/node_modules/.yarn-state.yml", "# Warning: This file is automatically generated. Removing it is fine, but will\n# cause your node_modules installation to become invalidated.\n\n__metadata:\n version: 1\n nmMode: classic\n")?;
624624
g.install_hoisted(t, "project/")?;
625625
t.mkdir("home")?;
626-
Ok(fixture(&g, g.patches(true), &["yarn.lock"], &[]))
626+
// Hosted Berry pins both descriptor resolutions and their lock entries.
627+
Ok(fixture(
628+
&g,
629+
g.patches(true),
630+
&["package.json", "yarn.lock"],
631+
&[],
632+
))
627633
}
628634

629635
// ── bun ────────────────────────────────────────────────────────────────

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
18711990
struct 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

‎crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,7 +2138,9 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() {
21382138
/// save_state failure AFTER a successful takeover revert: the wiring is gone
21392139
/// but the vendored ledger still claims it, so the purl must fail CLOSED —
21402140
/// `redirect_vendored_revert_failed` with the could-not-be-updated detail, a
2141-
/// `vendored_revert_failed` skip, and no redirect. Reached by making
2141+
/// `vendored_revert_failed` skip, and no redirect — and, since the package
2142+
/// is now unpatched in both modes, `redirect_takeover_unpatched` with
2143+
/// `partial_failure` and exit 1. Reached by making
21422144
/// `.socket/vendor` itself read-only (0o555): the entry's empty wiring
21432145
/// reverts trivially and its artifact dir under the still-writable
21442146
/// `.socket/vendor/npm/` is removed, but persisting the now-empty ledger
@@ -2185,7 +2187,14 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() {
21852187

21862188
let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]);
21872189

2188-
assert_eq!(code, 0, "the fail-closed refusal still exits 0: {doc:#}");
2190+
// The vendored wiring and artifact are already gone, so the package is
2191+
// unpatched in both modes: a stranded takeover, never a success.
2192+
assert_eq!(code, 1, "a stranded takeover exits 1: {doc:#}");
2193+
assert_eq!(doc["status"], "partial_failure", "envelope: {doc:#}");
2194+
assert!(
2195+
warning_detail(&doc, "redirect_takeover_unpatched").contains(PURL),
2196+
"the stranded package is named: {doc:#}"
2197+
);
21892198
let detail = warning_detail(&doc, "redirect_vendored_revert_failed");
21902199
assert!(
21912200
detail.contains("could not be updated"),

0 commit comments

Comments
 (0)