diff --git a/crates/nub-cli/src/config_fields.rs b/crates/nub-cli/src/config_fields.rs index 9dba27a31..93c790ebe 100644 --- a/crates/nub-cli/src/config_fields.rs +++ b/crates/nub-cli/src/config_fields.rs @@ -593,7 +593,6 @@ mod tests { .iter() .flat_map(|field| { aube_settings::all() - .iter() .filter(move |engine| { field.address == engine.name || engine.npmrc_keys.contains(&field.address) }) diff --git a/crates/nub-cli/src/pm_engine/identity.rs b/crates/nub-cli/src/pm_engine/identity.rs index e5adec1c4..77e9c85ed 100644 --- a/crates/nub-cli/src/pm_engine/identity.rs +++ b/crates/nub-cli/src/pm_engine/identity.rs @@ -249,6 +249,26 @@ pub(crate) const NUB: aube_util::Embedder = aube_util::Embedder { // token is constant-on and folds the scanner version, so a scanner-logic bump // invalidates a warm tree and re-links; standalone aube's `None` skips the fold. extra_settings_fingerprint: Some(crate::dynamic_phantom::settings_fingerprint), + // `aubeNoAutoInstall` skips the engine's own pre-run staleness check, which + // lives in `commands::auto_install::ensure_installed` — reached only from the + // engine's `run` / `exec` / `restart`. None of those is an `ENGINE_VERB`: nub + // runs scripts through its own frontend and gates freshness in + // `crate::verify_deps`, so the engine's gate never executes and the setting + // decides nothing here. Before this entry `nub config set aubeNoAutoInstall + // true` wrote that key into the user's `.npmrc` and `nub config list --all` + // advertised it — nub putting the ENGINE's brand in a user's config for a + // value nub never reads. + // + // Deliberately the ONLY entry. `verifyDepsBeforeRun` and + // `optimisticRepeatInstall` are read by that same dead gate, but their names + // are neutral and pnpm-shared, and nub honors `verify-deps-before-run` on its + // own path — hiding them would break the pnpm surface to fix nothing. + unsupported_settings: &[( + "aubeNoAutoInstall", + "nub does not auto-install before a run. Use `verifyDeps` in nub.jsonc, or \ + `verify-deps-before-run` in .npmrc, to choose what happens when dependencies \ + are stale.", + )], }; /// Register [`NUB`] as the active embedder profile. Idempotent (the engine's @@ -293,4 +313,65 @@ const _: () = { assert!(!NUB.warm_trust_revalidate); assert!(matches!(NUB.trust_policy_ignore_after_default, Some(20160))); assert!(NUB.extra_settings_fingerprint.is_some()); + assert!(matches!(NUB.unsupported_settings, [(n, a)] + if matches!(n.as_bytes(), b"aubeNoAutoInstall") && !a.is_empty())); }; + +#[cfg(test)] +mod tests { + use super::NUB; + + /// A name in `unsupported_settings` that no longer spells a real setting is + /// a SILENT no-op — the filter simply never matches, the setting it was + /// meant to hide (if it was renamed) comes back, and nothing anywhere + /// fails. The engine cannot catch this: standalone aube's list is empty, so + /// its own tests exercise the empty case only. This is the one place the + /// pairing is checked, so it looks the names up in the UNFILTERED table — + /// the filtered `find` would report exactly the entries under test as + /// absent and pass vacuously. + #[test] + fn every_unsupported_setting_names_a_real_one() { + for (name, advice) in NUB.unsupported_settings { + assert!( + aube_settings::meta::find_unfiltered(name).is_some(), + "`{name}` is not in the settings table — the entry hides nothing" + ); + assert!( + !advice.is_empty(), + "`{name}` has no replacement advice; `config set` would refuse it with no next step" + ); + } + } + + /// The filter has to actually reach the shared lookup, not just sit in the + /// profile. Guards against a future refactor that keeps the field but stops + /// consulting it — the failure mode would otherwise be invisible until a + /// user saw the engine's brand back in `config list --all`. + #[test] + fn the_profile_entry_removes_the_setting_from_the_table() { + // `set_embedder` is a silent set-once, so a sibling test registering a + // different profile first would make every assertion below read the + // WRONG tool and fail obscurely. Name that up front. + super::register(); + assert_eq!( + aube_util::embedder().name, + NUB.name, + "another test registered a different embedder first" + ); + assert!( + aube_settings::meta::find("aubeNoAutoInstall").is_none(), + "the embedder filter is not wired into `meta::find`" + ); + assert!( + aube_settings::meta::all().all(|m| m.name != "aubeNoAutoInstall"), + "the embedder filter is not wired into `meta::all`" + ); + assert!( + aube_settings::meta::unsupported_for_key("aube-no-auto-install").is_some(), + "an alias write must still be recognizable so `config set` can refuse it" + ); + // The positive control: an ordinary setting is untouched, so the two + // assertions above are reading the filter rather than a broken lookup. + assert!(aube_settings::meta::find("autoInstallPeers").is_some()); + } +} diff --git a/crates/nub-cli/src/pm_engine/install_report.rs b/crates/nub-cli/src/pm_engine/install_report.rs index 13411bbf3..ca394c053 100644 --- a/crates/nub-cli/src/pm_engine/install_report.rs +++ b/crates/nub-cli/src/pm_engine/install_report.rs @@ -240,7 +240,6 @@ impl SourceIndex { }; let yaml_settings = |raw| { aube_settings::all() - .iter() .filter(|meta| !aube_settings::workspace_yaml_suppressed(meta)) .filter_map(|meta| workspace_yaml_scalar(meta, raw).map(|value| (meta.name, value))) .collect::>() @@ -252,7 +251,7 @@ impl SourceIndex { // the settings the YAML still supplies, this one asks whether the // current pnpm posture rejected a layout key. let pnpm_yaml_layout_dropped = [&raw, &global_raw].into_iter().any(|raw| { - aube_settings::all().iter().any(|meta| { + aube_settings::all().any(|meta| { aube_settings::workspace_yaml_suppressed(meta) && meta.layout && !meta.npmrc_keys.is_empty() diff --git a/crates/nub-cli/src/pm_engine/store_config_family.rs b/crates/nub-cli/src/pm_engine/store_config_family.rs index e921e7eeb..4f0db2afe 100644 --- a/crates/nub-cli/src/pm_engine/store_config_family.rs +++ b/crates/nub-cli/src/pm_engine/store_config_family.rs @@ -605,6 +605,13 @@ fn dispatch_config(parsed: ConfigArgs) -> Result { Some(ConfigCommand::Set(set)) => { super::engine_brand_preflight(); if global { + // Global scope has no router, so it repeats the refusals it + // needs — the same shape as the map refusal below. A setting + // nub does not consume is refused in BOTH scopes; `--global` + // would otherwise be an open door straight to `~/.npmrc`. + if let Some(err) = npmrc_first::unsupported_setting_refusal(&set.key) { + return Err(err); + } // Neutral global write. npm-shared/auth keys FIRST (a key like // `registry` is auth, not the `registries` map — the shared // check must win before the map refusal below). @@ -800,6 +807,16 @@ mod npmrc_first { /// non-pnpm / nub-identity surface. npm-shared keys (`.npmrc`) and map /// refusals are independent of this signal. pub(super) fn classify_set(key: &str, scalar_to_yaml: bool) -> SetRoute { + // A setting nub's embedder profile declares it does not consume. First, + // and its own arm rather than a case of the `setting_for_key` match + // below — that lookup is embedder-FILTERED, so an unsupported setting + // reads as unknown and falls to the free-form `ProjectNpmrc` route, + // writing the key verbatim into the user's `.npmrc`. That is how + // `aubeNoAutoInstall` used to land there: inert, unreadable by anything, + // and carrying the engine's brand into a file nub wrote. + if let Some(err) = unsupported_setting_refusal(key) { + return SetRoute::Refuse(err); + } if is_npm_shared_key(key) { return SetRoute::Engine; } @@ -941,7 +958,7 @@ mod npmrc_first { /// then any alias surface (npmrc/yaml/env/cli spellings). fn setting_for_key(key: &str) -> Option<&'static SettingMeta> { meta::find(key).or_else(|| { - meta::all().iter().find(|meta| { + meta::all().find(|meta| { meta.npmrc_keys.contains(&key) || meta.workspace_yaml_keys.contains(&key) || meta.env_vars.contains(&key) @@ -1032,6 +1049,21 @@ mod npmrc_first { cwd } + /// The refusal for a key naming a setting nub's embedder profile declares + /// it does not consume, `None` for every other key. Both write scopes ask + /// this — the project route through [`classify_set`], the global one + /// directly, since it has no router. + /// + /// `key` is echoed as the user spelled it; the advice is looked up by the + /// CANONICAL name, which is where the profile hangs it. + pub(super) fn unsupported_setting_refusal(key: &str) -> Option { + let meta = meta::unsupported_for_key(key)?; + let advice = meta::unsupported_advice(meta.name).unwrap_or_default(); + Some(anyhow!( + "nub config set {key}: `{key}` is not a nub setting\n\x20\x20{advice}" + )) + } + fn map_setting_error(name: &str) -> anyhow::Error { anyhow!( "nub config set {name}: `{name}` is a workspace map setting and can't be set as a single value\n\ diff --git a/crates/nub-cli/tests/pm_config_defaults.rs b/crates/nub-cli/tests/pm_config_defaults.rs index eac8792fa..e6080440b 100644 --- a/crates/nub-cli/tests/pm_config_defaults.rs +++ b/crates/nub-cli/tests/pm_config_defaults.rs @@ -1,5 +1,6 @@ -//! What `nub config list --all` renders in the DEFAULT column, through the -//! real binary. +//! What nub's `config` surface SHOWS and ACCEPTS, driven through the real +//! binary: the DEFAULT column of `config list --all`, and which settings +//! `config set` will write. //! //! That column is the shared engine table's `default` field — a build-time //! constant. A directory default written as a literal is therefore baked with @@ -10,12 +11,12 @@ //! //! Offline: `config list` reads config files and the static table, nothing else. //! -//! These rows deliberately do NOT reuse `pm_publish_store_config`'s harness, -//! which asserts brand-cleanliness over ALL output on every spawn. The `--all` -//! listing cannot satisfy that yet for a reason unrelated to the default -//! column: `aubeNoAutoInstall` is an engine-branded setting NAME that nub -//! genuinely reads. Scoping to the rows under test keeps this file honest -//! about what it proves. +//! The file also holds the brand-cleanliness assertion for this command, which +//! nothing covered while `aubeNoAutoInstall` was still in the listing — the +//! engine's brand in a setting NAME, on a setting nub never reads. It reaches +//! the listing through the shared settings table rather than through any nub +//! code path, so `pm_publish_store_config`'s per-spawn `assert_brand_clean` +//! could never have caught it: that harness only ever spawned other commands. use std::path::PathBuf; use std::process::Command; @@ -28,10 +29,17 @@ fn nub_binary() -> PathBuf { path } -/// `config list --all` in a throwaway project with every config root pinned to -/// the fixture, so no host `.npmrc` can supply a value where a default is -/// expected. -fn list_all(tag: &str) -> String { +/// Run a `config` subcommand in a throwaway project with every config root +/// pinned to the fixture, so no host `.npmrc` can supply a value where a +/// default is expected — or absorb a write that was supposed to be refused. +/// Returns stdout, stderr, the exit code, and the project dir. +fn spawn(tag: &str, args: &[&str]) -> (String, String, i32, PathBuf) { + spawn_in(&fixture(tag), args) +} + +/// A fresh throwaway project with a sibling `home` the env pinning points at. +/// Unique per call, so rows running in parallel cannot see each other's writes. +fn fixture(tag: &str) -> PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static N: AtomicU64 = AtomicU64::new(0); let root = std::env::temp_dir().join(format!( @@ -41,18 +49,28 @@ fn list_all(tag: &str) -> String { )); let _ = std::fs::remove_dir_all(&root); let project = root.join("project"); - let home = root.join("home"); std::fs::create_dir_all(&project).unwrap(); - std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(root.join("home")).unwrap(); std::fs::write( project.join("package.json"), r#"{"name":"app","version":"1.0.0"}"#, ) .unwrap(); + project +} +/// [`spawn`] against a project fixture that already exists, for the rows that +/// run two commands against one `.npmrc`. The home roots are derived from the +/// project path rather than passed, so both entry points pin the same set. +fn spawn_in(project: &std::path::Path, args: &[&str]) -> (String, String, i32, PathBuf) { + let home = project + .parent() + .expect("fixture project has a root") + .join("home"); let mut cmd = Command::new(nub_binary()); - cmd.args(["config", "list", "--all"]) - .current_dir(&project) + cmd.arg("config") + .args(args) + .current_dir(project) .env("NUB_SELF_SHIM", "0") .env("HOME", &home) .env("USERPROFILE", &home) @@ -67,12 +85,26 @@ fn list_all(tag: &str) -> String { cmd.env_remove(key); } let out = cmd.output().expect("failed to spawn nub"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!( + ( + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), out.status.code().unwrap_or(-1), - 0, - "`nub config list --all` failed\nstdout: {stdout}\nstderr: {}", - String::from_utf8_lossy(&out.stderr) + project.to_path_buf(), + ) +} + +/// [`spawn`] for the write-path rows, which name their own fixture tag off the +/// verb rather than the assertion. +fn config(args: &[&str]) -> (String, String, i32, PathBuf) { + spawn(args[0], args) +} + +/// [`spawn`] for `config list --all`, asserting the command itself succeeded. +fn list_all(tag: &str) -> String { + let (stdout, stderr, code, _) = spawn(tag, &["list", "--all"]); + assert_eq!( + code, 0, + "`nub config list --all` failed\nstdout: {stdout}\nstderr: {stderr}" ); stdout } @@ -121,3 +153,120 @@ fn no_known_token_survives_into_the_listing() { "unsubstituted namespace tokens in the listing: {leaked:?}" ); } + +/// Nothing in the listing names the engine. +/// +/// The last leak was `aubeNoAutoInstall` — the setting behind the engine's own +/// pre-run auto-install gate, which nub never reaches because it runs scripts +/// through its own frontend. Left in the table it was pure misdirection: a +/// brand-named row a user could set and nub would never read. Nub's embedder +/// profile now declares it unsupported, which drops it from the table here. +/// +/// Broad on purpose. A narrow `!listing.contains("aubeNoAutoInstall")` would +/// pass the day someone adds the next branded setting, and the whole point of +/// this row is that no such setting reached a user-visible surface. +#[test] +fn the_listing_never_names_the_engine() { + let listing = list_all("brand"); + let leaked: Vec<&str> = listing + .lines() + .filter(|line| line.to_lowercase().contains("aube")) + .collect(); + assert!( + leaked.is_empty(), + "engine branding in `nub config list --all`: {leaked:#?}" + ); + // Positive control: the listing really was populated, so the emptiness + // above is a clean sweep rather than an empty read. + assert!( + listing.lines().count() > 50, + "expected a full `--all` listing, got:\n{listing}" + ); +} + +/// `config set` refuses a setting nub does not consume, and writes nothing. +/// +/// Refusing is the load-bearing half. Making the setting absent from the table +/// is not enough on its own: an unrecognized key is legal config, so the write +/// would fall through to the free-form path and land verbatim — nub putting the +/// engine's brand into the user's own `.npmrc` for a value nub never reads. +#[test] +fn config_set_refuses_a_setting_nub_does_not_consume() { + // Both spellings, and both SCOPES. `--global` matters on its own: it takes + // a different branch that never reaches the project write router, so a + // guard on the router alone left it writing straight to `~/.npmrc`. + for key in ["aubeNoAutoInstall", "aube-no-auto-install"] { + for scope in [&[][..], &["--global"][..]] { + let mut argv = vec!["set", key, "true"]; + argv.extend_from_slice(scope); + let (stdout, stderr, code, project) = config(&argv); + assert_ne!( + code, 0, + "`config set {key} {scope:?}` must fail: {stdout}{stderr}" + ); + assert!( + stderr.contains("verifyDeps") || stderr.contains("verify-deps-before-run"), + "the refusal must name what to use instead: {stderr}" + ); + // Neither scope's file may appear. The fixture pins HOME, so the + // global target is inside it and a stray write is visible here. + assert!( + !project.join(".npmrc").exists(), + "`config set {key} {scope:?}` wrote a project .npmrc it had refused" + ); + let home = project.parent().unwrap().join("home").join(".npmrc"); + assert!( + !home.exists(), + "`config set {key} {scope:?}` wrote a user .npmrc it had refused" + ); + } + } +} + +/// The positive control for the row above: the same harness, a real setting, +/// and the write lands. Without it the refusal test would pass just as well +/// against a `config set` that was broken for every key. +#[test] +fn config_set_still_writes_a_setting_nub_does_consume() { + let (stdout, stderr, code, project) = config(&["set", "auto-install-peers", "false"]); + assert_eq!(code, 0, "`config set auto-install-peers` failed: {stderr}"); + let npmrc = std::fs::read_to_string(project.join(".npmrc")).unwrap_or_default(); + assert!( + npmrc.contains("auto-install-peers=false"), + "expected the write in .npmrc, got {npmrc:?} (stdout: {stdout})" + ); +} + +/// A user who set the key under an older nub can still see it and REMOVE it. +/// +/// Refusing the write is not the same as pretending the line isn't there. Their +/// `.npmrc` is their file, so `config list` echoes it verbatim — and `delete` +/// has to keep working, or the guard on `set` would leave a dead key in their +/// config with no supported way to take it out. Easy to break by copying the +/// `set` refusal onto `delete`, and silent when broken. +#[test] +fn a_stale_key_from_an_older_nub_can_still_be_deleted() { + let project = fixture("stale"); + let npmrc = project.join(".npmrc"); + std::fs::write(&npmrc, "aubeNoAutoInstall=true\nauto-install-peers=false\n").unwrap(); + + let (listing, _, code, _) = spawn_in(&project, &["list"]); + assert_eq!(code, 0); + assert!( + listing.contains("aubeNoAutoInstall=true"), + "the user's own .npmrc line must be echoed, not hidden: {listing}" + ); + + let (_, stderr, code, _) = spawn_in(&project, &["delete", "aubeNoAutoInstall"]); + assert_eq!(code, 0, "`config delete` must still work: {stderr}"); + let after = std::fs::read_to_string(&npmrc).unwrap(); + assert!( + !after.contains("aubeNoAutoInstall"), + "the stale key survived the delete: {after:?}" + ); + // Control: the delete was surgical, not a truncation of the whole file. + assert!( + after.contains("auto-install-peers=false"), + "the delete took the neighbouring setting with it: {after:?}" + ); +} diff --git a/scripts/remote-build.ts b/scripts/remote-build.ts index ac18187da..8422d27d9 100644 --- a/scripts/remote-build.ts +++ b/scripts/remote-build.ts @@ -460,8 +460,16 @@ tests/brand-lint/check-path-literals.sh`; // loader tests dlopen it — an 11-byte placeholder makes them fail on a malformed library // rather than skip. Build it here for the same reason. if (job === "test") { + // `unset NUB_ALLOW_INCOMPLETE_RUNTIME` before `cargo test`, not before the addon build: + // PREPARE exports it for the CLIPPY gate, and ci.yml's TEST job deliberately does not — + // this job builds the real addon, so it needs no opt-out. Leaving it set made + // `brand_boundary_no_globals_no_env` fail: that test spawns nub and asserts the child + // sees ZERO `NUB_*` vars, and cargo passes its own environment straight through. The one + // failure aborted the run at `tests/integration.rs`, so every suite after it + // alphabetically was silently never reached by ANY remote test job. return `${PREPARE}(cd crates/nub-native && cargo build) cp "$CARGO_TARGET_DIR/debug/libnub_native.so" runtime/addons/nub-native.node +unset NUB_ALLOW_INCOMPLETE_RUNTIME cargo test`; } } diff --git a/vendor/aube/crates/aube-codes/src/errors.rs b/vendor/aube/crates/aube-codes/src/errors.rs index b6f9c2269..e31eac181 100644 --- a/vendor/aube/crates/aube-codes/src/errors.rs +++ b/vendor/aube/crates/aube-codes/src/errors.rs @@ -105,6 +105,7 @@ pub const ERR_AUBE_USAGE_SPEC_WRITE_FAILED: &str = "ERR_AUBE_USAGE_SPEC_WRITE_FA pub const ERR_AUBE_REMOVE_PRIOR_INSTALL_DIR: &str = "ERR_AUBE_REMOVE_PRIOR_INSTALL_DIR"; pub const ERR_AUBE_CONFIG_NESTED_AUBE_KEY: &str = "ERR_AUBE_CONFIG_NESTED_AUBE_KEY"; pub const ERR_AUBE_NO_BRANDED_CONFIG_FILE: &str = "ERR_AUBE_NO_BRANDED_CONFIG_FILE"; +#[rustfmt::skip] pub const ERR_AUBE_CONFIG_SETTING_UNSUPPORTED: &str = "ERR_AUBE_CONFIG_SETTING_UNSUPPORTED"; pub const ERR_AUBE_CONFLICTING_BUILD_FLAGS: &str = "ERR_AUBE_CONFLICTING_BUILD_FLAGS"; pub const ERR_AUBE_ACCESS_INVALID_ARGUMENT: &str = "ERR_AUBE_ACCESS_INVALID_ARGUMENT"; pub const ERR_AUBE_SHIM_CREATE_FAILED: &str = "ERR_AUBE_SHIM_CREATE_FAILED"; @@ -595,6 +596,12 @@ pub const ALL: &[CodeMeta] = &[ description: "`aube config set . …` was used for a key whose prefix is an aube map setting (e.g. `allowBuilds.`). Such nested writes would otherwise land in `.npmrc` where aube doesn't read them and npm warns/errors about the unknown key — set the map in workspace yaml or `package.json#aube.` instead.", exit_code: None, }, + CodeMeta { + name: ERR_AUBE_CONFIG_SETTING_UNSUPPORTED, + category: category::ENGINE_CLI, + description: "`config set` named a setting the active embedder profile declares it does not consume (`unsupported_settings`), because it does not route the command that reads it. Writing the key would put a value in the user config that nothing will ever read — and, for an engine-branded name, put the engine's brand there under the host's hand. The error names the host's equivalent knob. Unreachable for standalone aube, whose list is empty.", + exit_code: None, + }, CodeMeta { name: ERR_AUBE_NO_BRANDED_CONFIG_FILE, category: category::ENGINE_CLI, diff --git a/vendor/aube/crates/aube-lockfile/tests/custom_lock_filename.rs b/vendor/aube/crates/aube-lockfile/tests/custom_lock_filename.rs index cd3189249..ad75cd732 100644 --- a/vendor/aube/crates/aube-lockfile/tests/custom_lock_filename.rs +++ b/vendor/aube/crates/aube-lockfile/tests/custom_lock_filename.rs @@ -48,6 +48,7 @@ static MYTOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; #[test] diff --git a/vendor/aube/crates/aube-lockfile/tests/embedder_identity_detection.rs b/vendor/aube/crates/aube-lockfile/tests/embedder_identity_detection.rs index 2de93c570..739293eb7 100644 --- a/vendor/aube/crates/aube-lockfile/tests/embedder_identity_detection.rs +++ b/vendor/aube/crates/aube-lockfile/tests/embedder_identity_detection.rs @@ -54,6 +54,7 @@ static MYTOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; fn project(files: &[(&str, &str)]) -> tempfile::TempDir { diff --git a/vendor/aube/crates/aube-lockfile/tests/no_churn_write_guard.rs b/vendor/aube/crates/aube-lockfile/tests/no_churn_write_guard.rs index e92db05d8..a594bf605 100644 --- a/vendor/aube/crates/aube-lockfile/tests/no_churn_write_guard.rs +++ b/vendor/aube/crates/aube-lockfile/tests/no_churn_write_guard.rs @@ -58,6 +58,7 @@ static NO_CHURN_TOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; fn pkg(name: &str, version: &str, integrity: &str) -> LockedPackage { diff --git a/vendor/aube/crates/aube-lockfile/tests/package_lock_rename.rs b/vendor/aube/crates/aube-lockfile/tests/package_lock_rename.rs index 1481ccf74..6e28605f8 100644 --- a/vendor/aube/crates/aube-lockfile/tests/package_lock_rename.rs +++ b/vendor/aube/crates/aube-lockfile/tests/package_lock_rename.rs @@ -51,6 +51,7 @@ static MYTOOL: Embedder = Embedder { warm_trust_revalidate: false, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; #[test] diff --git a/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs b/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs index fde5d5925..78dc958b1 100644 --- a/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs +++ b/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs @@ -51,6 +51,7 @@ static STRICT: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; fn parse(files: &[(&str, &str)]) -> Result { diff --git a/vendor/aube/crates/aube-manifest/tests/root_namespace_write.rs b/vendor/aube/crates/aube-manifest/tests/root_namespace_write.rs index baadbf57d..50ccda827 100644 --- a/vendor/aube/crates/aube-manifest/tests/root_namespace_write.rs +++ b/vendor/aube/crates/aube-manifest/tests/root_namespace_write.rs @@ -54,6 +54,7 @@ static ROOT_TOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; fn read_manifest(dir: &std::path::Path) -> serde_json::Value { diff --git a/vendor/aube/crates/aube-registry/tests/user_agent_product.rs b/vendor/aube/crates/aube-registry/tests/user_agent_product.rs index 9e7bfb209..875d3b172 100644 --- a/vendor/aube/crates/aube-registry/tests/user_agent_product.rs +++ b/vendor/aube/crates/aube-registry/tests/user_agent_product.rs @@ -50,6 +50,7 @@ static MYTOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; #[tokio::test] diff --git a/vendor/aube/crates/aube-scripts/tests/root_namespace_allow_builds.rs b/vendor/aube/crates/aube-scripts/tests/root_namespace_allow_builds.rs index f3ff9ee23..b7f5cc8b9 100644 --- a/vendor/aube/crates/aube-scripts/tests/root_namespace_allow_builds.rs +++ b/vendor/aube/crates/aube-scripts/tests/root_namespace_allow_builds.rs @@ -45,6 +45,7 @@ static ROOT_TOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; fn build_decision(manifest: &PackageJson, name: &str, version: &str) -> AllowDecision { diff --git a/vendor/aube/crates/aube-scripts/tests/user_agent_product.rs b/vendor/aube/crates/aube-scripts/tests/user_agent_product.rs index 1437e6f07..61260dcfe 100644 --- a/vendor/aube/crates/aube-scripts/tests/user_agent_product.rs +++ b/vendor/aube/crates/aube-scripts/tests/user_agent_product.rs @@ -44,6 +44,7 @@ static MYTOOL: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; #[test] diff --git a/vendor/aube/crates/aube-settings/src/bin/generate_settings_docs.rs b/vendor/aube/crates/aube-settings/src/bin/generate_settings_docs.rs index 46e6899d1..4a6b189c1 100644 --- a/vendor/aube/crates/aube-settings/src/bin/generate_settings_docs.rs +++ b/vendor/aube/crates/aube-settings/src/bin/generate_settings_docs.rs @@ -20,7 +20,9 @@ fn main() { let ordered = ordered_settings(&raw) .into_iter() .map(|(name, category)| { - let meta = aube_settings::find(&name).unwrap_or_else(|| { + // Unfiltered: this generator documents the TABLE, so it must resolve every + // entry even one the running embedder would treat as absent. + let meta = aube_settings::meta::find_unfiltered(&name).unwrap_or_else(|| { panic!("settings.toml entry `{name}` missing from generated metadata") }); SettingRef { meta, category } diff --git a/vendor/aube/crates/aube-settings/src/lib.rs b/vendor/aube/crates/aube-settings/src/lib.rs index 84c7dd0f5..c85151f59 100644 --- a/vendor/aube/crates/aube-settings/src/lib.rs +++ b/vendor/aube/crates/aube-settings/src/lib.rs @@ -22,7 +22,10 @@ pub mod meta; pub mod values; -pub use meta::{SettingMeta, all, find, is_layout_npmrc_key}; +pub use meta::{ + SettingMeta, all, find, is_layout_npmrc_key, is_supported, unsupported_advice, + unsupported_for_key, +}; pub use values::{ ResolveCtx, embedder_defaults, parse_bool, resolved, set_embedder_defaults, set_global_cli_overrides, workspace_yaml_suppressed, workspace_yaml_value, diff --git a/vendor/aube/crates/aube-settings/src/meta.rs b/vendor/aube/crates/aube-settings/src/meta.rs index eb19c6d87..3378df5af 100644 --- a/vendor/aube/crates/aube-settings/src/meta.rs +++ b/vendor/aube/crates/aube-settings/src/meta.rs @@ -158,18 +158,66 @@ impl SettingMeta { // Pulls in `pub const SETTINGS: &[SettingMeta] = &[...]` generated by build.rs. include!(concat!(env!("OUT_DIR"), "/settings_meta_data.rs")); -/// Return the full slice of settings, alphabetically sorted by name. -pub fn all() -> &'static [SettingMeta] { +/// Every setting the ACTIVE embedder consumes, alphabetically sorted by name. +/// +/// Filtered, not the raw table: a host that does not route the command reading +/// a setting declares it in +/// [`Embedder::unsupported_settings`](aube_util::Embedder::unsupported_settings), +/// and this is where that declaration takes effect for every enumerating +/// surface at once — `config list`, `config get`, the TUI, `config find`. +/// Identity for standalone aube, whose list is empty. +/// +/// Reach for [`all_unfiltered`] only to describe the TABLE rather than the +/// running tool — the docs generator, and the audits that must see every entry. +pub fn all() -> impl Iterator { + SETTINGS.iter().filter(|meta| is_supported(meta.name)) +} + +/// The raw table, embedder filtering NOT applied. See [`all`]. +pub fn all_unfiltered() -> &'static [SettingMeta] { SETTINGS } +/// Whether the active embedder consumes `name`. False only for a setting the +/// host declared inert; see +/// [`Embedder::unsupported_settings`](aube_util::Embedder::unsupported_settings). +pub fn is_supported(name: &str) -> bool { + unsupported_advice(name).is_none() +} + +/// The host's "use this instead" line for an unsupported setting, `None` when +/// the active embedder consumes it. +pub fn unsupported_advice(name: &str) -> Option<&'static str> { + aube_util::embedder() + .unsupported_settings + .iter() + .find(|(declared, _)| *declared == name) + .map(|(_, advice)| *advice) +} + +/// The unsupported setting `key` spells, under its canonical name or any +/// declared `.npmrc` / workspace-YAML alias. +/// +/// The inverse of what [`find`] will tell you: `find` reports an unsupported +/// setting as simply absent, which is right for every RESOLUTION path but wrong +/// for a user who typed the name — `config set` needs to say "not a setting +/// here" rather than write the key through as free-form config. +pub fn unsupported_for_key(key: &str) -> Option<&'static SettingMeta> { + SETTINGS.iter().find(|meta| { + !is_supported(meta.name) + && (meta.name == key + || meta.npmrc_keys.contains(&key) + || meta.workspace_yaml_keys.contains(&key)) + }) +} + /// Whether an `.npmrc` key spells a `layout`-flagged setting, under any of /// its aliases. Lets a caller filtering `.npmrc` entries by key — where no /// [`SettingMeta`] is in hand — ask the layout question. pub fn is_layout_npmrc_key(key: &str) -> bool { SETTINGS .iter() - .any(|meta| meta.layout && meta.npmrc_keys.contains(&key)) + .any(|meta| meta.layout && meta.npmrc_keys.contains(&key) && is_supported(meta.name)) } /// Look up a setting by its canonical pnpm name. `SETTINGS` is @@ -182,7 +230,19 @@ pub fn is_layout_npmrc_key(key: &str) -> bool { /// edit of `SETTINGS` (or a regression in the build.rs sort) would /// silently flip valid lookups to `None`; the assert catches that /// in test runs without slowing release builds. +/// +/// A setting the active embedder declared unsupported reports as ABSENT here, +/// which is what makes that declaration reach the resolver: every generated +/// `resolved::*` accessor opens with this lookup, so a miss falls the whole +/// source chain through to the default. Ask [`unsupported_for_key`] when the +/// caller needs to distinguish "not a setting" from "not a setting HERE". pub fn find(name: &str) -> Option<&'static SettingMeta> { + find_unfiltered(name).filter(|meta| is_supported(meta.name)) +} + +/// [`find`] without the embedder filter — the TABLE's answer rather than the +/// running tool's. For the docs generator and the audits only. +pub fn find_unfiltered(name: &str) -> Option<&'static SettingMeta> { debug_assert!( SETTINGS.windows(2).all(|w| w[0].name <= w[1].name), "SETTINGS must be sorted by name for find() to work" @@ -342,4 +402,26 @@ mod tests { assert_eq!(token_names("{cache_namepsace}"), vec!["cache_namepsace"]); assert_eq!(token_names("{Cache_Namespace}"), vec!["Cache_Namespace"]); } + + /// The default profile declares nothing unsupported, so the filtered views + /// are the raw table — this is the "byte-for-byte unchanged for standalone + /// aube" claim, checked rather than asserted in a comment. + /// + /// It can only ever exercise the EMPTY list: the filter is driven by the + /// active embedder, and this crate's tests run under aube's own profile. A + /// host that populates the list tests the populated case on its own side. + #[test] + fn the_default_profile_filters_nothing() { + assert!(aube_util::embedder().unsupported_settings.is_empty()); + assert_eq!(all().count(), all_unfiltered().len()); + for s in all_unfiltered() { + assert!(is_supported(s.name), "{} filtered out unexpectedly", s.name); + assert!(find(s.name).is_some(), "{} vanished from find()", s.name); + assert!( + unsupported_for_key(s.name).is_none(), + "{} reported as unsupported", + s.name + ); + } + } } diff --git a/vendor/aube/crates/aube-settings/tests/accessor_audit.rs b/vendor/aube/crates/aube-settings/tests/accessor_audit.rs index 86205bd55..d79e799b2 100644 --- a/vendor/aube/crates/aube-settings/tests/accessor_audit.rs +++ b/vendor/aube/crates/aube-settings/tests/accessor_audit.rs @@ -23,7 +23,9 @@ //! setting through" into a CI failure with a pointer to the call //! site that's missing. -use aube_settings::meta::{SettingMeta, all}; +// `all_unfiltered`, not `all`: this audit is about the TABLE, so it must see +// every entry regardless of which ones a given embedder declares inert. +use aube_settings::meta::{SettingMeta, all_unfiltered}; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; @@ -166,7 +168,7 @@ fn every_setting_has_a_typed_accessor_caller() { // settings ever happen to snake_case-collapse. let mut reported: BTreeSet = BTreeSet::new(); - for s in all() { + for s in all_unfiltered() { if s.typed_accessor_unused { continue; } @@ -235,7 +237,7 @@ fn typed_accessor_unused_flag_is_accurate() { } let mut stale: Vec<&SettingMeta> = Vec::new(); - for s in all() { + for s in all_unfiltered() { if !s.typed_accessor_unused { continue; } diff --git a/vendor/aube/crates/aube-settings/tests/branded_settings_env_gate.rs b/vendor/aube/crates/aube-settings/tests/branded_settings_env_gate.rs index df22fc777..0817c0e4c 100644 --- a/vendor/aube/crates/aube-settings/tests/branded_settings_env_gate.rs +++ b/vendor/aube/crates/aube-settings/tests/branded_settings_env_gate.rs @@ -59,6 +59,7 @@ static MYTOOL_NO_BRANDED_ENV: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; fn ctx<'a>( diff --git a/vendor/aube/crates/aube-util/src/identity.rs b/vendor/aube/crates/aube-util/src/identity.rs index d9ea2b80f..e3aef68f2 100644 --- a/vendor/aube/crates/aube-util/src/identity.rs +++ b/vendor/aube/crates/aube-util/src/identity.rs @@ -406,6 +406,36 @@ pub struct Embedder { /// no aube setting. Same function-pointer hook shape as /// [`cpu_budget`](Self::cpu_budget); embedder-fixed pluggability. pub extra_settings_fingerprint: Option String>, + /// Canonical names of settings this host does NOT consume, because it does + /// not route the command that reads them. The engine treats each as absent + /// from the table: `meta::find` misses it, `meta::all` skips it, every + /// generated `resolved::*` accessor falls through to the default, and + /// `config set` refuses to write it. + /// + /// The engine's verb surface is a superset of any given host's. A host that + /// reimplements `run`/`exec` in its own frontend never reaches the engine's + /// auto-install gate, so `aubeNoAutoInstall` — the setting that gate reads — + /// is inert under it. Left in the table an inert setting still shows up in + /// `config list --all` and still WRITES through `config set`, which for a + /// brand-named one means the host puts the ENGINE's brand into the user's + /// `.npmrc` under a key nothing will ever read. + /// + /// Scope it to settings that are both inert here AND misleading to offer — + /// a brand-named one above all. A neutrally-named setting the host happens + /// not to read is better left visible: the name costs nothing, and hiding it + /// breaks the pnpm-surface parity a user expects. A name that is not in the + /// table is a silent no-op, so a host that populates this pins each entry + /// with a test. + /// + /// Each entry pairs the canonical name with the one line `config set` prints + /// to send the user somewhere real — the host's own equivalent knob, or why + /// there isn't one. A bare refusal would leave them with no next step, and + /// the engine cannot name the replacement because the replacement is the + /// HOST's surface. + /// + /// Standalone aube consumes its own whole table, so `&[]` — every lookup and + /// enumeration is byte-for-byte unchanged. + pub unsupported_settings: &'static [(&'static str, &'static str)], } /// Standalone aube's embedder profile. Reproduces every hardcoded branding @@ -467,6 +497,9 @@ pub const AUBE: Embedder = Embedder { // No extra settings-fingerprint fold: standalone aube's `settings_hash` is // byte-for-byte unchanged (the hook block is skipped when `None`). extra_settings_fingerprint: None, + // Standalone aube routes every verb in its own table, so nothing in it is + // inert — the settings surface is unchanged. + unsupported_settings: &[], }; static ACTIVE: OnceLock<&'static Embedder> = OnceLock::new(); diff --git a/vendor/aube/crates/aube-util/tests/embedder_env_brand_gate.rs b/vendor/aube/crates/aube-util/tests/embedder_env_brand_gate.rs index de590f5db..b8e5f736f 100644 --- a/vendor/aube/crates/aube-util/tests/embedder_env_brand_gate.rs +++ b/vendor/aube/crates/aube-util/tests/embedder_env_brand_gate.rs @@ -57,6 +57,7 @@ static NUBLIKE: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; /// Restore the previous value of an env var around a closure. Integration-test diff --git a/vendor/aube/crates/aube-util/tests/source_branding_brand_gate.rs b/vendor/aube/crates/aube-util/tests/source_branding_brand_gate.rs index 3e5e9e925..e125b0a7c 100644 --- a/vendor/aube/crates/aube-util/tests/source_branding_brand_gate.rs +++ b/vendor/aube/crates/aube-util/tests/source_branding_brand_gate.rs @@ -53,6 +53,7 @@ static NUBLIKE: Embedder = Embedder { warm_trust_revalidate: true, trust_policy_ignore_after_default: None, extra_settings_fingerprint: None, + unsupported_settings: &[], }; #[test] diff --git a/vendor/aube/crates/aube/src/commands/completion.rs b/vendor/aube/crates/aube/src/commands/completion.rs index e58c9d05a..3bacf927d 100644 --- a/vendor/aube/crates/aube/src/commands/completion.rs +++ b/vendor/aube/crates/aube/src/commands/completion.rs @@ -333,7 +333,6 @@ fn workspace_candidates(cwd: &Path) -> Vec<(String, String)> { fn setting_candidates() -> Vec<(String, String)> { aube_settings::all() - .iter() .flat_map(|setting| { std::iter::once((setting.name.to_string(), setting.description.to_string())).chain( setting diff --git a/vendor/aube/crates/aube/src/commands/config/find.rs b/vendor/aube/crates/aube/src/commands/config/find.rs index 62acb227f..063163b5f 100644 --- a/vendor/aube/crates/aube/src/commands/config/find.rs +++ b/vendor/aube/crates/aube/src/commands/config/find.rs @@ -16,7 +16,6 @@ pub fn run(args: FindArgs) -> miette::Result<()> { .collect::>(); let mut matches = settings_meta::all() - .iter() .filter_map(|meta| { let score = setting_search_score(meta, &terms); (score > 0).then_some((score, *meta)) diff --git a/vendor/aube/crates/aube/src/commands/config/mod.rs b/vendor/aube/crates/aube/src/commands/config/mod.rs index 058dc663f..ac3255528 100644 --- a/vendor/aube/crates/aube/src/commands/config/mod.rs +++ b/vendor/aube/crates/aube/src/commands/config/mod.rs @@ -278,7 +278,7 @@ pub fn is_protected_key(key: &str) -> bool { pub(super) fn setting_for_key(key: &str) -> Option<&'static settings_meta::SettingMeta> { settings_meta::find(key).or_else(|| { - settings_meta::all().iter().find(|meta| { + settings_meta::all().find(|meta| { meta.npmrc_keys.iter().any(|candidate| candidate == &key) || meta .workspace_yaml_keys diff --git a/vendor/aube/crates/aube/src/commands/config/set.rs b/vendor/aube/crates/aube/src/commands/config/set.rs index d123695f7..5b36a7833 100644 --- a/vendor/aube/crates/aube/src/commands/config/set.rs +++ b/vendor/aube/crates/aube/src/commands/config/set.rs @@ -1,6 +1,7 @@ use super::{ Location, NpmrcEdit, aube_config, is_npm_shared_key, resolve_aliases, setting_for_key, }; +use aube_settings::meta as settings_meta; use clap::Args; use miette::miette; @@ -62,6 +63,12 @@ pub fn set_project_scalar_to_workspace_yaml( key: &str, value: &str, ) -> miette::Result> { + // The same refusal [`set_value`] opens with. This seam is a SECOND entry to + // the write path, taken instead of that one under a pnpm incumbent, so a + // guard on only one of them leaves the key writable through the other. + if let Some(meta) = settings_meta::unsupported_for_key(key) { + return Err(reject_unsupported_setting(key, meta)); + } // Object-typed (map) settings can't be written as a single scalar. if let Some(meta) = setting_for_key(key) && meta.type_ == "object" @@ -86,6 +93,17 @@ pub(super) fn set_value( location: Location, report: bool, ) -> miette::Result<()> { + // 0. A setting the active embedder declares it does not consume. This has + // to run FIRST and as its own step, because every route below would + // otherwise write the key: `setting_for_key` no longer resolves it (the + // embedder filter makes it absent), so it falls all the way through to + // the free-form-unknown write at step 6 and lands verbatim in the user's + // config — silently inert, and for a brand-named setting that means this + // tool put the ENGINE's brand in their file. + if let Some(meta) = settings_meta::unsupported_for_key(key) { + return Err(reject_unsupported_setting(key, meta)); + } + // 1. Genuinely npm-shared keys (auth tokens, registries, npm // scalars) keep their old `.npmrc` routing so npm/pnpm/yarn see // the value. Everything else falls through to aube's own config. @@ -286,6 +304,23 @@ fn sweep_stale_aube_config( Ok(()) } +/// The refusal for a setting this embedder declares inert. The message echoes +/// the spelling the user TYPED while the advice is looked up by canonical name, +/// so an alias write is answered in the user's own words and still gets the +/// host's pointer. +fn reject_unsupported_setting( + key: &str, + meta: &aube_settings::meta::SettingMeta, +) -> miette::Report { + let help = aube_settings::meta::unsupported_advice(meta.name).unwrap_or_default(); + miette!( + code = aube_codes::errors::ERR_AUBE_CONFIG_SETTING_UNSUPPORTED, + help = help.to_string(), + "`{key}` is not a {} setting.", + aube_util::prog(), + ) +} + fn reject_aube_map_key(key: &str, meta: &aube_settings::meta::SettingMeta) -> miette::Report { miette!( code = aube_codes::errors::ERR_AUBE_CONFIG_NESTED_AUBE_KEY, diff --git a/vendor/aube/crates/aube/src/commands/config/tui.rs b/vendor/aube/crates/aube/src/commands/config/tui.rs index ff9c8b85a..32f33c11a 100644 --- a/vendor/aube/crates/aube/src/commands/config/tui.rs +++ b/vendor/aube/crates/aube/src/commands/config/tui.rs @@ -70,7 +70,7 @@ enum StatusKind { impl ConfigTui { fn new() -> Self { - let settings = settings_meta::all().iter().collect::>(); + let settings = settings_meta::all().collect::>(); let filtered = (0..settings.len()).collect::>(); Self { settings,