Skip to content
Merged
5 changes: 3 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,8 @@ pub async fn run(args: RemoveArgs) -> i32 {

// ── nested in-place rollback ────────────────────────────────────────
// Vendor-owned purls are excluded from the in-place restore (the
// vendored leg below reverts them); an unreadable ledger degrades to
// vendored leg below reverts them) unless their Cargo shared-cache copy
// still carries an agent-mode patch (#336); an unreadable ledger degrades to
// "nothing vendored" here and fails closed at that leg.
let vendored_keys: HashSet<PurlKey> = vendor_state_result
.as_ref()
Expand Down
51 changes: 49 additions & 2 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,11 @@ pub async fn run(args: RollbackArgs) -> i32 {
if vendored_excluded.contains(purl) {
return vendored_reverted_ok(purl);
}
// A vendored Cargo crate whose shared-cache copy the
// in-place leg restored too (#336) needs BOTH legs done.
if purl_keys_cover(&vendored_keys, purl) && !vendored_reverted_ok(purl) {
return false;
}
succeeded_purls.contains(*purl)
|| not_installed.contains(purl)
|| superseded.contains(purl)
Expand Down Expand Up @@ -1981,7 +1986,8 @@ pub async fn run(args: RollbackArgs) -> i32 {
/// The in-place (agent) rollback engine over an already-loaded `manifest`.
/// `vendored_keys` is the ledger's ownership set (see
/// [`VendorState::purl_keys`]): vendor-owned purls are excluded from the
/// in-place restore. Both `run()` and `remove`'s delegation load each
/// in-place restore, except a vendored Cargo crate whose shared-cache copy
/// still carries an agent-mode patch (#336). Both `run()` and `remove`'s delegation load each
/// store once under the lock and thread it in here.
pub(crate) async fn rollback_patches_inner(
common: &GlobalArgs,
Expand Down Expand Up @@ -2052,9 +2058,32 @@ pub(crate) async fn rollback_patches_inner(
// ledger-key / base-purl / qualifier-stripped triple; the caller
// degrades unreadable state to "nothing vendored".
let is_vendored = |p: &str| purl_keys_cover(vendored_keys, p);
let (vendored_targets, patches_to_rollback): (Vec<_>, Vec<_>) = patches_to_rollback
let (vendored_targets, mut patches_to_rollback): (Vec<_>, Vec<_>) = patches_to_rollback
.into_iter()
.partition(|p| is_vendored(&p.purl));
// Except a vendored Cargo crate whose shared registry-cache copy still
// carries an earlier agent-mode patch (#336): vendoring never touched
// that copy, and the manifest record about to be dropped holds the only
// before-blobs that can restore it. Only a copy that is actually
// patched is restored — the vendored copy under `.socket/vendor/` is
// never a rollback location — so a plain vendored crate (cache copy
// absent or pristine) is skipped exactly as before. A project `vendor/`
// dir (`cargo vendor`) hides the registry cache from the crawl, so its
// copies are looked up there too: the record is dropped after this run,
// and an unrestored copy would be orphaned with no before-blobs.
let vendored_purls: Vec<String> = vendored_targets.iter().map(|p| p.purl.clone()).collect();
let cache_patched = crate::ecosystem_dispatch::cargo_copies_still_patched(
manifest,
&vendored_purls,
&common.crawler_options(),
&blobs_path,
true,
)
.await;
let (cache_targets, vendored_targets): (Vec<_>, Vec<_>) = vendored_targets
.into_iter()
.partition(|p| cache_patched.contains(&p.purl));
patches_to_rollback.extend(cache_targets);
let mut vendored_skipped: Vec<String> = vendored_targets.into_iter().map(|p| p.purl).collect();
vendored_skipped.sort();
if patches_to_rollback.is_empty() {
Expand Down Expand Up @@ -2128,6 +2157,24 @@ pub(crate) async fn rollback_patches_inner(
common.silent || common.json,
)
.await;
// The shared-cache copies of the vendored Cargo crates restored above
// (#336), including those a `cargo vendor` dir hides from the crawl.
let cache_purls: Vec<String> = cache_patched
.into_iter()
.filter(|p| in_scope.contains(p))
.collect();
if !cache_purls.is_empty() {
let shadowed =
crate::ecosystem_dispatch::find_cargo_copies(cache_purls, &crawler_options, true).await;
for (purl, paths) in shadowed {
let copies = all_packages_multi.entry(purl).or_default();
for path in paths {
if !copies.contains(&path) {
copies.push(path);
}
}
}
}
// One restore per physical copy, as apply patches them (#633).
distinct_npm_copies(&mut all_packages_multi).await;

Expand Down
48 changes: 46 additions & 2 deletions crates/socket-patch-cli/src/commands/scan/gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ pub(super) async fn run_apply_gc(
Ok(Some(m)) => m,
_ => return GcSummary::vendor_only(vendor_gc),
};
let prunable = detect_prunable(&manifest, scanned_purls, vendored);
let mut prunable = detect_prunable(&manifest, scanned_purls, vendored);
let kept = keep_patched_cargo_copies(common, &manifest, socket_dir, &mut prunable).await;
for purl in &prunable {
manifest.patches.remove(purl);
}
Expand All @@ -258,10 +259,50 @@ pub(super) async fn run_apply_gc(
}
let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ false).await;
gc.absorb_vendor_gc(vendor_gc);
gc.warnings.extend(kept);
gc.warnings.extend(write_failure);
gc
}

/// Drop from `prunable` every Cargo entry whose agent-mode patch is still
/// on disk in a copy the crawl no longer reports (#1278): the project
/// crawl is scoped to the crates `Cargo.lock` resolves, but a crate bumped
/// or dropped from the lock keeps its patched copy in the machine-wide
/// `$CARGO_HOME/registry/src` cache, which nothing deletes. Its manifest
/// record holds the only before-blobs that can restore that copy, so it is
/// kept, with one `cargo_cache_patch_kept` warning per entry naming the
/// `rollback` that restores the copy and then drops the record.
async fn keep_patched_cargo_copies(
common: &GlobalArgs,
manifest: &PatchManifest,
socket_dir: &Path,
prunable: &mut Vec<String>,
) -> Vec<(&'static str, String)> {
let kept = crate::ecosystem_dispatch::cargo_copies_still_patched(
manifest,
prunable.iter(),
&common.crawler_options(),
&socket_dir.join("blobs"),
true,
)
.await;
prunable.retain(|p| !kept.contains(p));
kept.into_iter()
.map(|purl| {
(
"cargo_cache_patch_kept",
format!(
"kept {purl}: the project no longer resolves it, but its copy in the \
shared Cargo registry cache is still patched (or could not be \
read to tell); run `socket-patch rollback {purl}` (with `--global` \
when a `cargo vendor` dir hides the registry cache) to restore \
that copy and drop the entry"
),
)
})
.collect()
}

/// The vendored-state half of the GC alone, for a `--prune` whose crawl
/// found nothing (the manifest half is skipped there: pruning against an
/// empty crawl would drop every entry). Reverting entries whose patch left
Expand Down Expand Up @@ -334,7 +375,10 @@ async fn preview_apply_gc(
}
}
}
let prunable = detect_prunable(&manifest, scanned_purls, vendored);
let mut prunable = detect_prunable(&manifest, scanned_purls, vendored);
// The wet pass keeps a Cargo entry whose shared-cache copy is still
// patched; so does the preview.
let _ = keep_patched_cargo_copies(common, &manifest, socket_dir, &mut prunable).await;
// Likewise drop the prunable entries in memory before the sweep: the
// cleanup helpers derive the referenced set from this manifest.
for purl in &prunable {
Expand Down
92 changes: 92 additions & 0 deletions crates/socket-patch-cli/src/ecosystem_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,98 @@ pub fn crawl_covers_purl(purl: &str) -> bool {
Ecosystem::from_purl(purl).is_some()
}

/// Of `purls` (manifest keys), the Cargo ones whose agent-mode in-place
/// patch may still be on disk: a copy [`find_cargo_copies`] locates
/// (`shadowed_registry`: see there) with at least one file at its record's afterHash, or one that cannot be read to
/// tell (an I/O error, an unsafe key: kept fail-closed). Sorted.
///
/// Cargo is the one ecosystem whose patched copy outlives the project's
/// use of it: the registry cache is shared machine-wide and nothing
/// deletes a crate from it when a project vendors the crate (#336) or
/// stops locking it (#1278). Its manifest record holds the only
/// before-blobs that can restore that copy, so callers about to drop the
/// record must first restore the copy (`rollback`) or keep the record
/// (`scan --prune`). A vendored crate's committed copy under
/// `.socket/vendor/` is never one of these locations.
pub async fn cargo_copies_still_patched<'a>(
manifest: &socket_patch_core::manifest::schema::PatchManifest,
purls: impl IntoIterator<Item = &'a String>,
options: &CrawlerOptions,
blobs_path: &std::path::Path,
shadowed_registry: bool,
) -> Vec<String> {
use socket_patch_core::patch::rollback::{verify_file_rollback, VerifyRollbackStatus};
let cargo: Vec<String> = purls
.into_iter()
.filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Cargo))
.filter(|p| manifest.patches.contains_key(p.as_str()))
.cloned()
.collect();
if cargo.is_empty() {
return Vec::new();
}
let found = find_cargo_copies(cargo, options, shadowed_registry).await;
let mut patched = Vec::new();
for (purl, paths) in &found {
let Some(record) = manifest.patches.get(purl) else {
continue;
};
'copies: for path in paths {
for (file, info) in &record.files {
let v = verify_file_rollback(path, file, info, blobs_path).await;
let still = match v.status {
VerifyRollbackStatus::Ready | VerifyRollbackStatus::MissingBlob => true,
VerifyRollbackStatus::NotFound => !v.is_absent(),
VerifyRollbackStatus::AlreadyOriginal | VerifyRollbackStatus::HashMismatch => {
false
}
};
if still {
patched.push(purl.clone());
break 'copies;
}
}
}
}
patched.sort();
patched
}

/// The copies of the Cargo `purls` [`find_all_packages_for_rollback`]
/// locates (the roots rollback restores), plus, with `shadowed_registry`,
/// the shared `$CARGO_HOME/registry/src` copies of a local Cargo project
/// with a `cargo vendor` dir, whose crawl searches only that dir — where
/// an apply from before `cargo vendor` may have left the patch. The prune
/// only reads them (its keep decision); rollback restores them only for a
/// vendored crate whose record it is about to drop (#336).
pub async fn find_cargo_copies(
purls: Vec<String>,
options: &CrawlerOptions,
shadowed_registry: bool,
) -> HashMap<String, Vec<PathBuf>> {
let partitioned = HashMap::from([(Ecosystem::Cargo, purls)]);
let mut found = find_all_packages_for_rollback(&partitioned, options, true).await;
let local = !options.global && options.global_prefix.is_none();
let cargo_project =
options.cwd.join("Cargo.toml").is_file() || options.cwd.join("Cargo.lock").is_file();
if shadowed_registry && local && cargo_project && options.cwd.join("vendor").is_dir() {
let registry = CrawlerOptions {
cwd: options.cwd.clone(),
global: true,
global_prefix: None,
};
for (purl, paths) in find_all_packages_for_rollback(&partitioned, &registry, true).await {
let copies = found.entry(purl).or_default();
for path in paths {
if !copies.contains(&path) {
copies.push(path);
}
}
}
}
found
}

/// Partition PURLs by ecosystem, filtering by the `--ecosystems` flag if set.
pub fn partition_purls(
purls: &[String],
Expand Down
Loading
Loading