From 2089c987f5723430b5a99c2c2c0ff47da31b7497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Sat, 14 Nov 2026 14:31:40 +0100 Subject: [PATCH 1/3] fix(test): isolate native executables in private temporary directories --- .github/workflows/ci.yml | 4 + CHANGELOG.md | 12 ++ CONTRIBUTING.md | 13 ++ crates/nera/src/eunit_runner.rs | 2 +- crates/nera/src/main.rs | 1 + crates/nera/src/runtime_cache.rs | 4 +- crates/nera/src/test_runner.rs | 78 +++++++++--- crates/nera/src/test_storage.rs | 157 ++++++++++++++++++++++++ scripts/ci/changes.py | 1 + scripts/ci/test_changes.py | 3 + scripts/test-test-storage.py | 203 +++++++++++++++++++++++++++++++ tests/README.md | 13 ++ 12 files changed, 470 insertions(+), 21 deletions(-) create mode 100644 crates/nera/src/test_storage.rs create mode 100644 scripts/test-test-storage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4c1fee..96eb7f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,10 @@ jobs: if: matrix.part == 1 run: python3 scripts/test-stacktrace-order.py + - name: Private native test storage + if: matrix.part == 1 + run: python3 scripts/test-test-storage.py + cowlib: name: Cowlib (Linux x86_64) needs: [changes, build] diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5fae5..2553b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ All notable milestones for the nera project are documented in this file. memory reservation does not guarantee sufficient physical memory; this change does not contain operating-system out-of-memory termination. +## Native test executables use fresh private storage + +- `nera test` no longer compiles into a predictable PID-based directory that + another local user could pre-create. Every test gets exclusively created, + randomized temporary storage, retained through execution and cleaned on all + normal result paths. EUnit uses the same storage policy. +- On Unix, test directories are created with mode 0700 even under a permissive + umask. Nera checks temporary-root ancestry against rename attacks, falls back + to a protected home directory when needed, and fails before compilation if + neither location is safe. Existing paths are never repaired or removed. +- Relative test fixtures still resolve from the project working directory. + ## Stacktraces list the raising function first - Captured exception stacktraces now list the raising function before its diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f929542..a40e6bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,19 @@ The [current baseline](docs/PROJECT_STATUS.md) records known failures; do not infer a green workspace from passing one crate. Preserve explicit expected output/completion checks when adding runtime regressions. +When changing native test storage, also run the Unix linker-boundary regression: + +```bash +python3 scripts/test-test-storage.py +``` + +It runs the real compiler and native linker under `umask 000`, plants the former +PID-based paths, checks owner-only fresh executable directories and cleanup, +and exercises private-home fallback and refusal of replaceable ancestors. +The EUnit storage cases use a local framework stand-in, not external OTP sources. +The fixtures use only disposable directories and an isolated `HOME`; native +multi-account access checks and Windows ACL validation remain separate coverage. + When changing embedded runtime extraction, also run the standalone cache regression with two compilers built with different native runtime archives. Build each runtime before its compiler and retain both matching artifacts: diff --git a/crates/nera/src/eunit_runner.rs b/crates/nera/src/eunit_runner.rs index be7cbe4..8ca42be 100644 --- a/crates/nera/src/eunit_runner.rs +++ b/crates/nera/src/eunit_runner.rs @@ -129,7 +129,7 @@ pub(crate) fn run( opts.verbose, Some(root), )?; - let temp = tempfile::tempdir()?; + let temp = crate::test_storage::new()?; let source = temp.path().join("nera_eunit_driver.erl"); std::fs::write(&source, driver(&modules, opts.verbose))?; let binary = temp diff --git a/crates/nera/src/main.rs b/crates/nera/src/main.rs index c2ad372..3396c93 100644 --- a/crates/nera/src/main.rs +++ b/crates/nera/src/main.rs @@ -29,6 +29,7 @@ mod test_runner; mod eunit_runner; mod module_info; mod runtime_cache; +mod test_storage; // ── Embedded stdlib ────────────────────────────────────────────────── diff --git a/crates/nera/src/runtime_cache.rs b/crates/nera/src/runtime_cache.rs index b08a5d3..577646a 100644 --- a/crates/nera/src/runtime_cache.rs +++ b/crates/nera/src/runtime_cache.rs @@ -101,7 +101,7 @@ fn effective_uid() -> u32 { /// Canonicalize before validation so a mutable ancestor symlink is not retained /// in the path passed to the linker. System-owned sticky /tmp is allowed. #[cfg(unix)] -fn protected_base(base: &Path, uid: u32) -> std::io::Result { +pub(crate) fn protected_base(base: &Path, uid: u32) -> std::io::Result { let base = base.canonicalize()?; protected_ancestors(&base, uid, None, "TMPDIR")?; Ok(base) @@ -166,7 +166,7 @@ fn kernel_overflow_uid() -> Option { /// The configured account home must protect its cache entries from replacement. #[cfg(unix)] -fn home_base(home: &Path, uid: u32) -> std::io::Result { +pub(crate) fn home_base(home: &Path, uid: u32) -> std::io::Result { use std::os::unix::fs::MetadataExt; if !home.is_absolute() { diff --git a/crates/nera/src/test_runner.rs b/crates/nera/src/test_runner.rs index 52adfa5..72ee734 100644 --- a/crates/nera/src/test_runner.rs +++ b/crates/nera/src/test_runner.rs @@ -95,9 +95,6 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box> { let total = tests.len(); let num_threads = opts.jobs.min(total).max(1); - let tmp_dir = std::env::temp_dir().join(format!("nera_tests_{}", std::process::id())); - std::fs::create_dir_all(&tmp_dir)?; - println!( "{}Running {} tests ({} threads){}", c.bold, total, num_threads, c.reset @@ -114,7 +111,6 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box> { let work = Arc::clone(&work); let tx = tx.clone(); let nera_bin = nera_bin.clone(); - let tmp_dir = tmp_dir.clone(); let include_paths = Arc::clone(&include_paths); let no_stdlib = opts.no_stdlib; let no_warnings = opts.no_warnings; @@ -124,7 +120,7 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box> { let test = { work.lock().unwrap().next() }; let Some(test) = test else { break }; let result = run_single_test( - &test, &nera_bin, &tmp_dir, &include_paths, no_stdlib, no_warnings, timeout, + &test, &nera_bin, &include_paths, no_stdlib, no_warnings, timeout, ); if tx.send(result).is_err() { break; @@ -203,7 +199,6 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box> { } println!(); - let _ = std::fs::remove_dir_all(&tmp_dir); if failed > 0 { println!("{}{}SOME TESTS FAILED{}", c.red, c.bold, c.reset); @@ -470,7 +465,6 @@ pub(crate) fn glob_match(pattern: &str, name: &str) -> bool { fn run_single_test( test: &TestDescriptor, nera_bin: &Path, - tmp_dir: &Path, include_paths: &[PathBuf], no_stdlib: bool, no_warnings: bool, @@ -489,20 +483,21 @@ fn run_single_test( }; } - // Per-test temp subdirectory (avoids .o file collisions between parallel builds) - let test_tmp = tmp_dir.join(&test.name); - if std::fs::create_dir_all(&test_tmp).is_err() { - return TestResult { + // Keep this exclusive owner-only directory alive through compile, execution + // and output capture. Its guard also cleans up every early error return. + let test_tmp = match crate::test_storage::new() { + Ok(directory) => directory, + Err(error) => return TestResult { name: test.name.clone(), outcome: TestOutcome::Fail { - reason: "cannot create temp dir".to_string(), - detail: None, + reason: "cannot create private test directory".to_string(), + detail: Some(error.to_string()), }, duration: start.elapsed(), - }; - } + }, + }; - let binary = test_tmp.join(&test.name); + let binary = test_tmp.path().join(&test.name); let entry = format!("{}:test", test.name); // Make source paths absolute (CWD will be the per-test tmp dir) @@ -516,7 +511,7 @@ fn run_single_test( // ── Compile ────────────────────────────────────────────────── let mut cmd = std::process::Command::new(nera_bin); - cmd.current_dir(&test_tmp); + cmd.current_dir(test_tmp.path()); cmd.arg("build"); cmd.arg(&abs_test_path); for m in &abs_extra { @@ -787,6 +782,7 @@ mod tests { let compiler = tmp.path().join("compiler"); // Exercise the real compile/execute/kill/report path with a tiny fixture. fs::write(&compiler, r#"#!/bin/sh +printf '%s' "$PWD" > "$0.cwd" while [ "$1" != "-o" ]; do shift; done shift cat > "$1" <<'PROGRAM' @@ -808,7 +804,9 @@ chmod +x "$1" expected_compile_error: None, extra_modules: vec![], }; - let result = run_single_test(&test, &compiler, tmp.path(), &[], false, false, 1); + let result = run_single_test(&test, &compiler, &[], false, false, 1); + let directory = fs::read_to_string(compiler.with_extension("cwd")).unwrap(); + assert!(!Path::new(&directory).exists(), "leaked timed-out executable"); match result.outcome { TestOutcome::Fail { reason, detail } => { assert_eq!(reason, "timeout after 1s"); @@ -820,6 +818,50 @@ chmod +x "$1" } } + #[cfg(unix)] + #[test] + fn private_storage_is_removed_on_each_compile_and_runtime_result() { + use std::os::unix::fs::PermissionsExt; + let fixture = crate::test_storage::new().unwrap(); + let compiler = fixture.path().join("compiler"); + let source = fixture.path().join("probe.erl"); + fs::write(&source, "").unwrap(); + for (compile, program, expected_error, expected_output, passes) in [ + ("exit 0", "echo complete", None, Some("complete"), true), + ("echo deliberate >&2; exit 1", "", None, None, false), + ("echo deliberate >&2; exit 1", "", Some("deliberate"), None, true), + ("echo deliberate >&2; exit 1", "", Some("different"), None, false), + ("exit 0", "echo complete", Some("deliberate"), None, false), + ("exit 0", "exit 1", None, None, false), + ("exit 0", "echo wrong", None, Some("complete"), false), + ] { + fs::write(&compiler, format!(r#"#!/bin/sh +printf '%s' "$PWD" > "$0.cwd" +while [ "$1" != "-o" ]; do shift; done +shift +cat > "$1" <<'PROGRAM' +#!/bin/sh +{program} +PROGRAM +chmod +x "$1" +{compile} +"#)).unwrap(); + fs::set_permissions(&compiler, fs::Permissions::from_mode(0o700)).unwrap(); + let test = TestDescriptor { + name: "probe".into(), + path: source.clone(), + skip_reason: None, + expected_output: expected_output.map(str::to_owned), + expected_compile_error: expected_error.map(str::to_owned), + extra_modules: vec![], + }; + let result = run_single_test(&test, &compiler, &[], false, false, 1); + assert_eq!(matches!(result.outcome, TestOutcome::Pass), passes); + let directory = fs::read_to_string(compiler.with_extension("cwd")).unwrap(); + assert!(!Path::new(&directory).exists(), "leaked {compile}: {program}"); + } + } + /// Helper: create a temp project layout with an optional nera.toml. fn setup_project(dir: &std::path::Path, toml_content: Option<&str>, subdirs: &[&str]) { fs::create_dir_all(dir).unwrap(); diff --git a/crates/nera/src/test_storage.rs b/crates/nera/src/test_storage.rs new file mode 100644 index 0000000..3ad1c65 --- /dev/null +++ b/crates/nera/src/test_storage.rs @@ -0,0 +1,157 @@ +//! Fresh private storage for each native test executable and its object files. + +use std::path::Path; + +pub(crate) fn new() -> std::io::Result { + #[cfg(unix)] + { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + // SAFETY: geteuid takes no arguments and has no preconditions. + unix(&std::env::temp_dir(), home.as_deref(), unsafe { + libc::geteuid() + }) + } + #[cfg(not(unix))] + { + // Windows uses the account's temporary directory and inherited ACLs. + create(&std::env::temp_dir()) + } +} + +#[cfg(unix)] +fn unix(temp: &Path, home: Option<&Path>, uid: u32) -> std::io::Result { + let base = crate::runtime_cache::protected_base(temp, uid).or_else(|temp_error| { + let home = home.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "TMPDIR {} is not protected ({}); HOME is not set", + temp.display(), + temp_error + ), + ) + })?; + crate::runtime_cache::home_base(home, uid).map_err(|err| { + std::io::Error::new( + err.kind(), + format!( + "TMPDIR {} is not protected ({}); HOME {} fallback failed: {}", + temp.display(), + temp_error, + home.display(), + err + ), + ) + }) + })?; + create(&base) +} + +fn create(base: &Path) -> std::io::Result { + let mut builder = tempfile::Builder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Apply at mkdir, not afterwards: even umask 000 must expose no window. + builder.permissions(std::fs::Permissions::from_mode(0o700)); + } + // tempfile creates the randomized name exclusively; existing entries are + // never reused, repaired or removed. Retain its guard through execution. + builder.prefix("nera-test-").tempdir_in(base) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; + + fn fixture() -> tempfile::TempDir { + create(&std::env::temp_dir()).unwrap() + } + + fn uid() -> u32 { + // SAFETY: geteuid takes no arguments and has no preconditions. + unsafe { libc::geteuid() } + } + + #[test] + fn directories_are_fresh_private_owned_and_removed_on_drop() { + let base = fixture(); + let old = base + .path() + .join(format!("nera_tests_{}", std::process::id())); + let target = fixture(); + let sentinel = target.path().join("sentinel"); + std::fs::write(&sentinel, b"untouched").unwrap(); + symlink(target.path(), &old).unwrap(); + let first = unix(base.path(), None, uid()).unwrap(); + let second = unix(base.path(), None, uid()).unwrap(); + assert_ne!(first.path(), second.path()); + for directory in [&first, &second] { + let metadata = std::fs::symlink_metadata(directory.path()).unwrap(); + assert!(metadata.is_dir()); + assert_eq!(metadata.uid(), uid()); + assert_eq!(metadata.mode() & 0o777, 0o700); + assert_eq!( + directory.path().parent().unwrap(), + base.path().canonicalize().unwrap() + ); + } + let path = first.path().to_owned(); + std::fs::write(path.join("test.o"), b"object").unwrap(); + drop(first); + assert!(!path.exists()); + assert!(std::fs::symlink_metadata(&old).unwrap().is_symlink()); + assert_eq!(std::fs::read(sentinel).unwrap(), b"untouched"); + } + + #[test] + fn unsafe_temp_uses_home_but_unsafe_home_fails_closed() { + let base = fixture(); + let home = fixture(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let directory = unix(base.path(), Some(home.path()), uid()).unwrap(); + assert_eq!( + directory.path().parent().unwrap(), + home.path().canonicalize().unwrap() + ); + assert_eq!( + std::fs::metadata(directory.path()).unwrap().mode() & 0o777, + 0o700 + ); + assert!(unix(base.path(), None, uid()) + .unwrap_err() + .to_string() + .contains("HOME")); + assert!(unix(base.path(), Some(home.path()), uid().wrapping_add(1)).is_err()); + std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(unix(base.path(), Some(home.path()), uid()).is_err()); + } + + #[test] + fn writable_ancestors_are_rejected_and_sticky_roots_are_supported() { + let base = fixture(); + let nested = base.path().join("nested"); + std::fs::create_dir(&nested).unwrap(); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(unix(&nested, None, uid()).is_err()); + std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o1777)).unwrap(); + assert!(unix(&nested, None, uid()).is_ok()); + assert!(unix(base.path(), None, uid()).is_ok()); + } + + #[test] + fn canonical_path_does_not_retain_replaceable_symlink_alias() { + let base = fixture(); + let alias = base.path().join("alias"); + let target = fixture(); + symlink(target.path(), &alias).unwrap(); + let directory = unix(&alias, None, uid()).unwrap(); + std::fs::remove_file(alias).unwrap(); + assert!(directory.path().is_dir()); + assert_eq!( + directory.path().parent().unwrap(), + target.path().canonicalize().unwrap() + ); + } +} diff --git a/scripts/ci/changes.py b/scripts/ci/changes.py index 4083a02..dd1b152 100644 --- a/scripts/ci/changes.py +++ b/scripts/ci/changes.py @@ -64,6 +64,7 @@ "Cargo.lock": COMPILER, "rust-toolchain.toml": COMPILER, "scripts/test-cross.sh": frozenset({"build", "cross"}), + "scripts/test-test-storage.py": frozenset({"build", "e2e"}), ".github/workflows/release.yml": PACKAGING, # Release milestone: runs from main on its own; the target file is its input. ".github/workflows/release-milestone.yml": NONE, diff --git a/scripts/ci/test_changes.py b/scripts/ci/test_changes.py index cd0e36d..e27b771 100644 --- a/scripts/ci/test_changes.py +++ b/scripts/ci/test_changes.py @@ -77,6 +77,9 @@ def test_release_target_and_its_workflow_start_no_job(self): self.assertEqual(selected([".github/release-target.json", ".github/workflows/release-milestone.yml"]), set()) + def test_native_test_storage_regression_runs_with_the_compiler(self): + self.assertEqual(selected(["scripts/test-test-storage.py"]), {"build", "e2e"}) + def test_cross_script(self): self.assertEqual(selected(["scripts/test-cross.sh"]), {"build", "cross"}) diff --git a/scripts/test-test-storage.py b/scripts/test-test-storage.py new file mode 100644 index 0000000..89fed79 --- /dev/null +++ b/scripts/test-test-storage.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Observe real `nera test` linkage in private, disposable storage on Unix. + +The linker probe delegates to the native C linker. All hostile paths and HOME +values belong to one disposable fixture; no other account or real home is used. +""" +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile + + +LINKER = r'''#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import subprocess +import sys + +args = sys.argv[1:] +output = Path(args[args.index("-o") + 1]).resolve() +cwd = Path.cwd() +metadata = cwd.stat() +record = {"directory": str(cwd), "binary": str(output), + "mode": metadata.st_mode & 0o777, "uid": metadata.st_uid} +with open(os.environ["STORAGE_LOG"], "a") as log: + log.write(json.dumps(record) + "\n") +if os.environ.get("FAIL_LINK"): + sys.exit(1) +sys.exit(subprocess.call([os.environ["REAL_CC"], *args])) +''' + +# Plant the former PID-based name before exec, so the runner has the same PID. +LAUNCH = r''' +import os +from pathlib import Path +import sys +old = Path(os.environ["TMPDIR"]) / ("nera_tests_" + str(os.getpid())) +kind = os.environ["PLANT_KIND"] +if kind == "symlink": + old.symlink_to(os.environ["PLANT_TARGET"], target_is_directory=True) +elif kind == "file": + old.write_text("untouched") +else: + old.mkdir() + (old / "sentinel").write_text("untouched") +os.execve(sys.argv[1], sys.argv[1:], os.environ) +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--nera", type=Path, default=Path("target/release/nera")) + parser.add_argument("--temp-parent", type=Path) + args = parser.parse_args() + if os.name != "posix": + print("SKIP: Unix ownership and permission regression") + return + compiler = args.nera.resolve() + real_cc = shutil.which("cc") + assert real_cc, "native C linker required" + with tempfile.TemporaryDirectory(prefix="nera test storage ", dir=args.temp_parent) as directory: + root = Path(directory) + root.chmod(0o700) + tools = root / "tools" + tools.mkdir() + probe = tools / "cc" + probe.write_text(LINKER.replace("#!/usr/bin/env python3", f"#!{sys.executable}", 1)) + probe.chmod(0o700) + project = root / "project" + project.mkdir() + tests = project / "tests" + tests.mkdir() + for name, body, expect in [ + ("alpha", 'io:format("storage complete~n"), ok', "storage complete"), + ("beta", 'io:format("storage complete~n"), ok', "storage complete"), + ("crash", "erlang:error(storage_failure)", "not reached"), + ("timeout", "receive after 5000 -> ok end", "not reached"), + ("mismatch", 'io:format("wrong~n"), ok', "not reached"), + ]: + (tests / f"{name}.erl").write_text( + f"%% EXPECT: {expect}\n-module({name}).\n-export([test/0]).\ntest() -> {body}.\n" + ) + (tests / "skip.erl").write_text("%% SKIP: storage fixture\n") + home = root / "home" + home.mkdir(mode=0o700) + temporary = root / "tmp" + temporary.mkdir(mode=0o700) + target = root / "planted target" + target.mkdir() + (target / "sentinel").write_text("untouched") + log = root / "link.jsonl" + env = dict(os.environ, HOME=str(home), TMPDIR=str(temporary), + PATH=str(tools) + os.pathsep + os.environ["PATH"], + CC=str(probe), REAL_CC=real_cc, STORAGE_LOG=str(log), PLANT_TARGET=str(target)) + seen = set() + + def run(label, patterns, expected_code=0, expected_links=1, base=temporary, + plant="symlink", extra_env=None, message=None): + log.write_text("") + current_env = dict(env, PLANT_KIND=plant, **(extra_env or {})) + command = [sys.executable, "-c", LAUNCH, str(compiler), "test", *patterns, + "--no-stdlib", "-j", "2", "--timeout", "1"] + # Include a permissive umask: private storage must not depend on it. + process = subprocess.Popen(command, cwd=project, env=current_env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, umask=0, start_new_session=True) + try: + out, err = process.communicate(timeout=120) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.communicate() + raise + print(f"=== {label} ===\n{out}{err}", flush=True) + assert (process.returncode == 0) == (expected_code == 0), process.returncode + if message: + assert message in out + err, out + err + records = [json.loads(line) for line in log.read_text().splitlines()] + for record in records: + print(json.dumps(record, sort_keys=True), flush=True) + old = Path(current_env["TMPDIR"]) / f"nera_tests_{process.pid}" + if plant == "symlink": + assert old.is_symlink() and old.resolve() == target + elif plant == "file": + assert old.read_text() == "untouched" + else: + assert sorted(p.name for p in old.iterdir()) == ["sentinel"] + assert sorted(p.name for p in target.iterdir()) == ["sentinel"] + assert len(records) == expected_links, records + for record in records: + path = Path(record["directory"]) + assert record["mode"] == 0o700, record + assert record["uid"] == os.geteuid(), record + assert Path(record["binary"]).parent == path, record + assert path.parent == base.resolve(), record + assert str(path) not in seen, record + seen.add(str(path)) + assert not path.exists(), f"leaked directory: {path}" + print(f"{label}: private linkage, result and cleanup verified", flush=True) + + run("parallel / planted symlink", ["alpha", "beta"], expected_links=2) + run("repeat / planted directory", ["alpha"], plant="directory") + run("planted file", ["alpha"], plant="file") + run("compile failure", ["alpha"], expected_code=1, + extra_env={"FAIL_LINK": "1"}, message="compile error") + run("runtime failure", ["crash"], expected_code=1, message="runtime crash") + run("timeout", ["timeout"], expected_code=1, message="timeout after 1s") + run("output mismatch", ["mismatch"], expected_code=1, message="output mismatch") + run("skip", ["skip"], expected_links=0, message="1 skipped") + run("list", ["alpha", "--list"], expected_links=0, message="Available tests (1)") + temporary.chmod(0o1777) + run("sticky shared root", ["alpha"]) + temporary.chmod(0o777) + run("unsafe root / private home fallback", ["alpha"], base=home) + home.chmod(0o777) + run("unsafe temp and home / fail closed", ["alpha"], expected_code=1, + expected_links=0, message="HOME") + home.chmod(0o700) + shared = root / "unsafe ancestor" + shared.mkdir(mode=0o777) + shared.chmod(0o777) + nested_home = shared / "home" + nested_home.mkdir(mode=0o700) + run("replaceable home ancestor / fail closed", ["alpha"], expected_code=1, + expected_links=0, extra_env={"HOME": str(nested_home)}, message="HOME") + temporary.chmod(0o700) + alias = root / "tmp alias" + alias.symlink_to(temporary, target_is_directory=True) + run("canonical temp path", ["alpha"], extra_env={"TMPDIR": str(alias)}) + + # A tiny local stand-in isolates EUnit storage from external OTP sources. + # This checks real compilation/execution and project-root fixture access, + # not EUnit's own assertions or scheduling. + eunit_tests = project / "eunit_tests" + eunit_tests.mkdir() + shutil.copyfile(tests / "alpha.erl", eunit_tests / "alpha.erl") + (project / "nera.toml").write_text( + '[project]\nname = "storage"\n[test]\nframework = "eunit"\n' + 'modules = ["alpha"]\nsrc = ["eunit_tests"]\n' + ) + src = project / "src" + src.mkdir() + (project / "fixture.txt").write_text("root fixture") + (src / "eunit.erl").write_text( + '-module(eunit).\n-export([test/2, stop/0]).\n' + 'test(_, _) -> {ok, <<"root fixture">>} = file:read_file("fixture.txt"), ok.\n' + 'stop() -> ok.\n' + ) + run("EUnit storage / project-root execution", ["alpha"], message="NERA_EUNIT_OK") + temporary.chmod(0o777) + run("EUnit home fallback", ["alpha"], base=home, message="NERA_EUNIT_OK") + home.chmod(0o777) + run("EUnit fail closed", ["alpha"], expected_code=1, expected_links=0, message="HOME") + print(f"Verified {len(seen)} distinct private executable directories; none leaked.") + + +if __name__ == "__main__": + main() diff --git a/tests/README.md b/tests/README.md index 844a347..a411685 100644 --- a/tests/README.md +++ b/tests/README.md @@ -34,6 +34,19 @@ The legacy shell script `./tests/run_tests.sh` is still available but `nera test is the recommended runner — it runs tests in parallel, has colored output, and produces a failure summary. +Each native test executable and its object files live in a fresh temporary +directory retained until execution finishes, then removed on success, failure or +timeout. On Unix, Nera creates it with mode 0700, regardless of the umask, under +a temporary root whose ownership and ancestry prevent replacement by other +users. A sticky shared `/tmp` is supported. If a custom temporary root is unsafe, +Nera instead creates the disposable directory directly under a protected `HOME`. +If neither location is protected, the test fails before compilation. Existing +PID-based paths are never reused, changed or removed. Tests still execute with +the project working directory so relative fixtures continue to work. + +Run `python3 scripts/test-test-storage.py` after rebuilding to check this contract +at the real native linker boundary on Unix. + ## Focused runtime contracts The [runtime contract index](../docs/RUNTIME_CONTRACTS.md) names the semantic From 2e4f9a0928d46e3b44f2f016d55e09600aa660ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Sat, 14 Nov 2026 14:49:45 +0100 Subject: [PATCH 2/3] test(test): avoid duplicate EUnit storage fixture sources --- scripts/test-test-storage.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test-test-storage.py b/scripts/test-test-storage.py index 89fed79..63b8aec 100644 --- a/scripts/test-test-storage.py +++ b/scripts/test-test-storage.py @@ -179,6 +179,9 @@ def run(label, patterns, expected_code=0, expected_links=1, base=temporary, eunit_tests = project / "eunit_tests" eunit_tests.mkdir() shutil.copyfile(tests / "alpha.erl", eunit_tests / "alpha.erl") + # --src also discovers a neighbouring tests/ by convention. Remove the + # legacy fixtures so alpha is not supplied twice to the native linker. + shutil.rmtree(tests) (project / "nera.toml").write_text( '[project]\nname = "storage"\n[test]\nframework = "eunit"\n' 'modules = ["alpha"]\nsrc = ["eunit_tests"]\n' From 614b5fb3b72857893ab4b0982d013e1eb981bcea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Sat, 14 Nov 2026 15:17:59 +0100 Subject: [PATCH 3/3] test(test): canonicalize aliased temporary fixture roots --- scripts/test-test-storage.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test-test-storage.py b/scripts/test-test-storage.py index 63b8aec..177489a 100644 --- a/scripts/test-test-storage.py +++ b/scripts/test-test-storage.py @@ -65,7 +65,9 @@ def main(): real_cc = shutil.which("cc") assert real_cc, "native C linker required" with tempfile.TemporaryDirectory(prefix="nera test storage ", dir=args.temp_parent) as directory: - root = Path(directory) + # macOS exposes temp roots through aliases such as /var -> /private/var. + # Compare canonical fixture paths with the canonical linker observations. + root = Path(directory).resolve() root.chmod(0o700) tools = root / "tools" tools.mkdir()