Skip to content

Commit 05958fd

Browse files
committed
test(policy_fn): strengthen Rust integration tests + add missing enforcement coverage
The Rust policy_fn integration tests had the same false-passing patterns the Python suite did, and lacked coverage for the bugs just fixed: - test_policy_fn_deny_connect connected to a dead port and accepted any error as "blocked". Now targets a live allowlisted listener and asserts EPERM (errno 1) from the policy_fn deny specifically. - test_policy_fn_restrict_network_takes_effect used restrict_network(&[]) (a no-op) and a dead port. Now uses two live loopback listeners and a non-empty restriction, asserting the listed IP connects and the non-listed one is refused (errno 111). - test_policy_fn_restrict_pid_network_without_allowlist connected to a dead port. Now uses a live listener so the refusal can only come from the per-pid restriction. New tests covering behavior no Rust test exercised (so the no-op / deadlock bugs would have gone uncaught): - test_policy_fn_restrict_max_memory_enforced — 256 MiB ceiling, restrict to 64 MiB, assert a 128 MiB alloc is killed while the control succeeds. - test_policy_fn_restrict_max_processes_enforced — restrict to 1, assert the fork is denied with EAGAIN while the control forks. - test_policy_fn_fork_does_not_deadlock — 30 forks under an active policy_fn complete within a bounded timeout (fork-tracking regression). The enforcement tests use run() (captured stdout) rather than run_interactive() (inherited stdio). Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 9df56bd commit 05958fd

1 file changed

Lines changed: 207 additions & 68 deletions

File tree

crates/sandlock-core/tests/integration/test_policy_fn.rs

Lines changed: 207 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
use sandlock_core::{Sandbox};
22
use sandlock_core::policy_fn::{Verdict, SyscallCategory};
3+
use sandlock_core::sandbox::ByteSize;
4+
use std::net::{IpAddr, TcpListener};
35
use std::path::PathBuf;
46
use std::sync::{Arc, Mutex};
7+
use std::time::Duration;
58

69
fn temp_file(name: &str) -> PathBuf {
710
std::env::temp_dir().join(format!("sandlock-test-policyfn-{}-{}", name, std::process::id()))
811
}
912

13+
/// A live TCP listener on `host:ephemeral`. Connecting to it *succeeds* when
14+
/// allowed, so a deny-test can tell a real block from a connection that would
15+
/// have failed anyway (e.g. ECONNREFUSED to a dead port).
16+
fn loopback_listener(host: &str) -> (TcpListener, u16) {
17+
let l = TcpListener::bind((host, 0)).expect("bind loopback listener");
18+
let port = l.local_addr().unwrap().port();
19+
(l, port)
20+
}
21+
1022
fn base_policy() -> sandlock_core::SandboxBuilder {
1123
Sandbox::builder()
1224
.fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin")
@@ -42,17 +54,21 @@ async fn test_policy_fn_receives_events_with_metadata() {
4254
"should include execve, got: {:?}", &captured[..captured.len().min(5)]);
4355
}
4456

45-
/// Test that Verdict::Deny blocks a connect syscall.
57+
/// Verdict::Deny blocks a connect syscall (with EPERM), attributable to the
58+
/// callback. The previous version connected to a dead port (127.0.0.1:1) and
59+
/// accepted any error as "blocked", so it passed even if the deny did nothing.
60+
/// Target a live listener on an allowlisted port: Landlock permits it and the
61+
/// listener would accept it, so the EPERM can only come from the policy_fn.
4662
#[tokio::test]
4763
async fn test_policy_fn_deny_connect() {
4864
let out = temp_file("deny-connect");
65+
let (_listener, port) = loopback_listener("127.0.0.1");
4966

5067
let policy = base_policy()
51-
.net_allow("127.0.0.1:443")
68+
.net_allow(format!("127.0.0.1:{port}"))
5269
.policy_fn(move |event, _ctx| {
53-
// Deny all connect attempts
5470
if event.syscall == "connect" {
55-
return Verdict::Deny;
71+
return Verdict::Deny; // EPERM
5672
}
5773
Verdict::Allow
5874
})
@@ -61,35 +77,43 @@ async fn test_policy_fn_deny_connect() {
6177

6278
let script = format!(concat!(
6379
"import socket\n",
80+
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
81+
"s.settimeout(3)\n",
6482
"try:\n",
65-
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
66-
" s.settimeout(1)\n",
67-
" s.connect(('127.0.0.1', 1))\n",
68-
" s.close()\n",
83+
" s.connect(('127.0.0.1', {port}))\n",
6984
" open('{out}', 'w').write('CONNECTED')\n",
70-
"except (ConnectionRefusedError, PermissionError, OSError) as e:\n",
71-
" open('{out}', 'w').write(f'BLOCKED:{{e.errno}}')\n",
72-
), out = out.display());
85+
"except OSError as e:\n",
86+
" open('{out}', 'w').write('BLOCKED:%d' % e.errno)\n",
87+
), port = port, out = out.display());
7388

7489
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap();
7590
assert!(result.success());
7691

7792
let content = std::fs::read_to_string(&out).unwrap_or_default();
78-
assert!(content.starts_with("BLOCKED"), "connect should be denied, got: {}", content);
79-
8093
let _ = std::fs::remove_file(&out);
94+
// EPERM (1) from the policy_fn deny — not a dead-port ECONNREFUSED.
95+
assert_eq!(content, "BLOCKED:1", "connect should be denied by policy_fn (EPERM)");
8196
}
8297

83-
/// Test restrict_network feedback loop — changes actually take effect.
98+
/// restrict_network narrows outbound to the listed IPs and is enforced. The
99+
/// previous version called `restrict_network(&[])` — an empty list is a no-op —
100+
/// and connected to a dead port, so it verified nothing. Use two live loopback
101+
/// listeners (127.0.0.1 and 127.0.0.2), both allowlisted up front so either
102+
/// would connect; restricting to ["127.0.0.1"] must then permit the first and
103+
/// refuse the second (ECONNREFUSED, errno 111).
84104
#[tokio::test]
85105
async fn test_policy_fn_restrict_network_takes_effect() {
86106
let out = temp_file("restrict-net-effect");
107+
let (_l1, p1) = loopback_listener("127.0.0.1");
108+
let (_l2, p2) = loopback_listener("127.0.0.2");
87109

110+
let allowed: Vec<IpAddr> = vec!["127.0.0.1".parse().unwrap()];
88111
let policy = base_policy()
89-
.net_allow("127.0.0.1:443")
112+
.net_allow(format!("127.0.0.1:{p1}"))
113+
.net_allow(format!("127.0.0.2:{p2}"))
90114
.policy_fn(move |event, ctx| {
91115
if event.syscall == "execve" {
92-
ctx.restrict_network(&[]); // block all
116+
ctx.restrict_network(&allowed); // narrow to 127.0.0.1
93117
}
94118
Verdict::Allow
95119
})
@@ -98,30 +122,22 @@ async fn test_policy_fn_restrict_network_takes_effect() {
98122

99123
let script = format!(concat!(
100124
"import socket\n",
101-
"try:\n",
102-
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
103-
" s.settimeout(1)\n",
104-
" s.connect(('127.0.0.1', 1))\n",
105-
" s.close()\n",
106-
" open('{out}', 'w').write('CONNECTED')\n",
107-
"except ConnectionRefusedError:\n",
108-
" open('{out}', 'w').write('REFUSED')\n",
109-
"except (PermissionError, OSError) as e:\n",
110-
" open('{out}', 'w').write(f'BLOCKED:{{e.errno}}')\n",
111-
), out = out.display());
125+
"def probe(ip, port):\n",
126+
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(3)\n",
127+
" try:\n",
128+
" s.connect((ip, port)); return 'OK'\n",
129+
" except OSError as e: return 'ERR%d' % e.errno\n",
130+
" finally: s.close()\n",
131+
"open('{out}', 'w').write('allowed=' + probe('127.0.0.1', {p1}) + ' denied=' + probe('127.0.0.2', {p2}))\n",
132+
), out = out.display(), p1 = p1, p2 = p2);
112133

113134
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap();
114135
assert!(result.success());
115136

116137
let content = std::fs::read_to_string(&out).unwrap_or_default();
117-
// After restrict_network([]), connect to 127.0.0.1 should be blocked
118-
// May show as BLOCKED (EPERM) or REFUSED (ECONNREFUSED from our handler)
119-
assert!(content.starts_with("BLOCKED") || content.starts_with("REFUSED"),
120-
"network should be restricted, got: {}", content);
121-
// It should NOT be CONNECTED
122-
assert!(!content.starts_with("CONNECTED"), "network restrict should prevent connection");
123-
124138
let _ = std::fs::remove_file(&out);
139+
assert!(content.contains("allowed=OK"), "listed IP should still connect, got: {}", content);
140+
assert!(content.contains("denied=ERR111"), "non-listed IP should be refused, got: {}", content);
125141
}
126142

127143
/// Test deny_path blocks filesystem access dynamically.
@@ -395,17 +411,21 @@ async fn test_policy_fn_audit() {
395411
assert!(!captured.is_empty(), "should have audited file events");
396412
}
397413

398-
/// Test that restrict_pid_network works even without net_allow_hosts.
399-
/// This verifies that policy_fn alone enables network syscall interception.
414+
/// restrict_pid_network blocks a pid's outbound even without a net_allow
415+
/// allowlist (policy_fn alone enables network interception). The previous
416+
/// version connected to a dead port and accepted any error, so it passed even
417+
/// if nothing was restricted. Target a *live* listener with no net_allow: the
418+
/// connect would succeed (network unrestricted by default under policy_fn), so
419+
/// the refusal can only come from restrict_pid_network([]).
400420
#[tokio::test]
401421
async fn test_policy_fn_restrict_pid_network_without_allowlist() {
402-
// No net_allow_hosts — network should be unrestricted by default,
403-
// but policy_fn can still restrict specific PIDs.
422+
let out = temp_file("restrict-pid-net");
423+
let (_listener, port) = loopback_listener("127.0.0.1");
424+
425+
// No net_allow: outbound is otherwise unrestricted; the callback restricts
426+
// the exec'd pid's network to the empty set (deny all) on execve.
404427
let policy = base_policy()
405428
.policy_fn(move |event, ctx| {
406-
// On any execve, restrict that PID's network to nothing.
407-
// (Previously gated on path-substring; path strings were
408-
// dropped from events for TOCTOU reasons — issue #27.)
409429
if event.syscall == "execve" {
410430
ctx.restrict_pid_network(event.pid, &[]);
411431
}
@@ -414,36 +434,155 @@ async fn test_policy_fn_restrict_pid_network_without_allowlist() {
414434
.build()
415435
.unwrap();
416436

417-
// Create a script that attempts to connect to localhost
418-
let script = temp_file("connect_test");
419-
std::fs::write(&script, r#"#!/bin/sh
420-
exec python3 -c "
421-
import socket, sys
422-
try:
423-
s = socket.create_connection(('127.0.0.1', 1), timeout=2)
424-
s.close()
425-
except ConnectionRefusedError:
426-
print('REFUSED')
427-
sys.exit(0)
428-
except OSError as e:
429-
print(f'BLOCKED: {e}')
430-
sys.exit(0)
431-
print('CONNECTED')
432-
sys.exit(1)
433-
"
434-
"#).unwrap();
435-
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
436-
437-
let result = policy.clone().with_name("test").run(&[script.to_str().unwrap()])
437+
let script = format!(concat!(
438+
"import socket\n",
439+
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(3)\n",
440+
"try:\n",
441+
" s.connect(('127.0.0.1', {port}))\n",
442+
" open('{out}', 'w').write('CONNECTED')\n",
443+
"except OSError as e:\n",
444+
" open('{out}', 'w').write('ERR%d' % e.errno)\n",
445+
), port = port, out = out.display());
446+
447+
let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap();
448+
assert!(result.success());
449+
450+
let content = std::fs::read_to_string(&out).unwrap_or_default();
451+
let _ = std::fs::remove_file(&out);
452+
// The listener is live, so a successful connect would read "CONNECTED";
453+
// ERR111 (ECONNREFUSED from the on-behalf deny) can only come from the
454+
// per-pid restriction.
455+
assert_eq!(content, "ERR111", "restrict_pid_network([]) must deny the connect");
456+
}
457+
458+
// ---------------------------------------------------------------------------
459+
// Dynamic resource-limit enforcement + fork-tracking regression
460+
// ---------------------------------------------------------------------------
461+
462+
/// restrict_max_memory tightens the static ceiling and is enforced. Set a
463+
/// 256 MiB ceiling, restrict to 64 MiB on execve, then allocate 128 MiB: the
464+
/// process is killed. The control (same ceiling, no restriction) allocates the
465+
/// same 128 MiB fine — proving the kill is the dynamic limit, not the ceiling.
466+
#[tokio::test]
467+
async fn test_policy_fn_restrict_max_memory_enforced() {
468+
let alloc_128 = concat!(
469+
"import sys\n",
470+
"print('STARTED', flush=True)\n",
471+
"b = bytearray(128 * 1024 * 1024)\n",
472+
"b[::4096] = b'\\x01' * (len(b) // 4096)\n", // commit the pages
473+
"print('ALLOC_OK')\n",
474+
);
475+
476+
let restricted = base_policy()
477+
.max_memory(ByteSize::mib(256))
478+
.policy_fn(|event, ctx| {
479+
if event.syscall == "execve" {
480+
ctx.restrict_max_memory(64 * 1024 * 1024);
481+
}
482+
Verdict::Allow
483+
})
484+
.build()
485+
.unwrap()
486+
.with_name("test")
487+
.run(&["python3", "-c", alloc_128])
488+
.await
489+
.unwrap();
490+
let out = String::from_utf8_lossy(restricted.stdout.as_deref().unwrap_or(b""));
491+
assert!(out.contains("STARTED"), "should start, got: {}", out);
492+
assert!(!out.contains("ALLOC_OK"), "128 MiB must exceed the 64 MiB dynamic limit, got: {}", out);
493+
assert!(!restricted.success(), "process should be killed by the memory limit");
494+
495+
let baseline = base_policy()
496+
.max_memory(ByteSize::mib(256))
497+
.policy_fn(|_e, _c| Verdict::Allow)
498+
.build()
499+
.unwrap()
500+
.with_name("test")
501+
.run(&["python3", "-c", alloc_128])
502+
.await
503+
.unwrap();
504+
let out = String::from_utf8_lossy(baseline.stdout.as_deref().unwrap_or(b""));
505+
assert!(out.contains("ALLOC_OK"), "128 MiB under the 256 MiB ceiling should succeed, got: {}", out);
506+
assert!(baseline.success());
507+
}
508+
509+
/// restrict_max_processes tightens the concurrent-process limit and is
510+
/// enforced. Restrict to 1, then fork: the fork is denied with EAGAIN. The
511+
/// control (no restriction) forks successfully.
512+
#[tokio::test]
513+
async fn test_policy_fn_restrict_max_processes_enforced() {
514+
let fork_once = concat!(
515+
"import os\n",
516+
"print('STARTED', flush=True)\n",
517+
"try:\n",
518+
" pid = os.fork()\n",
519+
" if pid == 0: os._exit(0)\n",
520+
" os.waitpid(pid, 0); print('FORK_OK')\n",
521+
"except OSError as e: print('FORK_DENIED', e.errno)\n",
522+
);
523+
524+
let restricted = base_policy()
525+
.policy_fn(|event, ctx| {
526+
if event.syscall == "execve" {
527+
ctx.restrict_max_processes(1);
528+
}
529+
Verdict::Allow
530+
})
531+
.build()
532+
.unwrap()
533+
.with_name("test")
534+
.run(&["python3", "-c", fork_once])
535+
.await
536+
.unwrap();
537+
let out = String::from_utf8_lossy(restricted.stdout.as_deref().unwrap_or(b""));
538+
assert!(out.contains("STARTED"), "should start, got: {}", out);
539+
assert!(!out.contains("FORK_OK"), "fork must be denied under the limit, got: {}", out);
540+
assert!(out.contains("FORK_DENIED 11"), "fork should be denied with EAGAIN, got: {}", out);
541+
542+
let baseline = base_policy()
543+
.policy_fn(|_e, _c| Verdict::Allow)
544+
.build()
545+
.unwrap()
546+
.with_name("test")
547+
.run(&["python3", "-c", fork_once])
438548
.await
439549
.unwrap();
550+
let out = String::from_utf8_lossy(baseline.stdout.as_deref().unwrap_or(b""));
551+
assert!(out.contains("FORK_OK"), "unrestricted fork should succeed, got: {}", out);
552+
}
440553

441-
let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or(b""));
442-
// Connection should be refused (restricted by policy_fn)
443-
assert!(
444-
stdout.contains("REFUSED") || stdout.contains("BLOCKED"),
445-
"Expected connection to be blocked, got: {}", stdout,
554+
/// Regression: a workload that forks under an active policy_fn must not
555+
/// deadlock the supervisor's fork-event ptrace tracking. Fork many times in one
556+
/// run and require it to complete (bounded so a regression fails instead of
557+
/// hanging the suite forever).
558+
#[tokio::test]
559+
async fn test_policy_fn_fork_does_not_deadlock() {
560+
let many_forks = concat!(
561+
"import os\n",
562+
"ok = 0\n",
563+
"for _ in range(30):\n",
564+
" pid = os.fork()\n",
565+
" if pid == 0: os._exit(0)\n",
566+
" os.waitpid(pid, 0); ok += 1\n",
567+
"print('FORKS_OK', ok)\n",
446568
);
447569

448-
let _ = std::fs::remove_file(&script);
570+
let mut sb = base_policy()
571+
// count events so the fork path is exercised under an active callback
572+
.policy_fn(|_e, _c| Verdict::Allow)
573+
.build()
574+
.unwrap()
575+
.with_name("test");
576+
577+
let result = tokio::time::timeout(
578+
Duration::from_secs(30),
579+
sb.run(&["python3", "-c", many_forks]),
580+
)
581+
.await
582+
.expect("policy_fn + fork() must not deadlock")
583+
.unwrap();
584+
585+
assert!(result.success(), "run should complete");
586+
let out = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or(b""));
587+
assert!(out.contains("FORKS_OK 30"), "all 30 forks should complete, got: {}", out);
449588
}

0 commit comments

Comments
 (0)