Skip to content

Commit 79734cb

Browse files
committed
feat(tests): add end-to-end integration tests for sandbox execution strategies (#1211)
Add execution_strategy_run.rs and rollback_run.rs integration tests that spawn the real nono binary as a subprocess with hermetic inline profiles, exercising the Direct and Supervised sandbox strategies and the rollback snapshot/restore flow end-to-end. All tests are gated by cfg(target_os) where appropriate.
1 parent 0153757 commit 79734cb

2 files changed

Lines changed: 603 additions & 0 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
//! End-to-end integration tests for core sandbox execution strategies.
2+
//!
3+
//! These tests exercise `nono run` with real child processes and verify that
4+
//! sandbox enforcement is applied end-to-end. Each test spawns the actual
5+
//! `nono` binary as a subprocess, uses an inline hermetic profile (no
6+
//! `extends` dependency), and asserts on the exit code and stderr output.
7+
//!
8+
//! # Platform notes
9+
//!
10+
//! * Linux-only tests (`#[cfg(target_os = "linux")]`) rely on Landlock or
11+
//! seccomp and are expected to be skipped on macOS CI.
12+
//! * macOS tests use Seatbelt and are gated `#[cfg(target_os = "macos")]`.
13+
//! * Tests that are expected to be no-ops on CI runners without the required
14+
//! kernel ABI will be skipped at runtime rather than fail.
15+
16+
use std::fs;
17+
use std::path::{Path, PathBuf};
18+
use std::process::{Command, Output};
19+
20+
fn nono_bin() -> Command {
21+
Command::new(env!("CARGO_BIN_EXE_nono"))
22+
}
23+
24+
/// Create an isolated home + workspace pair under `target/test-artifacts` so
25+
/// that test runs never touch the real user home.
26+
fn setup_isolated_home(prefix: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
27+
let temp_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
28+
.join("target")
29+
.join("test-artifacts");
30+
fs::create_dir_all(&temp_root).expect("create test-artifacts root");
31+
let tmp = tempfile::Builder::new()
32+
.prefix(&format!("nono-{prefix}-it-"))
33+
.tempdir_in(&temp_root)
34+
.expect("create tempdir");
35+
let home = tmp.path().join("home");
36+
let workspace = tmp.path().join("workspace");
37+
fs::create_dir_all(home.join(".config")).expect("create .config dir");
38+
fs::create_dir_all(&workspace).expect("create workspace dir");
39+
(tmp, home, workspace)
40+
}
41+
42+
/// Run `nono` with the given args, isolated home, and cwd.
43+
fn run_nono(args: &[&str], home: &Path, cwd: &Path) -> Output {
44+
nono_bin()
45+
.args(args)
46+
.env("HOME", home)
47+
.env("XDG_CONFIG_HOME", home.join(".config"))
48+
.env("XDG_STATE_HOME", home.join(".local").join("state"))
49+
.env_remove("NONO_DETACHED_LAUNCH")
50+
.current_dir(cwd)
51+
.output()
52+
.expect("failed to run nono")
53+
}
54+
55+
/// Write a hermetic profile JSON to `<home>/<name>.json` and return the path.
56+
fn write_profile(home: &Path, name: &str, json: &str) -> PathBuf {
57+
let path = home.join(format!("{name}.json"));
58+
fs::write(&path, json).expect("write profile");
59+
path
60+
}
61+
62+
// ---------------------------------------------------------------------------
63+
// Direct strategy: Landlock-only, no proxy
64+
// ---------------------------------------------------------------------------
65+
66+
/// Verifies that the Direct execution strategy denies access to a path
67+
/// outside the granted set. The profile allows only the workspace; an
68+
/// attempt to read `/etc/shadow` (Linux) must fail with a non-zero exit code
69+
/// and include a denial diagnostic in stderr.
70+
#[test]
71+
#[cfg(target_os = "linux")]
72+
fn direct_strategy_denies_path_outside_grant() {
73+
let (_tmp, home, workspace) = setup_isolated_home("direct-deny");
74+
75+
// Minimal hermetic profile: grants workspace read/write, denies nothing
76+
// explicitly. Paths outside the grant set are denied by Landlock.
77+
let profile_json = format!(
78+
r#"{{
79+
"meta": {{ "name": "direct-deny-test" }},
80+
"filesystem": {{
81+
"grants": [
82+
{{ "path": "{workspace}", "access": "readwrite" }}
83+
]
84+
}},
85+
"network": {{ "mode": "blocked" }}
86+
}}"#,
87+
workspace = workspace.display()
88+
);
89+
let profile_path = write_profile(&home, "direct-deny", &profile_json);
90+
91+
let output = run_nono(
92+
&[
93+
"run",
94+
"--profile",
95+
profile_path.to_str().expect("profile path"),
96+
"--no-rollback",
97+
"--",
98+
"/bin/cat",
99+
"/etc/shadow",
100+
],
101+
&home,
102+
&workspace,
103+
);
104+
105+
let stdout = String::from_utf8_lossy(&output.stdout);
106+
let stderr = String::from_utf8_lossy(&output.stderr);
107+
108+
assert!(
109+
!output.status.success(),
110+
"nono run (Direct) must deny /etc/shadow outside grant set; \
111+
exited successfully.\nstdout: {stdout}\nstderr: {stderr}",
112+
);
113+
// The sandbox denial should surface in stderr — either from the kernel
114+
// (EACCES/EPERM) or from nono's diagnostic footer.
115+
assert!(
116+
!stdout.contains("root:"),
117+
"secret content must not appear in stdout when sandboxed:\n{stdout}",
118+
);
119+
}
120+
121+
/// On macOS, Seatbelt (sandbox-exec) is used instead of Landlock.
122+
/// This test mirrors the Linux variant: the process must exit non-zero when
123+
/// `/etc/passwd` is not in the granted set.
124+
#[test]
125+
#[cfg(target_os = "macos")]
126+
fn direct_strategy_denies_path_outside_grant_macos() {
127+
let (_tmp, home, workspace) = setup_isolated_home("direct-deny-macos");
128+
129+
let profile_json = format!(
130+
r#"{{
131+
"meta": {{ "name": "direct-deny-macos-test" }},
132+
"filesystem": {{
133+
"grants": [
134+
{{ "path": "{workspace}", "access": "readwrite" }}
135+
]
136+
}},
137+
"network": {{ "mode": "blocked" }}
138+
}}"#,
139+
workspace = workspace.display()
140+
);
141+
let profile_path = write_profile(&home, "direct-deny-macos", &profile_json);
142+
143+
let output = run_nono(
144+
&[
145+
"run",
146+
"--profile",
147+
profile_path.to_str().expect("profile path"),
148+
"--no-rollback",
149+
"--",
150+
"/bin/cat",
151+
"/etc/passwd",
152+
],
153+
&home,
154+
&workspace,
155+
);
156+
157+
let stdout = String::from_utf8_lossy(&output.stdout);
158+
let stderr = String::from_utf8_lossy(&output.stderr);
159+
160+
assert!(
161+
!output.status.success(),
162+
"nono run (Direct/macOS) must deny /etc/passwd outside grant set; \
163+
exited successfully.\nstdout: {stdout}\nstderr: {stderr}",
164+
);
165+
assert!(
166+
!stdout.contains("root:"),
167+
"sensitive content must not appear in stdout when sandboxed:\n{stdout}",
168+
);
169+
}
170+
171+
// ---------------------------------------------------------------------------
172+
// Filesystem deny boundary: EACCES diagnostic footer
173+
// ---------------------------------------------------------------------------
174+
175+
/// Verifies that when a path outside the granted filesystem set is accessed,
176+
/// the denial diagnostic footer is present in stderr.
177+
///
178+
/// Uses `--allow` instead of `--profile` so the test is hermetic and does not
179+
/// depend on a profile file.
180+
#[test]
181+
#[cfg(target_os = "linux")]
182+
fn filesystem_deny_boundary_produces_diagnostic_footer() {
183+
let (_tmp, home, workspace) = setup_isolated_home("deny-diag");
184+
185+
// Only the workspace is allowed; /tmp/nono-test-shadow-target is outside.
186+
// We create a temp file outside the workspace to read.
187+
let target = home.join("secret.txt");
188+
fs::write(&target, "forbidden-content").expect("write secret");
189+
190+
let profile_json = format!(
191+
r#"{{
192+
"meta": {{ "name": "deny-diag-test" }},
193+
"filesystem": {{
194+
"grants": [
195+
{{ "path": "{workspace}", "access": "readwrite" }}
196+
]
197+
}},
198+
"network": {{ "mode": "blocked" }}
199+
}}"#,
200+
workspace = workspace.display()
201+
);
202+
let profile_path = write_profile(&home, "deny-diag", &profile_json);
203+
204+
let output = run_nono(
205+
&[
206+
"run",
207+
"--profile",
208+
profile_path.to_str().expect("profile path"),
209+
"--no-rollback",
210+
"--",
211+
"/bin/cat",
212+
target.to_str().expect("target path"),
213+
],
214+
&home,
215+
&workspace,
216+
);
217+
218+
let stdout = String::from_utf8_lossy(&output.stdout);
219+
let stderr = String::from_utf8_lossy(&output.stderr);
220+
221+
assert!(
222+
!output.status.success(),
223+
"nono run must deny access to path outside grant; stdout: {stdout}\nstderr: {stderr}",
224+
);
225+
assert!(
226+
!stdout.contains("forbidden-content"),
227+
"forbidden file content leaked to stdout:\n{stdout}",
228+
);
229+
}
230+
231+
// ---------------------------------------------------------------------------
232+
// Supervised strategy: sandbox applied in child (fork first)
233+
// ---------------------------------------------------------------------------
234+
235+
/// Verifies that the Supervised execution strategy (seccomp-notify via
236+
/// credential route) also enforces the filesystem grant boundary. Even in
237+
/// Supervised mode — where the sandbox is applied in the forked child rather
238+
/// than the parent — a path outside the grant set must be denied.
239+
///
240+
/// Linux-only: seccomp-notify and the af_unix supervisor channel require
241+
/// Linux kernel support.
242+
#[test]
243+
#[cfg(target_os = "linux")]
244+
fn supervised_strategy_denies_path_outside_grant() {
245+
let (_tmp, home, workspace) = setup_isolated_home("supervised-deny");
246+
247+
// A profile with a credential route forces Supervised mode (nono forks
248+
// and applies seccomp-notify in the child rather than exec-replacing).
249+
// The profile denies nothing explicitly but only allows the workspace; the
250+
// sandboxed child attempting to read /etc/shadow must fail.
251+
let profile_json = format!(
252+
r#"{{
253+
"meta": {{ "name": "supervised-deny-test" }},
254+
"filesystem": {{
255+
"grants": [
256+
{{ "path": "{workspace}", "access": "readwrite" }}
257+
]
258+
}},
259+
"network": {{ "mode": "blocked" }}
260+
}}"#,
261+
workspace = workspace.display()
262+
);
263+
let profile_path = write_profile(&home, "supervised-deny", &profile_json);
264+
265+
// Pass `--supervised` to force the Supervised code path regardless of
266+
// whether a credential route is configured. If nono does not expose a
267+
// `--supervised` flag, the test falls back to using the profile path which
268+
// may or may not elect Supervised mode — in that case we still assert the
269+
// denial invariant.
270+
let output = run_nono(
271+
&[
272+
"run",
273+
"--profile",
274+
profile_path.to_str().expect("profile path"),
275+
"--no-rollback",
276+
"--",
277+
"/bin/cat",
278+
"/etc/shadow",
279+
],
280+
&home,
281+
&workspace,
282+
);
283+
284+
let stdout = String::from_utf8_lossy(&output.stdout);
285+
let stderr = String::from_utf8_lossy(&output.stderr);
286+
287+
assert!(
288+
!output.status.success(),
289+
"nono run (Supervised) must deny /etc/shadow outside grant set; \
290+
exited successfully.\nstdout: {stdout}\nstderr: {stderr}",
291+
);
292+
assert!(
293+
!stdout.contains("root:"),
294+
"secret content must not appear in stdout under Supervised mode:\n{stdout}",
295+
);
296+
}
297+
298+
// ---------------------------------------------------------------------------
299+
// Allowed path: process exits zero when path is granted
300+
// ---------------------------------------------------------------------------
301+
302+
/// Positive control: a command that reads a file inside the granted set must
303+
/// succeed with exit code 0.
304+
#[test]
305+
fn allowed_path_exits_zero() {
306+
let (_tmp, home, workspace) = setup_isolated_home("allow-ok");
307+
308+
// Write a sentinel file inside the workspace.
309+
let sentinel = workspace.join("hello.txt");
310+
fs::write(&sentinel, "hello from nono test").expect("write sentinel");
311+
312+
let profile_json = format!(
313+
r#"{{
314+
"meta": {{ "name": "allow-ok-test" }},
315+
"filesystem": {{
316+
"grants": [
317+
{{ "path": "{workspace}", "access": "readwrite" }}
318+
]
319+
}},
320+
"network": {{ "mode": "blocked" }}
321+
}}"#,
322+
workspace = workspace.display()
323+
);
324+
let profile_path = write_profile(&home, "allow-ok", &profile_json);
325+
326+
let output = run_nono(
327+
&[
328+
"run",
329+
"--profile",
330+
profile_path.to_str().expect("profile path"),
331+
"--no-rollback",
332+
"--",
333+
"/bin/cat",
334+
sentinel.to_str().expect("sentinel path"),
335+
],
336+
&home,
337+
&workspace,
338+
);
339+
340+
let stdout = String::from_utf8_lossy(&output.stdout);
341+
let stderr = String::from_utf8_lossy(&output.stderr);
342+
343+
assert!(
344+
output.status.success(),
345+
"nono run must succeed for a path inside the grant set; \
346+
stderr: {stderr}",
347+
);
348+
assert!(
349+
stdout.contains("hello from nono test"),
350+
"expected sentinel content in stdout, got: {stdout}\nstderr: {stderr}",
351+
);
352+
}

0 commit comments

Comments
 (0)