Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 66 additions & 36 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::args::{apply_env_toggles, GlobalArgs};
use crate::commands::fetch_stage::{stage_patch_sources, StageOutcome, StagedSources};
use crate::commands::lock_cli::acquire_or_emit;
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
use crate::ecosystem_dispatch::{find_all_packages_for_purls, partition_purls};
use crate::json_envelope::{
AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
VexSummary,
Expand Down Expand Up @@ -1071,13 +1071,26 @@ async fn apply_patches_inner(
global_prefix: args.common.global_prefix.clone(),
};

let all_packages = find_packages_for_purls(
// Multi-copy aware: npm nests genuine duplicates of one `name@version`
// (nested dupes, diamonds, `file:` dups), so the resolver returns EVERY
// physical copy per PURL. Patching only one would leave a live,
// vulnerable copy while reporting success (the multi-copy silent
// partial). The apply loop below iterates every copy.
let all_packages = find_all_packages_for_purls(
&partitioned,
&crawler_options,
args.common.silent || args.common.json,
)
.await;

// One representative path per PURL, for the mismatch-blob gate and the
// pre-attempt checks that only need "is it installed / a sample copy"
// (the gate's fetch decision is per-hash, identical across copies).
let first_paths: HashMap<String, PathBuf> = all_packages
.iter()
.filter_map(|(purl, paths)| paths.first().map(|p| (purl.clone(), p.clone())))
.collect();

if all_packages.is_empty() && partitioned.is_empty() {
// Nothing in scope: the manifest lists no patches (or every patch was
// filtered out by `--ecosystems`). There is genuinely no work to do,
Expand Down Expand Up @@ -1115,7 +1128,7 @@ async fn apply_patches_inner(
}

// Apply patches
ensure_blobs_for_mismatches(args, &manifest, &all_packages, &vendored_purls, &mut staged).await;
ensure_blobs_for_mismatches(args, &manifest, &first_paths, &vendored_purls, &mut staged).await;
let sources = staged.as_patch_sources();
let policy = mismatch_policy(args.force, args.common.strict);
let mut has_errors = false;
Expand All @@ -1139,7 +1152,14 @@ async fn apply_patches_inner(

let mut applied_base_purls: HashSet<String> = HashSet::new();

for (purl, pkg_path) in &all_packages {
for (purl, pkg_paths) in &all_packages {
// Release-variant ecosystems install exactly one directory per
// `package@version` (the variants are jars/wheels inside it), so a
// representative path is all the variant branch needs. npm's branch
// below iterates every physical copy.
let pkg_path = pkg_paths
.first()
.expect("all_packages only holds PURLs with at least one resolved copy");
if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) {
let base_purl = strip_purl_qualifiers(purl).to_string();
if applied_base_purls.contains(&base_purl) {
Expand Down Expand Up @@ -1294,42 +1314,52 @@ async fn apply_patches_inner(
None => continue,
};

// Local go redirects to a project-local patched copy under
// `.socket/go-patches/` wired via a `go.mod` `replace` (the module
// cache is `go.sum`-verified, so in-place patching can't build).
// Everything else — npm/pypi/gem and cargo (vendored or registry
// cache) — patches in place via `apply_package_patch`.
let result =
match try_local_go_apply(purl, pkg_path, patch, &sources, &args.common, policy)
.await
{
Some(r) => r,
None => {
apply_package_patch(
purl,
pkg_path,
&patch.files,
&sources,
Some(&patch.uuid),
args.common.dry_run,
policy,
)
// Patch EVERY physical copy of this PURL. npm materializes more
// than one on-disk copy of a single `name@version` (nested
// dupes, diamonds, `file:` dups); the resolver returns them all
// (root copy first). A per-copy result means the JSON summary
// counts each copy — the signal a second copy exists — and a
// failure on any copy fails the run. (Non-npm single-copy
// ecosystems simply have a one-element list here.)
for pkg_path in pkg_paths {
// Local go redirects to a project-local patched copy under
// `.socket/go-patches/` wired via a `go.mod` `replace` (the
// module cache is `go.sum`-verified, so in-place patching
// can't build). Everything else — npm/pypi/gem and cargo
// (vendored or registry cache) — patches in place via
// `apply_package_patch`.
let result =
match try_local_go_apply(purl, pkg_path, patch, &sources, &args.common, policy)
.await
}
};
{
Some(r) => r,
None => {
apply_package_patch(
purl,
pkg_path,
&patch.files,
&sources,
Some(&patch.uuid),
args.common.dry_run,
policy,
)
.await
}
};

warn_mismatch_overwrites(&result, &args.common);
if !result.success {
has_errors = true;
if !args.common.silent && !args.common.json {
eprintln!(
"Failed to patch {}: {}",
purl,
result.error.as_deref().unwrap_or("unknown error")
);
warn_mismatch_overwrites(&result, &args.common);
if !result.success {
has_errors = true;
if !args.common.silent && !args.common.json {
eprintln!(
"Failed to patch {}: {}",
purl,
result.error.as_deref().unwrap_or("unknown error")
);
}
}
results.push(result);
}
results.push(result);
matched_manifest_purls.insert(purl.clone());
}
}
Expand Down
47 changes: 38 additions & 9 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use clap::Args;
use socket_patch_core::api::blob_fetcher::{fetch_blobs_by_hash, format_fetch_result};
use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
use socket_patch_core::crawlers::CrawlerOptions;
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
use socket_patch_core::manifest::operations::{get_before_hash_blobs, read_manifest};
use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
use socket_patch_core::patch::apply::select_installed_variants;
Expand All @@ -19,7 +19,7 @@ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs};
use crate::commands::apply::is_local_go;
use crate::commands::lock_cli::acquire_or_emit;
use crate::commands::remove::patch_matches;
use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls};
use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls};
use crate::json_envelope::Command as EnvelopeCommand;

#[derive(Args)]
Expand Down Expand Up @@ -730,13 +730,26 @@ async fn rollback_patches_inner(
global_prefix: args.common.global_prefix.clone(),
};

let all_packages = find_packages_for_rollback(
// Multi-copy aware: npm nests genuine duplicates of one `name@version`,
// so the resolver returns EVERY physical copy per PURL. Restoring only
// one would leave the other copy still patched (silently divergent from
// the manifest's rolled-back state). The rollback loop below restores
// every copy.
let all_packages_multi = find_all_packages_for_rollback(
&partitioned,
&crawler_options,
args.common.silent || args.common.json,
)
.await;

// One representative path per PURL for the "is it installed" checks and
// the before-blob gate (the gate's fetch decision is per-hash, identical
// across copies). The per-copy restore uses `all_packages_multi`.
let all_packages: HashMap<String, PathBuf> = all_packages_multi
.iter()
.filter_map(|(purl, paths)| paths.first().map(|p| (purl.clone(), p.clone())))
.collect();

// Local-redirect rollback (local-mode go) drops a project-local redirect
// and reads nothing out of the ecosystem's package store, so — unlike an
// in-place restore — it must NOT depend on the crawler finding the package
Expand Down Expand Up @@ -764,17 +777,33 @@ async fn rollback_patches_inner(
// mirroring apply — we collapse each group to the variant(s) whose
// hashes actually match the installed bytes. PyPI/RubyGems yield one
// such variant; Maven's coexisting classifier jars may yield several.
//
// Non-variant ecosystems (npm/cargo/go/…) have no qualifiers, but npm
// does have genuine MULTIPLE physical copies of one `name@version`
// (nested dupes, diamonds, `file:` dups). Those must NOT be collapsed
// into a release-variant group — each copy is restored independently —
// so they are pushed straight to `rollback_targets`. Only the
// release-variant ecosystems (whose multiple qualified PURLs share ONE
// install dir) go through the group + narrow path.
let mut rollback_targets: Vec<(&String, &PathBuf)> = Vec::new();
let mut groups: HashMap<String, Vec<(&String, &PathBuf)>> = HashMap::new();
for (purl, pkg_path) in &all_packages {
groups
.entry(strip_purl_qualifiers(purl).to_string())
.or_default()
.push((purl, pkg_path));
for (purl, pkg_paths) in &all_packages_multi {
if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) {
for pkg_path in pkg_paths {
groups
.entry(strip_purl_qualifiers(purl).to_string())
.or_default()
.push((purl, pkg_path));
}
} else {
for pkg_path in pkg_paths {
rollback_targets.push((purl, pkg_path));
}
}
Comment thread
mikolalysenko marked this conversation as resolved.
Comment thread
mikolalysenko marked this conversation as resolved.
}

// Resolve which variant(s) each base PURL will actually roll back,
// BEFORE the before-blob gate below, so the gate covers only them.
let mut rollback_targets: Vec<(&String, &PathBuf)> = Vec::new();
for (_base, entries) in groups {
let to_rollback: Vec<(&String, &PathBuf)> = if entries.len() == 1 {
entries
Expand Down
Loading
Loading