Skip to main content

cortexkit_test_support/
fence.rs

1use std::ffi::{OsStr, OsString};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5const ROOTS: &[(&str, &str)] = &[
6    ("HOME", "home"),
7    ("XDG_DATA_HOME", "data"),
8    ("XDG_RUNTIME_DIR", "runtime"),
9    ("XDG_CONFIG_HOME", "config"),
10    ("XDG_STATE_HOME", "state"),
11    ("XDG_CACHE_HOME", "cache"),
12    ("TMPDIR", "tmp"),
13];
14const PASSTHROUGH: &[&str] = &[
15    "PATH",
16    "LANG",
17    "LANGUAGE",
18    "LC_ALL",
19    "LC_CTYPE",
20    "LC_COLLATE",
21    "LC_MESSAGES",
22    "LC_NUMERIC",
23    "LC_TIME",
24    "LC_MONETARY",
25    "TZ",
26    "USER",
27    "LOGNAME",
28    "RUST_BACKTRACE",
29    #[cfg(windows)]
30    "SystemRoot",
31];
32
33/// Return the scratch subdirectory assigned to a built-in directory variable,
34/// such as `XDG_DATA_HOME`. Unknown variable names panic rather than resolving
35/// to a directory that the child never uses.
36pub fn fenced_subc_env_dir(scratch: &Path, var: &str) -> PathBuf {
37    ROOTS
38        .iter()
39        .find(|(name, _)| *name == var)
40        .map(|(_, dir)| scratch.join(dir))
41        .unwrap_or_else(|| panic!("{var} is not a fenced daemon env root"))
42}
43
44/// Create directories beneath scratch and return their environment assignments.
45/// HOME, all five XDG directory variables, and TMPDIR point into this test tree
46/// so a daemon and its children do not write to the user's normal directories.
47pub fn fenced_subc_daemon_env(scratch: &Path) -> Vec<(OsString, OsString)> {
48    fenced_env_with_roots(scratch, &[])
49}
50
51pub(crate) fn fenced_env_with_roots(
52    scratch: &Path,
53    extra_roots: &[(&str, &str)],
54) -> Vec<(OsString, OsString)> {
55    let mut names = std::collections::HashSet::new();
56    for (name, directory) in extra_roots {
57        assert!(
58            !name.is_empty()
59                && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
60                && !name.as_bytes()[0].is_ascii_digit(),
61            "extra root must have a valid environment variable name"
62        );
63        assert!(
64            !ROOTS
65                .iter()
66                .any(|(builtin, _)| name.eq_ignore_ascii_case(builtin)),
67            "extra roots cannot replace a built-in directory variable"
68        );
69        assert!(
70            names.insert(name.to_ascii_uppercase()),
71            "duplicate extra root variable"
72        );
73        plain_name(directory);
74    }
75    ROOTS
76        .iter()
77        .chain(extra_roots)
78        .map(|(name, dir)| {
79            let path = scratch.join(dir);
80            std::fs::create_dir_all(&path).unwrap_or_else(|e| panic!("create fenced {name}: {e}"));
81            let metadata = std::fs::symlink_metadata(&path).expect("inspect test directory root");
82            assert!(
83                metadata.is_dir() && !metadata.is_symlink(),
84                "test directory root must be a real directory"
85            );
86            (OsString::from(name), path.into_os_string())
87        })
88        .collect()
89}
90
91/// Build a command with no inherited environment except the allowlisted PATH,
92/// locale, timezone, user-name, and backtrace variables. Inherited API keys,
93/// tokens, and module identity variables are removed; HOME, XDG directories,
94/// and TMPDIR instead point beneath scratch. A `ck-*` program is copied to a
95/// reusable `ckdev-*` executable before the command is constructed.
96/// Use [`crate::TestDaemonCommand`] when roots must also resist later overrides
97/// and the child must terminate with the test harness.
98pub fn fenced_command(program: impl AsRef<OsStr>, scratch: &Path) -> Command {
99    fenced_command_with_roots(program, scratch, &[])
100}
101
102/// Like [`fenced_command`], with caller-selected directory variables redirected
103/// beneath scratch. Each pair is `(variable, plain subdirectory name)`; built-in
104/// variables cannot be replaced. Invalid names, duplicates, and symlink roots panic.
105/// As with a standard `Command`, later `.env` calls can override these values;
106/// [`crate::TestDaemonCommand::fenced_root`] enforces them again when spawning.
107pub fn fenced_command_with_roots(
108    program: impl AsRef<OsStr>,
109    scratch: &Path,
110    extra_roots: &[(&str, &str)],
111) -> Command {
112    let program = crate::stage_test_binary(Path::new(program.as_ref()));
113    let mut command = crate::dev_command(program);
114    command.env_clear();
115    for name in PASSTHROUGH {
116        if let Some(value) = std::env::var_os(name) {
117            command.env(name, value);
118        }
119    }
120    command.envs(fenced_env_with_roots(scratch, extra_roots));
121    command
122}
123
124/// Run `--version` with the same environment fence used by test daemons.
125/// Assessment is best-effort; failed exits are reported with code or signal.
126pub fn warm_exec_fenced(binary: &Path) {
127    let scratch = crate::ScratchDir::new("warm-exec");
128    if let Err(error) = crate::checked_output(
129        fenced_command(binary, scratch.path())
130            .arg("--version")
131            .stderr(Stdio::null()),
132    ) {
133        eprintln!("warm-exec: {error}");
134    }
135}
136
137/// Best-effort macOS signing. The explicit identity wins over
138/// `CORTEXKIT_TEST_SIGNING_IDENTITY`; without either, signing is skipped.
139/// Pass `Some(OsStr::new("-"))` to select ad-hoc signing without a certificate
140/// or a signing identity from the user's keychain.
141/// Already-valid signatures are left alone; other platforms do nothing.
142pub fn sign_test_binary(path: &Path, identity: Option<&OsStr>) {
143    #[cfg(target_os = "macos")]
144    {
145        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
146        let _guard = LOCK.lock().unwrap_or_else(|p| p.into_inner());
147        let env = std::env::var_os("CORTEXKIT_TEST_SIGNING_IDENTITY");
148        let Some(identity) = identity.or(env.as_deref()) else {
149            return;
150        };
151        if Command::new("codesign")
152            .arg("-v")
153            .arg(path)
154            .output()
155            .is_ok_and(|o| o.status.success())
156        {
157            return;
158        }
159        if let Err(e) = crate::checked_output(
160            Command::new("codesign")
161                .args(["-f", "-s"])
162                .arg(identity)
163                .arg(path),
164        ) {
165            eprintln!("sign-test-binary: {e}; running {} anyway", path.display());
166        }
167    }
168    #[cfg(not(target_os = "macos"))]
169    let _ = (path, identity);
170}
171
172/// Build with a locked sibling manifest and an isolated, caller-owned target directory.
173pub fn sibling_cargo_build(repo: &Path, target_dir: &Path, bin: &str) -> Command {
174    let mut command = Command::new(env!("CARGO"));
175    command
176        .current_dir(repo)
177        .args(["build", "--locked", "--bin", bin])
178        .env("CARGO_TARGET_DIR", target_dir);
179    command
180}
181
182fn workspace_root() -> PathBuf {
183    if let Some(root) = std::env::var_os("CORTEXKIT_TEST_WORKSPACE_ROOT") {
184        return PathBuf::from(root);
185    }
186    // Resolve at runtime rather than from this library's manifest: consumers
187    // may use a registry dependency whose sources live outside their workspace.
188    let output = Command::new("git")
189        .args(["rev-parse", "--show-toplevel"])
190        .output()
191        .expect("locate test workspace");
192    assert!(
193        output.status.success(),
194        "set CORTEXKIT_TEST_WORKSPACE_ROOT outside a git workspace"
195    );
196    PathBuf::from(String::from_utf8(output.stdout).unwrap().trim())
197}
198pub(crate) fn plain_name(name: &str) {
199    assert!(
200        !name.is_empty()
201            && name
202                .bytes()
203                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
204        "expected a plain directory name (ASCII letters, digits, '-' or '_')"
205    );
206}
207/// Caller workspace's `target/sibling-target/<name>`; never writes into the sibling.
208pub fn sibling_target_dir(name: &str) -> PathBuf {
209    plain_name(name);
210    let dir = workspace_root().join("target/sibling-target").join(name);
211    std::fs::create_dir_all(&dir).unwrap();
212    dir
213}
214/// Adjacent checkout, falling back to the primary checkout's sibling for linked worktrees.
215/// `CORTEXKIT_TEST_WORKSPACE_ROOT` can explicitly select the consuming workspace.
216pub fn sibling_checkout(name: &str) -> Result<PathBuf, String> {
217    plain_name(name);
218    let workspace = workspace_root();
219    let direct = workspace.join("..").join(name);
220    let primary = || {
221        let marker = std::fs::read_to_string(workspace.join(".git")).ok()?;
222        let git_dir = workspace.join(marker.trim().strip_prefix("gitdir: ")?);
223        let primary = git_dir
224            .ancestors()
225            .find(|p| p.file_name().is_some_and(|n| n == ".git"))?
226            .parent()?;
227        Some(primary.parent()?.join(name))
228    };
229    [Some(direct.clone()), primary()]
230        .into_iter()
231        .flatten()
232        .find(|p| p.exists())
233        .and_then(|p| p.canonicalize().ok())
234        .ok_or_else(|| {
235            format!(
236                "sibling {name} checkout missing (looked at {})",
237                direct.display()
238            )
239        })
240}
241
242/// The platform daemon `ck-subc`, built from the `subconscious` sibling checkout.
243/// Its executable path is private so callers can only spawn it through a builder
244/// that redirects its directory variables and owns its process lifetime.
245#[derive(Debug, Clone)]
246pub struct SubcDaemonBinary {
247    path: PathBuf,
248}
249impl SubcDaemonBinary {
250    pub fn build(dependency_checkouts: &[&str]) -> Self {
251        Self::try_build(dependency_checkouts).unwrap_or_else(|reason| panic!("{reason}"))
252    }
253    /// Missing checkout is recoverable; a failed build is a test failure.
254    /// `dependency_checkouts` names the daemon's adjacent path dependencies
255    /// whose source changes should invalidate the executable cache.
256    pub fn try_build(dependency_checkouts: &[&str]) -> Result<Self, String> {
257        let repo = sibling_checkout("subconscious")?;
258        Ok(Self {
259            path: crate::build_sibling_binary(
260                &repo,
261                "subconscious",
262                "ck-subc",
263                dependency_checkouts,
264            ),
265        })
266    }
267    pub fn command(&self, scratch: &Path) -> crate::TestDaemonCommand {
268        crate::TestDaemonCommand::new(&self.path, scratch)
269    }
270}
271
272#[cfg(all(test, unix))]
273mod tests {
274    use super::*;
275    #[test]
276    fn caller_selected_root_lands_under_scratch() {
277        let scratch = crate::ScratchDir::new("extra-command-root");
278        let output = crate::checked_output(&mut fenced_command_with_roots(
279            "/usr/bin/env",
280            &scratch,
281            &[("EXAMPLE_STORAGE_ROOT", "storage")],
282        ))
283        .unwrap();
284        let text = String::from_utf8(output.stdout).unwrap();
285        assert!(text
286            .lines()
287            .any(|line| line
288                == format!("EXAMPLE_STORAGE_ROOT={}", scratch.join("storage").display())));
289        assert!(scratch.join("storage").is_dir());
290    }
291
292    #[test]
293    fn extra_roots_cannot_replace_builtin_variables_or_escape_scratch() {
294        let scratch = crate::ScratchDir::new("extra-root-validation");
295        for roots in [
296            vec![("XDG_DATA_HOME", "other")],
297            vec![("xdg_data_home", "other")],
298            vec![("EXTRA_ROOT", "..")],
299            vec![("EXTRA_ROOT", "/outside")],
300            vec![("EXTRA_ROOT", "../outside")],
301            vec![("BAD=NAME", "data")],
302            vec![("EXTRA_ROOT", "first"), ("EXTRA_ROOT", "second")],
303        ] {
304            assert!(
305                std::panic::catch_unwind(|| fenced_command_with_roots("env", &scratch, &roots))
306                    .is_err(),
307                "accepted invalid roots: {roots:?}"
308            );
309        }
310        let outside = crate::ScratchDir::new("extra-root-outside");
311        std::os::unix::fs::symlink(outside.path(), scratch.join("linked")).unwrap();
312        assert!(std::panic::catch_unwind(|| fenced_command_with_roots(
313            "env",
314            &scratch,
315            &[("EXTRA_ROOT", "linked")]
316        ))
317        .is_err());
318    }
319
320    #[test]
321    fn fenced_command_drops_ambient_variables_and_keeps_the_allowlist() {
322        let scratch = crate::ScratchDir::new("fence-env");
323        std::env::set_var("CORTEXKIT_TEST_LEAK_CANARY", "secret");
324        let output =
325            crate::checked_output(fenced_command("/usr/bin/env", &scratch).env("EXPLICIT", "kept"))
326                .unwrap();
327        let text = String::from_utf8(output.stdout).unwrap();
328        assert!(!text.contains("CORTEXKIT_TEST_LEAK_CANARY="));
329        assert!(text.lines().any(|s| s == "EXPLICIT=kept"));
330        assert!(text
331            .lines()
332            .any(|s| s == format!("PATH={}", std::env::var("PATH").unwrap())));
333        for (name, dir) in ROOTS {
334            assert!(text
335                .lines()
336                .any(|s| s == format!("{name}={}", scratch.join(dir).display())));
337        }
338        for line in text.lines() {
339            let name = line.split_once('=').unwrap().0;
340            assert!(
341                ROOTS.iter().any(|(root, _)| *root == name)
342                    || PASSTHROUGH.contains(&name)
343                    || name == "EXPLICIT",
344                "unexpected variable {name}"
345            );
346        }
347        std::env::remove_var("CORTEXKIT_TEST_LEAK_CANARY");
348    }
349    #[test]
350    fn sibling_cargo_build_fails_on_a_stale_lock_and_leaves_it_untouched() {
351        let scratch = crate::ScratchDir::new("locked-build");
352        std::fs::write(scratch.join("Cargo.toml"), "[package]\nname='lockprobe'\nversion='0.2.0'\nedition='2021'\n[[bin]]\nname='lockprobe'\npath='main.rs'\n[workspace]\n").unwrap();
353        std::fs::write(scratch.join("main.rs"), "fn main() {}\n").unwrap();
354        let lock = |version: &str| {
355            format!("version = 3\n\n[[package]]\nname = \"lockprobe\"\nversion = \"{version}\"\n")
356        };
357        std::fs::write(scratch.join("Cargo.lock"), lock("0.1.0")).unwrap();
358        let build = || {
359            sibling_cargo_build(&scratch, &scratch.join("target"), "lockprobe")
360                .stdout(Stdio::null())
361                .stderr(Stdio::null())
362                .status()
363                .unwrap()
364        };
365        assert!(!build().success());
366        assert_eq!(
367            std::fs::read_to_string(scratch.join("Cargo.lock")).unwrap(),
368            lock("0.1.0")
369        );
370        std::fs::write(scratch.join("Cargo.lock"), lock("0.2.0")).unwrap();
371        assert!(build().success());
372    }
373}