From 134d40eb593e88ce25770b47215589fcd06e40df Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 10:14:33 -0600 Subject: [PATCH 1/2] Move the Socket origin classifier into one module Discovery's classifier (the memoized hosted_uuid fast path, vendor_ref and its variants, locate, Wired, the socket-patch- name grammar) and the redirect's hosted URL rule (hosted_patch_uuid, hosted_patch_url_uuids, SOCKET_PATCH_SERVER_HOST) now live in crates/socket-patch-core/src/origin.rs, with their tests. The code moves verbatim. DiscoverCtx holds an OriginPolicy (the per-run origin allowlist and its memoized host set) and delegates hosted_uuid and locate to it. vex::discover and patch::redirect re-export the moved items, so no caller changes. origin.rs sits outside formats/ on purpose: format models are guarded to apply no host policy (formats/mod.rs architecture_tests). Budget: lock-readers 30,898 -> 30,976 (49 lines moved in from patch/redirect, ~30 lines of OriginPolicy and re-export glue); lock-writers 88,363 -> 88,314. No behavior change. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-core/src/lib.rs | 1 + crates/socket-patch-core/src/origin.rs | 849 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 214 +---- .../socket-patch-core/src/vex/discover/mod.rs | 605 +------------ scripts/loc-budget.json | 5 +- 5 files changed, 878 insertions(+), 796 deletions(-) create mode 100644 crates/socket-patch-core/src/origin.rs diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index c0a9cacb0..a8e08158b 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod hash; pub mod hosted; pub mod ledgers; pub mod manifest; +pub mod origin; pub mod patch; pub mod policy; pub mod rollout; diff --git a/crates/socket-patch-core/src/origin.rs b/crates/socket-patch-core/src/origin.rs new file mode 100644 index 000000000..27714f2d4 --- /dev/null +++ b/crates/socket-patch-core/src/origin.rs @@ -0,0 +1,849 @@ +//! Socket origin: whether a string a lockfile or package-manager config +//! records routes a package to a Socket patch, and to which one. +//! +//! This is the one classifier every reader applies: +//! +//! * **hosted** — [`hosted_patch_uuid`]: an `http(s)` url on Socket's patch +//! server or on an operator-configured origin, the patch uuid its last +//! canonical-uuid path segment. [`OriginPolicy::hosted_uuid`] answers the +//! same question with a memoized fast path for the plain registry urls +//! every lock is full of; +//! * **vendored** — [`vendor_ref`] / [`vendor_ref_decorated`] / +//! [`vendor_uuid_dir`]: a path that STARTS at this project's +//! `.socket/vendor///`, its leaf traversal-safe; +//! * **names** — [`socket_patch_name_uuid`]: the `socket-patch-` +//! registry, repository and source names. +//! +//! [`OriginPolicy::locate`] computes the hosted and vendored answers for one +//! string at once. +//! +//! The format models ([`crate::formats`]) stay pure: they hand out the raw +//! location strings and never apply the host policy (their architecture +//! test bans it). The readers that apply a model to a project apply this +//! module. See `docs/design/lock-model-unification.md`. + +use std::collections::BTreeSet; +use std::sync::OnceLock; + +use crate::patch::path_safety::{is_canonical_uuid, is_safe_multi_segment}; +use crate::utils::purl_key::canonical_base_purl; +use crate::vendor::path::{leaf_to_purl, ECOSYSTEM_DIRS, VENDOR_DIR}; + +// ── hosted urls ────────────────────────────────────────────────────────── + +/// Public host of Socket's patch server: the origin every production hosted +/// artifact / registry URL is served from (`https://patch.socket.dev/patch/…`, +/// `…/patch-registry/…`), and the root of the Go module namespace +/// [`crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX`]. +pub const SOCKET_PATCH_SERVER_HOST: &str = "patch.socket.dev"; + +/// The Socket patch uuid a lockfile-recorded HOSTED reference names, or +/// `None` when `url` is not a Socket-hosted patch URL — the inverse of the +/// rewriters, used by `vex`'s manifest-less lockfile discovery. +/// +/// Recognition is deliberately strict, because the answer decides whether a +/// committed (tamper-able) lockfile line becomes an attestation input: +/// +/// * the ORIGIN must be Socket's patch server (`https://` + +/// [`SOCKET_PATCH_SERVER_HOST`]) or one of `extra_origins` — the +/// operator's `--patch-server-url` deployment, compared on scheme + host + +/// port. A uuid inside any other host's URL is a user's own dependency +/// source, never a patch reference; +/// * no userinfo — a Socket-written URL never carries credentials; +/// * the uuid is the LAST path segment passing the canonical-uuid grammar: +/// hosted URLs carry the grant token in the level before the uuid +/// (`…/patch/npm/////`, +/// `…/patch-registry////…`), and grant tokens may +/// themselves be uuid-shaped, so "the first uuid" would elect the token. +/// +/// Every spelling the lock formats record the same URL in is accepted: a +/// `#fragment` (yarn classic `#`, pip `#sha256=`) and a `?query` are +/// ignored; `\/`-escaped slashes (older composer locks) are unescaped; a +/// wholly percent-encoded URL (yarn berry's `__archiveUrl=` binding) is +/// decoded; a cargo source-kind prefix (`sparse+`, `registry+`) is dropped. +/// Path segments are percent-decoded AFTER splitting, so an encoded `/` +/// can never manufacture a segment. +pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option { + hosted_patch_url_uuids(url, extra_origins)?.pop() +} + +/// EVERY canonical-uuid path segment of a Socket-HOSTED url, in path order +/// (the grant token first when it is uuid-shaped, the patch uuid last), or +/// `None` when `url` is not on an accepted origin — [`hosted_patch_uuid`]'s +/// exact acceptance rules, without electing one segment. `vex` discovery +/// uses it to RECOGNIZE every Socket identity a lockfile mentions, including +/// the malformed or rejected shapes whose "last uuid" is not a patch. +pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { + use crate::patch::path_safety::is_canonical_uuid; + use crate::utils::purl::percent_decode_purl_component; + + let unescaped = url.trim().replace("\\/", "/"); + let lower = unescaped.to_ascii_lowercase(); + let decoded = if lower.starts_with("https%3a%2f%2f") || lower.starts_with("http%3a%2f%2f") { + percent_decode_purl_component(&unescaped).into_owned() + } else { + unescaped + }; + // `sparse+https://…` / `registry+https://…` (Cargo.lock `source`). + let (scheme, _) = decoded.split_once("://")?; + let text = match scheme.rsplit_once('+') { + Some((kind, _)) if !kind.is_empty() && kind.bytes().all(|b| b.is_ascii_alphabetic()) => { + &decoded[kind.len() + 1..] + } + _ => decoded.as_str(), + }; + let parsed = reqwest::Url::parse(text).ok()?; + if !matches!(parsed.scheme(), "https" | "http") + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return None; + } + let socket_host = parsed.scheme() == "https" + && parsed.host_str() == Some(SOCKET_PATCH_SERVER_HOST) + && parsed.port_or_known_default() == Some(443); + let configured = extra_origins.iter().any(|origin| { + reqwest::Url::parse(origin.trim()).is_ok_and(|o| { + o.scheme() == parsed.scheme() + && o.host_str().is_some() + && o.host_str() == parsed.host_str() + && o.port_or_known_default() == parsed.port_or_known_default() + }) + }); + if !socket_host && !configured { + return None; + } + Some( + parsed + .path_segments()? + .map(|segment| percent_decode_purl_component(segment).into_owned()) + .filter(|segment| is_canonical_uuid(segment)) + .collect(), + ) +} + +/// The host of `url` when it is a PLAIN `http(s)` url — printable ASCII +/// only, no `%`, `\\` or userinfo, a DNS name of letters, digits, `.` and +/// `-` whose last label starts with a letter — lowercased, which is then +/// exactly what [`hosted_patch_url_uuids`]'s url +/// parse would call its host (no decoding, IDNA mapping or IP-address +/// reading applies to such a name). `None` for anything else, which takes +/// the full parse. +fn plain_url_domain(url: &str) -> Option { + let url = url.trim(); + if !url + .bytes() + .all(|b| b.is_ascii_graphic() && b != b'%' && b != b'\\') + { + return None; + } + let (scheme, rest) = url.split_once("://")?; + // `sparse+https://…` / `registry+https://…`, as the parse strips it. + let scheme = match scheme.rsplit_once('+') { + Some((kind, scheme)) + if !kind.is_empty() && kind.bytes().all(|b| b.is_ascii_alphabetic()) => + { + scheme + } + _ => scheme, + }; + if !scheme.eq_ignore_ascii_case("https") && !scheme.eq_ignore_ascii_case("http") { + return None; + } + let authority = rest.split(['/', '?', '#']).next()?; + let host = match authority.rsplit_once(':') { + Some((host, port)) if port.bytes().all(|b| b.is_ascii_digit()) => host, + Some(_) => return None, + None => authority, + }; + let last = host.strip_suffix('.').unwrap_or(host).rsplit('.').next()?; + let plain = !host.is_empty() + && !rest.starts_with('/') + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + && last.starts_with(|c: char| c.is_ascii_alphabetic()) + && !host + .split('.') + .any(|label| label.len() >= 4 && label[..4].eq_ignore_ascii_case("xn--")); + plain.then(|| host.to_ascii_lowercase()) +} + +/// The host policy one run classifies under: Socket's patch server plus the +/// operator's `--patch-server-url` origins. +#[derive(Debug)] +pub(crate) struct OriginPolicy<'a> { + origins: &'a [String], + /// The hosts a Socket-hosted url can name (see [`Self::hosted_uuid`]). + hosts: OnceLock>, +} + +impl<'a> OriginPolicy<'a> { + pub(crate) fn new(origins: &'a [String]) -> Self { + OriginPolicy { + origins, + hosts: OnceLock::new(), + } + } + + /// The configured extra origins. + pub(crate) fn origins(&self) -> &'a [String] { + self.origins + } + + /// The patch uuid of a Socket-HOSTED url, or `None` for anything else + /// (see [`hosted_patch_uuid`] for the accepted + /// spellings and the host allowlist). + /// + /// A plain url ([`plain_url_domain`]) on any other host is answered + /// without parsing it: every lock entry's registry url comes through + /// here. + pub(crate) fn hosted_uuid(&self, url: &str) -> Option { + if let Some(host) = plain_url_domain(url) { + let hosts = self.hosts.get_or_init(|| { + let mut hosts = BTreeSet::from([SOCKET_PATCH_SERVER_HOST.to_string()]); + hosts.extend(self.origins.iter().filter_map(|o| { + reqwest::Url::parse(o.trim()) + .ok()? + .host_str() + .map(str::to_string) + })); + hosts + }); + if !hosts.contains(&host) { + return None; + } + } + hosted_patch_uuid(url, self.origins) + } + + /// Classify `s` (see [`Located`]). + pub(crate) fn locate(&self, s: &str, opts: LocateOpts) -> Located { + let vendored = match opts.grammar { + LeafGrammar::Literal => vendor_ref(s), + LeafGrammar::Decorated => vendor_ref_decorated(s), + }; + let decorated_leaf = opts.decorated_check + && opts.grammar == LeafGrammar::Literal + && is_decorated_vendor_path(s); + Located { + vendored, + hosted: self.hosted_uuid(s), + decorated_leaf, + } + } +} + +// ── names ──────────────────────────────────────────────────────────────── + +/// The uuid of a Socket-owned registry / repository / source NAME: +/// `socket-patch-` (cargo `[registries.*]` + Cargo.toml `registry =`, +/// maven ``, nuget ``) or, with +/// `vendored = true`, `socket-patch-vendor-` (maven's vendored repo +/// id). Exact grammar only — a suffix or a non-canonical uuid is not ours. +pub fn socket_patch_name_uuid(name: &str, vendored: bool) -> Option { + crate::patch::redirect::generation::pin_name_uuid(name.trim(), vendored).map(str::to_string) +} + +// ── vendored paths ─────────────────────────────────────────────────────── + +/// A vendored artifact reference recovered from a wiring string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorRef { + /// Vendor ecosystem dir (`npm`, `cargo`, `pypi`, …). + pub eco: String, + pub uuid: String, + /// The natural leaf, lock-format suffixes stripped ([`strip_leaf_suffix`]), + /// forward-slashed, no trailing slash. + pub leaf: String, + /// `.socket/vendor///` — the root-relative artifact path. + pub artifact_rel: String, +} + +/// Recover a vendored artifact reference from a lockfile/config string that +/// points into THIS project's `.socket/vendor/`: `file:` / `./` / bare +/// spellings, backslashed Windows spellings, and `\/`-escaped JSON text. +/// +/// Stricter than [`crate::vendor::parse_vendor_path`] (which finds the anchor +/// anywhere, for the orphan sweep): the anchor must START the path. A +/// `../.socket/vendor/…` or absolute spelling points the package manager at +/// an artifact OUTSIDE the project root, and verifying the root's same-named +/// artifact instead would attest bytes the build never consumes. Callers +/// whose format legitimately prefixes the project root (hatch +/// `{root:uri}/`, maven `file://${project.basedir}/`, pnpm-legacy absolute +/// importer paths) strip that exact prefix first. +/// +/// The leaf must be a traversal-safe relative path, taken LITERALLY: npm, +/// pnpm, bun, composer, bundler, go, cargo, maven, nuget and every pypi +/// lock read the string as a file path, so a leaf carrying `#` or `::` is +/// rejected (`None`) rather than cut — `…/foo-1.0.0.tgz#evil.tgz` is a file +/// of that literal name, which npm installs while the cut leaf would verify +/// the good `foo-1.0.0.tgz` beside it. The formats whose grammar DEFINES +/// those decorations use [`vendor_ref_decorated`] instead. +pub fn vendor_ref(s: &str) -> Option { + vendor_ref_with(s, false) +} + +/// [`vendor_ref`] for the formats that decorate a vendored path: yarn +/// classic `#`, berry `#./…::hash=…` fragments and `::locator=…` +/// bindings, and url-form pip / Hatch references (`file:…#sha256=…`, +/// `{root:uri}/…#sha256=…`). The leaf keeps only its natural name — the +/// decorations ([`strip_leaf_suffix`]) are cut before the literal-path +/// checks. +pub fn vendor_ref_decorated(s: &str) -> Option { + vendor_ref_with(s, true) +} + +fn vendor_ref_with(s: &str, decorated: bool) -> Option { + let rest = root_anchored_vendor_rest(s)?; + let mut it = rest.splitn(3, '/'); + let eco = it.next()?; + let uuid = it.next()?; + let leaf_raw = it.next()?; + if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) { + return None; + } + let leaf = if decorated { + strip_leaf_suffix(leaf_raw) + } else if leaf_raw.contains('#') || leaf_raw.contains("::") { + return None; + } else { + leaf_raw + }; + let leaf = leaf.trim_end_matches('/'); + if leaf.is_empty() || !is_safe_multi_segment(leaf) { + return None; + } + Some(VendorRef { + eco: eco.to_string(), + uuid: uuid.to_string(), + leaf: leaf.to_string(), + artifact_rel: format!("{VENDOR_DIR}/{eco}/{uuid}/{leaf}"), + }) +} + +/// `(eco, uuid)` for a root-anchored string that ends AT the uuid dir +/// (`.socket/vendor/nuget/`, a maven repository url's path) — the +/// shapes [`vendor_ref`] rejects for having no leaf. +pub fn vendor_uuid_dir(s: &str) -> Option<(String, String)> { + let rest = root_anchored_vendor_rest(s)?; + let (eco, uuid) = rest.trim_end_matches('/').split_once('/')?; + if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) { + return None; + } + Some((eco.to_string(), uuid.to_string())) +} + +/// Whether `s` is a vendored artifact path [`vendor_ref`] rejects ONLY for +/// a `#` / `::` in its leaf (what [`vendor_ref_decorated`] would have cut): +/// to a literal-path format that names another file, which extractors +/// diagnose rather than skip silently. +pub(crate) fn is_decorated_vendor_path(s: &str) -> bool { + vendor_ref(s).is_none() && vendor_ref_decorated(s).is_some() +} + +/// Everything after a LEADING `.socket/vendor/` anchor (after `file:`, one +/// `./`, backslash and `\/` normalization), or `None`. +fn root_anchored_vendor_rest(s: &str) -> Option { + let norm = s.trim().replace("\\/", "/").replace('\\', "/"); + let norm = norm.strip_prefix("file:").unwrap_or(&norm); + let norm = norm.strip_prefix("./").unwrap_or(norm); + norm.strip_prefix(&format!("{VENDOR_DIR}/")) + .map(str::to_string) +} + +/// Cut the lock-format decorations off a vendored leaf: everything from the +/// first `#` (yarn classic `#`, berry `#./::hash=…`, pip/hatch +/// `#sha256=…`) or `::` (berry `::locator=…`). +pub fn strip_leaf_suffix(leaf: &str) -> &str { + let cut = [leaf.find('#'), leaf.find("::")] + .into_iter() + .flatten() + .min() + .unwrap_or(leaf.len()); + &leaf[..cut] +} + +/// The canonical base purl a vendored leaf names (see +/// [`crate::vendor::path`]'s leaf table), suffixes stripped and pypi names +/// canonicalized. `None` when the leaf is not a recognizable artifact name. +pub fn vendored_leaf_purl(eco: &str, leaf: &str) -> Option { + leaf_to_purl(eco, strip_leaf_suffix(leaf).trim_end_matches('/')) + .map(|purl| canonical_base_purl(&purl)) +} + +/// How a lock format spells a vendored leaf: literally ([`vendor_ref`]) or +/// with `#` / `::` decorations after it ([`vendor_ref_decorated`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LeafGrammar { + /// The string is a file path: a `#` / `::` belongs to the leaf. + Literal, + /// The format defines `#` / `::` decorations after the leaf (yarn + /// classic / berry, url-form pip and Hatch references). + Decorated, +} + +/// What [`OriginPolicy::locate`] computes for one wiring string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LocateOpts { + pub(crate) grammar: LeafGrammar, + /// [`LeafGrammar::Literal`] only: also report a vendored path whose leaf + /// carries a decoration ([`is_decorated_vendor_path`]) — the npm, pnpm + /// and bun extractors diagnose it; composer and the pypi readers do not + /// check it. + pub(crate) decorated_check: bool, +} + +impl LocateOpts { + /// A literal path, decorated leaves reported (npm, pnpm, bun). + pub(crate) const LITERAL_CHECKED: Self = LocateOpts { + grammar: LeafGrammar::Literal, + decorated_check: true, + }; + /// A literal path, decorated leaves not checked (composer, pypi locks, + /// bare pip / Hatch paths). + pub(crate) const LITERAL: Self = LocateOpts { + grammar: LeafGrammar::Literal, + decorated_check: false, + }; + /// A decorated spelling (yarn, url-form pip / Hatch references). + pub(crate) const DECORATED: Self = LocateOpts { + grammar: LeafGrammar::Decorated, + decorated_check: false, + }; +} + +/// Where one lockfile / config string points — every identity decision an +/// extractor takes from it, computed once. Each extractor keeps its own +/// decision ORDER over the fields. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Located { + /// The root-anchored vendored artifact the string names, read with the + /// requested [`LeafGrammar`]. + pub(crate) vendored: Option, + /// The patch uuid of a Socket-hosted url ([`OriginPolicy::hosted_uuid`]), + /// computed eagerly: it needs an http(s) url on an allowed origin while + /// `vendored` needs the string to START at `.socket/vendor/`, so at most + /// one of the two is ever set. + pub(crate) hosted: Option, + /// [`is_decorated_vendor_path`] when [`LocateOpts::decorated_check`] is + /// set (Literal grammar only), else `false`. + pub(crate) decorated_leaf: bool, +} + +/// The Socket wiring a lock location carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Wired { + Hosted(String), + Vendored(VendorRef), +} + +impl Wired { + pub(crate) fn uuid(&self) -> &str { + match self { + Wired::Hosted(uuid) => uuid, + Wired::Vendored(vref) => &vref.uuid, + } + } + + /// The same patch artifact: the same hosted uuid, or the same vendored + /// artifact path. + pub(crate) fn same_artifact(&self, other: &Wired) -> bool { + match (self, other) { + (Wired::Hosted(a), Wired::Hosted(b)) => a == b, + (Wired::Vendored(a), Wired::Vendored(b)) => a.artifact_rel == b.artifact_rel, + _ => false, + } + } +} + +/// Whether `s` points into SOME `.socket/vendor/` tree, any anchoring +/// (backslashes folded) — a Socket-shaped string whose rejection is worth a +/// diagnostic (cargo `[patch]` paths, gem remotes, composer path dists, +/// maven repository urls). +pub(crate) fn names_vendor_dir(s: &str) -> bool { + s.replace('\\', "/").contains(&format!("{VENDOR_DIR}/")) +} + +/// [`names_vendor_dir`] that also folds JSON / TOML `\/` escapes (the pypi +/// readers). +pub(crate) fn mentions_vendor_dir(s: &str) -> bool { + s.replace("\\/", "/") + .replace('\\', "/") + .contains(&format!("{VENDOR_DIR}/")) +} + +/// Whether `s` STARTS at this project's `.socket/vendor/` after the `file:` +/// / `./` spellings [`vendor_ref_decorated`] accepts (yarn's rule; `\/` is +/// not folded). +pub(crate) fn root_anchored_spelling(s: &str) -> bool { + let norm = s.trim().replace('\\', "/"); + let norm = norm.strip_prefix("file:").unwrap_or(&norm); + let norm = norm.strip_prefix("./").unwrap_or(norm); + norm.starts_with(&format!("{VENDOR_DIR}/")) +} + +/// Whether vendored artifact `vref` is the npm tarball the vendor backends +/// write for `purl` (`[@scope/]-.tgz` in the npm vendor dir) +/// — the leaf rule the npm, pnpm and yarn extractors share. +pub(crate) fn npm_vendored_tarball_names(vref: &VendorRef, purl: &str) -> bool { + vref.eco == "npm" && vendored_leaf_purl("npm", &vref.leaf).as_deref() == Some(purl) +} + +#[cfg(test)] +mod hosted_patch_uuid_tests { + //! `hosted_patch_uuid` is the trust gate between a committed lockfile + //! line and a VEX attestation input: pin the accepted spellings AND the + //! rejections (foreign hosts, credentials, non-canonical tokens). + use super::*; + + const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + /// A uuid-SHAPED grant token: the fixtures use them, and production + /// tokens are not guaranteed otherwise — the LAST uuid segment must win. + const TOKEN: &str = "11111111-2222-4333-8444-555555555555"; + + fn none() -> Vec { + Vec::new() + } + + #[test] + fn artifact_and_registry_shapes_yield_the_patch_uuid_not_the_token() { + for url in [ + format!("https://patch.socket.dev/patch/npm/left-pad/1.3.0/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"), + format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"), + format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID}/"), + format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID}/gems/rack-2.2.3.gem"), + format!("https://patch.socket.dev/patch-registry/maven/{TOKEN}/{UUID}/maven2"), + format!("https://patch.socket.dev/patch-registry/nuget/{TOKEN}/{UUID}/index.json"), + format!("sparse+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"), + format!("registry+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"), + ] { + assert_eq!( + hosted_patch_uuid(&url, &none()).as_deref(), + Some(UUID), + "{url}" + ); + } + } + + #[test] + fn lock_format_spellings_are_normalized() { + let url = format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"); + // yarn classic `#`, pip/hatch `#sha256=`, a stray query. + for spelled in [ + format!("{url}#0123456789abcdef0123456789abcdef01234567"), + format!("{url}#sha256=abc"), + format!("{url}?x=1"), + // composer's `\/`-escaped slashes. + url.replace('/', "\\/"), + // yarn berry's percent-encoded `__archiveUrl=` binding value. + url.replace(':', "%3A").replace('/', "%2F"), + format!(" {url} "), + ] { + assert_eq!( + hosted_patch_uuid(&spelled, &none()).as_deref(), + Some(UUID), + "{spelled}" + ); + } + } + + #[test] + fn foreign_hosts_credentials_and_plain_http_are_refused() { + for url in [ + format!("https://registry.npmjs.org/{TOKEN}/{UUID}/x.tgz"), + format!("https://patch.socket.dev.evil.example/{TOKEN}/{UUID}/x.tgz"), + format!("https://evil.example/patch.socket.dev/{TOKEN}/{UUID}/x.tgz"), + format!("http://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("https://patch.socket.dev:8443/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("https://user:pw@patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("git+ssh://patch.socket.dev/{UUID}"), + format!("file:.socket/vendor/npm/{UUID}/x.tgz"), + format!("patch.socket.dev/gopatch/{UUID}"), + ] { + assert_eq!(hosted_patch_uuid(&url, &none()), None, "{url}"); + } + } + + #[test] + fn non_canonical_segments_are_not_patch_uuids() { + for url in [ + // Placeholder tokens (fixtures use `uuid`, `tok`, `some-uuid`). + "https://patch.socket.dev/patch/npm/tok/uuid/x.tgz".to_string(), + // Uppercase is not the canonical grammar. + format!( + "https://patch.socket.dev/patch/npm/tok/{}/x.tgz", + UUID.to_ascii_uppercase() + ), + // A uuid only in the query / fragment is not a path level. + format!("https://patch.socket.dev/patch/npm/x.tgz?u={UUID}"), + format!("https://patch.socket.dev/patch/npm/x.tgz#{UUID}"), + // An encoded `/` cannot split a segment into a uuid. + format!("https://patch.socket.dev/patch/npm/tok%2F{UUID}/x.tgz"), + ] { + assert_eq!(hosted_patch_uuid(&url, &none()), None, "{url}"); + } + } + + #[test] + fn configured_patch_server_origin_is_accepted_exactly() { + let origins = vec!["http://127.0.0.1:4545/some/base".to_string()]; + let url = format!("http://127.0.0.1:4545/patch/npm/{TOKEN}/{UUID}/x.tgz"); + assert_eq!(hosted_patch_uuid(&url, &origins).as_deref(), Some(UUID)); + // Same host, other port / scheme: a different origin. + for other in [ + format!("http://127.0.0.1:4546/patch/npm/{TOKEN}/{UUID}/x.tgz"), + format!("https://127.0.0.1:4545/patch/npm/{TOKEN}/{UUID}/x.tgz"), + ] { + assert_eq!(hosted_patch_uuid(&other, &origins), None, "{other}"); + } + // The default host stays accepted alongside the override, and a + // malformed override is ignored rather than widening the allowlist. + let default = format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"); + assert_eq!(hosted_patch_uuid(&default, &origins).as_deref(), Some(UUID)); + assert_eq!(hosted_patch_uuid(&url, &["not a url".to_string()]), None); + } + + /// The Go hosted namespace lives on the same host the URL allowlist + /// pins — the two spellings of "Socket's patch server" cannot drift. + #[test] + fn go_module_namespace_is_on_the_patch_server_host() { + assert!(crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX + .starts_with(&format!("{SOCKET_PATCH_SERVER_HOST}/"))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + /// A uuid-SHAPED grant token. + const TOKEN: &str = "11111111-2222-4333-8444-555555555555"; + + #[test] + fn vendor_ref_strips_lock_suffixes_and_requires_a_root_anchor() { + let a = UUID; + for spelled in [ + format!("file:.socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), + format!("file:./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), + format!("./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), + format!(".socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), + format!(".socket\\vendor\\npm\\{a}\\left-pad-1.3.0.tgz"), + format!(".socket\\/vendor\\/npm\\/{a}\\/left-pad-1.3.0.tgz"), + // yarn classic `#`, berry fragment + locator, pip `#sha256=`. + format!("file:./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz#0123abcd"), + format!( + "./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz#./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz::hash=abc123&locator=app%40workspace%3A." + ), + format!("./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz::locator=app%40workspace%3A."), + ] { + let decorated = spelled.contains('#') || spelled.contains("::"); + // The literal-path reader refuses a decorated leaf outright: to + // npm / pnpm / bun / … it names a DIFFERENT file. + assert_eq!(vendor_ref(&spelled).is_none(), decorated, "{spelled}"); + let v = vendor_ref_decorated(&spelled).unwrap_or_else(|| panic!("{spelled}")); + assert_eq!(v.eco, "npm", "{spelled}"); + assert_eq!(v.uuid, a, "{spelled}"); + assert_eq!(v.leaf, "left-pad-1.3.0.tgz", "{spelled}"); + assert_eq!( + v.artifact_rel, + format!(".socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), + "{spelled}" + ); + } + // Dir-shaped leaves keep their nesting, trailing slash dropped. + let v = vendor_ref(&format!( + "./.socket/vendor/golang/{a}/github.com/foo/bar@v1.2.3/" + )) + .unwrap(); + assert_eq!(v.leaf, "github.com/foo/bar@v1.2.3"); + } + + #[test] + fn vendor_ref_rejects_escapes_foreign_anchors_and_bad_uuids() { + let a = UUID; + for bad in [ + // The package manager would read OUTSIDE the project root. + format!("../.socket/vendor/npm/{a}/x-1.0.0.tgz"), + format!("file:../.socket/vendor/npm/{a}/x-1.0.0.tgz"), + format!("/abs/project/.socket/vendor/npm/{a}/x-1.0.0.tgz"), + format!("sub/.socket/vendor/npm/{a}/x-1.0.0.tgz"), + // Traversal inside the leaf. + format!(".socket/vendor/npm/{a}/../../../etc/passwd"), + format!(".socket/vendor/npm/{a}/a//b.tgz"), + // No leaf / uuid-dir only / unknown eco / non-canonical uuid. + format!(".socket/vendor/npm/{a}"), + format!(".socket/vendor/npm/{a}/"), + format!(".socket/vendor/jsr/{a}/x-1.0.0.tgz"), + format!(".socket/vendor/npm/{}/x-1.0.0.tgz", a.to_ascii_uppercase()), + ".socket/vendor/npm/not-a-uuid/x-1.0.0.tgz".to_string(), + "https://registry.npmjs.org/x/-/x-1.0.0.tgz".to_string(), + ] { + assert_eq!(vendor_ref(&bad), None, "{bad}"); + } + } + + #[test] + fn vendor_uuid_dir_accepts_only_the_bare_uuid_dir() { + let a = UUID; + assert_eq!( + vendor_uuid_dir(&format!(".socket/vendor/nuget/{a}")), + Some(("nuget".to_string(), a.to_string())) + ); + assert_eq!( + vendor_uuid_dir(&format!("./.socket/vendor/maven/{a}/")), + Some(("maven".to_string(), a.to_string())) + ); + assert_eq!( + vendor_uuid_dir(&format!(".socket/vendor/nuget/{a}/x.nupkg")), + None + ); + assert_eq!( + vendor_uuid_dir(&format!("../.socket/vendor/nuget/{a}")), + None + ); + assert_eq!(vendor_uuid_dir(".socket/vendor/nuget/nope"), None); + } + + #[test] + fn vendored_leaf_purl_strips_and_canonicalizes() { + assert_eq!( + vendored_leaf_purl("npm", "@scope/pkg-1.2.3.tgz#deadbeef").as_deref(), + Some("pkg:npm/@scope/pkg@1.2.3") + ); + assert_eq!( + vendored_leaf_purl( + "pypi", + "Python_Dateutil-2.8.2-py2.py3-none-any.whl#sha256=ab" + ) + .as_deref(), + Some("pkg:pypi/python-dateutil@2.8.2") + ); + assert_eq!( + vendored_leaf_purl("cargo", "serde-1.0.0/").as_deref(), + Some("pkg:cargo/serde@1.0.0") + ); + assert_eq!(vendored_leaf_purl("npm", "not-a-tarball"), None); + } + + #[test] + fn socket_patch_names_are_exact() { + assert_eq!( + socket_patch_name_uuid(&format!("socket-patch-{UUID}"), false).as_deref(), + Some(UUID) + ); + assert_eq!( + socket_patch_name_uuid(&format!("socket-patch-vendor-{UUID}"), true).as_deref(), + Some(UUID) + ); + // The hosted grammar never matches the vendored id and vice versa. + assert_eq!( + socket_patch_name_uuid(&format!("socket-patch-vendor-{UUID}"), false), + None + ); + assert_eq!( + socket_patch_name_uuid(&format!("socket-patch-{UUID}"), true), + None + ); + assert_eq!( + socket_patch_name_uuid(&format!("socket-patch-{UUID}-x"), false), + None + ); + assert_eq!(socket_patch_name_uuid("socket-patch-nope", false), None); + } + + /// [`OriginPolicy::hosted_uuid`]'s plain-url shortcut answers exactly + /// what the full parse does, for plain urls on other hosts and for + /// every spelling it must leave to the parse. + #[test] + fn hosted_uuid_shortcut_agrees_with_the_full_parse() { + let (a, t) = (UUID, TOKEN); + let path = format!("/patch/npm/x/1.0.0/{t}/{a}/x-1.0.0.tgz"); + let hosts = [ + "patch.socket.dev", + "PATCH.Socket.DEV", + "patch.socket.dev.", + "patch.socket.dev:443", + "patch.socket.dev:8443", + "patch.socket.dev:", + "registry.npmjs.org", + "registry.npmjs.org:443", + "127.0.0.1:4545", + "127.0.0.1", + "0x7f.0.0.1:4545", + "127.1:4545", + "[::1]:4545", + "staging.example.com:8443", + "Staging.Example.COM:8443", + "staging.example.com", + "xn--stging-bua.example.com", + "user@patch.socket.dev", + "user:pw@registry.npmjs.org", + "patch%2esocket.dev", + "patch.soc\tket.dev", + "patch.socket.dev\\", + "foo.123", + "foo.0x1f", + "", + ]; + let urls: Vec = hosts + .iter() + .flat_map(|host| { + [ + format!("https://{host}{path}"), + format!("http://{host}{path}"), + format!("HTTPS://{host}{path}"), + format!("sparse+https://{host}{path}"), + format!("git+https://{host}{path}"), + format!("https:///{host}{path}"), + format!("https://{host}?q={path}"), + format!(" https://{host}{path} "), + format!("https://{host}"), + ] + }) + .chain([ + format!("https:\\/\\/patch.socket.dev{}", path.replace('/', "\\/")), + format!("https%3A%2F%2Fpatch.socket.dev{}", path.replace('/', "%2F")), + "file:x.tgz".to_string(), + "npm:x@1.0.0".to_string(), + "https://".to_string(), + ]) + .collect(); + for origins in [ + vec![], + vec!["http://127.0.0.1:4545".to_string()], + vec![ + "https://staging.example.com:8443".to_string(), + "http://[::1]:4545".to_string(), + "https://xn--stging-bua.example.com".to_string(), + ], + ] { + let ctx = OriginPolicy::new(&origins); + for url in &urls { + assert_eq!( + ctx.hosted_uuid(url), + hosted_patch_uuid(url, &origins), + "{url} under {origins:?}" + ); + } + assert_eq!( + ctx.hosted_uuid(&format!("https://patch.socket.dev{path}")) + .as_deref(), + Some(a) + ); + } + assert_eq!( + plain_url_domain("https://Registry.NPMJS.org/x"), + Some("registry.npmjs.org".into()) + ); + for not_plain in [ + "https://127.0.0.1/x", + "https://a%2eb.com/x", + "https://u@a.com/x", + "https://xn--a.com/x", + ] { + assert_eq!(plain_url_domain(not_plain), None, "{not_plain}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..706b6eb21 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7606,96 +7606,7 @@ pub fn grant_token_path_segment(url: &str, patch_uuid: &str) -> Option { (!token.is_empty()).then(|| token.to_string()) } -/// Public host of Socket's patch server: the origin every production hosted -/// artifact / registry URL is served from (`https://patch.socket.dev/patch/…`, -/// `…/patch-registry/…`), and the root of the Go module namespace -/// [`crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX`]. -pub const SOCKET_PATCH_SERVER_HOST: &str = "patch.socket.dev"; - -/// The Socket patch uuid a lockfile-recorded HOSTED reference names, or -/// `None` when `url` is not a Socket-hosted patch URL — the inverse of the -/// rewriters, used by `vex`'s manifest-less lockfile discovery. -/// -/// Recognition is deliberately strict, because the answer decides whether a -/// committed (tamper-able) lockfile line becomes an attestation input: -/// -/// * the ORIGIN must be Socket's patch server (`https://` + -/// [`SOCKET_PATCH_SERVER_HOST`]) or one of `extra_origins` — the -/// operator's `--patch-server-url` deployment, compared on scheme + host + -/// port. A uuid inside any other host's URL is a user's own dependency -/// source, never a patch reference; -/// * no userinfo — a Socket-written URL never carries credentials; -/// * the uuid is the LAST path segment passing the canonical-uuid grammar: -/// hosted URLs carry the grant token in the level before the uuid -/// (`…/patch/npm/////`, -/// `…/patch-registry////…`), and grant tokens may -/// themselves be uuid-shaped, so "the first uuid" would elect the token. -/// -/// Every spelling the lock formats record the same URL in is accepted: a -/// `#fragment` (yarn classic `#`, pip `#sha256=`) and a `?query` are -/// ignored; `\/`-escaped slashes (older composer locks) are unescaped; a -/// wholly percent-encoded URL (yarn berry's `__archiveUrl=` binding) is -/// decoded; a cargo source-kind prefix (`sparse+`, `registry+`) is dropped. -/// Path segments are percent-decoded AFTER splitting, so an encoded `/` -/// can never manufacture a segment. -pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option { - hosted_patch_url_uuids(url, extra_origins)?.pop() -} - -/// EVERY canonical-uuid path segment of a Socket-HOSTED url, in path order -/// (the grant token first when it is uuid-shaped, the patch uuid last), or -/// `None` when `url` is not on an accepted origin — [`hosted_patch_uuid`]'s -/// exact acceptance rules, without electing one segment. `vex` discovery -/// uses it to RECOGNIZE every Socket identity a lockfile mentions, including -/// the malformed or rejected shapes whose "last uuid" is not a patch. -pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { - use crate::patch::path_safety::is_canonical_uuid; - use crate::utils::purl::percent_decode_purl_component; - - let unescaped = url.trim().replace("\\/", "/"); - let lower = unescaped.to_ascii_lowercase(); - let decoded = if lower.starts_with("https%3a%2f%2f") || lower.starts_with("http%3a%2f%2f") { - percent_decode_purl_component(&unescaped).into_owned() - } else { - unescaped - }; - // `sparse+https://…` / `registry+https://…` (Cargo.lock `source`). - let (scheme, _) = decoded.split_once("://")?; - let text = match scheme.rsplit_once('+') { - Some((kind, _)) if !kind.is_empty() && kind.bytes().all(|b| b.is_ascii_alphabetic()) => { - &decoded[kind.len() + 1..] - } - _ => decoded.as_str(), - }; - let parsed = reqwest::Url::parse(text).ok()?; - if !matches!(parsed.scheme(), "https" | "http") - || !parsed.username().is_empty() - || parsed.password().is_some() - { - return None; - } - let socket_host = parsed.scheme() == "https" - && parsed.host_str() == Some(SOCKET_PATCH_SERVER_HOST) - && parsed.port_or_known_default() == Some(443); - let configured = extra_origins.iter().any(|origin| { - reqwest::Url::parse(origin.trim()).is_ok_and(|o| { - o.scheme() == parsed.scheme() - && o.host_str().is_some() - && o.host_str() == parsed.host_str() - && o.port_or_known_default() == parsed.port_or_known_default() - }) - }); - if !socket_host && !configured { - return None; - } - Some( - parsed - .path_segments()? - .map(|segment| percent_decode_purl_component(segment).into_owned()) - .filter(|segment| is_canonical_uuid(segment)) - .collect(), - ) -} +pub use crate::origin::{hosted_patch_url_uuids, hosted_patch_uuid, SOCKET_PATCH_SERVER_HOST}; /// A dep's Socket index URL as a regex source with the per-request rotating /// segments (grant token, patch uuid) wildcarded — an exact-URL pattern @@ -29658,129 +29569,6 @@ mod hatch_tests { } } -#[cfg(test)] -mod hosted_patch_uuid_tests { - //! `hosted_patch_uuid` is the trust gate between a committed lockfile - //! line and a VEX attestation input: pin the accepted spellings AND the - //! rejections (foreign hosts, credentials, non-canonical tokens). - use super::*; - - const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; - /// A uuid-SHAPED grant token: the fixtures use them, and production - /// tokens are not guaranteed otherwise — the LAST uuid segment must win. - const TOKEN: &str = "11111111-2222-4333-8444-555555555555"; - - fn none() -> Vec { - Vec::new() - } - - #[test] - fn artifact_and_registry_shapes_yield_the_patch_uuid_not_the_token() { - for url in [ - format!("https://patch.socket.dev/patch/npm/left-pad/1.3.0/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"), - format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"), - format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID}/"), - format!("https://patch.socket.dev/patch-registry/gem/{TOKEN}/{UUID}/gems/rack-2.2.3.gem"), - format!("https://patch.socket.dev/patch-registry/maven/{TOKEN}/{UUID}/maven2"), - format!("https://patch.socket.dev/patch-registry/nuget/{TOKEN}/{UUID}/index.json"), - format!("sparse+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"), - format!("registry+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"), - ] { - assert_eq!( - hosted_patch_uuid(&url, &none()).as_deref(), - Some(UUID), - "{url}" - ); - } - } - - #[test] - fn lock_format_spellings_are_normalized() { - let url = format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/left-pad-1.3.0.tgz"); - // yarn classic `#`, pip/hatch `#sha256=`, a stray query. - for spelled in [ - format!("{url}#0123456789abcdef0123456789abcdef01234567"), - format!("{url}#sha256=abc"), - format!("{url}?x=1"), - // composer's `\/`-escaped slashes. - url.replace('/', "\\/"), - // yarn berry's percent-encoded `__archiveUrl=` binding value. - url.replace(':', "%3A").replace('/', "%2F"), - format!(" {url} "), - ] { - assert_eq!( - hosted_patch_uuid(&spelled, &none()).as_deref(), - Some(UUID), - "{spelled}" - ); - } - } - - #[test] - fn foreign_hosts_credentials_and_plain_http_are_refused() { - for url in [ - format!("https://registry.npmjs.org/{TOKEN}/{UUID}/x.tgz"), - format!("https://patch.socket.dev.evil.example/{TOKEN}/{UUID}/x.tgz"), - format!("https://evil.example/patch.socket.dev/{TOKEN}/{UUID}/x.tgz"), - format!("http://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"), - format!("https://patch.socket.dev:8443/patch/npm/{TOKEN}/{UUID}/x.tgz"), - format!("https://user:pw@patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"), - format!("git+ssh://patch.socket.dev/{UUID}"), - format!("file:.socket/vendor/npm/{UUID}/x.tgz"), - format!("patch.socket.dev/gopatch/{UUID}"), - ] { - assert_eq!(hosted_patch_uuid(&url, &none()), None, "{url}"); - } - } - - #[test] - fn non_canonical_segments_are_not_patch_uuids() { - for url in [ - // Placeholder tokens (fixtures use `uuid`, `tok`, `some-uuid`). - "https://patch.socket.dev/patch/npm/tok/uuid/x.tgz".to_string(), - // Uppercase is not the canonical grammar. - format!( - "https://patch.socket.dev/patch/npm/tok/{}/x.tgz", - UUID.to_ascii_uppercase() - ), - // A uuid only in the query / fragment is not a path level. - format!("https://patch.socket.dev/patch/npm/x.tgz?u={UUID}"), - format!("https://patch.socket.dev/patch/npm/x.tgz#{UUID}"), - // An encoded `/` cannot split a segment into a uuid. - format!("https://patch.socket.dev/patch/npm/tok%2F{UUID}/x.tgz"), - ] { - assert_eq!(hosted_patch_uuid(&url, &none()), None, "{url}"); - } - } - - #[test] - fn configured_patch_server_origin_is_accepted_exactly() { - let origins = vec!["http://127.0.0.1:4545/some/base".to_string()]; - let url = format!("http://127.0.0.1:4545/patch/npm/{TOKEN}/{UUID}/x.tgz"); - assert_eq!(hosted_patch_uuid(&url, &origins).as_deref(), Some(UUID)); - // Same host, other port / scheme: a different origin. - for other in [ - format!("http://127.0.0.1:4546/patch/npm/{TOKEN}/{UUID}/x.tgz"), - format!("https://127.0.0.1:4545/patch/npm/{TOKEN}/{UUID}/x.tgz"), - ] { - assert_eq!(hosted_patch_uuid(&other, &origins), None, "{other}"); - } - // The default host stays accepted alongside the override, and a - // malformed override is ignored rather than widening the allowlist. - let default = format!("https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/x.tgz"); - assert_eq!(hosted_patch_uuid(&default, &origins).as_deref(), Some(UUID)); - assert_eq!(hosted_patch_uuid(&url, &["not a url".to_string()]), None); - } - - /// The Go hosted namespace lives on the same host the URL allowlist - /// pins — the two spellings of "Socket's patch server" cannot drift. - #[test] - fn go_module_namespace_is_on_the_patch_server_host() { - assert!(crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX - .starts_with(&format!("{SOCKET_PATCH_SERVER_HOST}/"))); - } -} - /// #742 / #650: a superseding patch uuid for a package an earlier hosted /// scan already wired re-pins socket-patch's own source, end to end through /// the redirect planner, instead of warning "revert it" and leaving the old diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..5c08758c3 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -187,16 +187,25 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::crawlers::Ecosystem; -use crate::patch::path_safety::{is_canonical_uuid, is_safe_multi_segment}; +use crate::patch::path_safety::is_canonical_uuid; use crate::utils::purl_key::canonical_base_purl; use crate::utils::purl_key::PurlKey; use crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX; use crate::vendor::lock_inventory::{ inventory_project_every_lock, lookup, LockIntegrity, LockfileEntry, SourceKind, }; -use crate::vendor::path::{ecosystem_dir_for_purl, leaf_to_purl, ECOSYSTEM_DIRS, VENDOR_DIR}; +use crate::vendor::path::{ecosystem_dir_for_purl, ECOSYSTEM_DIRS, VENDOR_DIR}; use crate::vendor::state::VendorEntry; +pub(crate) use crate::origin::{ + mentions_vendor_dir, names_vendor_dir, npm_vendored_tarball_names, root_anchored_spelling, + socket_patch_name_uuid, LocateOpts, Located, OriginPolicy, Wired, +}; +pub use crate::origin::{ + strip_leaf_suffix, vendor_ref, vendor_ref_decorated, vendor_uuid_dir, vendored_leaf_purl, + VendorRef, +}; + pub(crate) mod bun; pub(crate) mod cargo; pub(crate) mod composer; @@ -1569,10 +1578,8 @@ async fn ignore_member_stray_locks(ctx: &mut DiscoverCtx<'_>, out: &mut Discover }; for rel in &stray.ignored { let mentions = match ctx.view.read_bytes(rel).await { - Ok(bytes) => { - !socket_identities(&String::from_utf8_lossy(&bytes), ctx.patch_server_origins) - .is_empty() - } + Ok(bytes) => !socket_identities(&String::from_utf8_lossy(&bytes), ctx.origin.origins()) + .is_empty(), Err(_) => false, }; ctx.recognize_ignored(rel).await; @@ -1604,7 +1611,8 @@ pub(crate) struct DiscoverCtx<'a> { /// Where the guarded reads read from: the root on disk, a per-run /// snapshot of it, or an in-memory project. pub(crate) view: crate::vendor::lock_inventory::ProjectView<'a>, - patch_server_origins: &'a [String], + /// The host policy [`Self::hosted_uuid`] and [`Self::locate`] classify under. + origin: OriginPolicy<'a>, /// Root-relative lock files the package manager never reads here (a /// workspace member's stray lock, see [`ignore_member_stray_locks`]): /// the guarded reads see them as absent. @@ -1614,8 +1622,6 @@ pub(crate) struct DiscoverCtx<'a> { /// scratch `Discovery` (a file parsed only to explain it) still counts. /// A `Mutex` keeps the ctx `Sync` across the extractors' `.await`s. recognized: Mutex>, - /// The hosts a Socket-hosted url can name (see [`DiscoverCtx::hosted_uuid`]). - hosted_hosts: std::sync::OnceLock>, /// The ecosystem the running extractor reads for (set by /// [`discover_with_ctx`] between extractors), tagging [`Self::read`]. ecosystem: &'static str, @@ -1637,10 +1643,9 @@ impl<'a> DiscoverCtx<'a> { ) -> Self { DiscoverCtx { view, - patch_server_origins, + origin: OriginPolicy::new(patch_server_origins), ignored: Vec::new(), recognized: Mutex::new(BTreeSet::new()), - hosted_hosts: std::sync::OnceLock::new(), ecosystem: "", read: Mutex::new(BTreeSet::new()), } @@ -1686,7 +1691,7 @@ impl<'a> DiscoverCtx<'a> { /// Record every Socket identity `text` (the content of root-relative /// `rel`) mentions — see [`socket_identities`]. fn recognize_text(&self, rel: &str, text: &str) { - let found = socket_identities(text, self.patch_server_origins); + let found = socket_identities(text, self.origin.origins()); if found.is_empty() { return; } @@ -1747,31 +1752,14 @@ impl<'a> DiscoverCtx<'a> { } } - /// The patch uuid of a Socket-HOSTED url, or `None` for anything else - /// (see [`crate::patch::redirect::hosted_patch_uuid`] for the accepted - /// spellings and the host allowlist). - /// - /// A plain url ([`plain_url_domain`]) on any other host is answered - /// without parsing it: every lock entry's registry url comes through - /// here. + /// [`OriginPolicy::hosted_uuid`] under this run's origins. pub(crate) fn hosted_uuid(&self, url: &str) -> Option { - if let Some(host) = plain_url_domain(url) { - let hosts = self.hosted_hosts.get_or_init(|| { - let mut hosts = - BTreeSet::from([crate::patch::redirect::SOCKET_PATCH_SERVER_HOST.to_string()]); - hosts.extend(self.patch_server_origins.iter().filter_map(|o| { - reqwest::Url::parse(o.trim()) - .ok()? - .host_str() - .map(str::to_string) - })); - hosts - }); - if !hosts.contains(&host) { - return None; - } - } - crate::patch::redirect::hosted_patch_uuid(url, self.patch_server_origins) + self.origin.hosted_uuid(url) + } + + /// [`OriginPolicy::locate`] under this run's origins. + pub(crate) fn locate(&self, s: &str, opts: LocateOpts) -> Located { + self.origin.locate(s, opts) } /// Whether `rel` exists (lstat — a dangling symlink still "exists", the @@ -1902,62 +1890,6 @@ impl<'a> DiscoverCtx<'a> { // ── identity helpers ───────────────────────────────────────────────────── -/// The host of `url` when it is a PLAIN `http(s)` url — printable ASCII -/// only, no `%`, `\\` or userinfo, a DNS name of letters, digits, `.` and -/// `-` whose last label starts with a letter — lowercased, which is then -/// exactly what [`crate::patch::redirect::hosted_patch_url_uuids`]'s url -/// parse would call its host (no decoding, IDNA mapping or IP-address -/// reading applies to such a name). `None` for anything else, which takes -/// the full parse. -fn plain_url_domain(url: &str) -> Option { - let url = url.trim(); - if !url - .bytes() - .all(|b| b.is_ascii_graphic() && b != b'%' && b != b'\\') - { - return None; - } - let (scheme, rest) = url.split_once("://")?; - // `sparse+https://…` / `registry+https://…`, as the parse strips it. - let scheme = match scheme.rsplit_once('+') { - Some((kind, scheme)) - if !kind.is_empty() && kind.bytes().all(|b| b.is_ascii_alphabetic()) => - { - scheme - } - _ => scheme, - }; - if !scheme.eq_ignore_ascii_case("https") && !scheme.eq_ignore_ascii_case("http") { - return None; - } - let authority = rest.split(['/', '?', '#']).next()?; - let host = match authority.rsplit_once(':') { - Some((host, port)) if port.bytes().all(|b| b.is_ascii_digit()) => host, - Some(_) => return None, - None => authority, - }; - let last = host.strip_suffix('.').unwrap_or(host).rsplit('.').next()?; - let plain = !host.is_empty() - && !rest.starts_with('/') - && host - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') - && last.starts_with(|c: char| c.is_ascii_alphabetic()) - && !host - .split('.') - .any(|label| label.len() >= 4 && label[..4].eq_ignore_ascii_case("xn--")); - plain.then(|| host.to_ascii_lowercase()) -} - -/// The uuid of a Socket-owned registry / repository / source NAME: -/// `socket-patch-` (cargo `[registries.*]` + Cargo.toml `registry =`, -/// maven ``, nuget ``) or, with -/// `vendored = true`, `socket-patch-vendor-` (maven's vendored repo -/// id). Exact grammar only — a suffix or a non-canonical uuid is not ours. -pub(crate) fn socket_patch_name_uuid(name: &str, vendored: bool) -> Option { - crate::patch::redirect::generation::pin_name_uuid(name.trim(), vendored).map(str::to_string) -} - /// Every Socket patch identity `text` MENTIONS, by wiring mode — the sweep /// behind rule 11, run over the content of every file discovery reads. It is /// deliberately a superset of what any extractor accepts (it must recognize @@ -2256,269 +2188,6 @@ fn token_end(bytes: &[u8], from: usize, extra: &[u8]) -> usize { end } -/// A vendored artifact reference recovered from a wiring string. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VendorRef { - /// Vendor ecosystem dir (`npm`, `cargo`, `pypi`, …). - pub eco: String, - pub uuid: String, - /// The natural leaf, lock-format suffixes stripped ([`strip_leaf_suffix`]), - /// forward-slashed, no trailing slash. - pub leaf: String, - /// `.socket/vendor///` — the root-relative artifact path. - pub artifact_rel: String, -} - -/// Recover a vendored artifact reference from a lockfile/config string that -/// points into THIS project's `.socket/vendor/`: `file:` / `./` / bare -/// spellings, backslashed Windows spellings, and `\/`-escaped JSON text. -/// -/// Stricter than [`crate::vendor::parse_vendor_path`] (which finds the anchor -/// anywhere, for the orphan sweep): the anchor must START the path. A -/// `../.socket/vendor/…` or absolute spelling points the package manager at -/// an artifact OUTSIDE the project root, and verifying the root's same-named -/// artifact instead would attest bytes the build never consumes. Callers -/// whose format legitimately prefixes the project root (hatch -/// `{root:uri}/`, maven `file://${project.basedir}/`, pnpm-legacy absolute -/// importer paths) strip that exact prefix first. -/// -/// The leaf must be a traversal-safe relative path, taken LITERALLY: npm, -/// pnpm, bun, composer, bundler, go, cargo, maven, nuget and every pypi -/// lock read the string as a file path, so a leaf carrying `#` or `::` is -/// rejected (`None`) rather than cut — `…/foo-1.0.0.tgz#evil.tgz` is a file -/// of that literal name, which npm installs while the cut leaf would verify -/// the good `foo-1.0.0.tgz` beside it. The formats whose grammar DEFINES -/// those decorations use [`vendor_ref_decorated`] instead. -pub fn vendor_ref(s: &str) -> Option { - vendor_ref_with(s, false) -} - -/// [`vendor_ref`] for the formats that decorate a vendored path: yarn -/// classic `#`, berry `#./…::hash=…` fragments and `::locator=…` -/// bindings, and url-form pip / Hatch references (`file:…#sha256=…`, -/// `{root:uri}/…#sha256=…`). The leaf keeps only its natural name — the -/// decorations ([`strip_leaf_suffix`]) are cut before the literal-path -/// checks. -pub fn vendor_ref_decorated(s: &str) -> Option { - vendor_ref_with(s, true) -} - -fn vendor_ref_with(s: &str, decorated: bool) -> Option { - let rest = root_anchored_vendor_rest(s)?; - let mut it = rest.splitn(3, '/'); - let eco = it.next()?; - let uuid = it.next()?; - let leaf_raw = it.next()?; - if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) { - return None; - } - let leaf = if decorated { - strip_leaf_suffix(leaf_raw) - } else if leaf_raw.contains('#') || leaf_raw.contains("::") { - return None; - } else { - leaf_raw - }; - let leaf = leaf.trim_end_matches('/'); - if leaf.is_empty() || !is_safe_multi_segment(leaf) { - return None; - } - Some(VendorRef { - eco: eco.to_string(), - uuid: uuid.to_string(), - leaf: leaf.to_string(), - artifact_rel: format!("{VENDOR_DIR}/{eco}/{uuid}/{leaf}"), - }) -} - -/// `(eco, uuid)` for a root-anchored string that ends AT the uuid dir -/// (`.socket/vendor/nuget/`, a maven repository url's path) — the -/// shapes [`vendor_ref`] rejects for having no leaf. -pub fn vendor_uuid_dir(s: &str) -> Option<(String, String)> { - let rest = root_anchored_vendor_rest(s)?; - let (eco, uuid) = rest.trim_end_matches('/').split_once('/')?; - if !ECOSYSTEM_DIRS.contains(&eco) || !is_canonical_uuid(uuid) { - return None; - } - Some((eco.to_string(), uuid.to_string())) -} - -/// Whether `s` is a vendored artifact path [`vendor_ref`] rejects ONLY for -/// a `#` / `::` in its leaf (what [`vendor_ref_decorated`] would have cut): -/// to a literal-path format that names another file, which extractors -/// diagnose rather than skip silently. -pub(crate) fn is_decorated_vendor_path(s: &str) -> bool { - vendor_ref(s).is_none() && vendor_ref_decorated(s).is_some() -} - -/// Everything after a LEADING `.socket/vendor/` anchor (after `file:`, one -/// `./`, backslash and `\/` normalization), or `None`. -fn root_anchored_vendor_rest(s: &str) -> Option { - let norm = s.trim().replace("\\/", "/").replace('\\', "/"); - let norm = norm.strip_prefix("file:").unwrap_or(&norm); - let norm = norm.strip_prefix("./").unwrap_or(norm); - norm.strip_prefix(&format!("{VENDOR_DIR}/")) - .map(str::to_string) -} - -/// Cut the lock-format decorations off a vendored leaf: everything from the -/// first `#` (yarn classic `#`, berry `#./::hash=…`, pip/hatch -/// `#sha256=…`) or `::` (berry `::locator=…`). -pub fn strip_leaf_suffix(leaf: &str) -> &str { - let cut = [leaf.find('#'), leaf.find("::")] - .into_iter() - .flatten() - .min() - .unwrap_or(leaf.len()); - &leaf[..cut] -} - -/// The canonical base purl a vendored leaf names (see -/// [`crate::vendor::path`]'s leaf table), suffixes stripped and pypi names -/// canonicalized. `None` when the leaf is not a recognizable artifact name. -pub fn vendored_leaf_purl(eco: &str, leaf: &str) -> Option { - leaf_to_purl(eco, strip_leaf_suffix(leaf).trim_end_matches('/')) - .map(|purl| canonical_base_purl(&purl)) -} - -// ── shared extractor helpers ───────────────────────────────────────────── - -/// How a lock format spells a vendored leaf: literally ([`vendor_ref`]) or -/// with `#` / `::` decorations after it ([`vendor_ref_decorated`]). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LeafGrammar { - /// The string is a file path: a `#` / `::` belongs to the leaf. - Literal, - /// The format defines `#` / `::` decorations after the leaf (yarn - /// classic / berry, url-form pip and Hatch references). - Decorated, -} - -/// What [`DiscoverCtx::locate`] computes for one wiring string. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct LocateOpts { - pub(crate) grammar: LeafGrammar, - /// [`LeafGrammar::Literal`] only: also report a vendored path whose leaf - /// carries a decoration ([`is_decorated_vendor_path`]) — the npm, pnpm - /// and bun extractors diagnose it; composer and the pypi readers do not - /// check it. - pub(crate) decorated_check: bool, -} - -impl LocateOpts { - /// A literal path, decorated leaves reported (npm, pnpm, bun). - pub(crate) const LITERAL_CHECKED: Self = LocateOpts { - grammar: LeafGrammar::Literal, - decorated_check: true, - }; - /// A literal path, decorated leaves not checked (composer, pypi locks, - /// bare pip / Hatch paths). - pub(crate) const LITERAL: Self = LocateOpts { - grammar: LeafGrammar::Literal, - decorated_check: false, - }; - /// A decorated spelling (yarn, url-form pip / Hatch references). - pub(crate) const DECORATED: Self = LocateOpts { - grammar: LeafGrammar::Decorated, - decorated_check: false, - }; -} - -/// Where one lockfile / config string points — every identity decision an -/// extractor takes from it, computed once. Each extractor keeps its own -/// decision ORDER over the fields. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct Located { - /// The root-anchored vendored artifact the string names, read with the - /// requested [`LeafGrammar`]. - pub(crate) vendored: Option, - /// The patch uuid of a Socket-hosted url ([`DiscoverCtx::hosted_uuid`]), - /// computed eagerly: it needs an http(s) url on an allowed origin while - /// `vendored` needs the string to START at `.socket/vendor/`, so at most - /// one of the two is ever set. - pub(crate) hosted: Option, - /// [`is_decorated_vendor_path`] when [`LocateOpts::decorated_check`] is - /// set (Literal grammar only), else `false`. - pub(crate) decorated_leaf: bool, -} - -impl DiscoverCtx<'_> { - /// Classify `s` (see [`Located`]). - pub(crate) fn locate(&self, s: &str, opts: LocateOpts) -> Located { - let vendored = match opts.grammar { - LeafGrammar::Literal => vendor_ref(s), - LeafGrammar::Decorated => vendor_ref_decorated(s), - }; - let decorated_leaf = opts.decorated_check - && opts.grammar == LeafGrammar::Literal - && is_decorated_vendor_path(s); - Located { - vendored, - hosted: self.hosted_uuid(s), - decorated_leaf, - } - } -} - -/// The Socket wiring a lock location carries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Wired { - Hosted(String), - Vendored(VendorRef), -} - -impl Wired { - pub(crate) fn uuid(&self) -> &str { - match self { - Wired::Hosted(uuid) => uuid, - Wired::Vendored(vref) => &vref.uuid, - } - } - - /// The same patch artifact: the same hosted uuid, or the same vendored - /// artifact path. - pub(crate) fn same_artifact(&self, other: &Wired) -> bool { - match (self, other) { - (Wired::Hosted(a), Wired::Hosted(b)) => a == b, - (Wired::Vendored(a), Wired::Vendored(b)) => a.artifact_rel == b.artifact_rel, - _ => false, - } - } -} - -/// Whether `s` points into SOME `.socket/vendor/` tree, any anchoring -/// (backslashes folded) — a Socket-shaped string whose rejection is worth a -/// diagnostic (cargo `[patch]` paths, gem remotes, composer path dists, -/// maven repository urls). -pub(crate) fn names_vendor_dir(s: &str) -> bool { - s.replace('\\', "/").contains(&format!("{VENDOR_DIR}/")) -} - -/// [`names_vendor_dir`] that also folds JSON / TOML `\/` escapes (the pypi -/// readers). -pub(crate) fn mentions_vendor_dir(s: &str) -> bool { - s.replace("\\/", "/") - .replace('\\', "/") - .contains(&format!("{VENDOR_DIR}/")) -} - -/// Whether `s` STARTS at this project's `.socket/vendor/` after the `file:` -/// / `./` spellings [`vendor_ref_decorated`] accepts (yarn's rule; `\/` is -/// not folded). -pub(crate) fn root_anchored_spelling(s: &str) -> bool { - let norm = s.trim().replace('\\', "/"); - let norm = norm.strip_prefix("file:").unwrap_or(&norm); - let norm = norm.strip_prefix("./").unwrap_or(norm); - norm.starts_with(&format!("{VENDOR_DIR}/")) -} - -/// Whether vendored artifact `vref` is the npm tarball the vendor backends -/// write for `purl` (`[@scope/]-.tgz` in the npm vendor dir) -/// — the leaf rule the npm, pnpm and yarn extractors share. -pub(crate) fn npm_vendored_tarball_names(vref: &VendorRef, purl: &str) -> bool { - vref.eco == "npm" && vendored_leaf_purl("npm", &vref.leaf).as_deref() == Some(purl) -} - /// Parse a JSON document read from root-relative `file`; `Err` carries the /// diagnostic detail `" is not valid JSON: "`. No BOM handling: /// the callers that tolerate one strip it first. @@ -3625,138 +3294,6 @@ mod tests { assert_eq!(discovery.vendor_entry_in_use(root, &entry).await, None); } - #[test] - fn vendor_ref_strips_lock_suffixes_and_requires_a_root_anchor() { - let a = UUID_A; - for spelled in [ - format!("file:.socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), - format!("file:./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), - format!("./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), - format!(".socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), - format!(".socket\\vendor\\npm\\{a}\\left-pad-1.3.0.tgz"), - format!(".socket\\/vendor\\/npm\\/{a}\\/left-pad-1.3.0.tgz"), - // yarn classic `#`, berry fragment + locator, pip `#sha256=`. - format!("file:./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz#0123abcd"), - format!( - "./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz#./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz::hash=abc123&locator=app%40workspace%3A." - ), - format!("./.socket/vendor/npm/{a}/left-pad-1.3.0.tgz::locator=app%40workspace%3A."), - ] { - let decorated = spelled.contains('#') || spelled.contains("::"); - // The literal-path reader refuses a decorated leaf outright: to - // npm / pnpm / bun / … it names a DIFFERENT file. - assert_eq!(vendor_ref(&spelled).is_none(), decorated, "{spelled}"); - let v = vendor_ref_decorated(&spelled).unwrap_or_else(|| panic!("{spelled}")); - assert_eq!(v.eco, "npm", "{spelled}"); - assert_eq!(v.uuid, a, "{spelled}"); - assert_eq!(v.leaf, "left-pad-1.3.0.tgz", "{spelled}"); - assert_eq!( - v.artifact_rel, - format!(".socket/vendor/npm/{a}/left-pad-1.3.0.tgz"), - "{spelled}" - ); - } - // Dir-shaped leaves keep their nesting, trailing slash dropped. - let v = vendor_ref(&format!( - "./.socket/vendor/golang/{a}/github.com/foo/bar@v1.2.3/" - )) - .unwrap(); - assert_eq!(v.leaf, "github.com/foo/bar@v1.2.3"); - } - - #[test] - fn vendor_ref_rejects_escapes_foreign_anchors_and_bad_uuids() { - let a = UUID_A; - for bad in [ - // The package manager would read OUTSIDE the project root. - format!("../.socket/vendor/npm/{a}/x-1.0.0.tgz"), - format!("file:../.socket/vendor/npm/{a}/x-1.0.0.tgz"), - format!("/abs/project/.socket/vendor/npm/{a}/x-1.0.0.tgz"), - format!("sub/.socket/vendor/npm/{a}/x-1.0.0.tgz"), - // Traversal inside the leaf. - format!(".socket/vendor/npm/{a}/../../../etc/passwd"), - format!(".socket/vendor/npm/{a}/a//b.tgz"), - // No leaf / uuid-dir only / unknown eco / non-canonical uuid. - format!(".socket/vendor/npm/{a}"), - format!(".socket/vendor/npm/{a}/"), - format!(".socket/vendor/jsr/{a}/x-1.0.0.tgz"), - format!(".socket/vendor/npm/{}/x-1.0.0.tgz", a.to_ascii_uppercase()), - ".socket/vendor/npm/not-a-uuid/x-1.0.0.tgz".to_string(), - "https://registry.npmjs.org/x/-/x-1.0.0.tgz".to_string(), - ] { - assert_eq!(vendor_ref(&bad), None, "{bad}"); - } - } - - #[test] - fn vendor_uuid_dir_accepts_only_the_bare_uuid_dir() { - let a = UUID_A; - assert_eq!( - vendor_uuid_dir(&format!(".socket/vendor/nuget/{a}")), - Some(("nuget".to_string(), a.to_string())) - ); - assert_eq!( - vendor_uuid_dir(&format!("./.socket/vendor/maven/{a}/")), - Some(("maven".to_string(), a.to_string())) - ); - assert_eq!( - vendor_uuid_dir(&format!(".socket/vendor/nuget/{a}/x.nupkg")), - None - ); - assert_eq!( - vendor_uuid_dir(&format!("../.socket/vendor/nuget/{a}")), - None - ); - assert_eq!(vendor_uuid_dir(".socket/vendor/nuget/nope"), None); - } - - #[test] - fn vendored_leaf_purl_strips_and_canonicalizes() { - assert_eq!( - vendored_leaf_purl("npm", "@scope/pkg-1.2.3.tgz#deadbeef").as_deref(), - Some("pkg:npm/@scope/pkg@1.2.3") - ); - assert_eq!( - vendored_leaf_purl( - "pypi", - "Python_Dateutil-2.8.2-py2.py3-none-any.whl#sha256=ab" - ) - .as_deref(), - Some("pkg:pypi/python-dateutil@2.8.2") - ); - assert_eq!( - vendored_leaf_purl("cargo", "serde-1.0.0/").as_deref(), - Some("pkg:cargo/serde@1.0.0") - ); - assert_eq!(vendored_leaf_purl("npm", "not-a-tarball"), None); - } - - #[test] - fn socket_patch_names_are_exact() { - assert_eq!( - socket_patch_name_uuid(&format!("socket-patch-{UUID_A}"), false).as_deref(), - Some(UUID_A) - ); - assert_eq!( - socket_patch_name_uuid(&format!("socket-patch-vendor-{UUID_A}"), true).as_deref(), - Some(UUID_A) - ); - // The hosted grammar never matches the vendored id and vice versa. - assert_eq!( - socket_patch_name_uuid(&format!("socket-patch-vendor-{UUID_A}"), false), - None - ); - assert_eq!( - socket_patch_name_uuid(&format!("socket-patch-{UUID_A}"), true), - None - ); - assert_eq!( - socket_patch_name_uuid(&format!("socket-patch-{UUID_A}-x"), false), - None - ); - assert_eq!(socket_patch_name_uuid("socket-patch-nope", false), None); - } - #[test] fn push_validates_canonicalizes_and_dedupes() { let mut out = Discovery::default(); @@ -4769,100 +4306,6 @@ mod tests { ); } - /// [`DiscoverCtx::hosted_uuid`]'s plain-url shortcut answers exactly - /// what the full parse does, for plain urls on other hosts and for - /// every spelling it must leave to the parse. - #[test] - fn hosted_uuid_shortcut_agrees_with_the_full_parse() { - let (a, t) = (UUID_A, TOKEN); - let path = format!("/patch/npm/x/1.0.0/{t}/{a}/x-1.0.0.tgz"); - let hosts = [ - "patch.socket.dev", - "PATCH.Socket.DEV", - "patch.socket.dev.", - "patch.socket.dev:443", - "patch.socket.dev:8443", - "patch.socket.dev:", - "registry.npmjs.org", - "registry.npmjs.org:443", - "127.0.0.1:4545", - "127.0.0.1", - "0x7f.0.0.1:4545", - "127.1:4545", - "[::1]:4545", - "staging.example.com:8443", - "Staging.Example.COM:8443", - "staging.example.com", - "xn--stging-bua.example.com", - "user@patch.socket.dev", - "user:pw@registry.npmjs.org", - "patch%2esocket.dev", - "patch.soc\tket.dev", - "patch.socket.dev\\", - "foo.123", - "foo.0x1f", - "", - ]; - let urls: Vec = hosts - .iter() - .flat_map(|host| { - [ - format!("https://{host}{path}"), - format!("http://{host}{path}"), - format!("HTTPS://{host}{path}"), - format!("sparse+https://{host}{path}"), - format!("git+https://{host}{path}"), - format!("https:///{host}{path}"), - format!("https://{host}?q={path}"), - format!(" https://{host}{path} "), - format!("https://{host}"), - ] - }) - .chain([ - format!("https:\\/\\/patch.socket.dev{}", path.replace('/', "\\/")), - format!("https%3A%2F%2Fpatch.socket.dev{}", path.replace('/', "%2F")), - "file:x.tgz".to_string(), - "npm:x@1.0.0".to_string(), - "https://".to_string(), - ]) - .collect(); - for origins in [ - vec![], - vec!["http://127.0.0.1:4545".to_string()], - vec![ - "https://staging.example.com:8443".to_string(), - "http://[::1]:4545".to_string(), - "https://xn--stging-bua.example.com".to_string(), - ], - ] { - let ctx = DiscoverCtx::with_origins(Path::new("/nonexistent"), &origins); - for url in &urls { - assert_eq!( - ctx.hosted_uuid(url), - crate::patch::redirect::hosted_patch_uuid(url, &origins), - "{url} under {origins:?}" - ); - } - assert_eq!( - ctx.hosted_uuid(&format!("https://patch.socket.dev{path}")) - .as_deref(), - Some(a) - ); - } - assert_eq!( - plain_url_domain("https://Registry.NPMJS.org/x"), - Some("registry.npmjs.org".into()) - ); - for not_plain in [ - "https://127.0.0.1/x", - "https://a%2eb.com/x", - "https://u@a.com/x", - "https://xn--a.com/x", - ] { - assert_eq!(plain_url_domain(not_plain), None, "{not_plain}"); - } - } - /// The claim API: a recognized uuid is decided by the refs alone (live /// only for the package — and, vendored, the artifact — a ref wires), /// while an unrecognized one is left to the caller (`None`). diff --git a/scripts/loc-budget.json b/scripts/loc-budget.json index 28d387299..efdb902f1 100644 --- a/scripts/loc-budget.json +++ b/scripts/loc-budget.json @@ -2,6 +2,7 @@ "groups": { "lock-readers": { "paths": [ + "crates/socket-patch-core/src/origin.rs", "crates/socket-patch-core/src/formats/", "crates/socket-patch-core/src/vex/discover/", "crates/socket-patch-core/src/vendor/lock_inventory/", @@ -16,7 +17,7 @@ "crates/socket-patch-core/src/utils/pipenv.rs", "crates/socket-patch-core/src/crawlers/deno_crawler.rs" ], - "max": 30898 + "max": 30976 }, "lock-writers": { "paths": [ @@ -29,7 +30,7 @@ "crates/socket-patch-core/src/vendor/npm_origin.rs", "crates/socket-patch-core/src/vendor/test_support" ], - "max": 88363 + "max": 88314 } } } From fbe644d8bcfb081da9357e2cd64860cda395362b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 6 Nov 2026 09:13:49 -0600 Subject: [PATCH 2/2] Address the review of the origin move - The extractor guard also bans OriginPolicy and crate::origin:: (not bare origin::, which would catch crate::vendor::npm_origin::), and vex::discover no longer re-exports OriginPolicy; mod.rs imports it from crate::origin. - The lock_inventory model guard bans OriginPolicy, crate::origin:: and hosted_uuid(; the formats guard bans OriginPolicy, hosted_uuid( and SOCKET_PATCH_SERVER_HOST. formats may still use origin's pure path grammar (vendor_ref), which applies no host policy. - socket_identities takes &OriginPolicy. The policy memoizes one lowercased host set, shared by hosted_uuid's fast path and a case-insensitive matcher (host_finder), so a discovery run builds the matcher once instead of on every swept file. hosted_uuids_in_text keeps its signature. Answers are unchanged: reqwest already lowercases http(s) hosts. - origin.rs's module doc states one classifier as the goal and names the readers that still classify on their own; it no longer links pub(crate) items (rustdoc private_intra_doc_links). - socket_patch_name_uuid is pub(crate) again; hosted_patch_url_uuids loses its duplicate is_canonical_uuid import. - patch::redirect stops re-exporting hosted_patch_uuid, hosted_patch_url_uuids and SOCKET_PATCH_SERVER_HOST; vlt_heal, upstream, lock_inventory::pypi and discover use crate::origin directly. Extractors keep importing origin helpers through vex::discover's re-export (now with SOCKET_PATCH_SERVER_HOST for pypi_other), as the guard requires. - New test: DiscoverCtx::with_origins(..).hosted_uuid(..) honours a configured origin and only for that run. Budget raised by hand: lock-readers 30540 -> 30544 (the shared host matcher and the re-export note), lock-writers 88313 -> 88315 (rustfmt wraps the upstream origin path). Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-core/src/formats/mod.rs | 7 +- crates/socket-patch-core/src/origin.rs | 63 +++++++---- .../src/patch/redirect/generation.rs | 2 +- .../src/patch/redirect/mod.rs | 2 - .../src/patch/redirect/upstream/mod.rs | 9 +- .../src/patch/redirect/vlt_heal.rs | 2 +- .../src/vendor/lock_inventory/mod.rs | 5 +- .../src/vendor/lock_inventory/pypi.rs | 4 +- crates/socket-patch-core/src/vendor/path.rs | 2 +- .../socket-patch-core/src/vex/discover/mod.rs | 101 +++++++++++------- .../src/vex/discover/pypi_other.rs | 6 +- scripts/loc-budget.json | 4 +- 12 files changed, 133 insertions(+), 74 deletions(-) diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 3f8fcb874..8c984979b 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -51,14 +51,19 @@ pub use registry::registry; mod architecture_tests { use std::path::Path; - const IMPURE: [&str; 9] = [ + const IMPURE: [&str; 12] = [ "tokio::fs", "std::fs", "read_regular_", "File::open", "OpenOptions", + // The host policy (`crate::origin`). Its pure path grammar + // (`origin::vendor_ref`) is allowed: it applies no host policy. "hosted_patch_uuid", "hosted_patch_url_uuids", + "hosted_uuid(", + "OriginPolicy", + "SOCKET_PATCH_SERVER_HOST", "async fn", ".await", ]; diff --git a/crates/socket-patch-core/src/origin.rs b/crates/socket-patch-core/src/origin.rs index 27714f2d4..6e0c168b3 100644 --- a/crates/socket-patch-core/src/origin.rs +++ b/crates/socket-patch-core/src/origin.rs @@ -1,20 +1,27 @@ //! Socket origin: whether a string a lockfile or package-manager config //! records routes a package to a Socket patch, and to which one. //! -//! This is the one classifier every reader applies: +//! The goal is one classifier that every reader applies. Today discovery and +//! the redirect's hosted unwind apply it, but some readers still classify +//! on their own: the lock inventory's PyPI view +//! (`vendor::lock_inventory::pypi::hosted_pypi_reference`) adds its own +//! url-tail grammar, discovery's PyPI diagnostics (`vex::discover::pypi_other`) +//! compare hosts directly, the upstream restore keeps its own known-origin +//! list, and the inventory matches hosted urls on any host. The lock-model +//! stack moves them here (`docs/design/lock-model-unification.md`). //! //! * **hosted** — [`hosted_patch_uuid`]: an `http(s)` url on Socket's patch //! server or on an operator-configured origin, the patch uuid its last -//! canonical-uuid path segment. [`OriginPolicy::hosted_uuid`] answers the +//! canonical-uuid path segment. `OriginPolicy::hosted_uuid` answers the //! same question with a memoized fast path for the plain registry urls //! every lock is full of; //! * **vendored** — [`vendor_ref`] / [`vendor_ref_decorated`] / //! [`vendor_uuid_dir`]: a path that STARTS at this project's //! `.socket/vendor///`, its leaf traversal-safe; -//! * **names** — [`socket_patch_name_uuid`]: the `socket-patch-` +//! * **names** — `socket_patch_name_uuid`: the `socket-patch-` //! registry, repository and source names. //! -//! [`OriginPolicy::locate`] computes the hosted and vendored answers for one +//! `OriginPolicy::locate` computes the hosted and vendored answers for one //! string at once. //! //! The format models ([`crate::formats`]) stay pure: they hand out the raw @@ -74,7 +81,6 @@ pub fn hosted_patch_uuid(url: &str, extra_origins: &[String]) -> Option /// uses it to RECOGNIZE every Socket identity a lockfile mentions, including /// the malformed or rejected shapes whose "last uuid" is not a patch. pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option> { - use crate::patch::path_safety::is_canonical_uuid; use crate::utils::purl::percent_decode_purl_component; let unescaped = url.trim().replace("\\/", "/"); @@ -174,8 +180,11 @@ fn plain_url_domain(url: &str) -> Option { #[derive(Debug)] pub(crate) struct OriginPolicy<'a> { origins: &'a [String], - /// The hosts a Socket-hosted url can name (see [`Self::hosted_uuid`]). + /// The hosts a Socket-hosted url can name, lowercased (see + /// [`Self::hosts`]). hosts: OnceLock>, + /// [`Self::hosts`] as an ASCII-case-insensitive matcher. + host_finder: OnceLock, } impl<'a> OriginPolicy<'a> { @@ -183,6 +192,7 @@ impl<'a> OriginPolicy<'a> { OriginPolicy { origins, hosts: OnceLock::new(), + host_finder: OnceLock::new(), } } @@ -191,6 +201,33 @@ impl<'a> OriginPolicy<'a> { self.origins } + /// The hosts a Socket-hosted url can name: Socket's patch server and + /// each configured origin's host, lowercased. Built once per policy. + fn hosts(&self) -> &BTreeSet { + self.hosts.get_or_init(|| { + let mut hosts = BTreeSet::from([SOCKET_PATCH_SERVER_HOST.to_string()]); + hosts.extend(self.origins.iter().filter_map(|o| { + reqwest::Url::parse(o.trim()) + .ok()? + .host_str() + .map(str::to_ascii_lowercase) + })); + hosts + }) + } + + /// A matcher for any of [`Self::hosts`] in any ASCII case: the cheap + /// pre-filter of discovery's identity sweep (only a token naming an + /// accepted host can be ours). Built once per policy. + pub(crate) fn host_finder(&self) -> &aho_corasick::AhoCorasick { + self.host_finder.get_or_init(|| { + aho_corasick::AhoCorasick::builder() + .ascii_case_insensitive(true) + .build(self.hosts()) + .expect("literal host patterns") + }) + } + /// The patch uuid of a Socket-HOSTED url, or `None` for anything else /// (see [`hosted_patch_uuid`] for the accepted /// spellings and the host allowlist). @@ -200,17 +237,7 @@ impl<'a> OriginPolicy<'a> { /// here. pub(crate) fn hosted_uuid(&self, url: &str) -> Option { if let Some(host) = plain_url_domain(url) { - let hosts = self.hosts.get_or_init(|| { - let mut hosts = BTreeSet::from([SOCKET_PATCH_SERVER_HOST.to_string()]); - hosts.extend(self.origins.iter().filter_map(|o| { - reqwest::Url::parse(o.trim()) - .ok()? - .host_str() - .map(str::to_string) - })); - hosts - }); - if !hosts.contains(&host) { + if !self.hosts().contains(&host) { return None; } } @@ -241,7 +268,7 @@ impl<'a> OriginPolicy<'a> { /// maven ``, nuget ``) or, with /// `vendored = true`, `socket-patch-vendor-` (maven's vendored repo /// id). Exact grammar only — a suffix or a non-canonical uuid is not ours. -pub fn socket_patch_name_uuid(name: &str, vendored: bool) -> Option { +pub(crate) fn socket_patch_name_uuid(name: &str, vendored: bool) -> Option { crate::patch::redirect::generation::pin_name_uuid(name.trim(), vendored).map(str::to_string) } diff --git a/crates/socket-patch-core/src/patch/redirect/generation.rs b/crates/socket-patch-core/src/patch/redirect/generation.rs index a1889db08..bf28a3274 100644 --- a/crates/socket-patch-core/src/patch/redirect/generation.rs +++ b/crates/socket-patch-core/src/patch/redirect/generation.rs @@ -51,7 +51,7 @@ pub(crate) fn hosted_pin_name(uuid: &str) -> String { /// `socket-patch-vendor-` (maven's vendored repository id). /// No trimming: the rewriter must never treat a user's padded pin as its /// own, while lockfile discovery trims at its call site -/// (`vex::discover::socket_patch_name_uuid`). +/// (`origin::socket_patch_name_uuid`). pub(crate) fn pin_name_uuid(name: &str, vendored: bool) -> Option<&str> { let prefix = if vendored { VENDOR_PIN_NAME_PREFIX diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 706b6eb21..f28fa9f14 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7606,8 +7606,6 @@ pub fn grant_token_path_segment(url: &str, patch_uuid: &str) -> Option { (!token.is_empty()).then(|| token.to_string()) } -pub use crate::origin::{hosted_patch_url_uuids, hosted_patch_uuid, SOCKET_PATCH_SERVER_HOST}; - /// A dep's Socket index URL as a regex source with the per-request rotating /// segments (grant token, patch uuid) wildcarded — an exact-URL pattern /// misses the URL a previous run wrote under an older grant. The grant token diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 1b1e54f55..4ab24aa93 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -476,8 +476,11 @@ pub fn foreign_dep_origins<'a>( dep_origins(deps) .into_iter() .filter(|origin| { - let known = std::iter::once(format!("https://{}", super::SOCKET_PATCH_SERVER_HOST)) - .chain(configured.iter().cloned()); + let known = std::iter::once(format!( + "https://{}", + crate::origin::SOCKET_PATCH_SERVER_HOST + )) + .chain(configured.iter().cloned()); !known .into_iter() .any(|k| dep_origins_of_url(&k).as_deref() == Some(origin.as_str())) @@ -768,7 +771,7 @@ impl Ctx<'_> { /// The hosted patch uuid `url` names, under the same host allowlist /// discovery applies. pub(crate) fn hosted_uuid(&self, url: &str) -> Option { - super::hosted_patch_uuid(url, self.origins) + crate::origin::hosted_patch_uuid(url, self.origins) } } diff --git a/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs b/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs index 13a28b124..dd5b82513 100644 --- a/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs +++ b/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs @@ -14,9 +14,9 @@ use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; -use super::hosted_patch_uuid; use crate::constants::npm_family::{VLT_HIDDEN_LOCK_REL, VLT_STORE_DIR}; use crate::manifest::schema::PatchRecord; +use crate::origin::hosted_patch_uuid; use crate::patch::apply::{is_safe_relative_subpath, normalize_file_path}; use crate::patch::file_hash::compute_file_git_sha256; use crate::patch::package::read_archive_bytes_to_map_strict; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 3d4bc58fb..7dc3dda28 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -402,7 +402,7 @@ mod architecture_tests { const PURE_READER: &str = "// ── pure reader ──"; /// Anything that reads content, does I/O, or applies the hosted-origin /// policy. - const IMPURE: [&str; 9] = [ + const IMPURE: [&str; 12] = [ "tokio::fs", "std::fs", "read_regular_", @@ -410,6 +410,9 @@ mod architecture_tests { "OpenOptions", "hosted_patch_uuid", "hosted_patch_url_uuids", + "hosted_uuid(", + "OriginPolicy", + "crate::origin::", "async fn", ".await", ]; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs index b1822066a..2d9636f7f 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs @@ -173,12 +173,12 @@ pub(crate) fn hosted_artifact_url(url: &str) -> Result/// /// /` tail is matched from the END, so a path-prefixed /// `--patch-server-url` deployment and a hosted sdist are recognized too. pub(crate) fn hosted_pypi_reference(url: &str, origins: &[String]) -> Option { - crate::patch::redirect::hosted_patch_url_uuids(url, origins)?; + crate::origin::hosted_patch_url_uuids(url, origins)?; hosted_artifact_url(url).ok().filter(|coords| { coords .uuid_level diff --git a/crates/socket-patch-core/src/vendor/path.rs b/crates/socket-patch-core/src/vendor/path.rs index 993d8b5d3..86c643154 100644 --- a/crates/socket-patch-core/src/vendor/path.rs +++ b/crates/socket-patch-core/src/vendor/path.rs @@ -289,7 +289,7 @@ fn split_nuget_leaf(stem: &str) -> Option<(&str, &str)> { /// /// The leaf must be CLEAN (no lock-format suffix such as yarn's `#` or /// berry's `::locator=…`) and pypi dist names come back un-canonicalized; -/// `vex::discover::vendored_leaf_purl` strips the former and canonicalizes +/// `origin::vendored_leaf_purl` strips the former and canonicalizes /// the latter for lockfile-discovered references. pub(crate) fn leaf_to_purl(eco: &str, leaf: &str) -> Option { match eco { diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 5c08758c3..fcefec8b5 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -78,7 +78,7 @@ //! panic, never `unwrap` on file data, bound every recursion. //! 4. **Hosted identity** comes ONLY from [`DiscoverCtx::hosted_uuid`] (the //! Socket host allowlist + last-canonical-uuid-segment rule of -//! [`crate::patch::redirect::hosted_patch_uuid`]) — except identities +//! [`crate::origin::hosted_patch_uuid`]) — except identities //! that are not URLs: Go's `patch.socket.dev/gopatch/` module path //! ([`crate::vendor::go_mod_edit::HOSTED_GO_MODULE_PREFIX`]) and the //! `socket-patch-` registry / repository / source names @@ -197,9 +197,14 @@ use crate::vendor::lock_inventory::{ use crate::vendor::path::{ecosystem_dir_for_purl, ECOSYSTEM_DIRS, VENDOR_DIR}; use crate::vendor::state::VendorEntry; +use crate::origin::OriginPolicy; +// The origin helpers the extractors use, re-exported so they import every +// identity helper from here (the architecture guard below bans +// `crate::origin::` in extractor files: hosted identity comes only from +// `DiscoverCtx::hosted_uuid`). pub(crate) use crate::origin::{ mentions_vendor_dir, names_vendor_dir, npm_vendored_tarball_names, root_anchored_spelling, - socket_patch_name_uuid, LocateOpts, Located, OriginPolicy, Wired, + socket_patch_name_uuid, LocateOpts, Located, Wired, SOCKET_PATCH_SERVER_HOST, }; pub use crate::origin::{ strip_leaf_suffix, vendor_ref, vendor_ref_decorated, vendor_uuid_dir, vendored_leaf_purl, @@ -1578,8 +1583,9 @@ async fn ignore_member_stray_locks(ctx: &mut DiscoverCtx<'_>, out: &mut Discover }; for rel in &stray.ignored { let mentions = match ctx.view.read_bytes(rel).await { - Ok(bytes) => !socket_identities(&String::from_utf8_lossy(&bytes), ctx.origin.origins()) - .is_empty(), + Ok(bytes) => { + !socket_identities(&String::from_utf8_lossy(&bytes), &ctx.origin).is_empty() + } Err(_) => false, }; ctx.recognize_ignored(rel).await; @@ -1691,7 +1697,7 @@ impl<'a> DiscoverCtx<'a> { /// Record every Socket identity `text` (the content of root-relative /// `rel`) mentions — see [`socket_identities`]. fn recognize_text(&self, rel: &str, text: &str) { - let found = socket_identities(text, self.origin.origins()); + let found = socket_identities(text, &self.origin); if found.is_empty() { return; } @@ -1903,7 +1909,7 @@ impl<'a> DiscoverCtx<'a> { /// ids, `§` in legacy ids), since a vlt node key may be its only mention; /// * [`WiringMode::Hosted`]: every canonical-uuid path segment of a url on /// an accepted patch-server origin -/// ([`crate::patch::redirect::hosted_patch_url_uuids`]: plain, +/// ([`crate::origin::hosted_patch_url_uuids`]: plain, /// `sparse+` / `registry+` prefixed, or a wholly percent-encoded berry /// `__archiveUrl`), and every canonical-uuid segment of a /// `patch.socket.dev/gopatch/…` Go module path. @@ -1917,9 +1923,10 @@ impl<'a> DiscoverCtx<'a> { /// /// Memoized by content ([`IdentityMemo`]): one scan runs several /// discoveries over mostly byte-identical lockfiles, and this sweep is the -/// largest per-file cost after parsing. -fn socket_identities(text: &str, origins: &[String]) -> BTreeSet<(String, WiringMode)> { - IdentityMemo::sweep(text, origins) +/// largest per-file cost after parsing. The host matcher is the policy's +/// own ([`OriginPolicy::host_finder`]), built once per run. +fn socket_identities(text: &str, policy: &OriginPolicy<'_>) -> BTreeSet<(String, WiringMode)> { + IdentityMemo::sweep(text, policy) } /// The process-wide memo behind [`socket_identities`]: the sweep's result @@ -1947,12 +1954,12 @@ impl IdentityMemo { ENTRIES.get_or_init(|| Mutex::new(std::collections::VecDeque::new())) } - fn sweep(text: &str, origins: &[String]) -> IdentitySet { + fn sweep(text: &str, policy: &OriginPolicy<'_>) -> IdentitySet { if text.len() < Self::MIN_LEN { - return socket_identities_inner(text, origins); + return socket_identities_inner(text, policy); } use sha2::Digest as _; - let mut origin_key = origins.to_vec(); + let mut origin_key = policy.origins().to_vec(); origin_key.sort(); origin_key.dedup(); let key: IdentityKey = (sha2::Sha256::digest(text.as_bytes()).into(), origin_key); @@ -1964,7 +1971,7 @@ impl IdentityMemo { if let Some((_, found)) = lock().iter().find(|(k, _)| *k == key) { return found.clone(); } - let found = socket_identities_inner(text, origins); + let found = socket_identities_inner(text, policy); let mut entries = lock(); if !entries.iter().any(|(k, _)| *k == key) { if entries.len() == Self::CAPACITY { @@ -1980,14 +1987,17 @@ impl IdentityMemo { /// rules as discovery's sweep (the upstream restore's "does anything in /// this file still need the hosted-side setting" probe). pub(crate) fn hosted_uuids_in_text(text: &str, origins: &[String]) -> BTreeSet { - socket_identities(text, origins) + socket_identities(text, &OriginPolicy::new(origins)) .into_iter() .filter(|(_, mode)| *mode == WiringMode::Hosted) .map(|(uuid, _)| uuid) .collect() } -fn socket_identities_inner(text: &str, origins: &[String]) -> BTreeSet<(String, WiringMode)> { +fn socket_identities_inner( + text: &str, + policy: &OriginPolicy<'_>, +) -> BTreeSet<(String, WiringMode)> { use aho_corasick::AhoCorasick; use std::borrow::Cow; @@ -2048,17 +2058,7 @@ fn socket_identities_inner(text: &str, origins: &[String]) -> BTreeSet<(String, // Cheap pre-filter: only a token naming an accepted host (any ASCII // case) can be ours. - let mut hosts = vec![crate::patch::redirect::SOCKET_PATCH_SERVER_HOST.to_string()]; - hosts.extend(origins.iter().filter_map(|o| { - reqwest::Url::parse(o.trim()) - .ok()? - .host_str() - .map(str::to_ascii_lowercase) - })); - let host_finder = AhoCorasick::builder() - .ascii_case_insensitive(true) - .build(&hosts) - .expect("literal host patterns"); + let host_finder = policy.host_finder(); static SEPARATORS: std::sync::OnceLock = std::sync::OnceLock::new(); let separators = SEPARATORS.get_or_init(|| { AhoCorasick::builder() @@ -2074,7 +2074,7 @@ fn socket_identities_inner(text: &str, origins: &[String]) -> BTreeSet<(String, if !host_finder.is_match(&bytes[start..end]) { continue; } - let uuids = crate::patch::redirect::hosted_patch_url_uuids(&norm[start..end], origins); + let uuids = crate::origin::hosted_patch_url_uuids(&norm[start..end], policy.origins()); for uuid in uuids.into_iter().flatten() { found.insert((uuid, WiringMode::Hosted)); } @@ -4156,15 +4156,16 @@ mod tests { fn socket_identities_recognize_every_spelling_and_nothing_else() { let a = UUID_A; let t = TOKEN; + let none = OriginPolicy::new(&[]); let hosted = |text: &str| { - socket_identities(text, &[]) + socket_identities(text, &none) .into_iter() .filter(|(_, m)| *m == WiringMode::Hosted) .map(|(u, _)| u) .collect::>() }; let vendored = |text: &str| { - socket_identities(text, &[]) + socket_identities(text, &none) .into_iter() .filter(|(_, m)| *m == WiringMode::Vendored) .map(|(u, _)| u) @@ -4241,17 +4242,20 @@ mod tests { ".socket/vendor/npm/not-a-uuid/x.tgz".to_string(), "https://patch.socket.dev/patch/npm/x/1.0.0/tok/placeholder/x.tgz".to_string(), ] { - assert!(socket_identities(&text, &[]).is_empty(), "{text}"); + assert!(socket_identities(&text, &none).is_empty(), "{text}"); } // A configured patch-server origin counts only when configured. let staging = format!("\"resolved\": \"http://127.0.0.1:4545/patch/npm/x/1/{t}/{a}/x.tgz\""); - assert!(socket_identities(&staging, &[]).is_empty()); + assert!(socket_identities(&staging, &none).is_empty()); assert_eq!( - socket_identities(&staging, &["http://127.0.0.1:4545".to_string()]) - .into_iter() - .map(|(u, _)| u) - .collect::>(), + socket_identities( + &staging, + &OriginPolicy::new(&["http://127.0.0.1:4545".to_string()]) + ) + .into_iter() + .map(|(u, _)| u) + .collect::>(), both ); } @@ -4273,7 +4277,7 @@ mod tests { }; let staging = ["http://127.0.0.1:4545".to_string()]; let uuids = |text: &str, origins: &[String]| { - socket_identities(text, origins) + socket_identities(text, &OriginPolicy::new(origins)) .into_iter() .map(|(u, m)| (u, m == WiringMode::Hosted)) .collect::>() @@ -4301,9 +4305,26 @@ mod tests { ]; assert_eq!(uuids(&lock(a), &twice), uuids(&lock(a), &staging)); assert_eq!( - socket_identities(&lock(a), &staging), - socket_identities_inner(&lock(a), &staging) + socket_identities(&lock(a), &OriginPolicy::new(&staging)), + socket_identities_inner(&lock(a), &OriginPolicy::new(&staging)) + ); + } + + /// The ctx classifies under the origins it was built with: a url on a + /// configured `--patch-server-url` origin is hosted for that run only + /// (the classifier itself is tested in `crate::origin`). + #[test] + fn ctx_hosted_uuid_honours_the_runs_configured_origins() { + let root = Path::new("/nonexistent"); + let staging = ["http://127.0.0.1:4545".to_string()]; + let url = format!("http://127.0.0.1:4545/patch/npm/x/1.0.0/{TOKEN}/{UUID_A}/x-1.0.0.tgz"); + let ctx = DiscoverCtx::with_origins(root, &staging); + assert_eq!(ctx.hosted_uuid(&url).as_deref(), Some(UUID_A)); + assert_eq!( + ctx.hosted_uuid("https://registry.npmjs.org/x/-/x-1.0.0.tgz"), + None ); + assert_eq!(DiscoverCtx::with_origins(root, &[]).hosted_uuid(&url), None); } /// The claim API: a recognized uuid is decided by the refs alone (live @@ -4502,6 +4523,10 @@ mod tests { "OpenOptions", "hosted_patch_uuid(", "hosted_patch_url_uuids(", + // The policy itself, and the origin module (its helpers come + // re-exported from this module; `npm_origin::` is unrelated). + "OriginPolicy", + "crate::origin::", // The file-reading entry points of the shared readers an // extractor imports (the lock inventory, the writers' config // readers): each reads content outside the recognizing ctx. diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index 9a724b3e6..313ec2f6f 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -116,7 +116,7 @@ use toml_edit::DocumentMut; use super::{ mentions_vendor_dir, pypi_purl, toml_or_diag, vendored_leaf_purl, DiscoverCtx, Discovery, LocateOpts, PatchedRef, TomlDiag, VendorRef, Wired, DIAG_LOCKFILE_UNPARSEABLE, - DIAG_LOCKFILE_UNREADABLE, DIAG_REF_INVALID, + DIAG_LOCKFILE_UNREADABLE, DIAG_REF_INVALID, SOCKET_PATCH_SERVER_HOST, }; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::digest::sha256_hex; @@ -572,9 +572,7 @@ fn classify(ctx: &DiscoverCtx<'_>, location: &str, uri: bool) -> Result/ path" )); } - if reqwest::Url::parse(location) - .is_ok_and(|u| u.host_str() == Some(crate::patch::redirect::SOCKET_PATCH_SERVER_HOST)) - { + if reqwest::Url::parse(location).is_ok_and(|u| u.host_str() == Some(SOCKET_PATCH_SERVER_HOST)) { return Err(format!( "{location:?} is a Socket patch-server url that names no patch uuid" )); diff --git a/scripts/loc-budget.json b/scripts/loc-budget.json index bfe5d936b..272b903fa 100644 --- a/scripts/loc-budget.json +++ b/scripts/loc-budget.json @@ -17,7 +17,7 @@ "crates/socket-patch-core/src/utils/pipenv.rs", "crates/socket-patch-core/src/crawlers/deno_crawler.rs" ], - "max": 30540 + "max": 30544 }, "lock-writers": { "paths": [ @@ -30,7 +30,7 @@ "crates/socket-patch-core/src/vendor/npm_origin.rs", "crates/socket-patch-core/src/vendor/test_support" ], - "max": 88313 + "max": 88315 } } }