Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

195 changes: 195 additions & 0 deletions crates/nub-cli/src/pm_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,22 @@ fn apply_config_scope(
c.embedder_overrides = Some(effective);
c.trusted_dependencies_honored = trusted;
c.embedder_package_extensions = Some(effective_pe);
// Bundled ecosystem defaults (Yarn ∪ pnpm ∪ nub-phantom), applied as
// the lowest-precedence packageExtensions layer. Role-gated to the
// identities that apply packageExtensions at all - nub identity and
// pnpm compat - so npm/yarn-classic/bun compat installs mirror the
// incumbent (which applies no curated extension list) instead of gaining
// resolve edges it would not. (Yarn Berry applies @yarnpkg/extensions
// by default via @yarnpkg/plugin-compat; the Yarn role doesn't
// currently split classic vs Berry, so Berry compat is a follow-up.)
// Kept out of the checksum by reading them through
// the separate `bundled_package_extensions` seam.
c.bundled_package_extensions =
if matches!(role, config_scope::Role::Nub | config_scope::Role::Pnpm) {
Some(bundled_package_extensions_defaults())
} else {
None
};
});

if noise == ConfigScopeNoise::Warn {
Expand Down Expand Up @@ -1306,6 +1322,29 @@ fn apply_config_scope(
Ok(())
}

/// Bundled ecosystem `packageExtensions` defaults (Yarn ∪ pnpm ∪
/// nub-phantom), vendored at `vendor/package-extensions/unified.json` and
/// kept fresh by `scripts/sync-package-extensions.ts`. Applied as the
/// lowest-precedence layer in aube's `resolve_dependency_policy` (user
/// extensions win per-key via `extend_missing`'s first-write-wins), and
/// deliberately excluded from the lockfile `packageExtensionsChecksum` by
/// flowing through the separate `bundled_package_extensions` seam.
///
/// A parse failure is non-fatal: the bundled file is committed and
/// compile-time-`include_str!`'d, so corruption would be a bad commit, not
/// a runtime input — warn and install with no bundled defaults rather than
/// abort an install over data the user never authored.
fn bundled_package_extensions_defaults() -> std::collections::BTreeMap<String, serde_json::Value> {
const BUNDLED: &str = include_str!("../../../../vendor/package-extensions/unified.json");
match serde_json::from_str(BUNDLED) {
Ok(map) => map,
Err(err) => {
tracing::warn!("ignoring unparseable bundled package-extensions defaults: {err}");
std::collections::BTreeMap::new()
}
}
}

/// Does the active PM honor `catalog:` specifiers? pnpm@9+, bun@1.2+, and
/// yarn-berry (v2+) implement catalogs; npm and yarn-classic (v1) do not. nub
/// identity honors catalogs (an un-branded cross-tool field, like
Expand Down Expand Up @@ -5104,4 +5143,160 @@ mod tests {
install state never saw"
);
}

// The bundled ecosystem defaults (Yarn ∪ pnpm ∪ nub-phantom) must load
// from the vendored `vendor/package-extensions/unified.json` and parse
// into the selector -> body map aube consumes. This guards the
// `include_str!` path and the data's correctness: the map is non-empty,
// carries the pnpm-specific `@angular/build@*` entry, and carries the
// Yarn `gatsby-core-utils@<2.14.0-next.1` entry with BOTH `got` and
// `@babel/runtime` — the latter checks the sync script's deep-merge of
// @yarnpkg/extensions' one duplicate selector (last-wins would drop
// `@babel/runtime`).
#[test]
fn bundled_package_extensions_defaults_load() {
let map = bundled_package_extensions_defaults();
assert!(
map.len() > 100,
"bundled defaults should carry 100+ entries, got {}",
map.len()
);
// pnpm-specific entry not in Yarn.
let angular = map
.get("@angular/build@*")
.expect("@angular/build@* present");
let tslib = angular
.get("dependencies")
.and_then(|d| d.get("tslib"))
.and_then(|v| v.as_str());
assert_eq!(
tslib,
Some("^2.3.0"),
"@angular/build@* -> dependencies.tslib"
);

// Yarn entry whose selector is duplicated in the source array; the
// two bodies (got, @babel/runtime) must both survive the deep-merge.
let gatsby = map
.get("gatsby-core-utils@<2.14.0-next.1")
.expect("gatsby-core-utils entry present");
let deps = gatsby
.get("dependencies")
.expect("gatsby-core-utils entry has dependencies");
assert_eq!(
deps.get("got").and_then(|v| v.as_str()),
Some("8.3.2"),
"gatsby-core-utils -> dependencies.got"
);
assert_eq!(
deps.get("@babel/runtime").and_then(|v| v.as_str()),
Some("^7.14.8"),
"gatsby-core-utils -> dependencies.@babel/runtime (survives dup-selector merge)"
);
}

// The bundled ecosystem `packageExtensions` defaults are role-gated in
// `apply_config_scope`: applied only under nub identity and pnpm compat
// (`Role::Nub | Role::Pnpm`), and dropped under npm/yarn/bun compat —
// whose incumbents apply no curated extension list, so nub mirrors them
// instead of grafting resolve edges the incumbent would not. This is the
// NEGATIVE-case guard for that gate (the positive case — the bundled set
// shaping the graph under nub identity — is covered by the network
// `bundled_default_shapes_graph_and_stays_out_of_checksum` install test in
// `tests/package_extensions.rs`). It drives `apply_config_scope` with a
// `DetectedLockfile` of each kind and reads the
// `bundled_package_extensions` seam back off the process-global
// `EngineContext` — no install, no network: the gate is a pure function of
// the resolved role, so exercising the role -> field mapping is sufficient.
#[test]
fn bundled_package_extensions_gate_blocks_compat_roles() {
use aube_util::{EngineContext, engine_context, set_engine_context};

// Takes `ENGINE_GLOBAL_LOCK` for the whole test and restores the
// process-global `EngineContext` on DROP (not a tail statement, so a
// panicking assert still restores it) — the same shape as
// `install_report.rs`'s `EngineGuard`. Without the lock this test's
// 8 `set_engine_context(EngineContext::default())` writes race any
// other test in this binary that reads or writes the same global.
struct EngineGuard {
context: EngineContext,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EngineGuard {
fn take() -> Self {
let lock = crate::pm_engine::ENGINE_GLOBAL_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
Self {
_lock: lock,
context: engine_context(),
}
}
}
impl Drop for EngineGuard {
fn drop(&mut self) {
set_engine_context(self.context.clone());
}
}
let _guard = EngineGuard::take();

// A manifest carrying a dependency the bundled set WOULD extend
// (`gatsby-core-utils@2.13.0` satisfies the bundled
// `gatsby-core-utils@<2.14.0-next.1` selector injecting `got`). The
// body is irrelevant to the gate — only the resolved role matters —
// but mirroring the real affected package keeps the intent legible.
let manifest =
r#"{"name":"gate","version":"1.0.0","dependencies":{"gatsby-core-utils":"2.13.0"}}"#;

// Reset the process-global EngineContext to a clean default before each
// role so a prior test's residue can't mask the gate. `apply_config_scope`
// is the single writer of `bundled_package_extensions` here. Safe under
// the guard above: this test now holds `ENGINE_GLOBAL_LOCK` for its
// full duration, and the guard restores the pre-test context on drop.
let bundled_for_kind = |kind: Option<LockfileKind>| -> Option<usize> {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), manifest).unwrap();
let detected = kind.map(|k| DetectedLockfile {
kind: k,
dir: dir.path().to_path_buf(),
fresh: false,
});
set_engine_context(EngineContext::default());
Comment thread
jdalton marked this conversation as resolved.
apply_config_scope(detected.as_ref(), dir.path(), ConfigScopeNoise::Silent)
.expect("Silent scoping never hard-errors on a bare manifest");
engine_context()
.bundled_package_extensions
.as_ref()
.map(std::collections::BTreeMap::len)
};

// Compat roles whose incumbents apply no curated extension list: the
// gate must block the bundled set, so the seam stays `None`.
for kind in [
LockfileKind::Npm,
LockfileKind::NpmShrinkwrap,
LockfileKind::Yarn,
LockfileKind::YarnBerry,
LockfileKind::Bun,
] {
assert_eq!(
bundled_for_kind(Some(kind)),
None,
"{kind:?} compat must NOT receive the bundled packageExtensions set \
(the gate blocks npm/yarn/bun); the seam should be None"
);
}

// Positive side of the same gate: nub identity (no lockfile → role
// defaults to Nub) and pnpm compat must receive the bundled set, and it
// must be the non-empty defaults map — not a vacuous `Some(empty)`.
for kind in [None, Some(LockfileKind::Aube), Some(LockfileKind::Pnpm)] {
let len =
bundled_for_kind(kind).expect("nub identity / pnpm must receive the bundled set");
assert!(
len > 100,
"nub/pnpm bundled set must carry the 100+ vendored defaults, got {len}"
);
}
}
}
139 changes: 139 additions & 0 deletions crates/nub-cli/tests/package_extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ fn store_has(dir: &Path, name: &str) -> bool {
.any(|n| n.starts_with(&prefix))
}

/// Whether the virtual store holds the exact `name@version` entry.
fn store_has_version(dir: &Path, name: &str, version: &str) -> bool {
let target = format!("{name}@{version}");
std::fs::read_dir(dir.join("node_modules/.store"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.any(|n| n == target)
}

/// A top-level `packageExtensions` entry injecting a dependency into a resolved
/// package must shape the graph under Nub identity, and editing it after an
/// install must invalidate the fast path so the injected dep lands.
Expand Down Expand Up @@ -130,3 +141,131 @@ fn top_level_package_extensions_shapes_resolution_and_invalidates_freshness() {
the edit must invalidate the install fast path so it re-resolves: {err2}"
);
}

/// Read the `packageExtensionsChecksum` aube stamps onto `nub.lock` (pnpm-v9
/// YAML format), or `None` when the lockfile carries no checksum.
fn lockfile_checksum(dir: &Path) -> Option<String> {
let lock = std::fs::read_to_string(dir.join("nub.lock")).ok()?;
for line in lock.lines() {
if let Some(rest) = line.trim_start().strip_prefix("packageExtensionsChecksum:") {
let v = rest.trim().trim_matches('"');
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}

/// A bundled ecosystem default (Yarn ∪ pnpm ∪ nub-phantom, vendored at
/// `vendor/package-extensions/unified.json`) must shape the resolved graph
/// with NO user `packageExtensions` — and must NOT leak into the lockfile
/// `packageExtensionsChecksum` (routing it there would drift every existing
/// lockfile on each bundled-list bump and abort `--frozen-lockfile`).
///
/// `gatsby-core-utils@2.13.0` declares neither `got` nor `@babel/runtime`,
/// and `2.13.0` satisfies the bundled selector
/// `gatsby-core-utils@<2.14.0-next.1`, so the bundled extension injecting
/// `got` is observable: without it, `got` is absent from the graph.
///
/// The checksum guard has two cases: (1) empty user `packageExtensions` →
/// aube writes NO `packageExtensionsChecksum` field (the checksum fn returns
/// `None` for an empty map), so a bundled-list bump cannot drift the
/// lockfile — there is nothing to mismatch; (2) non-empty user
/// `packageExtensions` with the bundled default ALSO shaping the graph → the
/// checksum must equal `package_extensions_checksum(&user_pe_only)`, proving
/// the bundled map is not folded into the checksum input.
#[test]
#[ignore = "network: resolves gatsby-core-utils@2.13.0 + the bundled got from the npm registry"]
fn bundled_default_shapes_graph_and_stays_out_of_checksum() {
use aube_lockfile::pnpm::package_extensions_checksum;
if !registry_reachable() {
eprintln!("skipping: registry.npmjs.org unreachable");
return;
}
let store = pm_tmpdir("store");
let cache = pm_tmpdir("cache");

// (1) No user packageExtensions: the bundled default must still apply,
// and the lockfile must carry NO checksum (empty user PE → None → a
// bundled-list bump cannot drift this lockfile).
let dir_a = pm_tmpdir("bundled-a");
let pkg_a =
r#"{"name":"bundled-a","version":"1.0.0","dependencies":{"gatsby-core-utils":"2.13.0"}}"#;
std::fs::write(dir_a.join("package.json"), pkg_a).unwrap();
let (err_a, code_a) = run_install_in_store(&dir_a, &store, &cache, &["install"]);
assert_eq!(code_a, 0, "bundled-default install A failed: {err_a}");
assert!(
store_has(&dir_a, "got"),
"the bundled `gatsby-core-utils@<2.14.0-next.1` extension must inject `got` \
(undeclared by 2.13.0) into the graph with no user packageExtensions: {err_a}"
);
assert!(
dir_a.join("nub.lock").is_file(),
"A: nub-identity install writes nub.lock: {err_a}"
);
assert_eq!(
lockfile_checksum(&dir_a),
None,
"empty user packageExtensions must produce NO packageExtensionsChecksum \
(the checksum fn returns None for an empty map), so a bundled-list bump \
cannot drift the lockfile: {err_a}"
);

// (2) Non-empty user packageExtensions, with the bundled default ALSO
// shaping the graph: the checksum must reflect ONLY the user's
// packageExtensions, not the bundled map. Compare against
// `package_extensions_checksum` computed on the user-PE-only map.
let dir_b = pm_tmpdir("bundled-b");
let user_pe = r#"{"is-positive@3.1.0":{"dependencies":{"is-number":"7.0.0"}}}"#;
let pkg_b = format!(
r#"{{"name":"bundled-b","version":"1.0.0","dependencies":{{"gatsby-core-utils":"2.13.0","is-positive":"3.1.0"}},"packageExtensions":{user_pe}}}"#
);
std::fs::write(dir_b.join("package.json"), pkg_b).unwrap();
let (err_b, code_b) = run_install_in_store(&dir_b, &store, &cache, &["install"]);
assert_eq!(code_b, 0, "bundled-default install B failed: {err_b}");
assert!(
store_has(&dir_b, "got"),
"B: bundled default still applies alongside user packageExtensions: {err_b}"
);
let user_pe_map: std::collections::BTreeMap<String, serde_json::Value> =
serde_json::from_str(user_pe).unwrap();
let expected =
package_extensions_checksum(&user_pe_map).expect("non-empty user PE yields a checksum");
assert_eq!(
lockfile_checksum(&dir_b).as_deref(),
Some(expected.as_str()),
"the lockfile packageExtensionsChecksum must equal the hash of the \
USER packageExtensions only — the bundled map (actively shaping this \
graph via `got`) must not be folded into the checksum input, or every \
bundled-list bump drifts existing lockfiles and aborts \
--frozen-lockfile: {err_b}"
);

// (3) User packageExtensions OVERRIDE the bundled default on a matching
// selector + dependency key. The bundled `gatsby-core-utils@<2.14.0-next.1`
// injects `got: 8.3.2`; a user entry for the SAME selector injecting
// `got: 8.3.0` must win (user-first Vec ordering + `extend_missing`
// first-write-wins), so the resolved `got` is the user's 8.3.0, not the
// bundled 8.3.2. This guards the precedence construction in
// `resolve_dependency_policy`, which the existing aube `extend_missing`
// unit tests do not cover.
let dir_c = pm_tmpdir("bundled-c");
let user_pe_c = r#"{"gatsby-core-utils@<2.14.0-next.1":{"dependencies":{"got":"8.3.0"}}}"#;
let pkg_c = format!(
r#"{{"name":"bundled-c","version":"1.0.0","dependencies":{{"gatsby-core-utils":"2.13.0"}},"packageExtensions":{user_pe_c}}}"#
);
std::fs::write(dir_c.join("package.json"), pkg_c).unwrap();
let (err_c, code_c) = run_install_in_store(&dir_c, &store, &cache, &["install"]);
assert_eq!(code_c, 0, "bundled-default install C failed: {err_c}");
assert!(
store_has_version(&dir_c, "got", "8.3.0"),
"user packageExtensions must override the bundled `got: 8.3.2` with the \
user's `got: 8.3.0` on the same selector: {err_c}"
);
assert!(
!store_has_version(&dir_c, "got", "8.3.2"),
"the bundled `got: 8.3.2` must NOT be resolved when the user overrides \
the same selector+key with 8.3.0: {err_c}"
);
}
3 changes: 3 additions & 0 deletions crates/nub-phantom-scan/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ path = "src/lib.rs"
nub-phantom-core = { path = "../nub-phantom-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Used by the `emit-extensions` bin's dedup matcher (selector → name+range
# against a scanned version), mirroring aube-resolver's `package_selector_matches`.
node-semver = "2"

[lints]
workspace = true
Loading
Loading