diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 360816407..480abc4dc 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -28,14 +28,15 @@ use crate::formats::yarn::berry_gates::YARN_LOCK; use crate::formats::yarn::is_berry_lock; use crate::patch::redirect::npmrc::npmrc_top_level_value; use crate::utils::fs::{ - open_regular_file, open_regular_file_sync, read_regular_to_string, read_regular_to_string_sync, + open_regular_file, open_regular_file_sync, read_regular_to_bytes, read_regular_to_string, + read_regular_to_string_sync, }; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; -use crate::vendor::bun_lockb; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; +use crate::vendor::{bun_lock_text, bun_lockb}; use super::engine::{Candidate, Refusal}; use super::guidance::{ @@ -524,7 +525,7 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat let found = !locks.is_empty(); let mut governing = Vec::new(); for lock in locks { - if workspaces_include_as(&patterns, &rel, lock_dialect(ancestor, lock).await) { + if lock_lists_member(ancestor, lock, &patterns, &rel).await { governing.push(lock); } } @@ -546,15 +547,74 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat None } +/// Whether the package manager that reads `lock` at `root` lists the +/// member `rel` under the root's `workspaces` `patterns`. An untagged Bun +/// lock (see [`lock_dialect`]) is ambiguous: Bun 1.3.0 and 1.3.1 already +/// honour `!` but write no config version (#1521). Only a directory that +/// a `!` pattern excludes reads differently, and for it the lock's own +/// workspace list decides: Bun before 1.3 lists every member it matched, +/// so a lock that lists the member, or any other `!`-excluded directory, +/// is older Bun's; 1.3.0/1.3.1 never list an excluded one. A list that +/// cannot be read keeps the wider pre-1.3 reading. +async fn lock_lists_member(root: &Path, lock: &str, patterns: &[String], rel: &[String]) -> bool { + let dialect = lock_dialect(root, lock).await; + if !workspaces_include_as(patterns, rel, dialect) { + return false; + } + if dialect != WorkspacesDialect::BUN_PRE_1_3 + || workspaces_include_as(patterns, rel, WorkspacesDialect::BUN) + { + return true; + } + let Some(listed) = bun_workspace_dirs(root, lock).await else { + return true; + }; + listed.iter().any(|dir| { + let dir: Vec = dir.split('/').map(str::to_string).collect(); + dir == rel + || (workspaces_include_as(patterns, &dir, WorkspacesDialect::BUN_PRE_1_3) + && !workspaces_include_as(patterns, &dir, WorkspacesDialect::BUN)) + }) +} + +/// The member directories the Bun lock `lock` at `root` lists, relative to +/// `root` as `/`-separated plain components (a `./` prefix or `\` +/// separators normalized, the root `""` left out); the text lock when both exist, as +/// Bun reads it. `None` when the lock is unreadable or not in Bun's shape. +async fn bun_workspace_dirs(root: &Path, lock: &str) -> Option> { + let listed = if lock == BUN_LOCK || root.join(BUN_LOCK).is_file() { + let text = read_regular_to_string(&root.join(BUN_LOCK)).await.ok()?; + let lines: Vec = strip_bom(&text).split('\n').map(str::to_string).collect(); + bun_lock_text::listed_workspace_dirs(&lines)? + } else { + let bytes = read_regular_to_bytes(&root.join(BUN_LOCKB)).await.ok()?; + bun_lockb::BunLockb::parse(&bytes) + .and_then(|lock| lock.workspace_paths()) + .ok()? + }; + Some( + listed + .iter() + .map(|dir| { + dir.split(['/', '\\']) + .filter(|part| !part.is_empty() && *part != ".") + .collect::>() + .join("/") + }) + .filter(|dir| !dir.is_empty()) + .collect(), + ) +} + /// The `package.json` `workspaces` dialect of the package manager that /// reads `lock` at `root` (#1097). yarn classic and berry tell apart by /// berry's `__metadata` key ([`is_berry_lock`]; an empty or unreadable /// `yarn.lock` reads as classic, the wider set), and Bun 1.3+ by the /// top-level `configVersion` it writes into `bun.lock`, or the trailing /// `cNfGvRsN` extension that carries it in a binary `bun.lockb` (#1492; -/// a lock without it, as Bun 1.3.0 and earlier write, reads as older Bun, -/// the wider set). Only the head of a text lock is read: both keys come -/// first. +/// a lock without it, as Bun 1.3.1 and earlier write, reads as older Bun, +/// the wider set, which [`lock_lists_member`] narrows). Only the head of +/// a text lock is read: both keys come first. async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { match lock { YARN_LOCK => { @@ -1387,7 +1447,7 @@ mod tests { write( tmp.path(), "bun.lock", - "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"configVersion\": \"1\",\n },\n },\n}\n", + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"configVersion\": \"1\",\n },\n \"packages/b\": {\n \"name\": \"b\",\n },\n },\n}\n", ); write(tmp.path(), "packages/b/package.json", "{}"); assert_eq!( @@ -1396,6 +1456,119 @@ mod tests { ); } + /// #1521: Bun 1.3.0 and 1.3.1 honour `!` but tag neither lock with a + /// config version, so an untagged lock is either theirs or older + /// Bun's. The lock's own workspace list tells them apart: Bun 1.2 + /// lists the `!`-excluded member, 1.3.0/1.3.1 leave it out, and then + /// the member's own lock is the one Bun reads. These are the locks + /// each release writes for the same `["packages/*","!packages/b"]` + /// root (`tests/fixtures/bun-lockb/negated-workspace`). + #[tokio::test] + async fn untagged_bun_lock_reads_its_workspace_list() { + const TEXT_1_2_23: &str = "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"root\",\n },\n \"packages/a\": {\n \"name\": \"a\",\n \"version\": \"1.0.0\",\n },\n \"packages/b\": {\n \"name\": \"b\",\n \"version\": \"1.0.0\",\n },\n },\n \"packages\": {\n \"a\": [\"a@workspace:packages/a\"],\n\n \"b\": [\"b@workspace:packages/b\"],\n }\n}\n"; + // Bun 1.3.1 writes the same bytes. + const TEXT_1_3_0: &str = "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"root\",\n },\n \"packages/a\": {\n \"name\": \"a\",\n \"version\": \"1.0.0\",\n },\n },\n \"packages\": {\n \"a\": [\"a@workspace:packages/a\"],\n }\n}\n"; + const BINARY_1_2_23: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb"); + const BINARY_1_3_0: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb"); + const BINARY_1_3_1: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb"); + let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); + for (name, lock, expected) in [ + ("bun.lock", TEXT_1_2_23.as_bytes(), refused), + ("bun.lock", TEXT_1_3_0.as_bytes(), None), + ("bun.lockb", BINARY_1_2_23, refused), + ("bun.lockb", BINARY_1_3_0, None), + ("bun.lockb", BINARY_1_3_1, None), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*","!packages/b"]}"#, + ); + std::fs::write(tmp.path().join(name), lock).unwrap(); + // The verdict comes from the list each lock holds, not from a + // lock that failed to read. + let listed = bun_workspace_dirs(tmp.path(), name).await; + assert_eq!( + listed.map(|dirs| dirs.contains(&"packages/b".to_string())), + Some(expected.is_some()), + "{name} of {} bytes", + lock.len() + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/b/package.json", "{}"); + let member = tmp.path().join("packages/b"); + assert_eq!( + code(&member, "npm").await.as_deref(), + expected, + "{name} of {} bytes", + lock.len() + ); + // The member's own lock is a stray only when the root's Bun + // lists the member. + std::fs::write(member.join(name), lock).unwrap(); + assert_eq!( + member_stray_lock(&member).await.is_some(), + expected.is_some(), + "{name} of {} bytes", + lock.len() + ); + // A member no `!` pattern excludes is governed either way. + assert_eq!( + code(&tmp.path().join("packages/a"), "npm").await.as_deref(), + refused, + "{name} of {} bytes", + lock.len() + ); + } + } + + /// #1521 (review): the version is read once per lock. An untagged lock + /// that lists one `!`-excluded directory is older Bun's, so another + /// excluded directory added since its last install is a member too; + /// and a member key spelled `./packages/b` is the same directory. + #[tokio::test] + async fn untagged_bun_lock_listing_any_excluded_dir_is_pre_1_3() { + let lock = |members: &[&str]| { + let mut text = String::from( + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"root\",\n },\n", + ); + for member in members { + text.push_str(&format!( + " \"{member}\": {{\n \"name\": \"m\",\n }},\n" + )); + } + text.push_str(" },\n \"packages\": {\n }\n}\n"); + text + }; + let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); + for (members, member, expected) in [ + (&["packages/a", "packages/b"][..], "packages/c", refused), + (&["packages/a", "./packages/b"][..], "packages/b", refused), + (&["packages/a", "./packages/b"][..], "packages/c", refused), + (&["packages/a"][..], "packages/c", None), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*","!packages/b","!packages/c"]}"#, + ); + write(tmp.path(), "bun.lock", &lock(members)); + for dir in ["a", "b", "c"] { + write(tmp.path(), &format!("packages/{dir}/package.json"), "{}"); + } + assert_eq!( + code(&tmp.path().join(member), "npm").await.as_deref(), + expected, + "{member} under a lock listing {members:?}" + ); + } + } + /// #1492: Bun 1.2+ still writes a binary `bun.lockb` under /// `saveTextLockfile = false`, and Bun 1.3.x+ marks it with a /// `cNfGvRsN` config-version tag (the twin of the text lock's @@ -1406,7 +1579,9 @@ mod tests { #[tokio::test] async fn bun_lockb_with_a_config_version_tag_honours_negated_workspaces() { const BUN_1_3_14: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); - const BUN_1_2_23: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); + // The lock Bun 1.2.23 writes for this root: it lists `packages/b`. + const BUN_1_2_23: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb"); // A workspace root's lock: the tag follows its workspace and // catalog extensions. const BUN_1_4_2_WORKSPACE: &[u8] = diff --git a/crates/socket-patch-core/src/utils/workspace_globs.rs b/crates/socket-patch-core/src/utils/workspace_globs.rs index d0bb0bc93..896a74fab 100644 --- a/crates/socket-patch-core/src/utils/workspace_globs.rs +++ b/crates/socket-patch-core/src/utils/workspace_globs.rs @@ -69,13 +69,17 @@ impl WorkspacesDialect { extglob: true, picomatch: true, }; - /// Bun 1.3 and later (a `bun.lock` with `configVersion`). + /// Bun 1.3 and later (a `bun.lock` with `configVersion`, or an + /// untagged 1.3.0/1.3.1 lock that leaves an excluded member out, + /// #1521). pub(crate) const BUN: Self = Self { negation: Negation::LastMatchWins, extglob: false, picomatch: false, }; - /// Bun before 1.3: it lists `!`-negated directories anyway. + /// Bun before 1.3: it lists `!`-negated directories anyway. An + /// untagged lock reads this way until its workspace list shows it + /// is 1.3.0/1.3.1's (`hosted::governing_root`, #1521). pub(crate) const BUN_PRE_1_3: Self = Self { negation: Negation::Ignored, extglob: false, diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 9fcafc4e8..e0b464ac6 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -669,9 +669,15 @@ fn parse_workspaces_section(lines: &[String]) -> Option { /// the project root), in lock order. Empty when the section is absent or /// not in bun's emitted shape. pub(crate) fn workspace_member_dirs(lines: &[String]) -> Vec { + listed_workspace_dirs(lines).unwrap_or_default() +} + +/// [`workspace_member_dirs`], but `None` when the section is absent or not +/// in bun's emitted shape, so a caller can tell "lists no such member" +/// from "could not read the list". +pub(crate) fn listed_workspace_dirs(lines: &[String]) -> Option> { parse_workspaces_section(lines) .map(|section| section.members.into_iter().map(|(dir, _)| dir).collect()) - .unwrap_or_default() } /// Whether a `workspaces` key is a plain relative directory (`""` is the diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md index 0caf38abe..b35380ad4 100644 --- a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md @@ -18,6 +18,10 @@ and an empty `BUN_INSTALL_CACHE_DIR` to regenerate. Bun 1.2+ fixtures include installed packages), whose `sCoPdOvR` extension Bun writes after `cNfGvRsN`, and a `!` workspace pattern (#1492); written on Linux x64. +- `negated-workspace/.lockb`: the root lock Bun 1.2.23, 1.3.0 + and 1.3.1 write for a `["packages/*","!packages/b"]` workspace, on Linux + x64. 1.2.23 lists `packages/b`, while 1.3.0 and 1.3.1 (identical bytes) + leave it out, and neither carries the `cNfGvRsN` tag (#1521). - `0.8.1-production` / `1.0.0-production`: a production dependency and a different version aliased in dev dependencies, pinning production-filter string-pool reconstruction on early binary readers. diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb new file mode 100644 index 000000000..cf31c383f Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb new file mode 100644 index 000000000..f9fa3e0fe Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb new file mode 100644 index 000000000..f9fa3e0fe Binary files /dev/null and b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb differ diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml new file mode 100644 index 000000000..30e63e0e1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml @@ -0,0 +1,2 @@ +[install] +saveTextLockfile = false diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json new file mode 100644 index 000000000..d26667bce --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json @@ -0,0 +1 @@ +{"name":"root","private":true,"workspaces":["packages/*","!packages/b"]} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json new file mode 100644 index 000000000..008d7d1fb --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json @@ -0,0 +1 @@ +{"name":"a","version":"1.0.0"} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json new file mode 100644 index 000000000..363a869fd --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json @@ -0,0 +1 @@ +{"name":"b","version":"1.0.0"} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json new file mode 100644 index 000000000..deded3b71 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json @@ -0,0 +1,19 @@ +{ + "os": "linux", + "arch": "x86_64", + "command": "bun install --ignore-scripts", + "locks": { + "1.2.23.lockb": { + "bun": "1.2.23", + "sha256": "e78d558895f6ba02e99c8bacbe2bea75a1b116be5dc8b7d881343c66f417dadb" + }, + "1.3.0.lockb": { + "bun": "1.3.0", + "sha256": "e766af5798b6f3f9c63b8cd0045f678b1556c15bd27624cf48c35dbad1f1708f" + }, + "1.3.1.lockb": { + "bun": "1.3.1", + "sha256": "e766af5798b6f3f9c63b8cd0045f678b1556c15bd27624cf48c35dbad1f1708f" + } + } +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json index c1e41b0b7..057e40f64 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json @@ -479,6 +479,30 @@ "elsewhere": [], "live_claims": [] }, + "bun-lockb/negated-workspace": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "bun-lockb/negated-workspace/packages/a": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "bun-lockb/negated-workspace/packages/b": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "bun-lockb/prerelease-pair-0.8.1": { "refs": [], "diagnostics": [],