From fcaf0b36fad24455c8450fd16653bddfc1c35bb3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 18:48:26 -0400 Subject: [PATCH] test(isolation): census, hostile-environment proof, adoption ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census first, because it decides how large the mechanical edit is (framing §7). Every occurrence was listed with its enclosing context and read; a grep for the bare name over-counts, which is how revision 1 reported 18 by grepping `Editor::new` — a pattern that does not match the real constructor. in-process 342 calls in 66 of 97 files (330 of 334 `EditorState::new()` occurrences; 4 are prose) (12 of 14 `EditorState::open(` occurrences; 2 are strings) spawned 14 real `pmacs` spawns in 8 files (of 36 `CARGO_BIN_EXE_pmacs` hits, 18 are the fake-LSP and fake-MCP siblings and 4 are path derivations for `pmacs-gpu`, not spawns) mixed 5 files are both, so sites — not files — are the unit The full census, with per-site attribution, is the module doc of `tests/ambient_isolation_acceptance.rs`. Four things it pins: * Isolated construction still finishes initialization, asserted twice — the flag, and the behaviour it gates (`pmacs.attach` must refuse). Falsified by wrapping the config block in `if roots.is_ambient()`; `m8_2_acceptance` does NOT catch that, because reopening an already-open init phase is a no-op. * The writes land in the redirected data root — content produced, not an invariant preserved. A "the real root did not change" check would pass vacuously wherever it already holds identical bytes, since `write_if_changed` is content-gated. * Bet 3, in two children with opposite jobs. The positive control proves the hostile environment IS hostile (an ambient editor loads its `init.lua` and writes its data root); without it the isolation half asserts nothing. The isolated child then stays green under the same environment and leaves its hostile root byte-identical. * A durable adoption ratchet, not a one-time census: a source scan that fails when a new ambient constructor appears outside a named allowlist, plus a check that no allowlist entry has gone dead. Its scanner strips comments, strings and raw strings, and that stripping has its own pin — the corpus contains all three shapes, and a grep-shaped answer already cost this lane a review round. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- tests/ambient_isolation_acceptance.rs | 657 ++++++++++++++++++++++++++ tests/common/iso.rs | 68 +++ tests/common/mod.rs | 6 + 3 files changed, 731 insertions(+) create mode 100644 tests/ambient_isolation_acceptance.rs create mode 100644 tests/common/iso.rs diff --git a/tests/ambient_isolation_acceptance.rs b/tests/ambient_isolation_acceptance.rs new file mode 100644 index 0000000..58e12a6 --- /dev/null +++ b/tests/ambient_isolation_acceptance.rs @@ -0,0 +1,657 @@ +// tests/ambient_isolation_acceptance.rs --- integration tests must not +// read or write the developer's real ambient roots. + +//! Acceptance for `docs/test-ambient-config-isolation-framing.md`. +//! +//! # The defect +//! +//! `src/editor.rs` guards user-config loading with `#[cfg(not(test))]`. +//! `cfg(test)` is set only while compiling the crate's *own* unit tests; +//! an integration test in `tests/` links `pmacs` as an ordinary +//! dependency, so the guard is inactive for all of them. `cargo test +//! --lib` is protected, `cargo test --test ` is not. And config +//! loading is only the read half: `EditorState::new` materializes +//! bundled packages into `$XDG_DATA_HOME/pmacs` (else +//! `$HOME/.local/share`) **unconditionally**, outside every `cfg` guard, +//! creating directories and writing files. +//! +//! # The census (framing acceptance 1), read at `54a092e` +//! +//! Method: every occurrence was listed with its enclosing context and +//! read. A grep for the bare name over-counts — the framing's revision 1 +//! reported 18 by grepping `Editor::new`, which does not even match the +//! real constructor `EditorState::new`. +//! +//! **In-process construction — 342 sites in 66 of 97 files.** +//! +//! * `EditorState::new()` — 334 textual occurrences, of which **330 are +//! calls**. The other 4 are prose: `persistence_acceptance.rs:6` and +//! `:76`, `m7_11_acceptance.rs:92`, `m8_2_acceptance.rs:75`. A fifth +//! file, `m5_6_acceptance.rs:94`, names the constructor only to say it +//! deliberately does **not** use it — the third place in the tree +//! documenting this same `cfg(test)` gap. +//! * `EditorState::open(` — 14 textual occurrences in 3 files, of which +//! **12 are calls** (`journey_acceptance` 7, `m4_acceptance` 4, +//! `theme_faces_acceptance` 1). The other 2 are assertion-message +//! strings in `journey_acceptance.rs:2030,2038`. +//! * Only 329 of the 330 `new()` calls are `let` bindings; the odd one +//! is `m8_1_acceptance.rs:39`, a bare tail expression in a +//! `fresh_editor()` helper. Sites, not files, are the unit. +//! +//! **Spawned `pmacs` — 14 sites in 8 files.** `grep CARGO_BIN_EXE_pmacs` +//! reports 36 sites in 26 files, but 18 of those are the *sibling* +//! binaries `CARGO_BIN_EXE_pmacs_fake_lsp` / `_fake_mcp`, which are LSP +//! and MCP stubs and not pmacs at all. Reading each occurrence: +//! +//! * `tests/common/daemon.rs:158` — the shared `--daemon` harness. +//! * `tests/common/pty.rs:111` — the shared real-PTY spawner. +//! * `m5_7_acceptance.rs` ×5, `m5_8_acceptance.rs` ×3, +//! `m5_5_acceptance.rs` ×1, `m5_perf_acceptance.rs` ×1, +//! `gpu_invocation_acceptance.rs` ×2 — direct `Command::new`. +//! * 4 further occurrences are **path derivations, not spawns**: +//! `gpu_invocation_acceptance.rs:115`, `vterm_stage3_acceptance.rs:644` +//! and `:1180`, `bottom_panel_stage2b_gpu_acceptance.rs:513` all take +//! `CARGO_BIN_EXE_pmacs`'s *parent* to locate the `pmacs-gpu` sibling. +//! +//! **Mixed files are real: 5 files are both in-process and spawned** — +//! `vterm_stage3_acceptance` constructs an editor at `:159` and reaches +//! a daemon at `:665`. A file-level partition cannot represent them, +//! which is why the ratchet below keys on sites. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use pmacs::bootstrap::BootstrapRoots; +use pmacs::editor::EditorState; + +#[path = "common/iso.rs"] +mod iso; + +// --------------------------------------------------------------------------- +// Isolated construction still finishes initialization (framing §1.8) +// --------------------------------------------------------------------------- + +/// **N** — isolated construction skips the ambient *reads* and still +/// flips the init-complete gate. +/// +/// Config loading and `set_init_complete()` share one conditional block, +/// so the tempting fix — skip the block when roots are redirected — +/// would leave every isolated test permanently in the init phase. +/// `tests/m8_2_acceptance.rs:75` documents its dependence on +/// integration-test construction being init-complete, so that is not a +/// hypothetical. +/// +/// Falsified by wrapping the block in `if roots.is_ambient()`. +#[test] +fn isolated_construction_is_init_complete() { + let state = EditorState::new_with_roots(&iso::roots()); + assert!( + state.lua_host.is_init_complete(), + "isolated construction must still leave the init phase, or every \ + suite that reopens it (m8_2) breaks" + ); + // The paired half: the ambient constructor is unchanged. + let ambient = EditorState::new(); + assert!(ambient.lua_host.is_init_complete()); +} + +/// **N** — the init phase is genuinely closed, not merely reported +/// closed. +/// +/// A flag read is one bool; this asserts the *behaviour* the flag gates, +/// so a fix that sets the flag without the surrounding block having run +/// cannot pass. `pmacs.attach` is init-only and must now refuse. +#[test] +fn isolated_construction_closes_the_init_only_lua_surface() { + let state = EditorState::new_with_roots(&iso::roots()); + let err = state + .lua_host + .lua() + .load(r#"pmacs.attach { target = "local:/run/pmacs/x.sock" }"#) + .exec() + .expect_err("pmacs.attach must refuse after init"); + let text = err.to_string(); + assert!( + text.contains("init"), + "the refusal must name the init phase; got {text}" + ); +} + +// --------------------------------------------------------------------------- +// Isolated construction redirects the writes (framing §1.6) +// --------------------------------------------------------------------------- + +/// **N** — the bundled-package materialization lands in the redirected +/// data root. +/// +/// Asserts content produced, not an invariant preserved: the package +/// tree has to actually exist under the isolated root. A test that only +/// checked "the real root was not modified" would pass on a machine +/// where the real root already held identical bytes, because +/// `write_if_changed` is content-gated. +#[test] +fn isolated_construction_materializes_into_the_redirected_data_root() { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("materialize-probe"); + let _ = std::fs::remove_dir_all(&base); + let roots = BootstrapRoots::isolated_under(&base); + let dir = roots.bundled_runtime_dir().expect("redirected data root"); + assert!(!dir.exists(), "the probe root must start absent"); + + let _state = EditorState::new_with_roots(&roots); + + let manifest = dir.join("repl").join("pmacs.toml"); + assert!( + manifest.is_file(), + "bundled packages must materialize under the redirected data \ + root; {} is missing", + manifest.display() + ); + let text = std::fs::read_to_string(&manifest).expect("read materialized manifest"); + assert!( + text.contains("repl"), + "the materialized manifest must be the bundled package's own; got {text:?}" + ); +} + +/// **N** — `install_state_dirs` honours the redirected state root. +/// +/// It runs *after* the constructor, so a constructor-only parameter +/// would leave it resolving `PMACS_STATE_HOME` / `XDG_STATE_HOME` from +/// the environment and hand an isolated session the developer's real +/// state dir. +#[test] +fn install_state_dirs_honours_the_redirected_state_root() { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("state-probe"); + let roots = BootstrapRoots::isolated_under(&base); + let state = EditorState::new_with_roots(&roots); + state.install_state_dirs(); + + let dir = state + .lua_host + .lua() + .app_data_ref::() + .expect("install_state_dirs must configure a state dir") + .0 + .clone(); + assert_eq!( + dir, + roots.state_dir().unwrap(), + "state dir must be redirected" + ); + let history = state.core.borrow().minibuffer.history_dir.clone(); + assert_eq!( + history, + roots.history_dir(), + "minibuffer history must be redirected too" + ); +} + +// --------------------------------------------------------------------------- +// Hostile ambient environment (framing Bet 3, acceptance 7) +// --------------------------------------------------------------------------- + +/// Names the isolated base for the isolated child; its presence is the +/// signal that this process *is* that child. +const ISOLATED_CHILD_BASE: &str = "PMACS_AMBIENT_ISOLATION_CHILD_BASE"; +/// Presence marks the ambient positive-control child. +const AMBIENT_CONTROL_CHILD: &str = "PMACS_AMBIENT_ISOLATION_CONTROL"; + +/// An `init.lua` that leaves a mark an assertion can see. The real +/// developer `init.lua` that produced this lane broke +/// `compile_mode_acceptance` by redefining a command pmacs already +/// defines; a marker global is the same exposure with a cheaper failure +/// mode, and it discriminates read-vs-not-read directly. +const HOSTILE_INIT_LUA: &str = "_G.HOSTILE_INIT_RAN = true\n"; + +fn hostile_root(name: &str) -> PathBuf { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&root); + let config = root.join("pmacs"); + std::fs::create_dir_all(&config).expect("create hostile config dir"); + std::fs::write(config.join("init.lua"), HOSTILE_INIT_LUA).expect("write hostile init.lua"); + // Pre-seed the data root the ambient resolver would use, so a write + // into it is visible as a *change*, not just as a new tree. + let seeded = root + .join("pmacs") + .join("builtin-packages") + .join(format!("v{}", env!("CARGO_PKG_VERSION"))); + std::fs::create_dir_all(&seeded).expect("seed hostile data root"); + std::fs::write(seeded.join("SEED"), b"pre-seeded\n").expect("write seed marker"); + root +} + +/// Flat snapshot of a tree: relative path → contents (directories map to +/// the empty vector). Sorted, so comparison is order-independent. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .expect("entry is under root") + .to_path_buf(); + if path.is_dir() { + out.insert(rel, Vec::new()); + walk(root, &path, out); + } else { + out.insert(rel, std::fs::read(&path).unwrap_or_default()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// The five storage variables plus `HOME`, all aimed at `root`. `HOME` +/// is included here — and only here — because this is the adversary: a +/// machine that leaves an XDG variable unset falls back to it, and the +/// point of a hostile environment is to leave no path out. +fn hostile_env(root: &Path) -> Vec<(&'static str, PathBuf)> { + vec![ + ("HOME", root.to_path_buf()), + ("XDG_CONFIG_HOME", root.to_path_buf()), + ("XDG_DATA_HOME", root.to_path_buf()), + ("XDG_STATE_HOME", root.to_path_buf()), + ("PMACS_STATE_HOME", root.to_path_buf()), + ("XDG_CACHE_HOME", root.to_path_buf()), + ] +} + +fn run_child(test_name: &str, env: Vec<(&'static str, PathBuf)>) -> (bool, String) { + let exe = std::env::current_exe().expect("current test binary"); + let output = Command::new(exe) + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .envs(env) + .output() + .unwrap_or_else(|e| panic!("re-exec `{test_name}`: {e}")); + let mut log = String::new(); + log.push_str("--- stdout ---\n"); + log.push_str(&String::from_utf8_lossy(&output.stdout)); + log.push_str("--- stderr ---\n"); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + assert!( + log.contains("1 passed") || !output.status.success(), + "child `{test_name}` ran no test — the `--exact` filter went stale\n{log}" + ); + (output.status.success(), log) +} + +/// **Positive control.** Under the hostile environment, the *ambient* +/// constructor really is captured by it. +/// +/// Without this the isolation assertion below is unfalsifiable: an +/// `init.lua` that never loads under any circumstances would satisfy +/// "the isolated editor did not load it" while proving nothing. +/// +/// Runs only as a re-exec'd child (marker set), so an ordinary suite run +/// does not construct an ambient editor. +#[test] +fn ambient_construction_under_a_hostile_environment_is_captured_by_it() { + if std::env::var_os(AMBIENT_CONTROL_CHILD).is_none() { + return; + } + let state = EditorState::new(); + let ran: bool = state + .lua_host + .lua() + .load("return _G.HOSTILE_INIT_RAN == true") + .eval() + .expect("read the hostile marker"); + assert!( + ran, + "the ambient constructor must load the hostile init.lua — if it \ + does not, the isolation assertion proves nothing" + ); + // And the write half: the ambient constructor materializes into the + // hostile data root. + let dir = pmacs::builtin_packages::bundled_runtime_dir(); + assert!( + dir.join("repl").join("pmacs.toml").is_file(), + "the ambient constructor must write into the hostile data root; \ + {} is empty", + dir.display() + ); +} + +/// The isolated child: same hostile environment, redirected roots. +#[test] +fn isolated_construction_under_a_hostile_environment_ignores_it() { + let Some(base) = std::env::var_os(ISOLATED_CHILD_BASE) else { + return; + }; + let base = PathBuf::from(base); + let roots = BootstrapRoots::isolated_under(&base); + let state = EditorState::new_with_roots(&roots); + let ran: bool = state + .lua_host + .lua() + .load("return _G.HOSTILE_INIT_RAN == true") + .eval() + .expect("read the hostile marker"); + assert!(!ran, "the hostile init.lua must not have been loaded"); + assert!( + state.lua_host.is_init_complete(), + "and initialization must still have finished" + ); + // Content produced, in the right place. + let dir = roots.bundled_runtime_dir().expect("redirected data root"); + assert!( + dir.join("repl").join("pmacs.toml").is_file(), + "bundled packages must land under the isolated root; {} is empty", + dir.display() + ); +} + +/// **N** — the whole of Bet 3: green under a hostile environment, and +/// the hostile root byte-identical afterwards. +/// +/// Two children, because the two halves need opposite environments to be +/// meaningful: the positive control must be *captured* by its hostile +/// root (and so modifies it), while the isolated child must leave its +/// own hostile root untouched. +#[test] +fn a_hostile_ambient_environment_is_neither_read_nor_written() { + // Half 1 — the control. Its hostile root is expected to change. + let control = hostile_root("hostile-control"); + let before_control = snapshot(&control); + let (ok, log) = run_child( + "ambient_construction_under_a_hostile_environment_is_captured_by_it", + { + let mut env = hostile_env(&control); + env.push((AMBIENT_CONTROL_CHILD, PathBuf::from("1"))); + env + }, + ); + assert!(ok, "the ambient positive control must be captured\n{log}"); + let after_control = snapshot(&control); + assert_ne!( + before_control, after_control, + "the control's hostile root must have been written into — if it \ + was not, this environment is not hostile and the isolated half \ + below asserts nothing" + ); + + // Half 2 — the isolated child. Its hostile root must be untouched. + let hostile = hostile_root("hostile-isolated"); + let isolated_base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hostile-isolated-roots"); + let _ = std::fs::remove_dir_all(&isolated_base); + let before = snapshot(&hostile); + assert!(!before.is_empty(), "the hostile root must not be empty"); + let (ok, log) = run_child( + "isolated_construction_under_a_hostile_environment_ignores_it", + { + let mut env = hostile_env(&hostile); + env.push((ISOLATED_CHILD_BASE, isolated_base.clone())); + env + }, + ); + assert!(ok, "the isolated child must stay green\n{log}"); + let after = snapshot(&hostile); + assert_eq!( + before, after, + "the hostile root must be byte-identical afterwards — a green \ + suite that still wrote into it has not demonstrated isolation" + ); + // And the writes went somewhere: the isolated tree exists. + assert!( + isolated_base.join("data").join("pmacs").is_dir(), + "the isolated data root must have been written instead" + ); +} + +// --------------------------------------------------------------------------- +// Adoption ratchet (framing acceptance 12) +// --------------------------------------------------------------------------- + +/// Files permitted to construct an editor through the **ambient** entry +/// points. +/// +/// `journey_acceptance` is ambient on purpose: it is the golden-journey +/// ratchet, and its whole claim is that the production entry point +/// `pmacs FILE` calls has a caller. It is isolated by re-execing itself +/// with controlled roots instead (framing §1.10). This file is ambient +/// only inside the positive control above, which never runs except as a +/// deliberately re-exec'd child. +const AMBIENT_ALLOWLIST: &[&str] = &["journey_acceptance.rs", "ambient_isolation_acceptance.rs"]; + +/// Strip comments and string-literal *contents* from Rust source, so a +/// scan counts calls rather than mentions. +/// +/// Both matter here. `m5_6_acceptance.rs:94` names `EditorState::new` in +/// a comment only to say it deliberately does not call it, and +/// `journey_acceptance.rs:2030` carries it inside an assertion message. +/// Raw strings (`r#"..."#`) are pervasive in this suite, so they are +/// handled rather than hoped about. +fn strip_comments_and_strings(src: &str) -> String { + let b: Vec = src.chars().collect(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < b.len() { + // Line comment. + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '/' { + while i < b.len() && b[i] != '\n' { + i += 1; + } + continue; + } + // Block comment (Rust's nest). + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '*' { + let mut depth = 1; + i += 2; + while i < b.len() && depth > 0 { + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '*' { + depth += 1; + i += 2; + } else if b[i] == '*' && i + 1 < b.len() && b[i + 1] == '/' { + depth -= 1; + i += 2; + } else { + i += 1; + } + } + out.push(' '); + continue; + } + // Raw string: r, then any number of #, then ". + if b[i] == 'r' { + let mut j = i + 1; + let mut hashes = 0; + while j < b.len() && b[j] == '#' { + hashes += 1; + j += 1; + } + if j < b.len() && b[j] == '"' { + j += 1; + loop { + if j >= b.len() { + break; + } + if b[j] == '"' { + let mut k = j + 1; + let mut seen = 0; + while k < b.len() && b[k] == '#' && seen < hashes { + seen += 1; + k += 1; + } + if seen == hashes { + j = k; + break; + } + } + j += 1; + } + out.push(' '); + i = j; + continue; + } + } + // Ordinary string. + if b[i] == '"' { + i += 1; + while i < b.len() { + if b[i] == '\\' { + i += 2; + continue; + } + if b[i] == '"' { + i += 1; + break; + } + i += 1; + } + out.push(' '); + continue; + } + out.push(b[i]); + i += 1; + } + out +} + +fn tests_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests") +} + +/// Every `.rs` file under `tests/`, including `tests/common/`. +fn test_sources() -> Vec<(String, String)> { + let mut out = Vec::new(); + let dir = tests_dir(); + let push_dir = |d: &Path, out: &mut Vec<(String, String)>| { + for entry in std::fs::read_dir(d).expect("read tests dir").flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "rs") { + let name = path + .file_name() + .expect("file name") + .to_string_lossy() + .into_owned(); + out.push((name, std::fs::read_to_string(&path).expect("read source"))); + } + } + }; + push_dir(&dir, &mut out); + push_dir(&dir.join("common"), &mut out); + out +} + +/// **N** — a *durable* adoption ratchet, not a one-time census. +/// +/// One self-spawning hostile-environment test proves the seam works; it +/// cannot notice a raw `EditorState::new()` added to a different binary +/// next month. This can. Falsified by adding an ambient constructor to +/// any non-allowlisted suite. +#[test] +fn no_test_outside_the_allowlist_constructs_an_ambient_editor() { + let sources = test_sources(); + // A broken glob must not read as a clean tree. + assert!( + sources.len() > 90, + "expected the whole tests/ corpus; found only {} files", + sources.len() + ); + let needles = [ + concat!("EditorState::", "new()"), + concat!("EditorState::", "open("), + ]; + let mut offenders: Vec = Vec::new(); + let mut seen_allowlisted: Vec<&str> = Vec::new(); + for (name, src) in &sources { + let code = strip_comments_and_strings(src); + let hits: usize = needles.iter().map(|n| code.matches(n).count()).sum(); + if hits == 0 { + continue; + } + if AMBIENT_ALLOWLIST.contains(&name.as_str()) { + seen_allowlisted.push( + AMBIENT_ALLOWLIST + .iter() + .find(|a| **a == name.as_str()) + .expect("just matched"), + ); + } else { + offenders.push(format!("{name} ({hits} site(s))")); + } + } + offenders.sort(); + assert!( + offenders.is_empty(), + "these suites construct an editor through the ambient entry \ + points, so they read the developer's real init.lua and write \ + into their real data root: {offenders:?}\n\ + Use `EditorState::new_with_roots(&crate::iso::roots())` (see \ + tests/common/iso.rs), or add the file to AMBIENT_ALLOWLIST with \ + a reason.", + ); + // Dead allowlist entries are how a ratchet rots: an entry that no + // longer needs to be there silently licenses a future regression. + let mut missing: Vec<&&str> = AMBIENT_ALLOWLIST + .iter() + .filter(|a| !seen_allowlisted.contains(&**a)) + .collect(); + missing.sort_unstable(); + assert!( + missing.is_empty(), + "allowlisted files that no longer construct an ambient editor — \ + remove them: {missing:?}" + ); +} + +/// **N** — the ratchet's scanner is not fooled by prose. +/// +/// A grep-shaped answer is what cost this lane a review round; the scan +/// above only ratchets if it distinguishes a call from a mention. Pinned +/// with the exact shapes the corpus actually contains. +#[test] +fn the_ratchet_scanner_counts_calls_not_mentions() { + let sample = r##" +//! `EditorState::new()` in a doc comment. +// `EditorState::new()` in a line comment. +/* `EditorState::new()` in a block comment. */ +fn f() { + let msg = "EditorState::open(file) must not greet"; + let raw = r#"EditorState::new() inside a raw string"#; + let _ = EditorState::new(); +} +"##; + let code = strip_comments_and_strings(sample); + assert_eq!( + code.matches(concat!("EditorState::", "new()")).count(), + 1, + "only the call survives; got {code:?}" + ); + assert_eq!( + code.matches(concat!("EditorState::", "open(")).count(), + 0, + "the assertion-message mention must not count; got {code:?}" + ); +} + +/// **N** — the seam actually reached the corpus. +/// +/// The ratchet above is an absence check, and an absence check passes on +/// a tree where nobody constructs an editor at all. This asserts the +/// positive: the isolated constructor has broad adoption. +#[test] +fn the_isolated_constructor_has_been_adopted_across_the_corpus() { + let sources = test_sources(); + let adopters: Vec<&String> = sources + .iter() + .filter(|(_, src)| { + let code = strip_comments_and_strings(src); + code.contains(concat!("EditorState::new", "_with_roots(")) + || code.contains(concat!("EditorState::open", "_with_roots(")) + }) + .map(|(name, _)| name) + .collect(); + assert!( + adopters.len() >= 60, + "expected the whole in-process population to have migrated; only \ + {} files did", + adopters.len() + ); +} diff --git a/tests/common/iso.rs b/tests/common/iso.rs new file mode 100644 index 0000000..bdc6f8f --- /dev/null +++ b/tests/common/iso.rs @@ -0,0 +1,68 @@ +// tests/common/iso.rs --- isolated bootstrap roots for integration tests. + +//! The integration suite's side of `pmacs::bootstrap`. +//! +//! An integration test links `pmacs` as an ordinary dependency, so it is +//! compiled **without** `cfg(test)` and the `#[cfg(not(test))]` guard in +//! `EditorState::new` is live for it: a raw `EditorState::new()` reads +//! the developer's real `~/.config/pmacs/init.lua` and writes bundled +//! packages into the developer's real `~/.local/share/pmacs`. It cannot +//! fix that by setting an environment variable --- `std::env::set_var` +//! is `unsafe` and `pmacs` is `#![forbid(unsafe_code)]`. So it passes +//! [`roots`] to `EditorState::new_with_roots` instead. +//! +//! See `docs/test-ambient-config-isolation-framing.md`. +//! +//! Included per-file rather than through `mod common;` so a suite that +//! needs no daemon or PTY fixture does not compile them: +//! +//! ```ignore +//! #[path = "common/iso.rs"] +//! mod iso; +//! ``` +//! +//! [`roots`] is a **pure function of the build environment** --- no +//! counter, no `OnceLock`. Two copies of this module in one test binary +//! (one via `mod common;`, one via `#[path]`) therefore return the same +//! value rather than racing over a shared counter. + +#![allow(dead_code)] // not every including suite uses every helper + +use std::path::PathBuf; + +use pmacs::bootstrap::BootstrapRoots; + +/// The shared isolated base, under Cargo's per-package integration-test +/// temp directory. +/// +/// `CARGO_TARGET_TMPDIR` (`target//tmp`) rather than `/tmp`: +/// nothing here is ever unlinked --- there is no libtest teardown hook +/// to unlink it from --- so the tree has to live somewhere `cargo clean` +/// owns instead of leaking into the system temp dir once per run. +/// +/// Deliberately shared across tests and across test binaries. +/// `materialize_all` is content-gated and idempotent, so after the first +/// construction every later one is a no-op read; a per-test directory +/// would repeat the whole materialization ~330 times per run for no +/// isolation gain (the tree is byte-identical for every caller). +#[must_use] +pub fn base() -> PathBuf { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ambient-isolation") +} + +/// Isolated bootstrap roots for an in-process editor. +/// +/// Pass to `EditorState::new_with_roots` / `open_with_roots`. +#[must_use] +pub fn roots() -> BootstrapRoots { + let base = base(); + let roots = BootstrapRoots::isolated_under(&base); + // Create the config dir eagerly. `load_user_config_at` registers it + // on Lua's `package.path` whether or not `init.lua` exists, and a + // suite that later writes a config chunk into it should not have to + // know the layout. + if let Some(dir) = roots.config_dir() { + std::fs::create_dir_all(&dir).expect("create isolated config dir"); + } + roots +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e157208..7d2e4ca 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -14,8 +14,14 @@ //! - [`daemon`]: `pmacs --daemon` subprocess fixture. First //! consumer M5.5 acceptance suite; second consumer M10.11 //! doubled-PTY tests. +//! - [`iso`]: isolated bootstrap storage roots, so an in-process +//! editor neither reads the developer's real `init.lua` nor writes +//! into their real data root. Most suites include it directly via +//! `#[path = "common/iso.rs"] mod iso;` rather than through this +//! module; it is re-exported here for `daemon`'s use. #![allow(dead_code)] // not every integration-test file uses every helper pub mod daemon; +pub mod iso; pub mod pty;