diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 6c7ca6f07..f293592a0 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -33,7 +33,7 @@ //! //! | Ecosystem | PURL | Patch UUID | Advisory | //! |-----------|------|------------|----------| -//! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h (CVE-2021-44906) | +//! | npm | `pkg:npm/minimist@1.2.2` | `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb` | GHSA-xvch-5gv4-984h (CVE-2021-44906) | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | GHSA-gm62-xv2j-4w53 &co | //! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_UUIDS`] (six today; the sixth merges three advisories) | GHSA-m42x-37p3-fv5w (CVE-2020-8162), GHSA-w749-p3v6-hccq (CVE-2022-21831), GHSA-9xrj-h377-fr87 (CVE-2026-33195), GHSA-r4mg-4433-c7g3 (CVE-2025-24293), GHSA-xr9x-r78c-5hrm (CVE-2026-66066) | //! @@ -123,7 +123,7 @@ const PATCH_HOST: &str = "patch.socket.dev"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; const NPM_NAME: &str = "minimist"; const NPM_VERSION: &str = "1.2.2"; -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; const PYPI_PURL: &str = "pkg:pypi/urllib3@1.26.18"; const PYPI_NAME: &str = "urllib3"; diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 63de6c636..4df29c4d7 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -1,7 +1,7 @@ //! End-to-end tests for the npm patch lifecycle. //! //! These tests exercise the full CLI against the real Socket API, using the -//! **minimist@1.2.2** patch (UUID `80630680-4da6-45f9-bba8-b888e0ffd58c`), +//! **minimist@1.2.2** patch (UUID `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`), //! which fixes CVE-2021-44906 (Prototype Pollution). //! //! # Prerequisites @@ -26,14 +26,14 @@ use common::cache_env; // Constants // --------------------------------------------------------------------------- -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; /// Git SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2. const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10"; /// Git SHA-256 of the *patched* `index.js` after the security fix. -const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28"; +const AFTER_HASH: &str = "ec956dcafb886f14315570bf3981d44aa12c561716abb46eed8b067aaa1f6bdf"; // --------------------------------------------------------------------------- // Helpers diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index c1d557d72..611ad484b 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -251,6 +251,17 @@ enum HostedDriver { GetUuid, } +/// The project the fixture installs. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Layout { + /// One manifest depending on `left-pad@1.3.0`. + Single, + /// #1271: a yarn workspace whose member `a` declares `"left-pad": ""` + /// (an empty range, the same as `*`) and member `b` `"^1.3.0"`, so yarn + /// locks both under one `left-pad@, left-pad@^1.3.0:` block. + EmptyRangeWorkspace, +} + /// Where the fixture configures `yarn-offline-mirror`. #[derive(Clone, Copy, PartialEq, Debug)] enum Mirror { @@ -278,6 +289,7 @@ async fn classic_hosted_project( tamper_served_tarball: bool, mirror: Mirror, driver: HostedDriver, + layout: Layout, ) -> Option { let offline_mirror = mirror != Mirror::None; if !require_yarn_classic(&format!("e2e_redirect_yarn_classic_build ({tag})"), |c| { @@ -288,13 +300,32 @@ async fn classic_hosted_project( let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); - std::fs::write( - proj.join("package.json"), - format!( - r#"{{"name":"redirect-classic-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# - ), - ) - .unwrap(); + match layout { + Layout::Single => std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"redirect-classic-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(), + Layout::EmptyRangeWorkspace => { + std::fs::write( + proj.join("package.json"), + r#"{"name":"redirect-classic-capstone","version":"0.0.0","private":true,"workspaces":["a","b"]}"#, + ) + .unwrap(); + for (member, range) in [("a", ""), ("b", "^1.3.0")] { + std::fs::create_dir_all(proj.join(member)).unwrap(); + std::fs::write( + proj.join(member).join("package.json"), + format!( + r#"{{"name":"{member}","version":"1.0.0","dependencies":{{"{DEP}":"{range}"}}}}"# + ), + ) + .unwrap(); + } + } + } let mirror_dir = proj.join("mirror"); let mirror_dir = mirror_dir.to_str().unwrap(); // Where yarn reads the mirror from; the env leg sets it for yarn AND @@ -356,6 +387,12 @@ async fn classic_hosted_project( lock_pristine.contains("# yarn lockfile v1"), "fixture must be a yarn classic v1 lock:\n{lock_pristine}" ); + if layout == Layout::EmptyRangeWorkspace { + assert!( + lock_pristine.contains(&format!("\n{DEP}@, {DEP}@^1.3.0:\n")), + "yarn must merge the empty range into one block:\n{lock_pristine}" + ); + } // 2. Patched tarball + the exact hashes classic will verify at install. let tgz_path = tmp.path().join(format!("{DEP}-{DEP_VERSION}.tgz")); @@ -591,6 +628,14 @@ fn fresh_checkout_yarn_install(fx: &ClassicRedirectFixture) -> (PathBuf, Output) std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(fx.proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap(); + // Workspace members' manifests (Layout::EmptyRangeWorkspace). + for member in ["a", "b"] { + let manifest = fx.proj.join(member).join("package.json"); + if manifest.is_file() { + std::fs::create_dir_all(fresh.join(member)).unwrap(); + std::fs::copy(manifest, fresh.join(member).join("package.json")).unwrap(); + } + } // v5 hosted mode writes nothing under `.socket/`; carry it when present. if fx.proj.join(".socket").is_dir() { copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); @@ -773,7 +818,14 @@ fn hosted_dev_resave_vex(fx: &ClassicRedirectFixture) { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_redirect_fresh_checkout_installs_patched_bytes() { - let Some(fx) = classic_hosted_project("main", false, Mirror::None, HostedDriver::Scan).await + let Some(fx) = classic_hosted_project( + "main", + false, + Mirror::None, + HostedDriver::Scan, + Layout::Single, + ) + .await else { return; }; @@ -802,6 +854,41 @@ async fn classic_redirect_fresh_checkout_installs_patched_bytes() { tokio::task::block_in_place(|| hosted_dev_resave_vex(&fx)); } +/// #1271: yarn 1 locks a member's `"left-pad": ""` (an empty range) under +/// one `left-pad@, left-pad@^1.3.0:` block with the other member's range. +/// That block is the installed registry copy: the hosted scan pins it (the +/// fixture asserts one redirect and the lock pin), and a fresh checkout +/// installs the patched bytes for both members. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_empty_range_workspace_key_is_pinned() { + let Some(fx) = classic_hosted_project( + "empty-range", + false, + Mirror::None, + HostedDriver::Scan, + Layout::EmptyRangeWorkspace, + ) + .await + else { + return; + }; + let lock = std::fs::read_to_string(fx.proj.join("yarn.lock")).unwrap(); + assert!( + lock.contains(&format!("\n{DEP}@, {DEP}@^1.3.0:\n")), + "the pin keeps the key line:\n{lock}" + ); + let (fresh, ci) = fresh_checkout_yarn_install(&fx); + assert!( + ci.status.success(), + "fresh-checkout install must succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!(installed, fx.patched, "both members load the patched bytes"); +} + /// get-driven hosted twin (v4.0): `get --mode hosted --json --yes` /// routes through the SAME hosted engine as `scan --mode hosted`, so the /// classic chain must hold unchanged — the fixture's lock pin (hosted URL + @@ -813,8 +900,14 @@ async fn classic_redirect_fresh_checkout_installs_patched_bytes() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_get_uuid_hosted_fresh_checkout_installs() { - let Some(fx) = - classic_hosted_project("get-uuid", false, Mirror::None, HostedDriver::GetUuid).await + let Some(fx) = classic_hosted_project( + "get-uuid", + false, + Mirror::None, + HostedDriver::GetUuid, + Layout::Single, + ) + .await else { return; }; @@ -847,7 +940,14 @@ async fn classic_get_uuid_hosted_fresh_checkout_installs() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_redirect_tampered_hosted_tarball_fails_integrity() { - let Some(fx) = classic_hosted_project("tampered", true, Mirror::None, HostedDriver::Scan).await + let Some(fx) = classic_hosted_project( + "tampered", + true, + Mirror::None, + HostedDriver::Scan, + Layout::Single, + ) + .await else { return; }; @@ -894,6 +994,7 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { false, Mirror::ProjectRc, HostedDriver::Scan, + Layout::Single, ) .await else { @@ -975,7 +1076,9 @@ async fn classic_offline_mirror_outside_project_rc_refuses_hosted() { ("offline-mirror-parent", Mirror::ParentRc), ("offline-mirror-env", Mirror::Env), ] { - let Some(fx) = classic_hosted_project(tag, false, mirror, HostedDriver::Scan).await else { + let Some(fx) = + classic_hosted_project(tag, false, mirror, HostedDriver::Scan, Layout::Single).await + else { continue; }; assert!( diff --git a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs index 783e7337a..e70b3511d 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs @@ -11,7 +11,7 @@ //! view and the store entry byte-identical. //! //! Fixture: minimist@1.2.2 + its Socket patch (UUID -//! `80630680-4da6-45f9-bba8-b888e0ffd58c`, CVE-2021-44906) — same +//! `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`, CVE-2021-44906) — same //! pair `e2e_npm.rs` uses, so the BEFORE/AFTER hashes are known. //! //! Network: yes (pnpm install + socket-patch get). Toolchain: pnpm. @@ -24,12 +24,12 @@ mod common; use common::{assert_run_ok, git_sha256_file, has_command, pnpm_run, write_package_json}; -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; /// Git-SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2. const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10"; /// Git-SHA-256 of the *patched* `index.js` after the security fix. -const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28"; +const AFTER_HASH: &str = "ec956dcafb886f14315570bf3981d44aa12c561716abb46eed8b067aaa1f6bdf"; // ── Setup helpers ───────────────────────────────────────────────────── diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 51a34a20f..53f2f2d9c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -49,7 +49,7 @@ //! //! | Ecosystem | PURL | Patch UUID | Marker in the patched bytes | //! |-----------|------|------------|-----------------------------| -//! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | `Socket Community Patch` header | +//! | npm | `pkg:npm/minimist@1.2.2` | `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb` | `Socket Community Patch` header | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | `Socket Community Patch` header | //! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_PATCHES`] | `Socket Community Patch` header | //! @@ -137,7 +137,7 @@ const PROXY: &str = "https://patches-api.socket.dev"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; const NPM_NAME: &str = "minimist"; const NPM_VERSION: &str = "1.2.2"; -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; const PYPI_PURL: &str = "pkg:pypi/urllib3@1.26.18"; const PYPI_NAME: &str = "urllib3"; diff --git a/crates/socket-patch-core/src/formats/yarn/patterns.rs b/crates/socket-patch-core/src/formats/yarn/patterns.rs index 35f052c79..85a9ef3b4 100644 --- a/crates/socket-patch-core/src/formats/yarn/patterns.rs +++ b/crates/socket-patch-core/src/formats/yarn/patterns.rs @@ -66,12 +66,38 @@ pub(crate) fn split_pattern(pattern: &str) -> Option<(&str, &str)> { Some((name, range)) } +/// Split a classic key pattern `name@range` like [`split_pattern`], but +/// keep an EMPTY range (#1271): `""` is valid npm semver (the same as `*`), +/// and yarn 1 locks `"left-pad": ""` under `left-pad@:` — merged with other +/// ranges as `left-pad@, left-pad@^1.3.0:`. Berry never writes a rangeless +/// descriptor (its ranges carry a protocol), so the berry readers keep the +/// strict [`split_pattern`] and treat `name@` as malformed. +pub(crate) fn split_classic_pattern(pattern: &str) -> Option<(&str, &str)> { + let from = usize::from(pattern.starts_with('@')); + let at = pattern[from..].find('@')? + from; + let (name, range) = (&pattern[..at], &pattern[at + 1..]); + if name.is_empty() { + return None; + } + Some((name, range)) +} + /// The real package a key pattern stands for: its name, unless the range is /// an `npm:` alias — then the aliased target's name. pub(crate) fn pattern_real_name(pattern: &str) -> Option<&str> { - let (name, range) = split_pattern(pattern)?; + real_name_with(pattern, split_pattern) +} + +/// [`pattern_real_name`] over the classic grammar ([`split_classic_pattern`]): +/// a rangeless `left-pad@` stands for `left-pad`. +pub(crate) fn classic_pattern_real_name(pattern: &str) -> Option<&str> { + real_name_with(pattern, split_classic_pattern) +} + +fn real_name_with(pattern: &str, split: fn(&str) -> Option<(&str, &str)>) -> Option<&str> { + let (name, range) = split(pattern)?; if let Some(aliased) = range.strip_prefix("npm:") { - return match split_pattern(aliased) { + return match split(aliased) { Some((real, _)) => Some(real), None => Some(aliased), // `npm:left-pad` with no range }; @@ -80,10 +106,10 @@ pub(crate) fn pattern_real_name(pattern: &str) -> Option<&str> { } /// The one real package EVERY pattern of a classic key stands for -/// ([`pattern_real_name`]): `None` when there is no pattern, one does not -/// parse, or they name different packages. +/// ([`classic_pattern_real_name`]): `None` when there is no pattern, one +/// does not parse, or they name different packages. pub(crate) fn classic_key_real_name(patterns: &[String]) -> Option<&str> { - let mut names = patterns.iter().map(|p| pattern_real_name(p)); + let mut names = patterns.iter().map(|p| classic_pattern_real_name(p)); let first = names.next()??; names.all(|n| n == Some(first)).then_some(first) } @@ -169,6 +195,36 @@ pub(crate) fn berry_npm_alias_target(range: &str) -> Option<&str> { mod tests { use super::*; + /// #1271: the classic grammar keeps an empty range (`left-pad@` from + /// `"left-pad": ""`); the strict berry grammar still rejects it. + #[test] + fn classic_patterns_keep_an_empty_range() { + assert_eq!(split_classic_pattern("left-pad@"), Some(("left-pad", ""))); + assert_eq!( + split_classic_pattern("@scope/pkg@"), + Some(("@scope/pkg", "")) + ); + assert_eq!( + split_classic_pattern("left-pad@^1.3.0"), + Some(("left-pad", "^1.3.0")) + ); + assert_eq!(split_classic_pattern("@scope/pkg"), None); + assert_eq!(split_classic_pattern("left-pad"), None); + assert_eq!(split_classic_pattern("@"), None); + assert_eq!(split_pattern("left-pad@"), None); + + assert_eq!(classic_pattern_real_name("left-pad@"), Some("left-pad")); + assert_eq!( + classic_pattern_real_name("lp@npm:left-pad@"), + Some("left-pad") + ); + let merged = split_key_patterns("left-pad@, left-pad@^1.3.0"); + assert_eq!(merged, ["left-pad@", "left-pad@^1.3.0"]); + assert_eq!(classic_key_real_name(&merged), Some("left-pad")); + let quoted = split_key_patterns("\"@scope/pkg@\", \"@scope/pkg@^1.0.0\""); + assert_eq!(classic_key_real_name("ed), Some("@scope/pkg")); + } + #[test] fn resolution_selector_targets() { for (sel, want) in [ diff --git a/crates/socket-patch-core/src/formats/yarn/source.rs b/crates/socket-patch-core/src/formats/yarn/source.rs index b9adf297a..224d5dbca 100644 --- a/crates/socket-patch-core/src/formats/yarn/source.rs +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -1,7 +1,7 @@ //! Where yarn 1 installs a classic lock block's copy from, decided once for //! every mode that reads or rewrites the block. -use super::patterns::split_pattern; +use super::patterns::split_classic_pattern; use crate::vendor::npm_origin::npm_spec_is_registry; /// Where yarn 1 installs a lock block's copy from: the ONE classifier every @@ -48,7 +48,7 @@ pub(crate) enum CopySource { /// [`CopySource`] of a classic block from its key patterns and `resolved`. pub(crate) fn classic_copy_source(patterns: &[String], resolved: Option<&str>) -> CopySource { for pattern in patterns { - let range = split_pattern(pattern).map(|(_, r)| r).unwrap_or(""); + let range = split_classic_pattern(pattern).map(|(_, r)| r).unwrap_or(""); if range.starts_with("link:") { return CopySource::Link; } @@ -66,7 +66,7 @@ pub(crate) fn classic_copy_source(patterns: &[String], resolved: Option<&str>) - }; let registry_ranges = patterns .iter() - .all(|p| split_pattern(p).is_some_and(|(_, range)| npm_spec_is_registry(range))); + .all(|p| split_classic_pattern(p).is_some_and(|(_, range)| npm_spec_is_registry(range))); if registry_ranges && !is_codeload_tarball(resolved) { CopySource::Registry } else { @@ -81,7 +81,7 @@ pub(crate) fn classic_copy_source(patterns: &[String], resolved: Option<&str>) - /// a path that leaves the root. pub(crate) fn classic_file_directory(patterns: &[String]) -> Option { patterns.iter().find_map(|p| { - let path = split_pattern(p)?.1.strip_prefix("file:")?; + let path = split_classic_pattern(p)?.1.strip_prefix("file:")?; let path = path.split('#').next().unwrap_or_default(); if is_tarball_path(path) { return None; @@ -180,9 +180,9 @@ fn is_codeload_tarball(resolved: &str) -> bool { /// here: yarn locks them to a codeload tarball and fetches that as one. pub(crate) fn classic_block_is_git(patterns: &[String], resolved: Option<&str>) -> bool { patterns.iter().any(|p| { - split_pattern(p).is_some_and(|(_, range)| { + split_classic_pattern(p).is_some_and(|(_, range)| { let range = match range.strip_prefix("npm:") { - Some(aliased) => split_pattern(aliased).map_or("", |(_, r)| r), + Some(aliased) => split_classic_pattern(aliased).map_or("", |(_, r)| r), None => range, }; yarn_classic_range_is_git(range) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7f007a03f..b916e1196 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -75,8 +75,8 @@ use crate::formats::yarn::blocks::{ classic_line_endings_supported, repin_classic_block, scan_blocks, LockBlock, }; use crate::formats::yarn::patterns::{ - berry_npm_alias_target, classic_key_real_name, split_berry_key_patterns, split_key_patterns, - split_pattern, + berry_npm_alias_target, classic_key_real_name, split_berry_key_patterns, split_classic_pattern, + split_key_patterns, split_pattern, }; use crate::formats::yarn::source::{classic_copy_source, CopySource}; use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; @@ -3927,7 +3927,7 @@ fn rewrite_yarn_classic_with( // unpatched artifact. if !patterns .iter() - .any(|p| split_pattern(p).is_some_and(|(n, _)| n == fname)) + .any(|p| split_classic_pattern(p).is_some_and(|(n, _)| n == fname)) { alias_skipped = true; result @@ -10554,6 +10554,47 @@ mod tests { r.warnings.iter().filter(|w| w.code == BERRY_RISK).count() } + /// #1271: a classic block keyed by an empty range (`left-pad@:` from + /// `"left-pad": ""`), alone or merged ahead of another member's range, + /// is the registry copy: hosted pins it, keeping the key line, and + /// names no alias skip or missing entry. + #[test] + fn yarn_classic_empty_range_key_is_pinned() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + for key in ["left-pad@:", "left-pad@, left-pad@^1.3.0:"] { + let lock = format!( + "# yarn lockfile v1\n\n\n{key}\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e\"\n \ + integrity sha512-ORIG==\n" + ); + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + let out = r + .files + .get("yarn.lock") + .unwrap_or_else(|| panic!("{key}: the block must be pinned: {:?}", r.warnings)); + assert!(out.contains(&format!("\n{key}\n")), "{key}: {out}"); + assert!( + out.contains("resolved \"http://p.test/lp.tgz\"") + && out.contains("integrity sha512-PATCHED=="), + "{key}: {out}" + ); + assert!( + !r.warnings.iter().any(|w| w.code.contains("not_found") + || w.code == "redirect_yarn_classic_alias_skipped"), + "{key}: {:?}", + r.warnings + ); + } + } + /// #907: a hosted pin in a classic lock is dropped by a yarn 2+ install /// exactly like vendored wiring, so the hosted rewrite must warn the /// way the vendored probe does — with no `packageManager` pin, with a 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 da222b751..fe957baaf 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2613,6 +2613,38 @@ packages: ); } +/// #1271: a block keyed by an empty range (`left-pad@:` from +/// `"left-pad": ""`), alone or merged ahead of another range, is a +/// registry package lock-only discovery sees. +#[tokio::test] +async fn yarn_classic_empty_range_key_is_inventoried() { + for key in [ + "left-pad@:", + "left-pad@, left-pad@^1.3.0:", + "\"@scope/pkg@\":", + ] { + let name = if key.contains("@scope") { + "@scope/pkg" + } else { + "left-pad" + }; + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "yarn.lock", + &format!( + "# yarn lockfile v1\n\n\n{key}\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/{name}/-/x-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e\"\n" + ), + ) + .await; + let entries = inventory_yarn_classic(tmp.path()).await.unwrap(); + let e = entry(&entries, name); + assert_eq!(e.version, "1.3.0", "{key}"); + assert!(e.resolved.is_some(), "{key}: a registry copy"); + } +} + /// Real classic-lock degenerations: a `resolved` URL without the legacy /// `#sha1` fragment (registries that strip fragments) and a block with /// no `resolved` at all (offline-pruned locks). Both stay listed for diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs index dcf8b704e..11089bc8d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -8,7 +8,7 @@ use crate::formats::yarn::blocks::{ berry_field, classic_field, live_blocks, scan_blocks, LockBlock, }; use crate::formats::yarn::patterns::{ - parse_berry_locator, pattern_real_name, split_berry_key_patterns, split_key_patterns, + classic_pattern_real_name, parse_berry_locator, split_berry_key_patterns, split_key_patterns, split_pattern, split_resolved_sha1, BerryLocator, }; use crate::formats::yarn::source::{classic_copy_source, CopySource}; @@ -129,7 +129,7 @@ fn classic_registry_view(text: &str) -> Vec { if yarn_classic_lock::block_points_into_vendor(&block.lines) { continue; } - let Some(name) = patterns.first().and_then(|p| pattern_real_name(p)) else { + let Some(name) = patterns.first().and_then(|p| classic_pattern_real_name(p)) else { continue; }; let Some(version) = classic_field(&block.lines, "version") else { diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 9e9d84594..12f28accc 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -1923,6 +1923,34 @@ left-pad@^1.3.0: assert!(detail.contains("yarn install"), "{detail}"); } + /// #1271: yarn 1 locks `"left-pad": ""` (an empty range, the same as + /// `*`) under `left-pad@:`, merged with another member's range as + /// `left-pad@, left-pad@^1.3.0:`. Both blocks are the installed + /// registry copy: vendoring wires them (key line kept) and the revert + /// restores the lock byte-for-byte. + #[tokio::test] + async fn empty_range_key_is_wired_and_reverted() { + for key in ["left-pad@:", "left-pad@, left-pad@^1.3.0:"] { + let lock = Y2_BEFORE.replace("left-pad@^1.3.0:", key); + let fx = fixture_with_lock(&lock).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{key}: {:?}", result.error); + let entry = entry.expect("success carries a ledger entry"); + assert_eq!(entry.wiring.len(), 1, "{key}"); + let wired = fx.lock_text().await; + assert!(wired.contains(&format!("\n{key}\n")), "{key}: {wired}"); + assert!( + !wired.contains("registry.yarnpkg.com") && wired.contains(".socket/vendor/npm/"), + "{key}: {wired}" + ); + + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{key}: {:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{key}: {:?}", outcome.warnings); + assert_eq!(fx.lock_text().await, lock, "{key}: lock restored"); + } + } + #[tokio::test] async fn revert_round_trips_the_lock_and_removes_the_artifact() { let fx = fixture_with_lock(Y5_BEFORE).await; diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 5efa3a844..d8a25e9a7 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -20,7 +20,7 @@ //! A block `name@range[, name@range2]:` with `version "X"` and //! `resolved ""`. The package is the REAL name of the key patterns //! (`alias@npm:real@range` names `real` — -//! [`crate::formats::yarn::patterns::pattern_real_name`]); every pattern +//! [`crate::formats::yarn::patterns::classic_pattern_real_name`]); every pattern //! must agree, otherwise a Socket-wired block is diagnosed (the rewriters //! refuse mixed keys). `link:` keys are skipped: yarn installs them from the //! working tree, never from `resolved`. @@ -92,8 +92,8 @@ use super::{ use crate::formats::yarn::blocks::{berry_field, classic_field}; use crate::formats::yarn::is_berry_lock; use crate::formats::yarn::patterns::{ - classic_key_real_name, pattern_real_name, resolution_selector_target, split_pattern, - split_resolved_sha1, BerryLocator, + classic_key_real_name, classic_pattern_real_name, resolution_selector_target, + split_classic_pattern, split_resolved_sha1, BerryLocator, }; use crate::formats::yarn::source::{ classic_copy_source, manifest_name, registry_tarball_name, CopySource, @@ -159,7 +159,7 @@ async fn extract_classic(ctx: &DiscoverCtx<'_>, entries: Vec, out: &m fn classic_copy(entry: &YarnEntry, resolved: Option<&str>) -> Option { let version = classic_field(&entry.block.lines, "version")?; let file = entry.patterns.iter().find_map(|p| { - let (_, range) = split_pattern(p)?; + let (_, range) = split_classic_pattern(p)?; range.strip_prefix("file:") }); let source = match file { @@ -259,8 +259,10 @@ fn classic_block( let Some(resolved) = resolved else { return; }; - let names: std::collections::BTreeSet> = - patterns.iter().map(|p| pattern_real_name(p)).collect(); + let names: std::collections::BTreeSet> = patterns + .iter() + .map(|p| classic_pattern_real_name(p)) + .collect(); let names: Vec> = names.into_iter().collect(); let Some(wiring) = classify(ctx, resolved, YARN_LOCK, &block.key, out) else { // Not Socket's (a rejected Socket spelling was diagnosed instead): @@ -1012,6 +1014,32 @@ mod tests { } } + /// #1271: a hosted pin on a block keyed by an empty range + /// (`left-pad@:` from `"left-pad": ""`), alone or merged ahead of + /// another range, is attributed like any registry key. + #[tokio::test] + async fn classic_hosted_empty_range_keys() { + let lp = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + for key in ["left-pad@", "left-pad@, left-pad@^1.3.0"] { + let p = Project::new(); + p.write( + "yarn.lock", + classic(&[classic_block( + key, + "1.3.0", + &format!("{lp}#{SHA1}"), + Some(SRI), + )]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert!(out.diagnostics.is_empty(), "{key}: {:?}", out.diagnostics); + } + } + /// Multi-pattern keys, scoped names, and `npm:` alias keys: the purl is /// the REAL package every pattern stands for. #[tokio::test] diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 26ef8e5d5..a65b4b96d 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -5,7 +5,7 @@ projects using text `bun.lock` or native binary `bun.lockb`. Real-Bun evidence b - **The native matrix** — `scripts/backtest-bun.py` runs real Bun releases against the public free Socket patch for `minimist@1.2.2` - (`80630680-4da6-45f9-bba8-b888e0ffd58c`) with the production CLI and patch + (`642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`) with the production CLI and patch service, without a token or substitute service, and checks the INSTALLED bytes, lock stability, digest rejection and rollback on Linux, macOS and Windows ([workflow](../../.github/workflows/bun-compatibility.yml)). diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 71020c4c8..8487c7b05 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -117,7 +117,7 @@ # former `vendored-detached` leg collapsed into `vendored`: same footprint. MODES = ['hosted', 'vendored'] PURL = 'pkg:npm/minimist@1.2.2' -UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' +UUID = '642d7f02-ebc1-4ab0-99e2-07f5dd8463cb' # The registry slot bun writes for a non-default registry: the full tarball URL. REGISTRY_SLOT = 'https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz' LOCAL_TUPLE_SPEC = f'minimist@.socket/vendor/npm/{UUID}/minimist-1.2.2.tgz' diff --git a/scripts/backtest-vlt.py b/scripts/backtest-vlt.py index 18ed56f9d..fb8533fa2 100644 --- a/scripts/backtest-vlt.py +++ b/scripts/backtest-vlt.py @@ -88,7 +88,7 @@ VERSIONS = ['0.0.0-16', '0.0.0-32', '1.0.0-rc.14', '1.0.0-rc.32', '1.0.4', '1.0.10', '1.2.0'] MODES = ['hosted', 'vendored', 'agent'] PURL = 'pkg:npm/minimist@1.2.2' -UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' +UUID = '642d7f02-ebc1-4ab0-99e2-07f5dd8463cb' NAME = 'minimist' VERSION = '1.2.2' TARGET = f'{NAME}@{VERSION}' @@ -1069,7 +1069,7 @@ def holds(self, root, lock_text, side): ok = True for copy_dir in self.copies(root, lock_text): for key, hashes in self.record['files'].items(): - path = copy_dir / key.split('/', 1)[1] + path = copy_dir / key.removeprefix('package/') digest = git_hash(path.read_bytes()) if path.is_file() else None details[str(path.relative_to(root))] = digest ok = ok and digest == hashes.get(f'{side}Hash')