From 38ca2987903894ed9c8eccce52ebb92d112f4776 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:10:01 +0000 Subject: [PATCH 1/7] Start refactor for #562 Assisted-by: Claude Code:claude-opus-5-5 From 1bc43883d11d789f5e359d1eb733166e21a8b107 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:21:24 +0000 Subject: [PATCH 2/7] Resolve vlt registry bases in one place vlt lock inventory (vendored fetch, VEX) and hosted rollback/remove each turned a DepID registry segment into a registry URL with their own precedence, so one lock could resolve to two registries. Both now call vlt_lock_text::registry_base, which follows vlt's DepID hydration: a mapped alias is its registries URL, the default registry is options.registry, then the default alias's URL, then npmjs. The private copies, the inline tarball URL, restore's NPM_REGISTRY and its default_alias helper are deleted. Fixes #562 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/vlt.rs | 122 ++++++++--------- .../src/vendor/lock_inventory/tests.rs | 27 ++++ .../src/vendor/lock_inventory/vlt.rs | 39 +----- .../src/vendor/vlt_lock_text.rs | 123 +++++++++++++++++- 4 files changed, 211 insertions(+), 100 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index 92cc07244..95eea235e 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -25,13 +25,11 @@ use serde_json::{Map, Value}; use super::npm::{by_uuid, fetch_dists, read_or_refuse, refuse_all_in}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::vendor::vlt_lock_text::{ - entry_text, is_default_registry, nodes_block, parse_node_line, render_entry_line, - render_tuple_with_slots, sniff_lock, split_dep_id, split_lines, DepIdEra, DepIdKind, LockSniff, + default_registry_alias, entry_text, is_default_registry, nodes_block, parse_node_line, + registry_base, render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, + split_lines, DepIdEra, DepIdKind, LockSniff, }; -/// The public npm registry, the default registry of an unconfigured lock. -const NPM_REGISTRY: &str = "https://registry.npmjs.org/"; - /// One hosted node line to restore. struct Hit { line: usize, @@ -43,34 +41,6 @@ struct Hit { segment: String, } -/// The default alias of a lock (`default-registry-alias`, else `npm`). -fn default_alias(options: Option<&Map>) -> &str { - options - .and_then(|o| o.get("default-registry-alias")) - .and_then(Value::as_str) - .unwrap_or("npm") -} - -/// The registry base URL a default-registry segment resolves to. -fn registry_base(segment: &str, options: Option<&Map>) -> String { - if reqwest::Url::parse(segment).is_ok_and(|u| matches!(u.scheme(), "http" | "https")) { - return segment.to_string(); - } - let alias = if segment.is_empty() { - default_alias(options) - } else { - segment - }; - let str_opt = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_string); - str_opt( - options - .and_then(|o| o.get("registries")) - .and_then(|r| r.get(alias)), - ) - .or_else(|| str_opt(options.and_then(|o| o.get("registry")))) - .unwrap_or_else(|| NPM_REGISTRY.to_string()) -} - /// The conventional tarball URL of `name@version` on `base`. fn tarball_url(base: &str, name: &str, version: &str) -> String { crate::vendor::registry_fetch::npm_tarball_url(base.trim_end_matches('/'), name, version) @@ -98,7 +68,7 @@ fn records_url( && options .and_then(|o| o.get("registries")) .is_some_and(Value::is_object) - && (segment.is_empty() || segment == default_alias(options)) + && (segment.is_empty() || default_registry_alias(options) == Some(segment)) && !under_configured_registry(segment, options) } @@ -114,8 +84,7 @@ fn under_configured_registry(segment: &str, options: Option<&Map> else { return false; }; - let resolved_prefix = format!("{}/", registry_base(segment, options).trim_end_matches('/')); - resolved_prefix.starts_with(registry) + registry_base(segment, options).is_some_and(|base| base.starts_with(registry)) } pub(crate) async fn restore( @@ -277,13 +246,18 @@ pub(crate) async fn restore( }; let line = parse_node_line(lines[hit.line]).expect("the hit line parsed above"); let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); - let slot3 = records_url(hit.era, &hit.segment, &siblings, options).then(|| { - json(&tarball_url( - ®istry_base(&hit.segment, options), - &hit.name, - &hit.version, - )) - }); + let slot3 = if records_url(hit.era, &hit.segment, &siblings, options) { + let Some(base) = registry_base(&hit.segment, options) else { + result.refuse( + &hit.uuid, + format!("{rel} maps no registry for the `{}` segment", hit.segment), + ); + continue; + }; + Some(json(&tarball_url(&base, &hit.name, &hit.version))) + } else { + None + }; let tuple = render_tuple_with_slots( &line.entry.elems, Some(&json(&integrity)), @@ -335,22 +309,7 @@ mod tests { } #[test] - fn registry_base_follows_the_segment() { - let o = - opts(r#"{"registries":{"npm":"https://mirror.example/npm/","corp":"https://corp/"}}"#); - assert_eq!(registry_base("", Some(&o)), "https://mirror.example/npm/"); - assert_eq!( - registry_base("npm", Some(&o)), - "https://mirror.example/npm/" - ); - assert_eq!(registry_base("corp", Some(&o)), "https://corp/"); - assert_eq!( - registry_base("https://registry.example.com/", Some(&o)), - "https://registry.example.com/" - ); - assert_eq!(registry_base("npm", None), NPM_REGISTRY); - let scalar = opts(r#"{"registry":"https://r.example/"}"#); - assert_eq!(registry_base("", Some(&scalar)), "https://r.example/"); + fn tarball_url_keeps_the_scope_in_the_path() { assert_eq!( tarball_url("https://mirror.example/npm/", "@a/b", "1.0.0"), "https://mirror.example/npm/@a/b/-/b-1.0.0.tgz" @@ -526,6 +485,51 @@ mod tests { assert!(!after.contains("patch.socket.dev")); } + /// Restore rebuilds slot [3] on the base the shared `registry_base` + /// resolves, for every default-registry row of the table, and refuses + /// every other row (the lock does not name vlt's default registry). + #[tokio::test] + #[serial_test::serial] + async fn restore_resolves_registries_through_the_shared_registry_base() { + use crate::vendor::vlt_lock_text::REGISTRY_BASE_CASES; + let server = registry(&[("left-pad", "1.3.0", Some(LP_UPSTREAM))]).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let url = hosted(LP_UUID, "left-pad-1.3.0.tgz"); + for (segment, options, want) in REGISTRY_BASE_CASES { + if segment.starts_with("http") { + continue; + } + // A same-era sibling that records slot [3] makes restore write + // one for the pin. + let text = format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {options},\n \"nodes\": {{\n \"~{segment}~left-pad@1.3.0\": [0,\"left-pad\",\"sha512-AA==\",\"{url}\"],\n \"~{segment}~ms@2.1.3\": [0,\"ms\",\"sha512-M==\",\"https://x.example/ms/-/ms-2.1.3.tgz\"]\n }},\n \"edges\": {{}}\n}}\n" + ); + let (outcome, after) = + run(&text, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; + let opts = opts(options); + if is_default_registry(segment, Some(&opts)) { + let want = want.expect("a default registry always has a base"); + assert!( + refused(&outcome).is_empty(), + "{segment:?} {options}: {:?}", + refused(&outcome) + ); + let upstream = format!( + "[0,\"left-pad\",\"{LP_UPSTREAM}\",\"{want}left-pad/-/left-pad-1.3.0.tgz\"]" + ); + assert!(after.contains(&upstream), "{segment:?} {options}: {after}"); + } else { + let why = refused(&outcome); + assert!( + why[0].contains("not on vlt's default registry"), + "{segment:?} {options}: {why:?}" + ); + assert_eq!(after, text); + } + } + std::env::remove_var("SOCKET_NPM_REGISTRY"); + } + #[tokio::test] #[serial_test::serial] async fn missing_registry_integrity_refuses() { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index e43f5d85a..03eb32ec1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2698,6 +2698,33 @@ async fn vlt_default_registry_base_follows_the_lock_options() { } } +#[tokio::test] +async fn vlt_inventory_resolves_registries_through_the_shared_registry_base() { + use crate::vendor::vlt_lock_text::REGISTRY_BASE_CASES; + for (segment, options, want) in REGISTRY_BASE_CASES { + // A URL segment is percent-encoded in a DepID; the table's other + // rows cover every precedence rule. + if segment.starts_with("http") { + continue; + } + let tmp = tempfile::tempdir().unwrap(); + let node = format!(r#""~{segment}~@s/a@1.0.0": [0,"@s/a","sha512-a=="]"#); + write( + tmp.path(), + "vlt-lock.json", + &vlt_lock(options, &[node.as_str()]), + ) + .await; + let entries = inventory_vlt(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 1, "{segment:?} {options}"); + assert_eq!( + entries[0].resolved, + want.map(|base| format!("{base}@s/a/-/a-1.0.0.tgz")), + "{segment:?} {options}" + ); + } +} + #[tokio::test] async fn unreadable_vlt_locks_inventory_to_nothing() { let good = vlt_lock("{}", &[r#""~npm~a@1.0.0": [0,"a","sha512-a=="]"#]); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs index f84eed675..8c9dfcd24 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs @@ -9,9 +9,11 @@ use serde_json::{Map, Value}; use crate::constants::npm_family::VLT_LOCK; use super::view::ProjectView; use crate::vendor::vlt_lock_text::{ - is_default_registry, sniff_lock, split_dep_id, DepId, DepIdKind, LockSniff, + registry_base, sniff_lock, split_dep_id, DepId, DepIdKind, LockSniff, }; +use crate::vendor::registry_fetch::npm_tarball_url; + use super::{http_url, LockIntegrity, LockfileEntry}; // ── entry model ── @@ -89,38 +91,6 @@ pub(crate) fn vlt_lock_model(text: &str) -> Result { }) } -fn with_slash(url: &str) -> String { - if url.ends_with('/') { - url.to_string() - } else { - format!("{url}/") - } -} - -/// The registry base a node's segment names: the segment URL itself, the -/// alias's `options.registries` URL, or for the default registry -/// `options.registry`, `options.registries.npm`, else the public registry. -/// `None` for an alias the options do not map. -fn registry_base(segment: &str, options: Option<&Map>) -> Option { - let option = |path: &[&str]| { - let mut value = options.map(|o| Value::Object(o.clone()))?; - for key in path { - value = value.get(*key)?.clone(); - } - value.as_str().map(str::to_string) - }; - if segment.starts_with("https://") || segment.starts_with("http://") { - return Some(with_slash(segment)); - } - if is_default_registry(segment, options) { - let base = option(&["registry"]) - .or_else(|| option(&["registries", "npm"])) - .unwrap_or_else(|| "https://registry.npmjs.org/".to_string()); - return Some(with_slash(&base)); - } - option(&["registries", segment]).map(|base| with_slash(&base)) -} - /// The registry entries of a readable lock: every registry node whose slot /// [1] is its DepID name. The location is slot [3] when it is an http(s) /// URL (a Socket-hosted pin included: it is the installed pair), else the @@ -135,10 +105,9 @@ pub(crate) fn vlt_registry_entries(lock: &VltLock) -> Vec { if node.name != name { return None; } - let bare = name.rsplit('/').next().unwrap_or(name); let resolved = node.location.as_deref().and_then(http_url).or_else(|| { registry_base(&node.dep_id.first, options) - .map(|base| format!("{base}{name}/-/{bare}-{version}.tgz")) + .map(|base| npm_tarball_url(base.trim_end_matches('/'), name, version)) }); let integrity = node .integrity diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index e94386d93..9e36ec112 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -411,12 +411,7 @@ pub(crate) fn is_default_registry(segment: &str, options: Option<&Map Some("npm"), - Some(Value::String(alias)) => Some(alias.as_str()), - Some(_) => None, - }; - if alias == Some(segment) { + if default_registry_alias(options) == Some(segment) { return true; } let Some(registry) = options @@ -435,6 +430,58 @@ pub(crate) fn is_default_registry(segment: &str, options: Option<&Map>) -> Option<&str> { + match options.and_then(|o| o.get("default-registry-alias")) { + None | Some(Value::Null) => Some("npm"), + Some(Value::String(alias)) => Some(alias.as_str()), + Some(_) => None, + } +} + +/// The registry base URL (with a trailing `/`) a decoded DepID registry +/// segment names, given the lock's `options`, as vlt hydrates the DepID +/// (`@vltpkg/dep-id` `hydrateTuple`, `@vltpkg/spec`): +/// - an http(s) URL segment is its own base; +/// - a named segment is its `options.registries` URL when the lock maps it; +/// - the empty segment, and an unmapped segment that still names the +/// default registry ([`is_default_registry`]), is `options.registry`, +/// else the default alias's `options.registries` URL, else the public +/// npm registry (`registry ?? registries[default-registry-alias]`); +/// - any other segment is `None`: the lock names an alias it never maps. +pub(crate) fn registry_base(segment: &str, options: Option<&Map>) -> Option { + let string = |value: Option<&Value>| { + value + .and_then(Value::as_str) + .filter(|url| !url.is_empty()) + .map(with_trailing_slash) + }; + let alias_url = |alias: &str| { + string( + options + .and_then(|o| o.get("registries")) + .and_then(|r| r.get(alias)), + ) + }; + if reqwest::Url::parse(segment).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) { + return Some(with_trailing_slash(segment)); + } + if let Some(base) = Some(segment).filter(|s| !s.is_empty()).and_then(alias_url) { + return Some(base); + } + if !is_default_registry(segment, options) { + return None; + } + let base = string(options.and_then(|o| o.get("registry"))) + .or_else(|| default_registry_alias(options).and_then(alias_url)) + .unwrap_or_else(|| { + with_trailing_slash(crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY) + }); + Some(base) +} + /// Is `segment` an http(s) URL naming `options.registry` (a trailing `/` /// aside)? pub(crate) fn is_registry_url_segment(segment: &str, options: Option<&Map>) -> bool { @@ -448,6 +495,53 @@ pub(crate) fn is_registry_url_segment(segment: &str, options: Option<&Map)] = &[ + ("", "{}", Some("https://registry.npmjs.org/")), + ("npm", "{}", Some("https://registry.npmjs.org/")), + ( + "", + r#"{"registry":"https://a.example"}"#, + Some("https://a.example/"), + ), + ( + "", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + Some("https://a.example/"), + ), + ( + "npm", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + Some("https://b.example/"), + ), + ( + "npm", + r#"{"registries":{"npm":"https://b.example/npm"}}"#, + Some("https://b.example/npm/"), + ), + ( + "corp", + r#"{"default-registry-alias":"corp","registries":{"corp":"https://c.example/"}}"#, + Some("https://c.example/"), + ), + ( + "", + r#"{"default-registry-alias":"corp","registries":{"corp":"https://c.example/"}}"#, + Some("https://c.example/"), + ), + ( + "corp", + r#"{"registries":{"corp":"https://c.example/"}}"#, + Some("https://c.example/"), + ), + ("corp", r#"{"registry":"https://a.example/"}"#, None), + ("corp", "{}", None), + ("https://u.example", "{}", Some("https://u.example/")), +]; + // ── lock-level sniff ───────────────────────────────────────────────────── /// A `vlt-lock.json` that parsed as a JSON object with a known version. @@ -1674,6 +1768,23 @@ mod tests { } } + #[test] + fn registry_base_follows_vlt_dep_id_hydration() { + for (segment, opts, want) in REGISTRY_BASE_CASES { + let opts = options(opts); + assert_eq!( + registry_base(segment, Some(&opts)).as_deref(), + *want, + "{segment:?} with {opts:?}" + ); + } + assert_eq!( + registry_base("npm", None).as_deref(), + Some("https://registry.npmjs.org/") + ); + assert_eq!(registry_base("corp", None), None); + } + fn readable(text: &str) -> ParsedLock { match sniff_lock(text) { LockSniff::Readable(lock) => lock, From cb35d5a091c174faef4c2a7960eb74164f79784b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 14:44:27 -0400 Subject: [PATCH 3/7] Respect scoped registries when resolving vlt lock nodes --- .../src/patch/redirect/upstream/vlt.rs | 62 ++++++++++--- .../src/vendor/lock_inventory/tests.rs | 27 ++++++ .../src/vendor/lock_inventory/vlt.rs | 2 +- .../src/vendor/vlt_lock_text.rs | 89 +++++++++++++++---- 4 files changed, 150 insertions(+), 30 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index 95eea235e..97c51ab03 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -52,6 +52,7 @@ fn tarball_url(base: &str, name: &str, version: &str) -> String { fn records_url( era: DepIdEra, segment: &str, + name: &str, siblings: &[(DepIdEra, bool)], options: Option<&Map>, ) -> bool { @@ -69,14 +70,18 @@ fn records_url( .and_then(|o| o.get("registries")) .is_some_and(Value::is_object) && (segment.is_empty() || default_registry_alias(options) == Some(segment)) - && !under_configured_registry(segment, options) + && !under_configured_registry(segment, name, options) } /// Would a default-registry node on `segment` resolve under the lock's /// recorded `options.registry`? vlt omits slot [3] for such a node /// (`lockfile/save.ts`: `customRegistry = resolved && (!registry || /// !resolved.startsWith(registry))`). -fn under_configured_registry(segment: &str, options: Option<&Map>) -> bool { +fn under_configured_registry( + segment: &str, + name: &str, + options: Option<&Map>, +) -> bool { let Some(registry) = options .and_then(|o| o.get("registry")) .and_then(Value::as_str) @@ -84,7 +89,7 @@ fn under_configured_registry(segment: &str, options: Option<&Map> else { return false; }; - registry_base(segment, options).is_some_and(|base| base.starts_with(registry)) + registry_base(segment, name, options).is_some_and(|base| base.starts_with(registry)) } pub(crate) async fn restore( @@ -246,8 +251,8 @@ pub(crate) async fn restore( }; let line = parse_node_line(lines[hit.line]).expect("the hit line parsed above"); let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); - let slot3 = if records_url(hit.era, &hit.segment, &siblings, options) { - let Some(base) = registry_base(&hit.segment, options) else { + let slot3 = if records_url(hit.era, &hit.segment, &hit.name, &siblings, options) { + let Some(base) = registry_base(&hit.segment, &hit.name, options) else { result.refuse( &hit.uuid, format!("{rel} maps no registry for the `{}` segment", hit.segment), @@ -323,12 +328,14 @@ mod tests { assert!(!records_url( DepIdEra::Tilde, "npm", + "left-pad", &[(DepIdEra::Tilde, false)], Some(&with_registries) )); assert!(records_url( DepIdEra::Tilde, "npm", + "left-pad", &[(DepIdEra::Tilde, true)], None )); @@ -336,6 +343,7 @@ mod tests { assert!(!records_url( DepIdEra::Legacy, "npm", + "left-pad", &[(DepIdEra::Tilde, true)], Some(&with_registries) )); @@ -343,29 +351,31 @@ mod tests { assert!(records_url( DepIdEra::Tilde, "npm", + "left-pad", &[], Some(&with_registries) )); - assert!(!records_url(DepIdEra::Tilde, "npm", &[], None)); + assert!(!records_url(DepIdEra::Tilde, "npm", "left-pad", &[], None)); assert!(!records_url( DepIdEra::Tilde, "https://registry.example.com/", + "left-pad", &[], Some(&with_registries) )); let alias = opts(r#"{"default-registry-alias":"corp","registries":{"corp":"https://c/"}}"#); - assert!(records_url(DepIdEra::Tilde, "corp", &[], Some(&alias))); + assert!(records_url(DepIdEra::Tilde, "corp", "left-pad", &[], Some(&alias))); // A recorded `registry` the node resolves under: vlt (rc.33 … 1.2.0 // with `config.registry`) writes no slot [3]. let configured = opts( r#"{"registry":"http://127.0.0.1:4873/","registries":{"npm":"http://127.0.0.1:4873/"}}"#, ); - assert!(!records_url(DepIdEra::Tilde, "npm", &[], Some(&configured))); + assert!(!records_url(DepIdEra::Tilde, "npm", "left-pad", &[], Some(&configured))); // ...but a node on another registry than the configured one does. let elsewhere = opts( r#"{"registry":"https://registry.npmjs.org/","registries":{"npm":"http://127.0.0.1:4873/"}}"#, ); - assert!(records_url(DepIdEra::Tilde, "npm", &[], Some(&elsewhere))); + assert!(records_url(DepIdEra::Tilde, "npm", "left-pad", &[], Some(&elsewhere))); } use super::super::{restore_upstream, RestoreOptions, RestoreOutcome}; @@ -412,7 +422,7 @@ mod tests { dist["integrity"] = (*i).into(); } Mock::given(method("GET")) - .and(path(format!("/{name}/{version}"))) + .and(path(format!("/{}/{version}", name.replace('/', "%2f")))) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({ "dist": dist })), ) @@ -530,6 +540,38 @@ mod tests { std::env::remove_var("SOCKET_NPM_REGISTRY"); } + #[tokio::test] + #[serial_test::serial] + async fn restore_honors_scope_for_slot3_and_configured_registry_omission() { + use crate::vendor::vlt_lock_text::SCOPED_REGISTRY_OPTIONS; + let server = registry(&[("@s/a", "1.0.0", Some(LP_UPSTREAM))]).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let url = hosted(LP_UUID, "a-1.0.0.tgz"); + for records_url in [false, true] { + let sibling = if records_url { + ",\n \"~npm~ms@2.1.3\": [0,\"ms\",\"sha512-M==\",\"https://b.example/ms/-/ms-2.1.3.tgz\"]" + } else { + "" + }; + let text = format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {SCOPED_REGISTRY_OPTIONS},\n \"nodes\": {{\n \"~npm~@s+a@1.0.0\": [0,\"@s/a\",\"sha512-AA==\",\"{url}\"]{sibling}\n }},\n \"edges\": {{}}\n}}\n" + ); + let (outcome, after) = + run(&text, &[pin("pkg:npm/@s/a@1.0.0", LP_UUID)], false).await; + assert!(refused(&outcome).is_empty(), "{:?}", refused(&outcome)); + let after: Value = serde_json::from_str(&after).unwrap(); + let expected = if records_url { + serde_json::json!([0, "@s/a", LP_UPSTREAM, "https://a.example/@s/a/-/a-1.0.0.tgz"]) + } else { + // The scoped URL is under options.registry, even though + // registries.npm names a different base: vlt omits slot 3. + serde_json::json!([0, "@s/a", LP_UPSTREAM]) + }; + assert_eq!(after["nodes"]["~npm~@s+a@1.0.0"], expected); + } + std::env::remove_var("SOCKET_NPM_REGISTRY"); + } + #[tokio::test] #[serial_test::serial] async fn missing_registry_integrity_refuses() { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index 03eb32ec1..130a8a6a9 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2725,6 +2725,33 @@ async fn vlt_inventory_resolves_registries_through_the_shared_registry_base() { } } +#[tokio::test] +async fn vlt_inventory_honors_scope_for_named_and_url_registry_segments() { + use crate::vendor::vlt_lock_text::{ + encode_segment, DepIdEra, SCOPED_REGISTRY_OPTIONS, + }; + for segment in ["npm", "corp", "https://explicit.example/npm"] { + let tmp = tempfile::tempdir().unwrap(); + let node = format!( + r#""~{}~@s+a@1.0.0": [0,"@s/a","sha512-a=="]"#, + encode_segment(segment, DepIdEra::Tilde) + ); + write( + tmp.path(), + "vlt-lock.json", + &vlt_lock(SCOPED_REGISTRY_OPTIONS, &[&node]), + ) + .await; + let entries = inventory_vlt(tmp.path()).await.unwrap(); + assert_eq!(entries.len(), 1, "{segment}"); + assert_eq!( + entries[0].resolved.as_deref(), + Some("https://a.example/@s/a/-/a-1.0.0.tgz"), + "{segment}" + ); + } +} + #[tokio::test] async fn unreadable_vlt_locks_inventory_to_nothing() { let good = vlt_lock("{}", &[r#""~npm~a@1.0.0": [0,"a","sha512-a=="]"#]); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs index 8c9dfcd24..1de6d638c 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs @@ -106,7 +106,7 @@ pub(crate) fn vlt_registry_entries(lock: &VltLock) -> Vec { return None; } let resolved = node.location.as_deref().and_then(http_url).or_else(|| { - registry_base(&node.dep_id.first, options) + registry_base(&node.dep_id.first, name, options) .map(|base| npm_tarball_url(base.trim_end_matches('/'), name, version)) }); let integrity = node diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index 9e36ec112..150223008 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -442,8 +442,9 @@ pub(crate) fn default_registry_alias(options: Option<&Map>) -> Op } /// The registry base URL (with a trailing `/`) a decoded DepID registry -/// segment names, given the lock's `options`, as vlt hydrates the DepID +/// segment names for `name`, given the lock's `options`, as vlt hydrates the DepID /// (`@vltpkg/dep-id` `hydrateTuple`, `@vltpkg/spec`): +/// First resolve the segment's base: /// - an http(s) URL segment is its own base; /// - a named segment is its `options.registries` URL when the lock maps it; /// - the empty segment, and an unmapped segment that still names the @@ -451,7 +452,14 @@ pub(crate) fn default_registry_alias(options: Option<&Map>) -> Op /// else the default alias's `options.registries` URL, else the public /// npm registry (`registry ?? registries[default-registry-alias]`); /// - any other segment is `None`: the lock names an alias it never maps. -pub(crate) fn registry_base(segment: &str, options: Option<&Map>) -> Option { +/// Once the segment resolves, a configured scope registry takes precedence, +/// including in a named/URL registry spec's final subspec. Unknown aliases +/// still fail before scope lookup, as `hydrateTuple` does. +pub(crate) fn registry_base( + segment: &str, + name: &str, + options: Option<&Map>, +) -> Option { let string = |value: Option<&Value>| { value .and_then(Value::as_str) @@ -465,21 +473,32 @@ pub(crate) fn registry_base(segment: &str, options: Option<&Map>) .and_then(|r| r.get(alias)), ) }; - if reqwest::Url::parse(segment).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) { - return Some(with_trailing_slash(segment)); - } - if let Some(base) = Some(segment).filter(|s| !s.is_empty()).and_then(alias_url) { - return Some(base); - } - if !is_default_registry(segment, options) { + let base = if reqwest::Url::parse(segment) + .is_ok_and(|url| matches!(url.scheme(), "http" | "https")) + { + with_trailing_slash(segment) + } else if let Some(base) = Some(segment).filter(|s| !s.is_empty()).and_then(alias_url) { + base + } else if is_default_registry(segment, options) { + string(options.and_then(|o| o.get("registry"))) + .or_else(|| default_registry_alias(options).and_then(alias_url)) + .unwrap_or_else(|| { + with_trailing_slash(crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY) + }) + } else { return None; - } - let base = string(options.and_then(|o| o.get("registry"))) - .or_else(|| default_registry_alias(options).and_then(alias_url)) - .unwrap_or_else(|| { - with_trailing_slash(crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY) - }); - Some(base) + }; + name.split_once('/') + .map(|(scope, _)| scope) + .filter(|scope| scope.starts_with('@')) + .and_then(|scope| { + string( + options + .and_then(|o| o.get("scoped-registries")) + .and_then(|registries| registries.get(scope)), + ) + }) + .or(Some(base)) } /// Is `segment` an http(s) URL naming `options.registry` (a trailing `/` @@ -542,6 +561,12 @@ pub(crate) const REGISTRY_BASE_CASES: &[(&str, &str, Option<&str>)] = &[ ("https://u.example", "{}", Some("https://u.example/")), ]; +/// @vltpkg/spec + dep-id 1.3.5 produce `~npm~@s+a@1.0.0` for +/// `@s/a@npm:@s/a@1.0.0` under these options. Its final registry is `a`, +/// while an unscoped `npm:` package uses `b`. +#[cfg(test)] +pub(crate) const SCOPED_REGISTRY_OPTIONS: &str = r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/","corp":"https://corp.example/"},"scoped-registries":{"@s":"https://a.example/"}}"#; + // ── lock-level sniff ───────────────────────────────────────────────────── /// A `vlt-lock.json` that parsed as a JSON object with a known version. @@ -1773,16 +1798,42 @@ mod tests { for (segment, opts, want) in REGISTRY_BASE_CASES { let opts = options(opts); assert_eq!( - registry_base(segment, Some(&opts)).as_deref(), + registry_base(segment, "left-pad", Some(&opts)).as_deref(), *want, "{segment:?} with {opts:?}" ); } assert_eq!( - registry_base("npm", None).as_deref(), + registry_base("npm", "left-pad", None).as_deref(), Some("https://registry.npmjs.org/") ); - assert_eq!(registry_base("corp", None), None); + assert_eq!(registry_base("corp", "left-pad", None), None); + } + + #[test] + fn registry_base_applies_scope_after_resolving_the_segment() { + let opts = options(SCOPED_REGISTRY_OPTIONS); + for (segment, unscoped) in [ + ("", "https://a.example/"), + ("npm", "https://b.example/"), + ("corp", "https://corp.example/"), + ("https://explicit.example/npm", "https://explicit.example/npm/"), + ] { + assert_eq!( + registry_base(segment, "@s/a", Some(&opts)).as_deref(), + Some("https://a.example/"), + "{segment}" + ); + for name in ["a", "@other/a"] { + assert_eq!( + registry_base(segment, name, Some(&opts)).as_deref(), + Some(unscoped), + "{segment}: {name}" + ); + } + } + // A scope mapping must not make an unknown registry alias valid. + assert_eq!(registry_base("unmapped", "@s/a", Some(&opts)), None); } fn readable(text: &str) -> ParsedLock { From d9f5278184e864ddd76c65d034e9db698ba11bac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:50:20 +0000 Subject: [PATCH 4/7] Fix vlt registry_base doc lint for clippy clippy -D warnings rejected the registry_base doc comment (doc_lazy_continuation), failing CI on cb35d5a. Separate the scoped-registry paragraph from the list, and rustfmt the three touched files that were rustfmt-clean on main. No behavior change. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/vlt.rs | 34 ++++++++++++++---- .../src/vendor/lock_inventory/tests.rs | 4 +-- .../src/vendor/vlt_lock_text.rs | 35 ++++++++++--------- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index 97c51ab03..380010630 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -364,18 +364,36 @@ mod tests { Some(&with_registries) )); let alias = opts(r#"{"default-registry-alias":"corp","registries":{"corp":"https://c/"}}"#); - assert!(records_url(DepIdEra::Tilde, "corp", "left-pad", &[], Some(&alias))); + assert!(records_url( + DepIdEra::Tilde, + "corp", + "left-pad", + &[], + Some(&alias) + )); // A recorded `registry` the node resolves under: vlt (rc.33 … 1.2.0 // with `config.registry`) writes no slot [3]. let configured = opts( r#"{"registry":"http://127.0.0.1:4873/","registries":{"npm":"http://127.0.0.1:4873/"}}"#, ); - assert!(!records_url(DepIdEra::Tilde, "npm", "left-pad", &[], Some(&configured))); + assert!(!records_url( + DepIdEra::Tilde, + "npm", + "left-pad", + &[], + Some(&configured) + )); // ...but a node on another registry than the configured one does. let elsewhere = opts( r#"{"registry":"https://registry.npmjs.org/","registries":{"npm":"http://127.0.0.1:4873/"}}"#, ); - assert!(records_url(DepIdEra::Tilde, "npm", "left-pad", &[], Some(&elsewhere))); + assert!(records_url( + DepIdEra::Tilde, + "npm", + "left-pad", + &[], + Some(&elsewhere) + )); } use super::super::{restore_upstream, RestoreOptions, RestoreOutcome}; @@ -556,12 +574,16 @@ mod tests { let text = format!( "{{\n \"lockfileVersion\": 1,\n \"options\": {SCOPED_REGISTRY_OPTIONS},\n \"nodes\": {{\n \"~npm~@s+a@1.0.0\": [0,\"@s/a\",\"sha512-AA==\",\"{url}\"]{sibling}\n }},\n \"edges\": {{}}\n}}\n" ); - let (outcome, after) = - run(&text, &[pin("pkg:npm/@s/a@1.0.0", LP_UUID)], false).await; + let (outcome, after) = run(&text, &[pin("pkg:npm/@s/a@1.0.0", LP_UUID)], false).await; assert!(refused(&outcome).is_empty(), "{:?}", refused(&outcome)); let after: Value = serde_json::from_str(&after).unwrap(); let expected = if records_url { - serde_json::json!([0, "@s/a", LP_UPSTREAM, "https://a.example/@s/a/-/a-1.0.0.tgz"]) + serde_json::json!([ + 0, + "@s/a", + LP_UPSTREAM, + "https://a.example/@s/a/-/a-1.0.0.tgz" + ]) } else { // The scoped URL is under options.registry, even though // registries.npm names a different base: vlt omits slot 3. diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index 130a8a6a9..4f72cc1fb 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2727,9 +2727,7 @@ async fn vlt_inventory_resolves_registries_through_the_shared_registry_base() { #[tokio::test] async fn vlt_inventory_honors_scope_for_named_and_url_registry_segments() { - use crate::vendor::vlt_lock_text::{ - encode_segment, DepIdEra, SCOPED_REGISTRY_OPTIONS, - }; + use crate::vendor::vlt_lock_text::{encode_segment, DepIdEra, SCOPED_REGISTRY_OPTIONS}; for segment in ["npm", "corp", "https://explicit.example/npm"] { let tmp = tempfile::tempdir().unwrap(); let node = format!( diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index 150223008..5f75fe0ce 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -452,6 +452,7 @@ pub(crate) fn default_registry_alias(options: Option<&Map>) -> Op /// else the default alias's `options.registries` URL, else the public /// npm registry (`registry ?? registries[default-registry-alias]`); /// - any other segment is `None`: the lock names an alias it never maps. +/// /// Once the segment resolves, a configured scope registry takes precedence, /// including in a named/URL registry spec's final subspec. Unknown aliases /// still fail before scope lookup, as `hydrateTuple` does. @@ -473,21 +474,20 @@ pub(crate) fn registry_base( .and_then(|r| r.get(alias)), ) }; - let base = if reqwest::Url::parse(segment) - .is_ok_and(|url| matches!(url.scheme(), "http" | "https")) - { - with_trailing_slash(segment) - } else if let Some(base) = Some(segment).filter(|s| !s.is_empty()).and_then(alias_url) { - base - } else if is_default_registry(segment, options) { - string(options.and_then(|o| o.get("registry"))) - .or_else(|| default_registry_alias(options).and_then(alias_url)) - .unwrap_or_else(|| { - with_trailing_slash(crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY) - }) - } else { - return None; - }; + let base = + if reqwest::Url::parse(segment).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) { + with_trailing_slash(segment) + } else if let Some(base) = Some(segment).filter(|s| !s.is_empty()).and_then(alias_url) { + base + } else if is_default_registry(segment, options) { + string(options.and_then(|o| o.get("registry"))) + .or_else(|| default_registry_alias(options).and_then(alias_url)) + .unwrap_or_else(|| { + with_trailing_slash(crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY) + }) + } else { + return None; + }; name.split_once('/') .map(|(scope, _)| scope) .filter(|scope| scope.starts_with('@')) @@ -1817,7 +1817,10 @@ mod tests { ("", "https://a.example/"), ("npm", "https://b.example/"), ("corp", "https://corp.example/"), - ("https://explicit.example/npm", "https://explicit.example/npm/"), + ( + "https://explicit.example/npm", + "https://explicit.example/npm/", + ), ] { assert_eq!( registry_base(segment, "@s/a", Some(&opts)).as_deref(), From faade961f6fdf133677a0089aaf8ebb060ffcd69 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 14:58:22 -0400 Subject: [PATCH 5/7] Resolve empty tilde registry segments as the npm alias --- .../src/patch/redirect/upstream/vlt.rs | 75 +++++++++-- .../src/vendor/lock_inventory/tests.rs | 21 ++-- .../src/vendor/lock_inventory/vlt.rs | 4 +- .../src/vendor/vlt_lock_text.rs | 116 ++++++++++++++---- 4 files changed, 174 insertions(+), 42 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index 380010630..4dd2307ad 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -26,8 +26,8 @@ use super::npm::{by_uuid, fetch_dists, read_or_refuse, refuse_all_in}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::vendor::vlt_lock_text::{ default_registry_alias, entry_text, is_default_registry, nodes_block, parse_node_line, - registry_base, render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, - split_lines, DepIdEra, DepIdKind, LockSniff, + registry_base, registry_segment, render_entry_line, render_tuple_with_slots, sniff_lock, + split_dep_id, split_lines, DepIdEra, DepIdKind, LockSniff, }; /// One hosted node line to restore. @@ -56,6 +56,7 @@ fn records_url( siblings: &[(DepIdEra, bool)], options: Option<&Map>, ) -> bool { + let segment = registry_segment(era, segment); let same_era: Vec = siblings .iter() .filter(|(e, _)| *e == era) @@ -70,7 +71,7 @@ fn records_url( .and_then(|o| o.get("registries")) .is_some_and(Value::is_object) && (segment.is_empty() || default_registry_alias(options) == Some(segment)) - && !under_configured_registry(segment, name, options) + && !under_configured_registry(era, segment, name, options) } /// Would a default-registry node on `segment` resolve under the lock's @@ -78,6 +79,7 @@ fn records_url( /// (`lockfile/save.ts`: `customRegistry = resolved && (!registry || /// !resolved.startsWith(registry))`). fn under_configured_registry( + era: DepIdEra, segment: &str, name: &str, options: Option<&Map>, @@ -89,7 +91,7 @@ fn under_configured_registry( else { return false; }; - registry_base(segment, name, options).is_some_and(|base| base.starts_with(registry)) + registry_base(era, segment, name, options).is_some_and(|base| base.starts_with(registry)) } pub(crate) async fn restore( @@ -173,7 +175,8 @@ pub(crate) async fn restore( let hosted = slot3.as_deref().and_then(|u| ctx.hosted_uuid(u)); let Some(uuid) = hosted else { if let Some(dep_id) = dep_id.filter(|d| { - d.kind == DepIdKind::Registry && is_default_registry(&d.first, options) + d.kind == DepIdKind::Registry + && is_default_registry(registry_segment(d.era, &d.first), options) }) { siblings.push((dep_id.era, slot3.is_some())); } @@ -205,7 +208,7 @@ pub(crate) async fn restore( ); continue; }; - if !is_default_registry(&dep_id.first, options) { + if !is_default_registry(registry_segment(dep_id.era, &dep_id.first), options) { result.refuse( &uuid, format!( @@ -252,7 +255,7 @@ pub(crate) async fn restore( let line = parse_node_line(lines[hit.line]).expect("the hit line parsed above"); let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); let slot3 = if records_url(hit.era, &hit.segment, &hit.name, &siblings, options) { - let Some(base) = registry_base(&hit.segment, &hit.name, options) else { + let Some(base) = registry_base(hit.era, &hit.segment, &hit.name, options) else { result.refuse( &hit.uuid, format!("{rel} maps no registry for the `{}` segment", hit.segment), @@ -523,19 +526,21 @@ mod tests { let server = registry(&[("left-pad", "1.3.0", Some(LP_UPSTREAM))]).await; std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); let url = hosted(LP_UUID, "left-pad-1.3.0.tgz"); - for (segment, options, want) in REGISTRY_BASE_CASES { + for (era, segment, options, want) in REGISTRY_BASE_CASES { if segment.starts_with("http") { continue; } // A same-era sibling that records slot [3] makes restore write // one for the pin. + let delimiter = era.delimiter(); + let lock_version = u8::from(*era == DepIdEra::Tilde); let text = format!( - "{{\n \"lockfileVersion\": 1,\n \"options\": {options},\n \"nodes\": {{\n \"~{segment}~left-pad@1.3.0\": [0,\"left-pad\",\"sha512-AA==\",\"{url}\"],\n \"~{segment}~ms@2.1.3\": [0,\"ms\",\"sha512-M==\",\"https://x.example/ms/-/ms-2.1.3.tgz\"]\n }},\n \"edges\": {{}}\n}}\n" + "{{\n \"lockfileVersion\": {lock_version},\n \"options\": {options},\n \"nodes\": {{\n \"{delimiter}{segment}{delimiter}left-pad@1.3.0\": [0,\"left-pad\",\"sha512-AA==\",\"{url}\"],\n \"{delimiter}{segment}{delimiter}ms@2.1.3\": [0,\"ms\",\"sha512-M==\",\"https://x.example/ms/-/ms-2.1.3.tgz\"]\n }},\n \"edges\": {{}}\n}}\n" ); let (outcome, after) = run(&text, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; let opts = opts(options); - if is_default_registry(segment, Some(&opts)) { + if is_default_registry(registry_segment(*era, segment), Some(&opts)) { let want = want.expect("a default registry always has a base"); assert!( refused(&outcome).is_empty(), @@ -558,6 +563,56 @@ mod tests { std::env::remove_var("SOCKET_NPM_REGISTRY"); } + #[tokio::test] + #[serial_test::serial] + async fn restore_empty_tilde_resolution_and_sibling_admission() { + let server = registry(&[("left-pad", "1.3.0", Some(LP_UPSTREAM))]).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let url = hosted(LP_UUID, "left-pad-1.3.0.tgz"); + for (segment, options, sibling, base) in [ + ( + "", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + "", + "https://b.example/", + ), + ( + "npm", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + "", + "https://b.example/", + ), + ( + "corp", + r#"{"default-registry-alias":"corp","registries":{"npm":"https://b.example/","corp":"https://c.example/"}}"#, + // Empty tilde means npm, which is foreign here. Its lack + // of slot 3 must not determine the corp node's convention. + ",\n \"~~ms@2.1.3\": [0,\"ms\",\"sha512-M==\"]", + "https://c.example/", + ), + ] { + let key = format!("~{segment}~left-pad@1.3.0"); + let text = format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {options},\n \"nodes\": {{\n \"{key}\": [0,\"left-pad\",\"sha512-AA==\",\"{url}\"]{sibling}\n }},\n \"edges\": {{}}\n}}\n" + ); + let (outcome, after) = + run(&text, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; + assert!(refused(&outcome).is_empty(), "{:?}", refused(&outcome)); + let after: Value = serde_json::from_str(&after).unwrap(); + assert_eq!( + after["nodes"][&key], + serde_json::json!([ + 0, + "left-pad", + LP_UPSTREAM, + format!("{base}left-pad/-/left-pad-1.3.0.tgz") + ]), + "{segment:?} {options}" + ); + } + std::env::remove_var("SOCKET_NPM_REGISTRY"); + } + #[tokio::test] #[serial_test::serial] async fn restore_honors_scope_for_slot3_and_configured_registry_omission() { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index 4f72cc1fb..c05dc13ca 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2701,20 +2701,23 @@ async fn vlt_default_registry_base_follows_the_lock_options() { #[tokio::test] async fn vlt_inventory_resolves_registries_through_the_shared_registry_base() { use crate::vendor::vlt_lock_text::REGISTRY_BASE_CASES; - for (segment, options, want) in REGISTRY_BASE_CASES { + for (era, segment, options, want) in REGISTRY_BASE_CASES { // A URL segment is percent-encoded in a DepID; the table's other // rows cover every precedence rule. if segment.starts_with("http") { continue; } let tmp = tempfile::tempdir().unwrap(); - let node = format!(r#""~{segment}~@s/a@1.0.0": [0,"@s/a","sha512-a=="]"#); - write( - tmp.path(), - "vlt-lock.json", - &vlt_lock(options, &[node.as_str()]), - ) - .await; + let delimiter = era.delimiter(); + let name = crate::vendor::vlt_lock_text::encode_segment("@s/a@1.0.0", *era); + let node = format!(r#""{delimiter}{segment}{delimiter}{name}": [0,"@s/a","sha512-a=="]"#); + let lock = vlt_lock(options, &[node.as_str()]); + let lock = if *era == crate::vendor::vlt_lock_text::DepIdEra::Legacy { + lock.replacen("\"lockfileVersion\": 1", "\"lockfileVersion\": 0", 1) + } else { + lock + }; + write(tmp.path(), "vlt-lock.json", &lock).await; let entries = inventory_vlt(tmp.path()).await.unwrap(); assert_eq!(entries.len(), 1, "{segment:?} {options}"); assert_eq!( @@ -2728,7 +2731,7 @@ async fn vlt_inventory_resolves_registries_through_the_shared_registry_base() { #[tokio::test] async fn vlt_inventory_honors_scope_for_named_and_url_registry_segments() { use crate::vendor::vlt_lock_text::{encode_segment, DepIdEra, SCOPED_REGISTRY_OPTIONS}; - for segment in ["npm", "corp", "https://explicit.example/npm"] { + for segment in ["", "npm", "corp", "https://explicit.example/npm"] { let tmp = tempfile::tempdir().unwrap(); let node = format!( r#""~{}~@s+a@1.0.0": [0,"@s/a","sha512-a=="]"#, diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs index 1de6d638c..0ba67e413 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs @@ -6,8 +6,8 @@ use std::path::Path; use serde_json::{Map, Value}; -use crate::constants::npm_family::VLT_LOCK; use super::view::ProjectView; +use crate::constants::npm_family::VLT_LOCK; use crate::vendor::vlt_lock_text::{ registry_base, sniff_lock, split_dep_id, DepId, DepIdKind, LockSniff, }; @@ -106,7 +106,7 @@ pub(crate) fn vlt_registry_entries(lock: &VltLock) -> Vec { return None; } let resolved = node.location.as_deref().and_then(http_url).or_else(|| { - registry_base(&node.dep_id.first, name, options) + registry_base(node.dep_id.era, &node.dep_id.first, name, options) .map(|base| npm_tarball_url(base.trim_end_matches('/'), name, version)) }); let integrity = node diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index 5f75fe0ce..ae57f920f 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -441,13 +441,28 @@ pub(crate) fn default_registry_alias(options: Option<&Map>) -> Op } } +/// vlt's tilde `splitDepID` fills an empty registry field with the literal +/// `npm`, independently of `default-registry-alias`. Keep the legacy-era +/// resolution policy separate from that modern normalization. +pub(crate) fn registry_segment(era: DepIdEra, segment: &str) -> &str { + if era == DepIdEra::Tilde && segment.is_empty() { + "npm" + } else { + segment + } +} + /// The registry base URL (with a trailing `/`) a decoded DepID registry -/// segment names for `name`, given the lock's `options`, as vlt hydrates the DepID -/// (`@vltpkg/dep-id` `hydrateTuple`, `@vltpkg/spec`): -/// First resolve the segment's base: +/// segment names for `name`, given the lock's `options`. Modern hydration +/// follows `@vltpkg/dep-id` / `@vltpkg/spec` 1.3.5. The legacy empty-segment +/// fallback preserves compatibility policy: older release/runtime behavior +/// varies, so it is not inferred from the modern hydration rule. +/// +/// First normalize an empty tilde segment to `npm`, as `splitDepID` does, +/// then resolve the segment's base: /// - an http(s) URL segment is its own base; /// - a named segment is its `options.registries` URL when the lock maps it; -/// - the empty segment, and an unmapped segment that still names the +/// - an empty legacy segment, and an unmapped segment that still names the /// default registry ([`is_default_registry`]), is `options.registry`, /// else the default alias's `options.registries` URL, else the public /// npm registry (`registry ?? registries[default-registry-alias]`); @@ -457,10 +472,12 @@ pub(crate) fn default_registry_alias(options: Option<&Map>) -> Op /// including in a named/URL registry spec's final subspec. Unknown aliases /// still fail before scope lookup, as `hydrateTuple` does. pub(crate) fn registry_base( + era: DepIdEra, segment: &str, name: &str, options: Option<&Map>, ) -> Option { + let segment = registry_segment(era, segment); let string = |value: Option<&Value>| { value .and_then(Value::as_str) @@ -514,51 +531,96 @@ pub(crate) fn is_registry_url_segment(segment: &str, options: Option<&Map)] = &[ - ("", "{}", Some("https://registry.npmjs.org/")), - ("npm", "{}", Some("https://registry.npmjs.org/")), +pub(crate) const REGISTRY_BASE_CASES: &[(DepIdEra, &str, &str, Option<&str>)] = &[ + ( + DepIdEra::Tilde, + "", + "{}", + Some("https://registry.npmjs.org/"), + ), + ( + DepIdEra::Tilde, + "npm", + "{}", + Some("https://registry.npmjs.org/"), + ), ( + DepIdEra::Tilde, "", r#"{"registry":"https://a.example"}"#, Some("https://a.example/"), ), ( + DepIdEra::Tilde, + "", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + Some("https://b.example/"), + ), + ( + DepIdEra::Legacy, "", r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, Some("https://a.example/"), ), ( + DepIdEra::Tilde, "npm", r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, Some("https://b.example/"), ), ( + DepIdEra::Tilde, "npm", r#"{"registries":{"npm":"https://b.example/npm"}}"#, Some("https://b.example/npm/"), ), ( + DepIdEra::Tilde, "corp", r#"{"default-registry-alias":"corp","registries":{"corp":"https://c.example/"}}"#, Some("https://c.example/"), ), ( + DepIdEra::Tilde, + "", + r#"{"default-registry-alias":"corp","registries":{"corp":"https://c.example/"}}"#, + None, + ), + ( + DepIdEra::Tilde, + "", + r#"{"default-registry-alias":"corp","registries":{"npm":"https://b.example/","corp":"https://c.example/"}}"#, + Some("https://b.example/"), + ), + ( + DepIdEra::Legacy, "", r#"{"default-registry-alias":"corp","registries":{"corp":"https://c.example/"}}"#, Some("https://c.example/"), ), ( + DepIdEra::Tilde, "corp", r#"{"registries":{"corp":"https://c.example/"}}"#, Some("https://c.example/"), ), - ("corp", r#"{"registry":"https://a.example/"}"#, None), - ("corp", "{}", None), - ("https://u.example", "{}", Some("https://u.example/")), + ( + DepIdEra::Tilde, + "corp", + r#"{"registry":"https://a.example/"}"#, + None, + ), + (DepIdEra::Tilde, "corp", "{}", None), + ( + DepIdEra::Tilde, + "https://u.example", + "{}", + Some("https://u.example/"), + ), ]; /// @vltpkg/spec + dep-id 1.3.5 produce `~npm~@s+a@1.0.0` for @@ -1794,27 +1856,36 @@ mod tests { } #[test] - fn registry_base_follows_vlt_dep_id_hydration() { - for (segment, opts, want) in REGISTRY_BASE_CASES { + fn registry_base_follows_modern_hydration_and_legacy_policy() { + for (era, segment, opts, want) in REGISTRY_BASE_CASES { let opts = options(opts); + let delimiter = era.delimiter(); + let key = format!( + "{delimiter}{}{delimiter}left-pad@1.3.0", + encode_segment(segment, *era) + ); + let id = split_dep_id(&key).unwrap(); assert_eq!( - registry_base(segment, "left-pad", Some(&opts)).as_deref(), + registry_base(id.era, &id.first, "left-pad", Some(&opts)).as_deref(), *want, "{segment:?} with {opts:?}" ); } assert_eq!( - registry_base("npm", "left-pad", None).as_deref(), + registry_base(DepIdEra::Tilde, "npm", "left-pad", None).as_deref(), Some("https://registry.npmjs.org/") ); - assert_eq!(registry_base("corp", "left-pad", None), None); + assert_eq!( + registry_base(DepIdEra::Tilde, "corp", "left-pad", None), + None + ); } #[test] fn registry_base_applies_scope_after_resolving_the_segment() { let opts = options(SCOPED_REGISTRY_OPTIONS); for (segment, unscoped) in [ - ("", "https://a.example/"), + ("", "https://b.example/"), ("npm", "https://b.example/"), ("corp", "https://corp.example/"), ( @@ -1823,20 +1894,23 @@ mod tests { ), ] { assert_eq!( - registry_base(segment, "@s/a", Some(&opts)).as_deref(), + registry_base(DepIdEra::Tilde, segment, "@s/a", Some(&opts)).as_deref(), Some("https://a.example/"), "{segment}" ); for name in ["a", "@other/a"] { assert_eq!( - registry_base(segment, name, Some(&opts)).as_deref(), + registry_base(DepIdEra::Tilde, segment, name, Some(&opts)).as_deref(), Some(unscoped), "{segment}: {name}" ); } } // A scope mapping must not make an unknown registry alias valid. - assert_eq!(registry_base("unmapped", "@s/a", Some(&opts)), None); + assert_eq!( + registry_base(DepIdEra::Tilde, "unmapped", "@s/a", Some(&opts)), + None + ); } fn readable(text: &str) -> ParsedLock { From d03ae6b647713545be2e457a988d98effa7920bc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 14:14:35 -0400 Subject: [PATCH 6/7] test: align Berry benchmarks with descriptor resolutions (cherry picked from commit 329b14672b418378d933f58a07b6c459d2594e28) --- crates/socket-patch-bench/src/fixtures/npm.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-bench/src/fixtures/npm.rs b/crates/socket-patch-bench/src/fixtures/npm.rs index 1985f703a..af1ee68e4 100644 --- a/crates/socket-patch-bench/src/fixtures/npm.rs +++ b/crates/socket-patch-bench/src/fixtures/npm.rs @@ -623,7 +623,13 @@ pub fn build_yarn_berry(t: &mut Tree, size: Size) -> std::io::Result { t.write("project/node_modules/.yarn-state.yml", "# Warning: This file is automatically generated. Removing it is fine, but will\n# cause your node_modules installation to become invalidated.\n\n__metadata:\n version: 1\n nmMode: classic\n")?; g.install_hoisted(t, "project/")?; t.mkdir("home")?; - Ok(fixture(&g, g.patches(true), &["yarn.lock"], &[])) + // Hosted Berry pins both descriptor resolutions and their lock entries. + Ok(fixture( + &g, + g.patches(true), + &["package.json", "yarn.lock"], + &[], + )) } // ── bun ──────────────────────────────────────────────────────────────── From ae7a03533870a7088eec798e9ee2e893c9eb1e03 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 15:19:43 -0400 Subject: [PATCH 7/7] Keep vlt restore admission consistent with forward rewrites --- .../src/patch/redirect/upstream/vlt.rs | 92 ++++++++++++++----- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index 4dd2307ad..1a2d84ca8 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -19,6 +19,9 @@ //! //! Every hosted instance of a pin is restored together, and every other //! byte of the lock (flags, trailing slots, indent, comma, `\r`) is kept. +//! Default-registry admission and sibling conventions use the same raw +//! segment policy as forward rewrite, heal and vendor. Only the shared +//! `registry_base` normalizes a modern empty segment when resolving a URL. use serde_json::{Map, Value}; @@ -26,8 +29,8 @@ use super::npm::{by_uuid, fetch_dists, read_or_refuse, refuse_all_in}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::vendor::vlt_lock_text::{ default_registry_alias, entry_text, is_default_registry, nodes_block, parse_node_line, - registry_base, registry_segment, render_entry_line, render_tuple_with_slots, sniff_lock, - split_dep_id, split_lines, DepIdEra, DepIdKind, LockSniff, + registry_base, render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, + split_lines, DepIdEra, DepIdKind, LockSniff, }; /// One hosted node line to restore. @@ -56,7 +59,6 @@ fn records_url( siblings: &[(DepIdEra, bool)], options: Option<&Map>, ) -> bool { - let segment = registry_segment(era, segment); let same_era: Vec = siblings .iter() .filter(|(e, _)| *e == era) @@ -175,8 +177,7 @@ pub(crate) async fn restore( let hosted = slot3.as_deref().and_then(|u| ctx.hosted_uuid(u)); let Some(uuid) = hosted else { if let Some(dep_id) = dep_id.filter(|d| { - d.kind == DepIdKind::Registry - && is_default_registry(registry_segment(d.era, &d.first), options) + d.kind == DepIdKind::Registry && is_default_registry(&d.first, options) }) { siblings.push((dep_id.era, slot3.is_some())); } @@ -208,7 +209,7 @@ pub(crate) async fn restore( ); continue; }; - if !is_default_registry(registry_segment(dep_id.era, &dep_id.first), options) { + if !is_default_registry(&dep_id.first, options) { result.refuse( &uuid, format!( @@ -540,8 +541,8 @@ mod tests { let (outcome, after) = run(&text, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; let opts = opts(options); - if is_default_registry(registry_segment(*era, segment), Some(&opts)) { - let want = want.expect("a default registry always has a base"); + let admitted = is_default_registry(segment, Some(&opts)); + if let (true, Some(want)) = (admitted, *want) { assert!( refused(&outcome).is_empty(), "{segment:?} {options}: {:?}", @@ -553,10 +554,12 @@ mod tests { assert!(after.contains(&upstream), "{segment:?} {options}: {after}"); } else { let why = refused(&outcome); - assert!( - why[0].contains("not on vlt's default registry"), - "{segment:?} {options}: {why:?}" - ); + let reason = if admitted { + "maps no registry" + } else { + "not on vlt's default registry" + }; + assert!(why[0].contains(reason), "{segment:?} {options}: {why:?}"); assert_eq!(after, text); } } @@ -565,7 +568,7 @@ mod tests { #[tokio::test] #[serial_test::serial] - async fn restore_empty_tilde_resolution_and_sibling_admission() { + async fn restore_empty_tilde_resolution_and_raw_sibling_convention() { let server = registry(&[("left-pad", "1.3.0", Some(LP_UPSTREAM))]).await; std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); let url = hosted(LP_UUID, "left-pad-1.3.0.tgz"); @@ -574,21 +577,21 @@ mod tests { "", r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, "", - "https://b.example/", + Some("https://b.example/"), ), ( "npm", r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, "", - "https://b.example/", + Some("https://b.example/"), ), ( "corp", r#"{"default-registry-alias":"corp","registries":{"npm":"https://b.example/","corp":"https://c.example/"}}"#, - // Empty tilde means npm, which is foreign here. Its lack - // of slot 3 must not determine the corp node's convention. + // Keep forward rewrite's raw-empty admission policy when + // choosing siblings, so its 3-tuple convention is retained. ",\n \"~~ms@2.1.3\": [0,\"ms\",\"sha512-M==\"]", - "https://c.example/", + None, ), ] { let key = format!("~{segment}~left-pad@1.3.0"); @@ -599,16 +602,61 @@ mod tests { run(&text, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; assert!(refused(&outcome).is_empty(), "{:?}", refused(&outcome)); let after: Value = serde_json::from_str(&after).unwrap(); - assert_eq!( - after["nodes"][&key], - serde_json::json!([ + let expected = match base { + Some(base) => serde_json::json!([ 0, "left-pad", LP_UPSTREAM, format!("{base}left-pad/-/left-pad-1.3.0.tgz") ]), - "{segment:?} {options}" + None => serde_json::json!([0, "left-pad", LP_UPSTREAM]), + }; + assert_eq!(after["nodes"][&key], expected, "{segment:?} {options}"); + } + std::env::remove_var("SOCKET_NPM_REGISTRY"); + } + + #[tokio::test] + #[serial_test::serial] + async fn rewritten_empty_tilde_under_custom_alias_restores_byte_for_byte() { + use crate::patch::redirect::{DepOverride, RewriteResult}; + + let server = registry(&[("left-pad", "1.3.0", Some(LP_UPSTREAM))]).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let url = hosted(LP_UUID, "left-pad-1.3.0.tgz"); + let dep: DepOverride = serde_json::from_value(serde_json::json!({ + "ecosystem": "npm", + "name": "left-pad", + "version": "1.3.0", + "token": "t", + "patchUuid": LP_UUID, + "artifactUrl": url, + "integrity": { "sha512": "sha512-PATCHED==" }, + })) + .unwrap(); + for sibling in [ + "", + ",\n \"~~ms@2.1.3\": [0,\"ms\",\"sha512-M==\",\"https://b.example/ms/-/ms-2.1.3.tgz\"]", + ] { + let original = format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {{\"default-registry-alias\":\"corp\",\"registry\":\"https://a.example/\",\"registries\":{{\"corp\":\"https://a.example/\",\"npm\":\"https://b.example/\"}}}},\n \"nodes\": {{\n \"~~left-pad@1.3.0\": [0,\"left-pad\",\"{LP_UPSTREAM}\",\"https://b.example/left-pad/-/left-pad-1.3.0.tgz\"]{sibling}\n }},\n \"edges\": {{}}\n}}\n" ); + let files = [("vlt-lock.json".to_string(), original.clone())] + .into_iter() + .collect(); + let mut rewritten = RewriteResult::default(); + crate::patch::redirect::vlt::rewrite_vlt_lock( + &files, + std::slice::from_ref(&dep), + false, + &mut rewritten, + ); + let pinned = rewritten.files.get("vlt-lock.json").expect("forward rewrite admits the raw empty segment"); + assert!(pinned.contains(&url), "{pinned}"); + let (outcome, restored) = + run(pinned, &[pin("pkg:npm/left-pad@1.3.0", LP_UUID)], false).await; + assert!(refused(&outcome).is_empty(), "{:?}", refused(&outcome)); + assert_eq!(restored, original); } std::env::remove_var("SOCKET_NPM_REGISTRY"); }