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 ──────────────────────────────────────────────────────────────── 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..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,19 +19,20 @@ //! //! 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}; 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 +44,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) @@ -82,6 +55,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 { @@ -98,15 +72,20 @@ fn records_url( && options .and_then(|o| o.get("registries")) .is_some_and(Value::is_object) - && (segment.is_empty() || segment == default_alias(options)) - && !under_configured_registry(segment, options) + && (segment.is_empty() || default_registry_alias(options) == Some(segment)) + && !under_configured_registry(era, 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( + era: DepIdEra, + segment: &str, + name: &str, + options: Option<&Map>, +) -> bool { let Some(registry) = options .and_then(|o| o.get("registry")) .and_then(Value::as_str) @@ -114,8 +93,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(era, segment, name, options).is_some_and(|base| base.starts_with(registry)) } pub(crate) async fn restore( @@ -277,13 +255,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, &hit.name, &siblings, options) { + 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), + ); + 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 +318,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" @@ -364,12 +332,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 )); @@ -377,6 +347,7 @@ mod tests { assert!(!records_url( DepIdEra::Legacy, "npm", + "left-pad", &[(DepIdEra::Tilde, true)], Some(&with_registries) )); @@ -384,29 +355,49 @@ 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}; @@ -453,7 +444,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 })), ) @@ -526,6 +517,186 @@ 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 (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\": {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); + let admitted = is_default_registry(segment, Some(&opts)); + if let (true, Some(want)) = (admitted, *want) { + 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); + 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); + } + } + std::env::remove_var("SOCKET_NPM_REGISTRY"); + } + + #[tokio::test] + #[serial_test::serial] + 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"); + for (segment, options, sibling, base) in [ + ( + "", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + "", + Some("https://b.example/"), + ), + ( + "npm", + r#"{"registry":"https://a.example/","registries":{"npm":"https://b.example/"}}"#, + "", + Some("https://b.example/"), + ), + ( + "corp", + r#"{"default-registry-alias":"corp","registries":{"npm":"https://b.example/","corp":"https://c.example/"}}"#, + // 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==\"]", + None, + ), + ] { + 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(); + let expected = match base { + Some(base) => serde_json::json!([ + 0, + "left-pad", + LP_UPSTREAM, + format!("{base}left-pad/-/left-pad-1.3.0.tgz") + ]), + 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"); + } + + #[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 e6392c30a..926f345e5 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2735,6 +2735,61 @@ 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 (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 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!( + entries[0].resolved, + want.map(|base| format!("{base}@s/a/-/a-1.0.0.tgz")), + "{segment:?} {options}" + ); + } +} + +#[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 f84eed675..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,12 +6,14 @@ 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::{ - 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")) + 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 .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..ae57f920f 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,94 @@ 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, + } +} + +/// 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`. 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; +/// - 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]`); +/// - 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. +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) + .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)), + ) + }; + 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('@')) + .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 `/` /// aside)? pub(crate) fn is_registry_url_segment(segment: &str, options: Option<&Map>) -> bool { @@ -448,6 +531,104 @@ pub(crate) fn is_registry_url_segment(segment: &str, options: Option<&Map)] = &[ + ( + 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/"), + ), + ( + 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 +/// `@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. @@ -1674,6 +1855,64 @@ mod tests { } } + #[test] + 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(id.era, &id.first, "left-pad", Some(&opts)).as_deref(), + *want, + "{segment:?} with {opts:?}" + ); + } + assert_eq!( + registry_base(DepIdEra::Tilde, "npm", "left-pad", None).as_deref(), + Some("https://registry.npmjs.org/") + ); + 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://b.example/"), + ("npm", "https://b.example/"), + ("corp", "https://corp.example/"), + ( + "https://explicit.example/npm", + "https://explicit.example/npm/", + ), + ] { + assert_eq!( + 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(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(DepIdEra::Tilde, "unmapped", "@s/a", Some(&opts)), + None + ); + } + fn readable(text: &str) -> ParsedLock { match sniff_lock(text) { LockSniff::Readable(lock) => lock,