test(isolation): census, hostile-environment proof, adoption ratchet
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
parent
85e4ee03bb
commit
fcaf0b36fa
|
|
@ -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 <name>` 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::<pmacs::lua_bindings::StateDir>()
|
||||
.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<PathBuf, Vec<u8>> {
|
||||
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
||||
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<char> = 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<String> = 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()
|
||||
);
|
||||
}
|
||||
|
|
@ -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/<profile>/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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue