From c33359aaccdc95a2c4803e24c86d76435fdcb03f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:09:13 +0000 Subject: [PATCH 1/3] Start refactor for #747 Assisted-by: Claude Code:claude-opus-5-5 From 47dd195e5885efe9be68de6d19a89c6ac084d996 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 11:09:13 +0000 Subject: [PATCH 2/3] Classify purls through Ecosystem::from_purl Seven production checks spelled a purl type prefix inline (`starts_with("pkg:npm/")` and friends) in the Bun and vlt vendor preflights, the PyPI fuzzy matcher, the Coursier sidecar retry and VEX verification. They now ask `Ecosystem::from_purl`, the one map from purl type to ecosystem, so a change to the type vocabulary has one place to land. No behavior change: a table test shows `from_purl(p) == Some(eco)` holds exactly when `p` starts with that ecosystem's prefix, near misses included. A one-sided source-scan guard fails on any new file that spells a prefix inline; the 16 files open PRs change are listed as pending for the next slice of #747. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/bun_preflight.rs | 10 +- .../src/commands/vlt_preflight.rs | 3 +- .../src/crawlers/fuzzy_match.rs | 4 +- .../socket-patch-core/src/crawlers/types.rs | 155 ++++++++++++++++++ .../src/patch/sidecars/coursier.rs | 3 +- crates/socket-patch-core/src/vex/verify.rs | 5 +- 6 files changed, 171 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs index 3655295e3..fb8f30bde 100644 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -19,6 +19,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::vendor::load_state; use socket_patch_core::vendor::state::VendorEntry; @@ -67,7 +68,7 @@ impl BunVendorRefusal { /// ecosystem's backend consults `bun.lock`), minus the already-vendored /// exemption. pub(crate) fn applies_to(&self, purl: &str) -> bool { - purl.starts_with("pkg:npm/") && !self.exempt.contains(purl) + Ecosystem::from_purl(purl) == Some(Ecosystem::Npm) && !self.exempt.contains(purl) } } @@ -113,7 +114,10 @@ async fn preflight_pairs( pairs: &[(&str, &str)], ledger: Option>, ) -> Option { - if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { + if !pairs + .iter() + .any(|(purl, _)| Ecosystem::from_purl(purl) == Some(Ecosystem::Npm)) + { return None; } // vlt wins the router (DESIGN D2): a Bun lock beside `vlt-lock.json` @@ -169,7 +173,7 @@ async fn refusal_with_exemptions( let lock_parsed = code == "vendor_bun_workspace_unsupported"; let mut exempt = HashSet::new(); for (purl, _) in pairs { - if !purl.starts_with("pkg:npm/") { + if Ecosystem::from_purl(purl) != Some(Ecosystem::Npm) { continue; } // A preserved ledger can outlive its wiring (rollback --preserve-state). diff --git a/crates/socket-patch-cli/src/commands/vlt_preflight.rs b/crates/socket-patch-cli/src/commands/vlt_preflight.rs index 57f1f533e..5e498fa5b 100644 --- a/crates/socket-patch-cli/src/commands/vlt_preflight.rs +++ b/crates/socket-patch-cli/src/commands/vlt_preflight.rs @@ -14,6 +14,7 @@ use std::path::Path; use socket_patch_core::api::types::PatchSearchResult; +use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::vendor::npm_flavor::{vlt_flavor_change_refusal, vlt_routes}; use socket_patch_core::vendor::vlt_lock::vlt_vendor_preflight; @@ -47,7 +48,7 @@ pub(crate) async fn vlt_vendor_preflight_pairs( ) -> Vec<(String, VltVendorRefusal)> { let npm: Vec<&(&str, &str)> = pairs .iter() - .filter(|(purl, _)| purl.starts_with("pkg:npm/")) + .filter(|(purl, _)| Ecosystem::from_purl(purl) == Some(Ecosystem::Npm)) .collect(); if npm.is_empty() { return Vec::new(); diff --git a/crates/socket-patch-core/src/crawlers/fuzzy_match.rs b/crates/socket-patch-core/src/crawlers/fuzzy_match.rs index 7973bf80b..075189982 100644 --- a/crates/socket-patch-core/src/crawlers/fuzzy_match.rs +++ b/crates/socket-patch-core/src/crawlers/fuzzy_match.rs @@ -1,5 +1,5 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::crawlers::types::CrawledPackage; +use crate::crawlers::types::{CrawledPackage, Ecosystem}; /// Match type for sorting results by relevance; declaration order is the /// ranking (earlier = better). Internal to this module — `fuzzy_match_packages` @@ -31,7 +31,7 @@ fn get_full_name(pkg: &CrawledPackage) -> String { /// Whether `pkg` is a PyPI distribution, whose name is PEP 503-insensitive. fn is_pypi(pkg: &CrawledPackage) -> bool { - pkg.purl.starts_with("pkg:pypi/") + Ecosystem::from_purl(&pkg.purl) == Some(Ecosystem::Pypi) } /// Determine the match type for a package against a query, or `None` if there diff --git a/crates/socket-patch-core/src/crawlers/types.rs b/crates/socket-patch-core/src/crawlers/types.rs index 283fb5e98..ec66e2264 100644 --- a/crates/socket-patch-core/src/crawlers/types.rs +++ b/crates/socket-patch-core/src/crawlers/types.rs @@ -509,3 +509,158 @@ mod tests { assert!(opts.global_prefix.is_none()); } } + +/// One map from purl type to ecosystem (#747): production code asks +/// [`Ecosystem::from_purl`] instead of spelling `starts_with("pkg:/")`. +#[cfg(test)] +mod purl_type_tests { + use super::Ecosystem; + use std::path::{Path, PathBuf}; + + /// Each former inline prefix and the ecosystem its caller now matches. + const PREFIXES: [(&str, Ecosystem); 9] = [ + ("pkg:npm/", Ecosystem::Npm), + ("pkg:pypi/", Ecosystem::Pypi), + ("pkg:cargo/", Ecosystem::Cargo), + ("pkg:gem/", Ecosystem::Gem), + ("pkg:golang/", Ecosystem::Golang), + ("pkg:maven/", Ecosystem::Maven), + ("pkg:composer/", Ecosystem::Composer), + ("pkg:nuget/", Ecosystem::Nuget), + ("pkg:jsr/", Ecosystem::Deno), + ]; + + /// `from_purl(p) == Some(eco)` holds exactly when `p` starts with that + /// ecosystem's prefix, so every migrated check keeps its answer, + /// including on the near misses an inline prefix test also rejects. + #[test] + fn from_purl_matches_each_former_inline_prefix() { + let inputs = [ + "pkg:npm/lodash@4.17.21", + "pkg:npm/@types/node@20.0.0", + "pkg:npm/", + "pkg:npm", + "pkg:NPM/lodash@1.0.0", + "pkg:npmx/a@1", + "npm/lodash@1", + " pkg:npm/a@1", + "pkg:pypi/requests@2.31.0?artifact_id=x.whl", + "pkg:pypi", + "pkg:PyPI/requests@2.31.0", + "pkg:cargo/serde@1.0.0", + "pkg:gem/rails@7.1.0?platform=x86_64-linux", + "pkg:golang/github.com/a/b@v1.0.0", + "pkg:golang", + "pkg:maven/org.a/b@1.0?classifier=c&ext=jar", + "pkg:maven:org.a/b@1.0", + "pkg:composer/vendor/pkg@1.0.0", + "pkg:nuget/Newtonsoft.Json@13.0.1", + "pkg:jsr/@std/path@1.0.0", + "pkg:deno/x@1", + "pkg:generic/x@1", + "", + ]; + for purl in inputs { + for (prefix, eco) in PREFIXES { + assert_eq!( + Ecosystem::from_purl(purl) == Some(eco), + purl.starts_with(prefix), + "{purl:?} against {prefix:?}" + ); + } + } + } + + /// Files that still spell a purl-type prefix inline, waiting on #747's + /// next slice (open PRs change them). The guard is one-sided: it fails + /// only on a file outside this list, so a PR that migrates one of + /// these can't turn `main` red. Drop the entry when you migrate it. + const PENDING_INLINE_PREFIXES: &[&str] = &[ + "cli/src/commands/get.rs", + "cli/src/commands/rollback.rs", + "cli/src/commands/scan/hosted.rs", + "cli/src/commands/scan/hosted/python.rs", + "cli/src/commands/scan/mod.rs", + "cli/src/commands/scan/vendor_flow.rs", + "cli/src/commands/vendor.rs", + "cli/src/commands/vex.rs", + "cli/src/ecosystem_dispatch.rs", + "core/src/api/client.rs", + "core/src/hosted/engine.rs", + "core/src/hosted/memory/stages.rs", + "core/src/patch/apply.rs", + "core/src/patch/jvm_jar.rs", + "core/src/patch/rollback.rs", + "core/src/patch/store_copies.rs", + ]; + + /// The production part of a source file: everything before its first + /// in-file test module (a one-line `#[cfg(test)] mod x;` doesn't count). + fn production(text: &str) -> &str { + let marker = + regex::Regex::new(r"(?m)^#\[cfg\(test\)\]\n(?:pub(?:\(crate\))? )?mod \w+ \{").unwrap(); + marker.find(text).map_or(text, |m| &text[..m.start()]) + } + + fn walk(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + #[test] + fn production_code_classifies_purls_through_from_purl() { + let inline = regex::Regex::new(r#"starts_with\("pkg:[A-Za-z0-9.+-]+/"\)"#).unwrap(); + let crates = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let mut offenders = Vec::new(); + for (krate, label) in [("socket-patch-core", "core"), ("socket-patch-cli", "cli")] { + let src = crates.join(krate).join("src"); + // The CLI crate is absent from a packaged core crate. + if !src.is_dir() { + continue; + } + let mut files = Vec::new(); + walk(&src, &mut files); + for path in files { + let rel = format!( + "{label}/src/{}", + path.strip_prefix(&src) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + ); + // The type map itself, the purl parsers, and test-only files. + if rel == "core/src/crawlers/types.rs" + || rel == "core/src/utils/purl.rs" + || rel.ends_with("tests.rs") + || rel.contains("test_support") + || rel.contains("oracle") + { + continue; + } + // Windows CI checks out with CRLF. + let text = std::fs::read_to_string(&path) + .unwrap() + .replace("\r\n", "\n"); + if inline.is_match(production(&text)) + && !PENDING_INLINE_PREFIXES.contains(&rel.as_str()) + { + offenders.push(rel); + } + } + } + offenders.sort(); + assert!( + offenders.is_empty(), + "these files test a purl type with an inline \ + `starts_with(\"pkg:/\")`: {offenders:?}. Use \ + `Ecosystem::from_purl(purl) == Some(Ecosystem::X)` \ + (crates/socket-patch-core/src/crawlers/types.rs) instead." + ); + } +} diff --git a/crates/socket-patch-core/src/patch/sidecars/coursier.rs b/crates/socket-patch-core/src/patch/sidecars/coursier.rs index 15efdb7ab..d1112c78f 100644 --- a/crates/socket-patch-core/src/patch/sidecars/coursier.rs +++ b/crates/socket-patch-core/src/patch/sidecars/coursier.rs @@ -29,6 +29,7 @@ use std::path::{Path, PathBuf}; use sha1::Digest as _; use super::{SidecarError, SidecarFile, SidecarFileAction, SidecarPayload}; +use crate::crawlers::Ecosystem; use crate::patch::apply::{is_safe_relative_subpath, normalize_file_path}; /// The checksum algorithms Coursier may keep a sidecar for, in the order @@ -203,7 +204,7 @@ pub(crate) fn retry_record( keys: &[String], failed: &str, ) -> Option { - if !package_key.starts_with("pkg:maven/") { + if Ecosystem::from_purl(package_key) != Some(Ecosystem::Maven) { return None; } match fixup_with(pkg_path, keys, resync_if_stale) { diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index b89530d8b..dfcafce24 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use crate::crawlers::Ecosystem; use crate::manifest::schema::{PatchManifest, PatchRecord}; use crate::patch::apply::{verify_file_patch, VerifyStatus}; use crate::vendor::state::{lookup_entry, VendorEntry}; @@ -193,7 +194,7 @@ pub async fn applied_patches_with_copies( } else if let Some(copies) = vendor.and_then(|ctx| ctx.hosted.get(purl)) { verify_hosted_copies(purl, copies, record).await } else if let Some(paths) = package_copies.get(purl).filter(|p| !p.is_empty()) { - if purl.starts_with("pkg:maven/") { + if Ecosystem::from_purl(purl) == Some(Ecosystem::Maven) { let mut first_failure = None; for copy in paths { if let Err(reason) = verify_patch_record_for(purl, copy, record).await { @@ -234,7 +235,7 @@ pub async fn applied_patches_with_copies( // build resolves the GAV from the committed repository // only (exclusiveContent), so the cache copy is a // pristine sibling the build never reads. - let go_cache_copy = purl.starts_with("pkg:golang/"); + let go_cache_copy = Ecosystem::from_purl(purl) == Some(Ecosystem::Golang); let installed = package_copies .get(purl) .filter(|_| !go_cache_copy) From f5fb195d0507acce4dcfdd31e2c760f9ed2a5b8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:47:15 +0000 Subject: [PATCH 3/3] List main's new inline purl checks as pending main gained inline purl-type prefix checks in six production files after this branch was cut. The one-sided guard would have failed on them once merged. List them as pending for #747's next slices so the guard passes on main as it stands; migrating them stays out of this slice. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/types.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/crawlers/types.rs b/crates/socket-patch-core/src/crawlers/types.rs index 8305bb226..9102f7072 100644 --- a/crates/socket-patch-core/src/crawlers/types.rs +++ b/crates/socket-patch-core/src/crawlers/types.rs @@ -552,15 +552,19 @@ mod purl_type_tests { } /// Files that still spell a purl-type prefix inline, waiting on #747's - /// next slice (open PRs change them). The guard is one-sided: it fails + /// next slices. The guard is one-sided: it fails /// only on a file outside this list, so a PR that migrates one of /// these can't turn `main` red. Drop the entry when you migrate it. const PENDING_INLINE_PREFIXES: &[&str] = &[ + "cli/src/commands/agent_download.rs", "cli/src/commands/get.rs", + "cli/src/commands/hosted_unwind.rs", "cli/src/commands/rollback.rs", "cli/src/commands/scan/hosted.rs", "cli/src/commands/scan/hosted/python.rs", + "cli/src/commands/scan/hosted/takeover.rs", "cli/src/commands/scan/mod.rs", + "cli/src/commands/scan/policy.rs", "cli/src/commands/scan/vendor_flow.rs", "cli/src/commands/vendor.rs", "cli/src/commands/vex.rs", @@ -568,10 +572,12 @@ mod purl_type_tests { "core/src/api/client.rs", "core/src/hosted/engine.rs", "core/src/hosted/memory/stages.rs", + "core/src/hosted/takeover.rs", "core/src/patch/apply.rs", "core/src/patch/jvm_jar.rs", "core/src/patch/rollback.rs", "core/src/patch/store_copies.rs", + "core/src/utils/target.rs", ]; /// The production part of a source file: everything before its first