Skip to content

Commit 578b63e

Browse files
Classify purls through Ecosystem::from_purl in free files (#747) (#1126)
* Start refactor for #747 Assisted-by: Claude Code:claude-opus-5-5 * 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 * 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 * Classify NuGet stale-install purls via from_purl main added an inline pkg:nuget/ prefix check in the hosted NuGet stale-install remedy after this branch last merged main, which the guard rejects. Route it through Ecosystem::from_purl like the other migrated checks; the answer is unchanged (see the table test). Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 468879e commit 578b63e

7 files changed

Lines changed: 179 additions & 11 deletions

File tree

‎crates/socket-patch-cli/src/commands/bun_preflight.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use std::collections::{HashMap, HashSet};
1919
use std::path::Path;
2020

2121
use socket_patch_core::api::types::PatchSearchResult;
22+
use socket_patch_core::crawlers::Ecosystem;
2223
use socket_patch_core::vendor::load_state;
2324
use socket_patch_core::vendor::state::VendorEntry;
2425

@@ -67,7 +68,7 @@ impl BunVendorRefusal {
6768
/// ecosystem's backend consults `bun.lock`), minus the already-vendored
6869
/// exemption.
6970
pub(crate) fn applies_to(&self, purl: &str) -> bool {
70-
purl.starts_with("pkg:npm/") && !self.exempt.contains(purl)
71+
Ecosystem::from_purl(purl) == Some(Ecosystem::Npm) && !self.exempt.contains(purl)
7172
}
7273
}
7374

@@ -113,7 +114,10 @@ async fn preflight_pairs(
113114
pairs: &[(&str, &str)],
114115
ledger: Option<LedgerLoad<'_>>,
115116
) -> Option<BunVendorRefusal> {
116-
if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) {
117+
if !pairs
118+
.iter()
119+
.any(|(purl, _)| Ecosystem::from_purl(purl) == Some(Ecosystem::Npm))
120+
{
117121
return None;
118122
}
119123
// vlt wins the router (DESIGN D2): a Bun lock beside `vlt-lock.json`
@@ -169,7 +173,7 @@ async fn refusal_with_exemptions(
169173
let lock_parsed = code == "vendor_bun_workspace_unsupported";
170174
let mut exempt = HashSet::new();
171175
for (purl, _) in pairs {
172-
if !purl.starts_with("pkg:npm/") {
176+
if Ecosystem::from_purl(purl) != Some(Ecosystem::Npm) {
173177
continue;
174178
}
175179
// A preserved ledger can outlive its wiring (rollback --preserve-state).

‎crates/socket-patch-cli/src/commands/scan/hosted/nuget.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
//! deleted: the remedy is prescribed, and the purl is withheld from the
1111
//! same-run VEX attestation.
1212
13-
use socket_patch_core::crawlers::NuGetCrawler;
13+
use socket_patch_core::crawlers::{Ecosystem, NuGetCrawler};
1414
use socket_patch_core::patch::redirect::DepOverride;
1515
use socket_patch_core::utils::purl::strip_purl_qualifiers;
1616
use socket_patch_core::vendor::nuget_feed::{extracted_content_hash, stale_global_package_detail};
@@ -30,7 +30,7 @@ pub(super) async fn stale_install_warnings(
3030
// (purl, the patched package's NuGet content hash)
3131
let candidates: Vec<(&String, String)> = confirmed
3232
.iter()
33-
.filter(|(purl, _)| purl.starts_with("pkg:nuget/"))
33+
.filter(|(purl, _)| Ecosystem::from_purl(purl) == Some(Ecosystem::Nuget))
3434
.filter_map(|(purl, uuid)| {
3535
let sha512 = overrides
3636
.iter()

‎crates/socket-patch-cli/src/commands/vlt_preflight.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use std::path::Path;
1515

1616
use socket_patch_core::api::types::PatchSearchResult;
17+
use socket_patch_core::crawlers::Ecosystem;
1718
use socket_patch_core::vendor::npm_flavor::{vlt_flavor_change_refusal, vlt_routes};
1819
use socket_patch_core::vendor::vlt_lock::vlt_vendor_preflight;
1920

@@ -47,7 +48,7 @@ pub(crate) async fn vlt_vendor_preflight_pairs(
4748
) -> Vec<(String, VltVendorRefusal)> {
4849
let npm: Vec<&(&str, &str)> = pairs
4950
.iter()
50-
.filter(|(purl, _)| purl.starts_with("pkg:npm/"))
51+
.filter(|(purl, _)| Ecosystem::from_purl(purl) == Some(Ecosystem::Npm))
5152
.collect();
5253
if npm.is_empty() {
5354
return Vec::new();

‎crates/socket-patch-core/src/crawlers/fuzzy_match.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::crawlers::python_crawler::canonicalize_pypi_name;
2-
use crate::crawlers::types::CrawledPackage;
2+
use crate::crawlers::types::{CrawledPackage, Ecosystem};
33

44
/// Match type for sorting results by relevance; declaration order is the
55
/// ranking (earlier = better). Internal to this module — `fuzzy_match_packages`
@@ -31,7 +31,7 @@ fn get_full_name(pkg: &CrawledPackage) -> String {
3131

3232
/// Whether `pkg` is a PyPI distribution, whose name is PEP 503-insensitive.
3333
fn is_pypi(pkg: &CrawledPackage) -> bool {
34-
pkg.purl.starts_with("pkg:pypi/")
34+
Ecosystem::from_purl(&pkg.purl) == Some(Ecosystem::Pypi)
3535
}
3636

3737
/// Determine the match type for a package against a query, or `None` if there

‎crates/socket-patch-core/src/crawlers/types.rs‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,164 @@ mod tests {
489489
assert!(opts.global_prefix.is_none());
490490
}
491491
}
492+
493+
/// One map from purl type to ecosystem (#747): production code asks
494+
/// [`Ecosystem::from_purl`] instead of spelling `starts_with("pkg:<type>/")`.
495+
#[cfg(test)]
496+
mod purl_type_tests {
497+
use super::Ecosystem;
498+
use std::path::{Path, PathBuf};
499+
500+
/// Each former inline prefix and the ecosystem its caller now matches.
501+
const PREFIXES: [(&str, Ecosystem); 9] = [
502+
("pkg:npm/", Ecosystem::Npm),
503+
("pkg:pypi/", Ecosystem::Pypi),
504+
("pkg:cargo/", Ecosystem::Cargo),
505+
("pkg:gem/", Ecosystem::Gem),
506+
("pkg:golang/", Ecosystem::Golang),
507+
("pkg:maven/", Ecosystem::Maven),
508+
("pkg:composer/", Ecosystem::Composer),
509+
("pkg:nuget/", Ecosystem::Nuget),
510+
("pkg:jsr/", Ecosystem::Deno),
511+
];
512+
513+
/// `from_purl(p) == Some(eco)` holds exactly when `p` starts with that
514+
/// ecosystem's prefix, so every migrated check keeps its answer,
515+
/// including on the near misses an inline prefix test also rejects.
516+
#[test]
517+
fn from_purl_matches_each_former_inline_prefix() {
518+
let inputs = [
519+
"pkg:npm/lodash@4.17.21",
520+
"pkg:npm/@types/node@20.0.0",
521+
"pkg:npm/",
522+
"pkg:npm",
523+
"pkg:NPM/lodash@1.0.0",
524+
"pkg:npmx/a@1",
525+
"npm/lodash@1",
526+
" pkg:npm/a@1",
527+
"pkg:pypi/requests@2.31.0?artifact_id=x.whl",
528+
"pkg:pypi",
529+
"pkg:PyPI/requests@2.31.0",
530+
"pkg:cargo/serde@1.0.0",
531+
"pkg:gem/rails@7.1.0?platform=x86_64-linux",
532+
"pkg:golang/github.com/a/b@v1.0.0",
533+
"pkg:golang",
534+
"pkg:maven/org.a/b@1.0?classifier=c&ext=jar",
535+
"pkg:maven:org.a/b@1.0",
536+
"pkg:composer/vendor/pkg@1.0.0",
537+
"pkg:nuget/Newtonsoft.Json@13.0.1",
538+
"pkg:jsr/@std/path@1.0.0",
539+
"pkg:deno/x@1",
540+
"pkg:generic/x@1",
541+
"",
542+
];
543+
for purl in inputs {
544+
for (prefix, eco) in PREFIXES {
545+
assert_eq!(
546+
Ecosystem::from_purl(purl) == Some(eco),
547+
purl.starts_with(prefix),
548+
"{purl:?} against {prefix:?}"
549+
);
550+
}
551+
}
552+
}
553+
554+
/// Files that still spell a purl-type prefix inline, waiting on #747's
555+
/// next slices. The guard is one-sided: it fails
556+
/// only on a file outside this list, so a PR that migrates one of
557+
/// these can't turn `main` red. Drop the entry when you migrate it.
558+
const PENDING_INLINE_PREFIXES: &[&str] = &[
559+
"cli/src/commands/agent_download.rs",
560+
"cli/src/commands/get.rs",
561+
"cli/src/commands/hosted_unwind.rs",
562+
"cli/src/commands/rollback.rs",
563+
"cli/src/commands/scan/hosted.rs",
564+
"cli/src/commands/scan/hosted/python.rs",
565+
"cli/src/commands/scan/hosted/takeover.rs",
566+
"cli/src/commands/scan/mod.rs",
567+
"cli/src/commands/scan/policy.rs",
568+
"cli/src/commands/scan/vendor_flow.rs",
569+
"cli/src/commands/vendor.rs",
570+
"cli/src/commands/vex.rs",
571+
"cli/src/ecosystem_dispatch.rs",
572+
"core/src/api/client.rs",
573+
"core/src/hosted/engine.rs",
574+
"core/src/hosted/memory/stages.rs",
575+
"core/src/hosted/takeover.rs",
576+
"core/src/patch/apply.rs",
577+
"core/src/patch/jvm_jar.rs",
578+
"core/src/patch/rollback.rs",
579+
"core/src/patch/store_copies.rs",
580+
"core/src/utils/target.rs",
581+
];
582+
583+
/// The production part of a source file: everything before its first
584+
/// in-file test module (a one-line `#[cfg(test)] mod x;` doesn't count).
585+
fn production(text: &str) -> &str {
586+
let marker =
587+
regex::Regex::new(r"(?m)^#\[cfg\(test\)\]\n(?:pub(?:\(crate\))? )?mod \w+ \{").unwrap();
588+
marker.find(text).map_or(text, |m| &text[..m.start()])
589+
}
590+
591+
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
592+
for entry in std::fs::read_dir(dir).unwrap() {
593+
let path = entry.unwrap().path();
594+
if path.is_dir() {
595+
walk(&path, out);
596+
} else if path.extension().is_some_and(|e| e == "rs") {
597+
out.push(path);
598+
}
599+
}
600+
}
601+
602+
#[test]
603+
fn production_code_classifies_purls_through_from_purl() {
604+
let inline = regex::Regex::new(r#"starts_with\("pkg:[A-Za-z0-9.+-]+/"\)"#).unwrap();
605+
let crates = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
606+
let mut offenders = Vec::new();
607+
for (krate, label) in [("socket-patch-core", "core"), ("socket-patch-cli", "cli")] {
608+
let src = crates.join(krate).join("src");
609+
// The CLI crate is absent from a packaged core crate.
610+
if !src.is_dir() {
611+
continue;
612+
}
613+
let mut files = Vec::new();
614+
walk(&src, &mut files);
615+
for path in files {
616+
let rel = format!(
617+
"{label}/src/{}",
618+
path.strip_prefix(&src)
619+
.unwrap()
620+
.to_string_lossy()
621+
.replace('\\', "/")
622+
);
623+
// The type map itself, the purl parsers, and test-only files.
624+
if rel == "core/src/crawlers/types.rs"
625+
|| rel == "core/src/utils/purl.rs"
626+
|| rel.ends_with("tests.rs")
627+
|| rel.contains("test_support")
628+
|| rel.contains("oracle")
629+
{
630+
continue;
631+
}
632+
// Windows CI checks out with CRLF.
633+
let text = std::fs::read_to_string(&path)
634+
.unwrap()
635+
.replace("\r\n", "\n");
636+
if inline.is_match(production(&text))
637+
&& !PENDING_INLINE_PREFIXES.contains(&rel.as_str())
638+
{
639+
offenders.push(rel);
640+
}
641+
}
642+
}
643+
offenders.sort();
644+
assert!(
645+
offenders.is_empty(),
646+
"these files test a purl type with an inline \
647+
`starts_with(\"pkg:<type>/\")`: {offenders:?}. Use \
648+
`Ecosystem::from_purl(purl) == Some(Ecosystem::X)` \
649+
(crates/socket-patch-core/src/crawlers/types.rs) instead."
650+
);
651+
}
652+
}

‎crates/socket-patch-core/src/patch/sidecars/coursier.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use std::path::{Path, PathBuf};
2929
use sha1::Digest as _;
3030

3131
use super::{SidecarError, SidecarFile, SidecarFileAction, SidecarPayload};
32+
use crate::crawlers::Ecosystem;
3233
use crate::patch::apply::{is_safe_relative_subpath, normalize_file_path};
3334

3435
/// The checksum algorithms Coursier may keep a sidecar for, in the order
@@ -203,7 +204,7 @@ pub(crate) fn retry_record(
203204
keys: &[String],
204205
failed: &str,
205206
) -> Option<super::SidecarRecord> {
206-
if !package_key.starts_with("pkg:maven/") {
207+
if Ecosystem::from_purl(package_key) != Some(Ecosystem::Maven) {
207208
return None;
208209
}
209210
match fixup_with(pkg_path, keys, resync_if_stale) {

‎crates/socket-patch-core/src/vex/verify.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use std::collections::HashMap;
1616
use std::path::{Path, PathBuf};
1717

18+
use crate::crawlers::Ecosystem;
1819
use crate::manifest::schema::{PatchManifest, PatchRecord};
1920
use crate::patch::apply::{verify_file_patch, VerifyStatus};
2021
use crate::vendor::state::{lookup_entry, VendorEntry};
@@ -193,7 +194,7 @@ pub async fn applied_patches_with_copies(
193194
} else if let Some(copies) = vendor.and_then(|ctx| ctx.hosted.get(purl)) {
194195
verify_hosted_copies(purl, copies, record).await
195196
} else if let Some(paths) = package_copies.get(purl).filter(|p| !p.is_empty()) {
196-
if purl.starts_with("pkg:maven/") {
197+
if Ecosystem::from_purl(purl) == Some(Ecosystem::Maven) {
197198
let mut first_failure = None;
198199
for copy in paths {
199200
if let Err(reason) = verify_patch_record_for(purl, copy, record).await {
@@ -234,7 +235,7 @@ pub async fn applied_patches_with_copies(
234235
// build resolves the GAV from the committed repository
235236
// only (exclusiveContent), so the cache copy is a
236237
// pristine sibling the build never reads.
237-
let go_cache_copy = purl.starts_with("pkg:golang/");
238+
let go_cache_copy = Ecosystem::from_purl(purl) == Some(Ecosystem::Golang);
238239
let installed = package_copies
239240
.get(purl)
240241
.filter(|_| !go_cache_copy)

0 commit comments

Comments
 (0)