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
12 changes: 10 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,8 +641,16 @@ the model is **not uniform** today:
is neither crawled nor allowed to hide the `gem env` homes (#915). When several roots hold
**coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's
flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy,
mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`,
`vex`) use the highest-precedence copy. PyPI follows the same rule: when the crawler resolves
mirroring npm's multi-copy fan-out, and `vex` / `apply --check` verify every copy — while
single-representative consumers (`get`, `vendor`) use the highest-precedence copy. One gem home
holding **several builds** of a version (the ruby-platform `x-1.0/` beside
`x-1.0-x86_64-linux-gnu/`, after `gem install --platform ruby` or a `force_ruby_platform`
toggle) counts as several copies too: the plain dir first, then each platform dir, so `apply`,
`rollback`, `apply --check` and `vex` cover the platform build RubyGems loads (#1092). Each build
is matched to its own release variant. A build is then not the only candidate, so the default
mismatch policy never overwrites it: a build whose bytes match no manifest variant is left
untouched, fails `apply` (`no matching variant found`) and withholds the `vex` statement, and
`rollback` skips it. PyPI follows the same rule: when the crawler resolves
one release in several site-packages dirs (a Pipenv WORKON_HOME venv beside an auto-detected
`./.venv`, or the user site beside a system dir in global scope), agent `apply` patches **every**
copy, one summary event per copy, because any of them may be the one the interpreter imports.
Expand Down
23 changes: 21 additions & 2 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ fn mismatch_event_detail(file: &str, dry_run: bool) -> String {
)
}

/// Whether another of a gem's `copies` sits in the same gem home as `copy`:
/// two builds of one gem version (`x-1.0/` and `x-1.0-<platform>/`)
/// installed side by side.
pub(crate) fn has_build_sibling(copy: &Path, copies: &[PathBuf]) -> bool {
copies
.iter()
.any(|other| other != copy && other.parent() == copy.parent())
}

/// One `content_mismatch_overwritten` run warning per mismatch-overwritten
/// file across `results`, in the event detail's words prefixed with the
/// package purl — what a nested apply hands back to `get` / `scan --mode
Expand Down Expand Up @@ -2333,8 +2342,18 @@ async fn apply_patches_inner(
// let a lone x86_64-linux record silently overwrite a
// darwin install in the Bundler plugin's `--silent`
// auto-apply, where the warn half of warn-and-apply is
// invisible.
if !args.force && (variants.len() > 1 || variants[0] != base_purl) {
// invisible. The same holds for a copy with a BUILD
// SIBLING (another copy in the same gem home: the
// ruby-platform `x-1.0/` beside `x-1.0-x86_64-linux/`,
// #1092): it is no longer the only candidate, so a
// mismatch there means another distribution, and the
// copy stays gated rather than overwritten.
if !args.force
&& (variants.len() > 1
|| variants[0] != base_purl
|| (Ecosystem::from_purl(purl) == Some(Ecosystem::Gem)
&& has_build_sibling(pkg_path, copy_paths)))
{
let first_status = match representative_file(&patch.files) {
Some((file_name, file_info)) => Some(
verify_file_patch(pkg_path, file_name, file_info)
Expand Down
107 changes: 71 additions & 36 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2572,47 +2572,82 @@ pub(crate) async fn rollback_patches_inner(
// (remove's identifier flow drops the whole group the same way).
let mut narrowed_out: Vec<String> = Vec::new();
for (_base, entries) in groups {
let to_rollback: Vec<(&String, &PathBuf)> = if entries.len() == 1 {
entries
} else {
// All variants in a group resolve to the same installed path.
let pkg_path = entries[0].1;
let candidates: Vec<(&str, &HashMap<String, PatchFileInfo>)> = entries
.iter()
.filter_map(|(purl, _)| {
filtered_manifest
.patches
.get(*purl)
.map(|p| (purl.as_str(), &p.files))
})
.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.
entries
} else {
let winners: HashSet<String> = matched
.iter()
.map(|&i| candidates[i].0.to_string())
.collect();
narrowed_out.extend(
entries
.iter()
.filter(|(p, _)| !winners.contains(*p))
.map(|(p, _)| (*p).clone()),
);
if entries.len() == 1 {
rollback_targets.extend(
entries
.into_iter()
.filter(|(p, _)| winners.contains(*p))
.collect()
.map(|(purl, path)| CopyTarget::plain(purl, path)),
);
continue;
}
// Narrow PER COPY: one gem home can hold several builds of the
// version (`x-1.0/` beside `x-1.0-x86_64-linux/`, #1092), each
// patched with its own variant, so the first copy's variant says
// nothing about the others.
let mut purls: Vec<&String> = Vec::new();
let mut paths: Vec<&PathBuf> = Vec::new();
for (purl, path) in &entries {
if !purls.contains(purl) {
purls.push(purl);
}
};
if !paths.contains(path) {
paths.push(path);
}
}
let candidates: Vec<(&str, &HashMap<String, PatchFileInfo>)> = purls
.iter()
.filter_map(|purl| {
filtered_manifest
.patches
.get(*purl)
.map(|p| (purl.as_str(), &p.files))
})
.collect();
let mut chosen: Vec<(&PathBuf, Vec<&str>)> = Vec::new();
let mut unmatched: Vec<&PathBuf> = Vec::new();
for path in &paths {
let matched = select_installed_variants(path, &candidates).await;
if matched.is_empty() {
unmatched.push(path);
} else {
chosen.push((path, matched.iter().map(|&i| candidates[i].0).collect()));
}
}
for path in unmatched {
// A gem build matching no variant beside a sibling build that
// does holds another distribution: nothing of this patch was
// ever written there.
let sibling_matched = chosen
.iter()
.any(|(other, _)| *other != path && other.parent() == path.parent());
if sibling_matched && purls.iter().all(|p| p.starts_with("pkg:gem/")) {
continue;
}
// 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.
chosen.push((path, candidates.iter().map(|(purl, _)| *purl).collect()));
}
let attempted: HashSet<&str> = chosen
.iter()
.flat_map(|(_, purls)| purls.iter().copied())
.collect();
// Narrowed-away variants: their distribution is on no copy.
narrowed_out.extend(
purls
.iter()
.filter(|p| !attempted.contains(p.as_str()))
.map(|p| (*p).clone()),
);
rollback_targets.extend(
to_rollback
entries
.into_iter()
.filter(|(purl, path)| {
chosen
.iter()
.any(|(p, purls)| p == path && purls.contains(&purl.as_str()))
})
.map(|(purl, path)| CopyTarget::plain(purl, path)),
);
}
Expand Down
64 changes: 62 additions & 2 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,7 @@ pub(crate) async fn vex_copy_sets(
} else {
None
};
copies
let mut sets: HashMap<String, Vec<PathBuf>> = copies
.iter()
.map(|(purl, paths)| {
let paths = match scope.as_ref().filter(|_| purl.starts_with("pkg:maven/")) {
Expand All @@ -1015,7 +1015,67 @@ pub(crate) async fn vex_copy_sets(
};
(purl.clone(), paths)
})
.collect()
.collect();
narrow_gem_variant_copies(manifest, &mut sets).await;
sets
}

/// RubyGems: give each qualified variant key (`?platform=`…) only the
/// copies holding its distribution. One gem home can hold several builds of
/// a version (`x-1.0/` beside `x-1.0-x86_64-linux/`, #1092), each patched
/// with its own variant, but the dispatch hands every key every copy, so
/// each key would be judged against the other build's bytes and never
/// attested. A copy holding no variant stays on every key, so the
/// statement is withheld while that build is unaccounted for (`apply
/// --check` reports it as `no_matching_variant`). An unqualified singleton
/// names no distribution and keeps every copy.
async fn narrow_gem_variant_copies(
manifest: &PatchManifest,
sets: &mut HashMap<String, Vec<PathBuf>>,
) {
use socket_patch_core::manifest::schema::PatchFileInfo;
use socket_patch_core::patch::apply::select_installed_variants;
use socket_patch_core::utils::purl::strip_purl_qualifiers;
let mut groups: std::collections::BTreeMap<&str, Vec<&String>> = Default::default();
for purl in sets.keys() {
if purl.starts_with("pkg:gem/") {
groups
.entry(strip_purl_qualifiers(purl))
.or_default()
.push(purl);
}
}
let mut narrowed: Vec<(String, Vec<PathBuf>)> = Vec::new();
for (base, mut keys) in groups {
if keys.len() == 1 && keys[0] == base {
continue;
}
keys.sort();
let variants: Vec<(&str, &HashMap<String, PatchFileInfo>)> = keys
.iter()
.filter_map(|k| manifest.patches.get(*k).map(|r| (k.as_str(), &r.files)))
.collect();
let mut paths: Vec<&PathBuf> = keys.iter().flat_map(|k| &sets[*k]).collect();
paths.sort();
paths.dedup();
let mut kept: HashMap<&str, Vec<PathBuf>> = HashMap::new();
for path in paths {
let matched = select_installed_variants(path, &variants).await;
if matched.is_empty() {
for (key, _) in &variants {
kept.entry(*key).or_default().push(path.clone());
}
}
for idx in matched {
kept.entry(variants[idx].0).or_default().push(path.clone());
}
}
for (key, _) in &variants {
let paths = kept.remove(*key).unwrap_or_default();
narrowed.push((key.to_string(), paths));
}
}
sets.extend(narrowed);
}

/// Maven / Gradle: withhold the statement of every purl a build may still
Expand Down
59 changes: 49 additions & 10 deletions crates/socket-patch-cli/src/ecosystem_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,10 @@ fn merge_variant_copies(
/// after the first.
fn merge_npm_copies(
out: &mut HashMap<String, Vec<PathBuf>>,
_purls: &[String],
purls: &[String],
packages: HashMap<String, Vec<CrawledPackage>>,
) {
for (purl, pkgs) in packages {
for pkg in pkgs {
push_path(out, purl.clone(), pkg.path);
}
}
merge_each_copy(merge_variant_copies, out, purls, packages);
}

/// `paths` in order with every path that resolves to an already-listed
Expand Down Expand Up @@ -439,15 +435,21 @@ async fn dispatch_find(
eco = Ecosystem::Gem,
options = options,
silent = silent,
crawler = RubyCrawler,
crawler = GemCopiesCrawler,
get_paths = get_gem_paths,
using_label = "ruby gem paths",
err_label = "Ruby gems",
// RubyGems has per-platform release variants (`?platform=`); the
// crawler emits the base PURL and the platform is resolved by
// hashing the installed files, same as PyPI.
purls_override = dedup_qualified_purls,
on_match = variant_merge,
// One gem home can hold a version's ruby-platform AND platform
// builds; fold every copy in (#1092).
on_match = |out: &mut HashMap<String, Vec<PathBuf>>,
purls: &[String],
packages: HashMap<String, Vec<CrawledPackage>>| {
merge_each_copy(variant_merge, out, purls, packages)
},
);

scan_ecosystem!(
Expand Down Expand Up @@ -669,6 +671,43 @@ impl NpmCrawlSnapshot {
}
}

/// [`RubyCrawler`] for [`dispatch_find`], returning every installed copy of
/// each gem in a gem home (the ruby-platform dir and each platform dir).
struct GemCopiesCrawler;

impl GemCopiesCrawler {
async fn get_gem_paths(
&self,
options: &CrawlerOptions,
) -> Result<Vec<PathBuf>, std::io::Error> {
RubyCrawler.get_gem_paths(options).await
}

async fn find_by_purls(
&self,
gem_path: &std::path::Path,
purls: &[String],
) -> Result<HashMap<String, Vec<CrawledPackage>>, std::io::Error> {
RubyCrawler.find_all_by_purls(gem_path, purls).await
}
}

/// Feed every copy in `packages` through `merge`, one copy at a time and in
/// order, so a crawler that returns several copies per PURL reaches the
/// one-copy [`MergeFn`]s unchanged.
fn merge_each_copy(
merge: MergeFn,
out: &mut HashMap<String, Vec<PathBuf>>,
purls: &[String],
packages: HashMap<String, Vec<CrawledPackage>>,
) {
for (purl, copies) in packages {
for pkg in copies {
merge(out, purls, HashMap::from([(purl.clone(), pkg)]));
}
}
}

/// [`NpmCrawler`] for [`dispatch_find`], answering its root discovery from
/// an earlier crawl's roots when it has them.
struct NpmRootsCrawler<'a> {
Expand Down Expand Up @@ -831,8 +870,8 @@ pub async fn find_manifest_package_copies_reusing(
.await;
let bases = dedup_qualified_purls(gem_purls);
for store in &stores {
if let Ok(found) = RubyCrawler.find_by_purls(store, &bases).await {
merge_qualified(&mut copies, gem_purls, found);
if let Ok(found) = RubyCrawler.find_all_by_purls(store, &bases).await {
merge_each_copy(merge_qualified, &mut copies, gem_purls, found);
}
}
}
Expand Down
Loading
Loading