Skip to content

Commit a2b946c

Browse files
feat: add Cargo/Rust crate patching support behind feature flag (#31)
Introduces Cargo ecosystem support gated behind a `cargo` feature flag, and refactors the duplicated ecosystem dispatch logic across CLI commands into a centralized module. - Add `Ecosystem` enum to replace string-based ecosystem dispatch - Add `CargoCrawler` supporting registry and vendor layouts - Add `ecosystem_dispatch.rs` with shared partition/crawl helpers - Refactor apply, rollback, scan, get commands to use dispatch helpers - Add cargo PURL functions (parse, build, is_cargo_purl) - Add 20 new tests (253 total with feature, 233 without) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e74558e commit a2b946c

File tree

13 files changed

+1317
-189
lines changed

13 files changed

+1317
-189
lines changed

crates/socket-patch-cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ uuid = { workspace = true }
2222
regex = { workspace = true }
2323
tempfile = { workspace = true }
2424

25+
[features]
26+
default = []
27+
cargo = ["socket-patch-core/cargo"]
28+
2529
[dev-dependencies]
2630
sha2 = { workspace = true }
2731
hex = { workspace = true }

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 20 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ use socket_patch_core::api::blob_fetcher::{
44
};
55
use socket_patch_core::api::client::get_api_client_from_env;
66
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
7-
use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler};
7+
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
88
use socket_patch_core::manifest::operations::read_manifest;
99
use socket_patch_core::patch::apply::{apply_package_patch, verify_file_patch, ApplyResult};
1010
use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result};
11-
use socket_patch_core::utils::purl::{is_npm_purl, is_pypi_purl, strip_purl_qualifiers};
11+
use socket_patch_core::utils::purl::strip_purl_qualifiers;
1212
use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed};
1313
use std::collections::{HashMap, HashSet};
1414
use std::path::{Path, PathBuf};
1515

16+
use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
17+
1618
#[derive(Args)]
1719
pub struct ApplyArgs {
1820
/// Working directory
@@ -174,18 +176,8 @@ async fn apply_patches_inner(
174176

175177
// Partition manifest PURLs by ecosystem
176178
let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
177-
let mut npm_purls: Vec<String> = manifest_purls.iter().filter(|p| is_npm_purl(p)).cloned().collect();
178-
let mut pypi_purls: Vec<String> = manifest_purls.iter().filter(|p| is_pypi_purl(p)).cloned().collect();
179-
180-
// Filter by ecosystem if specified
181-
if let Some(ref ecosystems) = args.ecosystems {
182-
if !ecosystems.iter().any(|e| e == "npm") {
183-
npm_purls.clear();
184-
}
185-
if !ecosystems.iter().any(|e| e == "pypi") {
186-
pypi_purls.clear();
187-
}
188-
}
179+
let partitioned =
180+
partition_purls(&manifest_purls, args.ecosystems.as_deref());
189181

190182
let crawler_options = CrawlerOptions {
191183
cwd: args.cwd.clone(),
@@ -194,63 +186,12 @@ async fn apply_patches_inner(
194186
batch_size: 100,
195187
};
196188

197-
let mut all_packages: HashMap<String, PathBuf> = HashMap::new();
189+
let all_packages =
190+
find_packages_for_purls(&partitioned, &crawler_options, args.silent).await;
198191

199-
// Find npm packages
200-
if !npm_purls.is_empty() {
201-
let npm_crawler = NpmCrawler;
202-
match npm_crawler.get_node_modules_paths(&crawler_options).await {
203-
Ok(nm_paths) => {
204-
if (args.global || args.global_prefix.is_some()) && !args.silent {
205-
if let Some(first) = nm_paths.first() {
206-
println!("Using global npm packages at: {}", first.display());
207-
}
208-
}
209-
for nm_path in &nm_paths {
210-
if let Ok(packages) = npm_crawler.find_by_purls(nm_path, &npm_purls).await {
211-
for (purl, pkg) in packages {
212-
all_packages.entry(purl).or_insert(pkg.path);
213-
}
214-
}
215-
}
216-
}
217-
Err(e) => {
218-
if !args.silent {
219-
eprintln!("Failed to find npm packages: {e}");
220-
}
221-
}
222-
}
223-
}
192+
let has_any_purls = !partitioned.is_empty();
224193

225-
// Find Python packages
226-
if !pypi_purls.is_empty() {
227-
let python_crawler = PythonCrawler;
228-
let base_pypi_purls: Vec<String> = pypi_purls
229-
.iter()
230-
.map(|p| strip_purl_qualifiers(p).to_string())
231-
.collect::<HashSet<_>>()
232-
.into_iter()
233-
.collect();
234-
235-
match python_crawler.get_site_packages_paths(&crawler_options).await {
236-
Ok(sp_paths) => {
237-
for sp_path in &sp_paths {
238-
if let Ok(packages) = python_crawler.find_by_purls(sp_path, &base_pypi_purls).await {
239-
for (purl, pkg) in packages {
240-
all_packages.entry(purl).or_insert(pkg.path);
241-
}
242-
}
243-
}
244-
}
245-
Err(e) => {
246-
if !args.silent {
247-
eprintln!("Failed to find Python packages: {e}");
248-
}
249-
}
250-
}
251-
}
252-
253-
if all_packages.is_empty() && npm_purls.is_empty() && pypi_purls.is_empty() {
194+
if all_packages.is_empty() && !has_any_purls {
254195
if !args.silent {
255196
if args.global || args.global_prefix.is_some() {
256197
eprintln!("No global packages found");
@@ -272,20 +213,22 @@ async fn apply_patches_inner(
272213
let mut results: Vec<ApplyResult> = Vec::new();
273214
let mut has_errors = false;
274215

275-
// Group pypi PURLs by base
216+
// Group pypi PURLs by base (for variant matching with qualifiers)
276217
let mut pypi_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
277-
for purl in &pypi_purls {
278-
let base = strip_purl_qualifiers(purl).to_string();
279-
pypi_qualified_groups
280-
.entry(base)
281-
.or_default()
282-
.push(purl.clone());
218+
if let Some(pypi_purls) = partitioned.get(&Ecosystem::Pypi) {
219+
for purl in pypi_purls {
220+
let base = strip_purl_qualifiers(purl).to_string();
221+
pypi_qualified_groups
222+
.entry(base)
223+
.or_default()
224+
.push(purl.clone());
225+
}
283226
}
284227

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

287230
for (purl, pkg_path) in &all_packages {
288-
if is_pypi_purl(purl) {
231+
if Ecosystem::from_purl(purl) == Some(Ecosystem::Pypi) {
289232
let base_purl = strip_purl_qualifiers(purl).to_string();
290233
if applied_base_purls.contains(&base_purl) {
291234
continue;

crates/socket-patch-cli/src/commands/get.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use clap::Args;
22
use regex::Regex;
33
use socket_patch_core::api::client::get_api_client_from_env;
44
use socket_patch_core::api::types::{PatchSearchResult, SearchResponse};
5-
use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler};
5+
use socket_patch_core::crawlers::CrawlerOptions;
66
use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
77
use socket_patch_core::manifest::schema::{
88
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
@@ -13,6 +13,8 @@ use std::collections::HashMap;
1313
use std::io::{self, Write};
1414
use std::path::PathBuf;
1515

16+
use crate::ecosystem_dispatch::crawl_all_ecosystems;
17+
1618
#[derive(Args)]
1719
pub struct GetArgs {
1820
/// Patch identifier (UUID, CVE ID, GHSA ID, PURL, or package name)
@@ -231,18 +233,17 @@ pub async fn run(args: GetArgs) -> i32 {
231233
global_prefix: args.global_prefix.clone(),
232234
batch_size: 100,
233235
};
234-
let npm_crawler = NpmCrawler;
235-
let python_crawler = PythonCrawler;
236-
let npm_packages = npm_crawler.crawl_all(&crawler_options).await;
237-
let python_packages = python_crawler.crawl_all(&crawler_options).await;
238-
let mut all_packages = npm_packages;
239-
all_packages.extend(python_packages);
236+
let (all_packages, _) = crawl_all_ecosystems(&crawler_options).await;
240237

241238
if all_packages.is_empty() {
242239
if args.global {
243240
println!("No global packages found.");
244241
} else {
245-
println!("No packages found. Run npm/yarn/pnpm/pip install first.");
242+
#[cfg(feature = "cargo")]
243+
let install_cmds = "npm/yarn/pnpm/pip/cargo";
244+
#[cfg(not(feature = "cargo"))]
245+
let install_cmds = "npm/yarn/pnpm/pip";
246+
println!("No packages found. Run {install_cmds} install first.");
246247
}
247248
return 0;
248249
}

crates/socket-patch-cli/src/commands/rollback.rs

Lines changed: 8 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ use socket_patch_core::api::blob_fetcher::{
44
};
55
use socket_patch_core::api::client::get_api_client_from_env;
66
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
7-
use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler};
7+
use socket_patch_core::crawlers::CrawlerOptions;
88
use socket_patch_core::manifest::operations::read_manifest;
99
use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
1010
use socket_patch_core::patch::rollback::{rollback_package_patch, RollbackResult};
11-
use socket_patch_core::utils::global_packages::get_global_prefix;
12-
use socket_patch_core::utils::purl::{is_pypi_purl, strip_purl_qualifiers};
1311
use socket_patch_core::utils::telemetry::{track_patch_rolled_back, track_patch_rollback_failed};
14-
use std::collections::{HashMap, HashSet};
12+
use std::collections::HashSet;
1513
use std::path::{Path, PathBuf};
1614

15+
use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls};
16+
1717
#[derive(Args)]
1818
pub struct RollbackArgs {
1919
/// Package PURL or patch UUID to rollback. Omit to rollback all patches.
@@ -330,17 +330,8 @@ async fn rollback_patches_inner(
330330

331331
// Partition PURLs by ecosystem
332332
let rollback_purls: Vec<String> = patches_to_rollback.iter().map(|p| p.purl.clone()).collect();
333-
let mut npm_purls: Vec<String> = rollback_purls.iter().filter(|p| !is_pypi_purl(p)).cloned().collect();
334-
let mut pypi_purls: Vec<String> = rollback_purls.iter().filter(|p| is_pypi_purl(p)).cloned().collect();
335-
336-
if let Some(ref ecosystems) = args.ecosystems {
337-
if !ecosystems.iter().any(|e| e == "npm") {
338-
npm_purls.clear();
339-
}
340-
if !ecosystems.iter().any(|e| e == "pypi") {
341-
pypi_purls.clear();
342-
}
343-
}
333+
let partitioned =
334+
partition_purls(&rollback_purls, args.ecosystems.as_deref());
344335

345336
let crawler_options = CrawlerOptions {
346337
cwd: args.cwd.clone(),
@@ -349,70 +340,8 @@ async fn rollback_patches_inner(
349340
batch_size: 100,
350341
};
351342

352-
let mut all_packages: HashMap<String, PathBuf> = HashMap::new();
353-
354-
// Find npm packages
355-
if !npm_purls.is_empty() {
356-
if args.global || args.global_prefix.is_some() {
357-
match get_global_prefix(args.global_prefix.as_ref().map(|p| p.to_str().unwrap_or(""))) {
358-
Ok(prefix) => {
359-
if !args.silent {
360-
println!("Using global npm packages at: {prefix}");
361-
}
362-
let npm_crawler = NpmCrawler;
363-
if let Ok(packages) = npm_crawler.find_by_purls(Path::new(&prefix), &npm_purls).await {
364-
for (purl, pkg) in packages {
365-
all_packages.entry(purl).or_insert(pkg.path);
366-
}
367-
}
368-
}
369-
Err(e) => {
370-
if !args.silent {
371-
eprintln!("Failed to find global npm packages: {e}");
372-
}
373-
return Ok((false, Vec::new()));
374-
}
375-
}
376-
} else {
377-
let npm_crawler = NpmCrawler;
378-
if let Ok(nm_paths) = npm_crawler.get_node_modules_paths(&crawler_options).await {
379-
for nm_path in &nm_paths {
380-
if let Ok(packages) = npm_crawler.find_by_purls(nm_path, &npm_purls).await {
381-
for (purl, pkg) in packages {
382-
all_packages.entry(purl).or_insert(pkg.path);
383-
}
384-
}
385-
}
386-
}
387-
}
388-
}
389-
390-
// Find Python packages
391-
if !pypi_purls.is_empty() {
392-
let python_crawler = PythonCrawler;
393-
let base_pypi_purls: Vec<String> = pypi_purls
394-
.iter()
395-
.map(|p| strip_purl_qualifiers(p).to_string())
396-
.collect::<HashSet<_>>()
397-
.into_iter()
398-
.collect();
399-
400-
if let Ok(sp_paths) = python_crawler.get_site_packages_paths(&crawler_options).await {
401-
for sp_path in &sp_paths {
402-
if let Ok(packages) = python_crawler.find_by_purls(sp_path, &base_pypi_purls).await {
403-
for (base_purl, pkg) in packages {
404-
for qualified_purl in &pypi_purls {
405-
if strip_purl_qualifiers(qualified_purl) == base_purl
406-
&& !all_packages.contains_key(qualified_purl)
407-
{
408-
all_packages.insert(qualified_purl.clone(), pkg.path.clone());
409-
}
410-
}
411-
}
412-
}
413-
}
414-
}
415-
}
343+
let all_packages =
344+
find_packages_for_rollback(&partitioned, &crawler_options, args.silent).await;
416345

417346
if all_packages.is_empty() {
418347
if !args.silent {

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

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use clap::Args;
22
use socket_patch_core::api::client::get_api_client_from_env;
33
use socket_patch_core::api::types::BatchPackagePatches;
4-
use socket_patch_core::crawlers::{CrawlerOptions, NpmCrawler, PythonCrawler};
4+
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
55
use std::collections::HashSet;
66
use std::path::PathBuf;
77

8+
use crate::ecosystem_dispatch::crawl_all_ecosystems;
9+
810
const DEFAULT_BATCH_SIZE: usize = 100;
911

1012
#[derive(Args)]
@@ -77,23 +79,10 @@ pub async fn run(args: ScanArgs) -> i32 {
7779
}
7880

7981
// Crawl packages
80-
let npm_crawler = NpmCrawler;
81-
let python_crawler = PythonCrawler;
82-
83-
let npm_packages = npm_crawler.crawl_all(&crawler_options).await;
84-
let python_packages = python_crawler.crawl_all(&crawler_options).await;
85-
86-
let mut all_purls: Vec<String> = Vec::new();
87-
for pkg in &npm_packages {
88-
all_purls.push(pkg.purl.clone());
89-
}
90-
for pkg in &python_packages {
91-
all_purls.push(pkg.purl.clone());
92-
}
82+
let (all_crawled, eco_counts) = crawl_all_ecosystems(&crawler_options).await;
9383

84+
let all_purls: Vec<String> = all_crawled.iter().map(|p| p.purl.clone()).collect();
9485
let package_count = all_purls.len();
95-
let npm_count = npm_packages.len();
96-
let python_count = python_packages.len();
9786

9887
if package_count == 0 {
9988
if !args.json {
@@ -116,18 +105,22 @@ pub async fn run(args: ScanArgs) -> i32 {
116105
} else if args.global || args.global_prefix.is_some() {
117106
println!("No global packages found.");
118107
} else {
119-
println!("No packages found. Run npm/yarn/pnpm/pip install first.");
108+
#[cfg(feature = "cargo")]
109+
let install_cmds = "npm/yarn/pnpm/pip/cargo";
110+
#[cfg(not(feature = "cargo"))]
111+
let install_cmds = "npm/yarn/pnpm/pip";
112+
println!("No packages found. Run {install_cmds} install first.");
120113
}
121114
return 0;
122115
}
123116

124117
// Build ecosystem summary
125118
let mut eco_parts = Vec::new();
126-
if npm_count > 0 {
127-
eco_parts.push(format!("{npm_count} npm"));
128-
}
129-
if python_count > 0 {
130-
eco_parts.push(format!("{python_count} python"));
119+
for eco in Ecosystem::all() {
120+
let count = eco_counts.get(eco).copied().unwrap_or(0);
121+
if count > 0 {
122+
eco_parts.push(format!("{count} {}", eco.display_name()));
123+
}
131124
}
132125
let eco_summary = if eco_parts.is_empty() {
133126
String::new()

0 commit comments

Comments
 (0)