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
42 changes: 27 additions & 15 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,29 +602,34 @@ async fn apply_patches_inner(
let mut results: Vec<ApplyResult> = Vec::new();
let mut has_errors = false;

// Group pypi PURLs by base (for variant matching with qualifiers)
let mut pypi_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
if let Some(pypi_purls) = partitioned.get(&Ecosystem::Pypi) {
for purl in pypi_purls {
let base = strip_purl_qualifiers(purl).to_string();
pypi_qualified_groups
.entry(base)
.or_default()
.push(purl.clone());
// Group release-variant PURLs by base. PyPI (`?artifact_id=`),
// RubyGems (`?platform=`), and Maven (`?classifier=&ext=`) carry
// qualifiers distinguishing releases of one `package@version`; the
// crawler emits the base PURL, so we match the manifest's qualified
// variants against it here.
let mut variant_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
for (eco, purls) in &partitioned {
if eco.supports_release_variants() {
for purl in purls {
variant_qualified_groups
.entry(strip_purl_qualifiers(purl).to_string())
.or_default()
.push(purl.clone());
}
}
}

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

for (purl, pkg_path) in &all_packages {
if Ecosystem::from_purl(purl) == Some(Ecosystem::Pypi) {
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) {
continue;
}

let variants = pypi_qualified_groups
let variants = variant_qualified_groups
.get(&base_purl)
.cloned()
.unwrap_or_else(|| vec![base_purl.clone()]);
Expand All @@ -636,7 +641,9 @@ async fn apply_patches_inner(
None => continue,
};

// Check first file hash match (skip when --force)
// Check first file hash match (skip when --force). A
// mismatch means this variant's distribution isn't the
// one on disk, so skip it.
if !args.force {
if let Some((file_name, file_info)) = patch.files.iter().next() {
let verify = verify_file_patch(pkg_path, file_name, file_info).await;
Expand Down Expand Up @@ -664,16 +671,21 @@ async fn apply_patches_inner(

if result.success {
applied = true;
applied_base_purls.insert(base_purl.clone());
results.push(result);
matched_manifest_purls.insert(variant_purl.clone());
break;
// No `break`: apply *every* matching variant. PyPI/gem
// have exactly one installed distribution (the rest
// hash-mismatch and were skipped above), so this
// applies a single variant for them; Maven's coexisting
// classifier jars each get patched.
} else {
results.push(result);
}
}

if !applied {
if applied {
applied_base_purls.insert(base_purl.clone());
} else {
has_errors = true;
if !args.common.silent && !args.common.json {
eprintln!("Failed to patch {base_purl}: no matching variant found");
Expand Down
102 changes: 54 additions & 48 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
use socket_patch_core::manifest::schema::{
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
};
use socket_patch_core::patch::apply::select_installed_variant;
use socket_patch_core::patch::apply::select_installed_variants;
use socket_patch_core::utils::fuzzy_match::fuzzy_match_packages;
use socket_patch_core::utils::purl::{is_purl, strip_purl_qualifiers};
use socket_patch_core::utils::telemetry::{track_patch_fetch_failed, track_patch_fetched};
Expand Down Expand Up @@ -329,11 +329,12 @@ pub struct GetArgs {
#[arg(long = "one-off", env = "SOCKET_ONE_OFF", default_value_t = false)]
pub one_off: bool,

/// Download patches for every release/distribution (artifact_id) of
/// a matched package, not just the one matching the locally-
/// installed distribution. Only affects PyPI today — the only
/// ecosystem with per-release artifact_id variants. Off by default:
/// only the patch for the installed dist is fetched.
/// Download patches for every release/distribution variant of a
/// matched package, not just the one(s) matching the locally-
/// installed distribution. Affects ecosystems with per-release
/// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems
/// (`platform`), and Maven (`classifier`). Off by default: only the
/// patch(es) for the installed dist are fetched.
#[arg(
long = "all-releases",
env = "SOCKET_ALL_RELEASES",
Expand Down Expand Up @@ -530,20 +531,18 @@ pub struct DownloadParams {
pub all_releases: bool,
}

/// Download and apply a set of selected patches.
///
/// Used by both `get` and `scan` commands. Returns (exit_code, json_result).
/// Narrow a selection of patches down to the release variant matching
/// each locally-installed distribution.
/// Narrow a selection of patches down to the release variant(s) present
/// in each locally-installed distribution.
///
/// A PyPI `package@version` can resolve to several patch variants — one
/// per `?artifact_id=...` release (wheel/sdist). Only one distribution
/// is ever installed in a given environment, so only one variant can
/// apply. With `--all-releases` off (the default) we keep just the
/// variant whose first patched file's hash matches the on-disk package,
/// dropping the rest so they are never downloaded or written to the
/// manifest. Non-PyPI ecosystems never carry `artifact_id` qualifiers,
/// so they pass through untouched.
/// A release-variant ecosystem `package@version` can resolve to several
/// patch variants — one per qualified PURL: PyPI `?artifact_id=`
/// (wheel/sdist), RubyGems `?platform=`, Maven `?classifier=&ext=`. With
/// `--all-releases` off (the default) we keep only the variant(s) whose
/// first patched file's hash matches what's on disk, dropping the rest so
/// they are never downloaded or written to the manifest. PyPI/RubyGems
/// install one distribution per environment (≤1 kept); Maven classifier
/// jars coexist, so several may be kept. Ecosystems that ship one
/// artifact per version never carry qualifiers and pass through untouched.
///
/// Fallbacks (keep all variants of the base, i.e. behave as broad):
/// * the base package is not installed on disk (nothing to match
Expand All @@ -560,14 +559,15 @@ async fn filter_to_installed_releases(
api_client: &socket_patch_core::api::client::ApiClient,
org: Option<&str>,
) -> (Vec<PatchSearchResult>, Vec<String>) {
// Group the PyPI selections by their base PURL (qualifiers stripped).
// Anything that isn't PyPI, or whose base has a single variant, is
// kept verbatim and needs no installed-dist resolution.
let mut pypi_groups: HashMap<String, Vec<PatchSearchResult>> = HashMap::new();
// Group release-variant ecosystem selections (PyPI / RubyGems / Maven)
// by their base PURL (qualifiers stripped). Anything that can't have
// release variants, or whose base has a single variant, is kept
// verbatim and needs no installed-dist resolution.
let mut variant_groups: HashMap<String, Vec<PatchSearchResult>> = HashMap::new();
let mut kept: Vec<PatchSearchResult> = Vec::new();
for sr in selected {
if Ecosystem::from_purl(&sr.purl) == Some(Ecosystem::Pypi) {
pypi_groups
if Ecosystem::from_purl(&sr.purl).is_some_and(|e| e.supports_release_variants()) {
variant_groups
.entry(strip_purl_qualifiers(&sr.purl).to_string())
.or_default()
.push(sr.clone());
Expand All @@ -578,10 +578,10 @@ async fn filter_to_installed_releases(

let mut warnings: Vec<String> = Vec::new();

// Singleton PyPI bases have nothing to disambiguate — keep as-is.
// Singleton bases have nothing to disambiguate — keep as-is.
// Collect the multi-variant bases that actually need resolution.
let mut multi: Vec<(String, Vec<PatchSearchResult>)> = Vec::new();
for (base, variants) in pypi_groups {
for (base, variants) in variant_groups {
if variants.len() <= 1 {
kept.extend(variants);
} else {
Expand All @@ -593,10 +593,11 @@ async fn filter_to_installed_releases(
return (kept, warnings);
}

// Discover the on-disk path for each multi-variant base. The pypi
// crawler is queried with base PURLs and the result is fanned back
// out to every qualified variant (all variants of one installed
// package resolve to the same path).
// Discover the on-disk path for each multi-variant base. The crawler
// is queried with base PURLs and the result is fanned back out to
// every qualified variant. For PyPI/RubyGems all variants of one
// installed package resolve to the same dir; for Maven the variants
// share a version dir but target distinct jar files within it.
let all_qualified: Vec<String> = multi
.iter()
.flat_map(|(_, variants)| variants.iter().map(|s| s.purl.clone()))
Expand All @@ -612,8 +613,8 @@ async fn filter_to_installed_releases(
let paths = find_packages_for_rollback(&partitioned, &crawler_options, true).await;

for (base, variants) in multi {
// Any variant's resolved path works — they all map to the single
// installed distribution.
// Any variant's resolved path works — they all map to the same
// installed package directory.
let pkg_path = variants.iter().find_map(|s| paths.get(&s.purl)).cloned();
let Some(pkg_path) = pkg_path else {
// Not installed: cannot determine the relevant release. Keep
Expand Down Expand Up @@ -645,21 +646,23 @@ async fn filter_to_installed_releases(
.map(|(purl, files)| (purl.as_str(), files))
.collect();

match select_installed_variant(&pkg_path, &refs).await {
Some(idx) => {
let winner = candidates[idx].0.clone();
kept.extend(variants.into_iter().filter(|s| s.purl == winner));
}
None => {
// Installed, but no variant matches the on-disk bytes.
// Fall back to broad rather than silently dropping a
// package the user asked about.
warnings.push(format!(
"No release variant of {base} matches the installed distribution; keeping all {} variant(s).",
variants.len()
));
kept.extend(variants);
}
// Keep every variant present on disk. PyPI/RubyGems install one
// distribution per env (≤1 match); Maven classifier jars coexist
// so several may match.
let matched = select_installed_variants(&pkg_path, &refs).await;
if matched.is_empty() {
// Installed, but no variant matches the on-disk bytes. Fall
// back to broad rather than silently dropping a package the
// user asked about.
warnings.push(format!(
"No release variant of {base} matches the installed distribution; keeping all {} variant(s).",
variants.len()
));
kept.extend(variants);
} else {
let winners: std::collections::HashSet<String> =
matched.iter().map(|&i| candidates[i].0.clone()).collect();
kept.extend(variants.into_iter().filter(|s| winners.contains(&s.purl)));
}
}

Expand All @@ -686,6 +689,9 @@ fn files_for_selection(patch: &PatchResponse) -> HashMap<String, PatchFileInfo>
files
}

/// Download and apply a set of selected patches.
///
/// Used by both `get` and `scan` commands. Returns (exit_code, json_result).
pub async fn download_and_apply_patches(
selected: &[PatchSearchResult],
params: &DownloadParams,
Expand Down
33 changes: 19 additions & 14 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use socket_patch_core::api::client::get_api_client_with_overrides;
use socket_patch_core::crawlers::CrawlerOptions;
use socket_patch_core::manifest::operations::read_manifest;
use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord};
use socket_patch_core::patch::apply::select_installed_variant;
use socket_patch_core::patch::apply::select_installed_variants;
use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult, VerifyRollbackStatus};
use socket_patch_core::utils::purl::{purl_matches_identifier, strip_purl_qualifiers};
use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed};
Expand Down Expand Up @@ -451,13 +451,14 @@ async fn rollback_patches_inner(
return Ok((true, Vec::new()));
}

// Group discovered packages by base PURL. A PyPI package@version may
// have several release variants (`?artifact_id=...`) in the manifest;
// `merge_pypi_qualified` resolves them all to the single on-disk
// distribution. Rolling back every variant against that one file
// would HashMismatch on the non-installed variants and report
// spurious failures, so — mirroring apply — we collapse each group to
// the single variant whose hashes match the installed bytes.
// Group discovered packages by base PURL. A release-variant
// `package@version` (PyPI/RubyGems/Maven) may have several variants
// in the manifest that `merge_qualified` resolves to the same
// installed package dir. Rolling back a variant that is *not* present
// on disk would HashMismatch and report a spurious failure, so —
// 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.
let mut groups: HashMap<String, Vec<(&String, &PathBuf)>> = HashMap::new();
for (purl, pkg_path) in &all_packages {
groups
Expand Down Expand Up @@ -486,16 +487,20 @@ async fn rollback_patches_inner(
.map(|p| (purl.as_str(), &p.files))
})
.collect();
match select_installed_variant(pkg_path, &candidates).await {
Some(idx) => {
let winner = candidates[idx].0.to_string();
entries.into_iter().filter(|(p, _)| **p == winner).collect()
}
let matched = select_installed_variants(pkg_path, &candidates).await;
if matched.is_empty() {
// No variant matches the installed distribution (e.g. a
// locally-modified file). Fall back to attempting every
// variant so the per-file verification surfaces the
// mismatch rather than silently skipping the package.
None => entries,
entries
} else {
let winners: HashSet<String> =
matched.iter().map(|&i| candidates[i].0.to_string()).collect();
entries
.into_iter()
.filter(|(p, _)| winners.contains(*p))
.collect()
}
};

Expand Down
11 changes: 6 additions & 5 deletions crates/socket-patch-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,12 @@ pub struct ScanArgs {
#[arg(long, default_value_t = false)]
pub sync: bool,

/// Download patches for every release/distribution (artifact_id) of
/// a matched package, not just the one matching the locally-
/// installed distribution. Only affects PyPI today — the only
/// ecosystem with per-release artifact_id variants. Off by default:
/// narrow scans store only the patch for the installed dist, keeping
/// Download patches for every release/distribution variant of a
/// matched package, not just the one(s) matching the locally-
/// installed distribution. Affects ecosystems with per-release
/// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems
/// (`platform`), and Maven (`classifier`). Off by default: narrow
/// scans store only the patch(es) for the installed dist, keeping
/// `.socket/` small; `--all-releases` makes the manifest portable
/// across environments (e.g. cross-platform CI caches).
#[arg(
Expand Down
Loading
Loading