Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion crates/nera/src/eunit_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/nera/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod test_runner;
mod eunit_runner;
mod module_info;
mod runtime_cache;
mod test_storage;

// ── Embedded stdlib ──────────────────────────────────────────────────

Expand Down
4 changes: 2 additions & 2 deletions crates/nera/src/runtime_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
pub(crate) fn protected_base(base: &Path, uid: u32) -> std::io::Result<PathBuf> {
let base = base.canonicalize()?;
protected_ancestors(&base, uid, None, "TMPDIR")?;
Ok(base)
Expand Down Expand Up @@ -166,7 +166,7 @@ fn kernel_overflow_uid() -> Option<u32> {

/// The configured account home must protect its cache entries from replacement.
#[cfg(unix)]
fn home_base(home: &Path, uid: u32) -> std::io::Result<PathBuf> {
pub(crate) fn home_base(home: &Path, uid: u32) -> std::io::Result<PathBuf> {
use std::os::unix::fs::MetadataExt;

if !home.is_absolute() {
Expand Down
78 changes: 60 additions & 18 deletions crates/nera/src/test_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,6 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box<dyn std::error::Error>> {
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
Expand All @@ -114,7 +111,6 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box<dyn std::error::Error>> {
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;
Expand All @@ -124,7 +120,7 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box<dyn std::error::Error>> {
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;
Expand Down Expand Up @@ -203,7 +199,6 @@ pub fn run_tests(opts: &TestOpts) -> Result<(), Box<dyn std::error::Error>> {
}

println!();
let _ = std::fs::remove_dir_all(&tmp_dir);

if failed > 0 {
println!("{}{}SOME TESTS FAILED{}", c.red, c.bold, c.reset);
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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'
Expand All @@ -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");
Expand All @@ -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();
Expand Down
157 changes: 157 additions & 0 deletions crates/nera/src/test_storage.rs
Original file line number Diff line number Diff line change
@@ -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<tempfile::TempDir> {
#[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<tempfile::TempDir> {
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<tempfile::TempDir> {
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()
);
}
}
Loading
Loading