1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
//! Cases for the namespace handover that can be read in-process.
//!
//! Kept beside the backend cases because they need the same Linux policy
//! fixtures, and separate from them because this one asserts a different kind
//! of claim: it names a step that fails silently and carries the control that
//! proves it can fail.
//!
//! The other half of the handover cannot be read here at all. Whether the
//! ruleset descriptor survives the `exec` is only answerable from the far
//! side of one, so that case runs a real helper process and lives in
//! `tests/harn_vm/namespace_handover.rs`.
use super::tests::linux_policy_with_workspace_ops;
use super::*;
/// A loopback grant admits the socket calls, and cannot do so outside a
/// namespace.
///
/// Both halves are the claim. Without the first, the grant is worth nothing:
/// a filter with no `socket` term refuses loopback exactly as hard as it
/// refuses the internet, so the build tool the grant was opened for still
/// cannot start while every layer above reports a working sandbox — a
/// boundary that refuses everything reads as a boundary that works.
///
/// Without the second, the first is a hole. Those terms carry no address
/// condition, so on the host network they would admit an outbound connection
/// anywhere. What makes them safe is that the child has no route off the
/// host, and the only thing guaranteeing that is the namespace. So the test
/// asserts the widened filter is unreachable without one: the same grant with
/// no helper to build the namespace is refused, rather than rendered with the
/// wider filter and no boundary.
#[test]
fn a_loopback_grant_admits_sockets_only_where_a_namespace_bounds_them() {
let mut policy = linux_policy_with_workspace_ops(&["read_text"]);
policy.side_effect_level = Some("process_exec".to_string());
assert!(
!allowed_syscalls(&policy).contains(&libc::SYS_socket),
"the premise is that this policy admits no socket calls before the grant",
);
policy.process_sandbox.allow_tcp_loopback = true;
policy.process_sandbox.netns_launcher_path = Some("/opt/example/launch".to_string());
let granted = allowed_syscalls(&policy);
for syscall in [
libc::SYS_socket,
libc::SYS_bind,
libc::SYS_listen,
libc::SYS_connect,
libc::SYS_accept,
] {
assert!(
granted.contains(&syscall),
"a loopback grant must admit the calls a loopback client needs",
);
}
// NEGATIVE CONTROL: the same grant with nothing to build the namespace.
// It must be refused, because the filter it would otherwise be rendered
// with places no limit on where a socket may reach.
let mut unnamespaced = policy;
unnamespaced.process_sandbox.netns_launcher_path = None;
resolve_netns_launcher(&unnamespaced)
.expect_err("a loopback grant with no namespace helper must be refused, never widened");
}
/// The private `/tmp` mount hides every host path under `/tmp`, so a child
/// granted anything there keeps the host's: its workspace, or the program it
/// was spawned to run.
#[test]
fn a_grant_under_tmp_keeps_the_host_tmp() {
if !super::tests::live_landlock_available("a_grant_under_tmp_keeps_the_host_tmp") {
return;
}
// Tests that point `HOME` under `/tmp` hold this lock; HOME-derived grants
// would otherwise land under `/tmp` mid-run.
let _env_lock = crate::runtime_paths::test_env_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let support = landlock_support::host_support().expect("Landlock support");
let home = PathBuf::from(std::env::var_os("HOME").expect("HOME"));
let under_tmp = tempfile::tempdir_in("/tmp").unwrap();
let tmp_program = under_tmp.path().join("tool");
std::fs::copy("/bin/true", &tmp_program).unwrap();
let cases = [
(home.clone(), PathBuf::from("/bin/true"), true),
(PathBuf::from("/tmp"), PathBuf::from("/bin/true"), false),
(home, tmp_program, false),
];
for (parent, program, mounted) in cases {
let workspace = tempfile::tempdir_in(&parent).unwrap();
let mut policy = linux_policy_with_workspace_ops(&["read_text", "write_text"]);
policy.workspace_roots = vec![workspace.path().display().to_string()];
policy.process_sandbox.allow_tcp_loopback = true;
let profile = landlock_profile_for(&program.display().to_string(), &policy, support)
.expect("profile");
assert_eq!(
profile.private_tmp.is_some(),
mounted,
"workspace under {}, program {}",
parent.display(),
program.display()
);
}
}