Skip to content

Commit a58cd7f

Browse files
committed
Fix: Run hosted safety refusals before withdrawn sweep
The withdrawn sweep's restore_upstream mutates lockfiles before the hosted rewrite refusals (bun.lockb symlink, governing root) run. A member or symlink project that hosted mode would refuse can still have its lockfiles rewritten, including an ancestor lock the run is not allowed to touch. Safety refusals must run before any restore or mutation. This change adds early refusal checks before the withdrawn sweep: 1. Extract ecosystems from pins that would be restored 2. Check bun.lockb symlink refusal for npm ecosystem 3. Check governing root refusals for cargo/pypi/npm ecosystems 4. Exit with error if any refusal would occur, before any mutation Refactored governing_root::refusal to share implementation with new refusal_for_ecosystems function that takes ecosystem flags instead of candidates, allowing the sweep to check layout safety before restore.
1 parent 268e0ed commit a58cd7f

2 files changed

Lines changed: 111 additions & 3 deletions

File tree

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2576,8 +2576,85 @@ async fn run_scan(
25762576
// longer serves, before the rewrite below re-pins any replacement.
25772577
// Runs ahead of every "nothing found" exit: a package whose only patch
25782578
// was withdrawn has no search results at all.
2579+
//
2580+
// Safety refusals run BEFORE the restore: a symlink or governing-root
2581+
// layout that hosted mode refuses must not be mutated.
25792582
let withdrawn_sweep = if hosted && !args.common.is_global() && policy.writes_allowed() {
25802583
let pins = withdrawn::pins_in_scope(&hosted_pin_list, &all_purls);
2584+
2585+
// Check layout refusals for the ecosystems involved in the sweep,
2586+
// before any restore (the sweep's `restore_upstream` would mutate
2587+
// lockfiles, including an ancestor lock or symlink the refusals
2588+
// prevent).
2589+
if !pins.is_empty() {
2590+
use socket_patch_core::vendor::lock_inventory::ProjectView;
2591+
let view = ProjectView::Disk(&args.common.cwd);
2592+
2593+
// Extract ecosystems from pins.
2594+
let mut has_npm = false;
2595+
let mut has_cargo = false;
2596+
let mut has_pypi = false;
2597+
for pin in &pins {
2598+
match Ecosystem::from_purl(&pin.purl) {
2599+
Some(Ecosystem::Npm) => has_npm = true,
2600+
Some(Ecosystem::Cargo) => has_cargo = true,
2601+
Some(Ecosystem::Pypi) => has_pypi = true,
2602+
_ => {}
2603+
}
2604+
}
2605+
2606+
// Check bun.lockb symlink refusal (npm ecosystem).
2607+
if has_npm {
2608+
use socket_patch_core::hosted::engine;
2609+
use socket_patch_core::vendor::lock_inventory::bun_text_lock_drives;
2610+
let bun_text_drives = bun_text_lock_drives(&view);
2611+
if !bun_text_drives && view.is_symlink("bun.lockb") {
2612+
let refusal = engine::bun_lockb_symlink_refusal();
2613+
if args.common.json {
2614+
let mut env = scan_envelope(&args.common);
2615+
env.mark_error(EnvelopeError::new(&refusal.code, &refusal.message));
2616+
emit_scan(&env);
2617+
} else {
2618+
eprintln!("Error: {}", ui::sentence_case(&refusal.message));
2619+
}
2620+
return 1;
2621+
}
2622+
}
2623+
2624+
// Check governing-root refusals (cargo/pypi/npm ecosystems).
2625+
if has_cargo || has_pypi || has_npm {
2626+
use socket_patch_core::hosted::governing_root;
2627+
// Read trust_lockfile_config the way the main rewrite does.
2628+
let trust_env_off =
2629+
socket_patch_core::utils::pnpm_workspace::trust_lockfile_env_for(
2630+
&view,
2631+
|var| std::env::var(var).ok(),
2632+
)
2633+
.await
2634+
.is_some_and(|found| found.as_bool() != Some(true));
2635+
let trust_lockfile_config = !args.common.no_trust_lockfile_config && !trust_env_off;
2636+
2637+
if let Some(refusal) = governing_root::refusal_for_ecosystems(
2638+
&view,
2639+
has_cargo,
2640+
has_pypi,
2641+
has_npm,
2642+
trust_lockfile_config,
2643+
)
2644+
.await
2645+
{
2646+
if args.common.json {
2647+
let mut env = scan_envelope(&args.common);
2648+
env.mark_error(EnvelopeError::new(&refusal.code, &refusal.message));
2649+
emit_scan(&env);
2650+
} else {
2651+
eprintln!("Error: {}", ui::sentence_case(&refusal.message));
2652+
}
2653+
return 1;
2654+
}
2655+
}
2656+
}
2657+
25812658
Some(withdrawn::sweep(&args.common, &api_client, &pins).await)
25822659
} else {
25832660
None

‎crates/socket-patch-core/src/hosted/governing_root.rs‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,51 @@ pub async fn refusal(
9292
ProjectView::Snapshot(snap) => snap.root(),
9393
ProjectView::Memory(_) => return None,
9494
};
95-
if candidates.iter().any(|c| c.dep.ecosystem == "cargo") {
95+
let has_cargo = candidates.iter().any(|c| c.dep.ecosystem == "cargo");
96+
let has_pypi = candidates.iter().any(|c| c.dep.ecosystem == "pypi");
97+
let has_npm = candidates.iter().any(|c| c.dep.ecosystem == "npm");
98+
refusal_for_ecosystems_impl(root, has_cargo, has_pypi, has_npm, trust_lockfile_config).await
99+
}
100+
101+
/// Like [`refusal`], but takes ecosystem flags instead of candidates. Used by
102+
/// the withdrawn-pin sweep to check layout safety before any restore.
103+
pub async fn refusal_for_ecosystems(
104+
view: &ProjectView<'_>,
105+
has_cargo: bool,
106+
has_pypi: bool,
107+
has_npm: bool,
108+
trust_lockfile_config: bool,
109+
) -> Option<Refusal> {
110+
let root: &Path = match view {
111+
ProjectView::Disk(root) => root,
112+
ProjectView::Snapshot(snap) => snap.root(),
113+
ProjectView::Memory(_) => return None,
114+
};
115+
refusal_for_ecosystems_impl(root, has_cargo, has_pypi, has_npm, trust_lockfile_config).await
116+
}
117+
118+
/// Shared implementation for [`refusal`] and [`refusal_for_ecosystems`].
119+
async fn refusal_for_ecosystems_impl(
120+
root: &Path,
121+
has_cargo: bool,
122+
has_pypi: bool,
123+
has_npm: bool,
124+
trust_lockfile_config: bool,
125+
) -> Option<Refusal> {
126+
if has_cargo {
96127
if let Some(refusal) = cargo_member_refusal(root).await {
97128
return Some(refusal);
98129
}
99130
}
100-
if candidates.iter().any(|c| c.dep.ecosystem == "pypi") {
131+
if has_pypi {
101132
if let Some(workspace) = crate::utils::uv_workspace::governing_uv_workspace(root).await {
102133
return Some(Refusal {
103134
code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(),
104135
message: crate::utils::uv_workspace::member_detail(root, &workspace),
105136
});
106137
}
107138
}
108-
if candidates.iter().any(|c| c.dep.ecosystem == "npm") {
139+
if has_npm {
109140
let workspace = if has_own_npm_family_lock(root) {
110141
member_stray_lock_refusal(root).await
111142
} else {

0 commit comments

Comments
 (0)