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
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1475,7 +1475,7 @@ command's integration tests check its output.
| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed <code>` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail `<purl> was hosted; restored its upstream registry entry (<files>) before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). |
| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore <purl> to its upstream registry entry: <why>; restore it from version control instead (`git checkout -- <files>`)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partialFailure`. |
| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/<uuid>`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. |
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; yarn berry (picomatch) also reads regex groups (`packages/(a|b)`; a group without `|` only in a pattern fast-glob deems dynamic, or in a `!` pattern), the regex quantifiers `?` / `+` after a group, `+` after a class and `+` inside a group, POSIX classes (`[[:alpha:]]`) and `..` segments in a static pattern or after a static prefix (`packages/a/../b` lists `packages/b`; literal in a `!` pattern); a group alternative holding `/` (`(packages/ui|apps/web)`) is matched across components, and a `?` or `+` quantifying such a group fails closed (the pattern lists every directory, and excludes none as a `!` pattern) (#1490); a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or `bun.lockb` alone); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`), read the way the root's lock's package manager reads them (#1097): extglobs (`@(a|b)`, `?(…)`, `*(…)`, `+(…)`, `!(…)`) match for npm and yarn but not Bun or vlt; yarn berry (picomatch) also reads regex groups (`packages/(a|b)`; a group without `|` only in a pattern fast-glob deems dynamic, or in a `!` pattern), the regex quantifiers `?` / `+` after a group, `+` after a class and `+` inside a group, POSIX classes (`[[:alpha:]]`) and `..` segments in a static pattern or after a static prefix (`packages/a/../b` lists `packages/b`; literal in a `!` pattern); a group alternative holding `/` (`(packages/ui|apps/web)`) is matched across components, and a `?` or `+` quantifying such a group fails closed (the pattern lists every directory, and excludes none as a `!` pattern) (#1490); a `!` pattern excludes what earlier patterns matched for npm, Bun 1.3+ (a `bun.lock` with a top-level `configVersion`) and vlt, excludes whatever the order for yarn berry (a `yarn.lock` with `__metadata`, where a leading `!(` is an extglob), and is ignored by yarn classic and older Bun (a `bun.lock` without `configVersion`, or a `bun.lockb` alone without the trailing `cNfGvRsN` config-version extension, its binary twin, #1492); only the root's locks whose package manager lists the directory govern it (and are named), and a lockless root lists it when any of them would; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). |
| `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. |
| `eject_refused` | top-level `error.code` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. |
| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. |
Expand Down
3 changes: 2 additions & 1 deletion crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,8 @@ async fn hosted_scan_from_brace_or_class_glob_workspace_member_refuses() {
/// #1097: the member matcher read every root's `workspaces` the npm way.
/// yarn classic ignores `!` patterns (`packages/a` stays a member of
/// `["packages/*", "!packages/a"]`), as does Bun before 1.3 (a `bun.lock`
/// with no `configVersion`, or a binary `bun.lockb`), and npm and yarn
/// with no `configVersion`, or a binary `bun.lockb` without its
/// `cNfGvRsN` twin, #1492), and npm and yarn
/// match extglobs (`packages/@(a|b)`). The matcher dropped those members,
/// so a hosted `scan` / `get` from one pinned nothing and exited 0.
#[tokio::test]
Expand Down
91 changes: 85 additions & 6 deletions crates/socket-patch-core/src/hosted/governing_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ use crate::formats::text::strip_bom;
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, read_regular_to_string, read_regular_to_string_sync};
use crate::utils::fs::{
open_regular_file, open_regular_file_sync, 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;
Expand Down Expand Up @@ -547,10 +550,11 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat
/// 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` (a lock without
/// it, or a binary `bun.lockb` with no `bun.lock` beside it, reads as
/// older Bun, the wider set). Only the head of a lock is read: both keys
/// come first.
/// 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.
async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect {
match lock {
YARN_LOCK => {
Expand All @@ -573,12 +577,34 @@ async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect {
}
// Bun reads the text lock when both exist.
BUN_LOCKB if root.join(BUN_LOCK).is_file() => Box::pin(lock_dialect(root, BUN_LOCK)).await,
BUN_LOCKB => WorkspacesDialect::BUN_PRE_1_3,
// Bun marks a binary lock with its config version too (#1492).
BUN_LOCKB => {
if bun_lockb_config_version(&root.join(lock)).await.is_some() {
WorkspacesDialect::BUN
} else {
WorkspacesDialect::BUN_PRE_1_3
}
}
VLT_LOCK => WorkspacesDialect::VLT,
_ => WorkspacesDialect::NPM,
}
}

/// The `configVersion` a binary `bun.lockb` carries in its `cNfGvRsN`
/// extension (#1492), read through the FIFO-safe opener on the blocking
/// pool. `None` when the lock is unreadable, not a binary lock, or
/// untagged (written before Bun wrote `configVersion`).
async fn bun_lockb_config_version(path: &Path) -> Option<u64> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
let (mut file, _) = open_regular_file_sync(&path).ok()?;
bun_lockb::read_config_version(&mut file)
})
.await
.ok()
.flatten()
}

/// How much of a lock [`lock_dialect`] reads: the yarn berry and Bun keys
/// it looks for sit in the first lines.
const LOCK_HEAD_BYTES: u64 = 4096;
Expand Down Expand Up @@ -1370,6 +1396,59 @@ mod tests {
);
}

/// #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
/// `configVersion`). A tagged lone `bun.lockb` honours `!`, so the
/// excluded member's own lock is the one Bun reads; an untagged one
/// (Bun 1.2.23 here) or one that is not a binary lock keeps the wider
/// pre-1.3 reading.
#[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");
// A workspace root's lock: the tag follows its workspace and
// catalog extensions.
const BUN_1_4_2_WORKSPACE: &[u8] =
include_bytes!("../../tests/fixtures/bun-lockb/1.4.2-extensions/bun.lockb");
// Scoped overrides: Bun writes their extension after the tag.
const BUN_1_4_2_SCOPED: &[u8] =
include_bytes!("../../tests/fixtures/bun-lockb/1.4.2-scoped-overrides/bun.lockb");
let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE);
for (lock, expected) in [
(BUN_1_3_14, None),
(BUN_1_4_2_WORKSPACE, None),
(BUN_1_4_2_SCOPED, None),
(BUN_1_2_23, refused),
(&b"\0"[..], refused),
] {
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
"package.json",
r#"{"private":true,"workspaces":["packages/*","!packages/b"]}"#,
);
std::fs::write(tmp.path().join("bun.lockb"), lock).unwrap();
write(tmp.path(), "packages/b/package.json", "{}");
let member = tmp.path().join("packages/b");
assert_eq!(
code(&member, "npm").await.as_deref(),
expected,
"bun.lockb of {} bytes",
lock.len()
);
// The member's own binary lock is a stray only when the
// root's Bun lists the member.
std::fs::write(member.join("bun.lockb"), lock).unwrap();
assert_eq!(
member_stray_lock(&member).await.is_some(),
expected.is_some(),
"bun.lockb of {} bytes",
lock.len()
);
}
}

/// #1097: a lockless intermediate root reads its `workspaces` in the
/// widest dialect, so a member it lists only through an extglob, or
/// excludes only with `!`, still reaches the outer root's lock.
Expand Down
Loading
Loading