11use sandlock_core:: { Sandbox } ;
22use sandlock_core:: policy_fn:: { Verdict , SyscallCategory } ;
3+ use sandlock_core:: sandbox:: ByteSize ;
4+ use std:: net:: { IpAddr , TcpListener } ;
35use std:: path:: PathBuf ;
46use std:: sync:: { Arc , Mutex } ;
7+ use std:: time:: Duration ;
58
69fn 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+
1022fn 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]
4763async 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]
85105async 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]
401421async 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