Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 183 additions & 8 deletions crates/socket-patch-core/src/hosted/governing_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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<String> = 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<Vec<String>> {
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<String> = 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::<Vec<_>>()
.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 => {
Expand Down Expand Up @@ -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!(
Expand All @@ -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
Expand All @@ -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] =
Expand Down
8 changes: 6 additions & 2 deletions crates/socket-patch-core/src/utils/workspace_globs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion crates/socket-patch-core/src/vendor/bun_lock_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,9 +669,15 @@ fn parse_workspaces_section(lines: &[String]) -> Option<WorkspacesSection> {
/// 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<String> {
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<Vec<String>> {
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
Expand Down
4 changes: 4 additions & 0 deletions crates/socket-patch-core/tests/fixtures/bun-lockb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>.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.
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[install]
saveTextLockfile = false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"root","private":true,"workspaces":["packages/*","!packages/b"]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"a","version":"1.0.0"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"b","version":"1.0.0"}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
Loading