Skip to main content

speccoder_runtime/sandbox/
procage.rs

1//! speccoder's sandbox, confined by [procage](https://github.com/hupe1980/procage).
2//!
3//! A [`Profile`] becomes a procage policy ([`policy`]). Commands that run to
4//! completion are spawned in this process ([`run`]); long-running servers
5//! start through the helper (`speccoder __sandbox <profile> -- argv…`), which
6//! confines itself and becomes the server ([`exec`]), so whatever kills the
7//! helper kills the server.
8//!
9//! Where procage cannot enforce part of a profile on this host (Linux
10//! without user namespaces, or with a kernel older than 6.12), [`fit`]
11//! leaves exactly that part out and says so.
12//!
13//! [`run`] reports what the cage denies; after a failed command the record
14//! becomes the outcome's denials. The helper's exec reports nothing (it
15//! becomes the server, which has no record).
16
17use std::os::unix::ffi::OsStrExt as _;
18use std::path::Path;
19use std::process::{Command, Stdio};
20use std::time::{Instant, SystemTime};
21
22use procage::native::{Cage, CageOptions, Grants};
23use procage::policy::{
24    AbsPath, Access, Base, EnvPolicy, Exec, FsRule, FsTarget, HardDenyClass, Lift, LowerOptions,
25    Network as Net, Policy, Ports, Relax, Relaxation, Relaxed, Signals,
26};
27
28use super::{Network, Profile};
29use crate::verify::process::{Captured, Invocation, Outcome, supervise};
30
31fn abs(p: &Path) -> Result<AbsPath, String> {
32    AbsPath::new(p.as_os_str().as_bytes()).map_err(|e| format!("{}: {e}", p.display()))
33}
34
35/// `profile` as a procage policy.
36///
37/// # Errors
38///
39/// A path procage cannot take (not absolute, not normalised).
40pub fn policy(profile: &Profile) -> Result<Policy, String> {
41    let mut p = Policy::default();
42    // Version 3 runs developer tools (Xcode's shims, Homebrew), keeps them
43    // on the private `TMPDIR`, and keeps the probes every program makes
44    // (logging, DTrace, the terminal) out of the record of denials; it
45    // allows nothing version 2 does not.
46    p.base = Base::V3;
47    p.process.exec = Exec::Any;
48    p.process.fork = true;
49    p.process.signals = if profile.unenforced.signal_scope {
50        Signals::SameUser
51    } else {
52        Signals::SelfAndChildren
53    };
54    // Exactly the variables set on the command (speccoder scrubs them).
55    p.env = EnvPolicy::Allowlist(Vec::new());
56    // Reads as wide as before: all of the file system except hidden trees.
57    p.fs.push(FsRule::new(
58        Access::Read,
59        FsTarget::Subpath(abs(Path::new("/"))?),
60    ));
61    // A rule on a path that does not exist fails the cage on Linux; missing
62    // trees (caches not created yet) are skipped.
63    let existing = |paths: &[std::path::PathBuf]| -> Result<Vec<AbsPath>, String> {
64        paths
65            .iter()
66            .filter(|p| p.exists())
67            .map(|p| abs(p))
68            .collect()
69    };
70    let mut writable = profile.writable.clone();
71    writable.push(profile.tmp.clone());
72    let writable = existing(&writable)?;
73    for w in &writable {
74        p.fs.push(FsRule::new(Access::Write, FsTarget::Subpath(w.clone())));
75    }
76    for r in existing(&profile.read_only)? {
77        p.fs.push(FsRule::new(Access::Protect, FsTarget::Subpath(r)));
78    }
79    for h in existing(&profile.hidden)? {
80        p.fs.push(FsRule::new(Access::Hide, FsTarget::Subpath(h)));
81    }
82    // Test servers and databases in the writable trees talk over Unix
83    // sockets; sockets elsewhere (a container engine's) stay out, with the
84    // network on too.
85    if !profile.unenforced.workspace_sockets {
86        p.ipc.unix_sockets = writable;
87    }
88    if profile.unenforced.trust_handoff {
89        p.waive.insert(HardDenyClass::TrustHandoff);
90    }
91    p.net = match profile.network {
92        Network::Off => Net::None,
93        // On Linux loopback is no network: seccomp cannot tell addresses apart.
94        Network::Loopback if cfg!(target_os = "linux") => Net::None,
95        Network::Loopback => Net::Loopback(Ports::Any),
96        // IP only: Unix sockets as above, so the container engines and D-Bus
97        // stay out on tier T2 too.
98        Network::On => Net::Ip,
99    };
100    Ok(p)
101}
102
103/// The cage for `profile`. The approximations procage makes from the file
104/// system as it is at spawn (masks on Linux with user namespaces, piecewise
105/// hiding without them) are accepted, and see [`options`].
106fn cage(profile: &Profile) -> Result<Cage, procage::native::SpawnError> {
107    cage_at(profile, None)
108}
109
110/// [`cage`] as on a Linux kernel whose Landlock is at most ABI `cap` (tests).
111fn cage_at(profile: &Profile, cap: Option<u8>) -> Result<Cage, procage::native::SpawnError> {
112    cage_with(profile, &options(profile, cap))
113}
114
115/// The cage for `profile` with `options`.
116fn cage_with(
117    profile: &Profile,
118    options: &CageOptions,
119) -> Result<Cage, procage::native::SpawnError> {
120    let policy = policy(profile).map_err(|e| {
121        procage::native::SpawnError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
122    })?;
123    Cage::with_options(&policy, options)
124}
125
126/// The cage options: spawn-snapshot approximations and other processes'
127/// arguments under `/proc` (Linux, no PID namespace) accepted, a protected
128/// repository's object store out of the link check (a local `git clone`
129/// hard-links it to the source's; objects are never rewritten in place), the
130/// Landlock ABI capped at `cap` on Linux (tests).
131fn options(profile: &Profile, cap: Option<u8>) -> CageOptions {
132    let mut options = CageOptions::default().with_lower(
133        LowerOptions::default()
134            .accept_spawn_snapshot()
135            .allow(procage::policy::Construct::ProcOtherProcesses),
136    );
137    for objects in profile
138        .read_only
139        .iter()
140        .filter(|r| r.ends_with(".git"))
141        .map(|r| r.join("objects"))
142        .filter(|o| o.is_dir())
143    {
144        options = options.with_link_exemption(objects);
145    }
146    #[cfg(target_os = "linux")]
147    let options = match cap {
148        Some(abi) => options.with_landlock_abi(abi),
149        None => options,
150    };
151    #[cfg(not(target_os = "linux"))]
152    let _ = cap;
153    options
154}
155
156/// The Landlock ABI `cage` enforces (Linux).
157#[cfg(target_os = "linux")]
158fn landlock_abi(cage: &Cage) -> Option<u8> {
159    Some(cage.landlock_abi())
160}
161
162#[cfg(not(target_os = "linux"))]
163fn landlock_abi(_: &Cage) -> Option<u8> {
164    None
165}
166
167/// Whether procage can confine commands on this machine at all.
168///
169/// # Errors
170///
171/// Why not (an unsupported system, a nested sandbox, no Landlock).
172pub fn available() -> Result<(), String> {
173    let mut p = Policy::default();
174    p.base = Base::V3;
175    Cage::new(&p).map(drop).map_err(|e| e.to_string())
176}
177
178/// The profile procage enforces here, with a notice for what it leaves out.
179///
180/// Where the host cannot enforce part of a speccoder profile, procage's
181/// [`Cage::fit`] gives up exactly that part, and only these:
182///
183/// - read-only or hidden trees inside a writable one (in place: `.git` and
184///   git-ignored secrets) and the trust-handoff names in writable trees
185///   (git hooks, shell startup files): Landlock cannot deny inside a tree
186///   it allows, without user namespaces;
187/// - Unix sockets in the writable trees: without user namespaces other
188///   sockets cannot be kept out, so none is reachable;
189/// - signals within the run: Linux older than 6.12 cannot keep them there.
190///
191/// The fitted profile records each of these, so the helper, which rebuilds
192/// the cage from it, confines exactly the same way.
193///
194/// # Errors
195///
196/// Any other refusal: procage cannot confine this profile here.
197pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
198    fit_at(profile, None)
199}
200
201/// The parts of a profile speccoder gives up where the host cannot enforce
202/// them.
203fn relax() -> Relax {
204    Relax::new()
205        .leave_out_protect()
206        .leave_out_hide()
207        .waive(HardDenyClass::TrustHandoff)
208        .no_unix_sockets()
209        .signals_same_user()
210}
211
212/// [`fit`] as on a Linux kernel whose Landlock is at most ABI `cap` (tests).
213fn fit_at(profile: &Profile, cap: Option<u8>) -> Result<(Profile, Option<String>), String> {
214    let (cage, relaxed) = Cage::fit(&policy(profile)?, &options(profile, cap), &relax())
215        .map_err(|e| e.to_string())?;
216    let mut fitted = profile.clone();
217    let mut notes = Vec::new();
218    for r in &relaxed {
219        let what = adopt(&mut fitted, r)?;
220        note(&mut notes, cause(&r.lift), what);
221    }
222    if !relaxed.is_empty() {
223        cage_at(&fitted, cap).map_err(|e| format!("the fitted profile does not hold: {e}"))?;
224    }
225    let abi = landlock_abi(&cage);
226    if abi.is_some_and(|a| a < 2) {
227        note(
228            &mut notes,
229            Cause::Kernel(2),
230            "files cannot move between directories, so builds fail",
231        );
232    }
233    if abi.is_some_and(|a| a < 3) {
234        note(
235            &mut notes,
236            Cause::Kernel(3),
237            "truncating a file by path fails",
238        );
239    }
240    Ok((fitted, notice(&notes, abi)))
241}
242
243/// Records `r` in `profile`, so the profile translates to the policy procage
244/// fitted; returns what it means for commands.
245fn adopt(profile: &mut Profile, r: &Relaxed) -> Result<&'static str, String> {
246    Ok(match r.relaxation {
247        Relaxation::LeaveOutProtect | Relaxation::LeaveOutHide => {
248            let is =
249                |p: &std::path::PathBuf| r.gap.subject.as_deref() == Some(&*p.to_string_lossy());
250            let before = profile.read_only.len() + profile.hidden.len();
251            profile.read_only.retain(|p| !is(p));
252            profile.hidden.retain(|p| !is(p));
253            if before == profile.read_only.len() + profile.hidden.len() {
254                return Err(format!("{r}: not a path of the profile"));
255            }
256            "commands can write `.git` and read git-ignored secrets in place (the run stops if `.git` changes)"
257        }
258        Relaxation::Waive(HardDenyClass::TrustHandoff) => {
259            profile.unenforced.trust_handoff = true;
260            "git hooks and shell startup files in the workspace stay writable"
261        }
262        Relaxation::NoUnixSockets => {
263            profile.unenforced.workspace_sockets = true;
264            "Unix sockets in the workspace are unreachable"
265        }
266        Relaxation::SignalsSameUser => {
267            profile.unenforced.signal_scope = true;
268            "commands can signal your other processes"
269        }
270        _ => return Err(format!("{r}: speccoder does not accept this")),
271    })
272}
273
274/// Why a part of the profile is left out, from what procage says would
275/// lift it: each cause has its own fix.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277enum Cause {
278    /// No unprivileged user namespaces (an AppArmor profile lifts it).
279    UserNamespaces,
280    /// The kernel's Landlock is too old; this ABI lifts it.
281    Kernel(u8),
282    /// Nothing procage knows lifts it.
283    Unknown,
284}
285
286fn cause(lift: &Lift) -> Cause {
287    match (lift.tier1, lift.landlock_abi) {
288        (true, _) => Cause::UserNamespaces,
289        (false, Some(abi)) => Cause::Kernel(abi),
290        (false, None) => Cause::Unknown,
291    }
292}
293
294/// The first mainline Linux with Landlock ABI `abi`.
295fn linux_with(abi: u8) -> Option<&'static str> {
296    Some(match abi {
297        1 => "5.13",
298        2 => "5.19",
299        3 => "6.2",
300        4 => "6.7",
301        5 => "6.10",
302        6 => "6.12",
303        7 => "6.15",
304        8 => "7.0",
305        _ => return None,
306    })
307}
308
309type Note = (Cause, String);
310
311fn note(notes: &mut Vec<Note>, cause: Cause, what: &str) {
312    let what = match cause {
313        Cause::Kernel(abi) => match linux_with(abi) {
314            Some(linux) => format!("{what} (Linux {linux} lifts this)"),
315            None => format!("{what} (Landlock ABI {abi} lifts this)"),
316        },
317        _ => what.to_owned(),
318    };
319    if !notes.iter().any(|(_, w)| *w == what) {
320        notes.push((cause, what));
321    }
322}
323
324/// One clause per cause, naming the kernel's Landlock ABI where it is known.
325fn notice(notes: &[Note], abi: Option<u8>) -> Option<String> {
326    let items = |pick: fn(&Cause) -> bool| {
327        notes
328            .iter()
329            .filter(|(c, _)| pick(c))
330            .map(|(_, w)| w.as_str())
331            .collect::<Vec<_>>()
332            .join("; ")
333    };
334    let mut clauses = Vec::new();
335    let userns = items(|c| *c == Cause::UserNamespaces);
336    if !userns.is_empty() {
337        clauses.push(format!(
338            "this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so {userns}"
339        ));
340    }
341    let kernel = items(|c| matches!(c, Cause::Kernel(_)));
342    if !kernel.is_empty() {
343        let which = abi.map_or_else(String::new, |a| format!(" (Landlock ABI {a})"));
344        clauses.push(format!("this kernel is too old{which}, so {kernel}"));
345    }
346    let unknown = items(|c| *c == Cause::Unknown);
347    if !unknown.is_empty() {
348        clauses.push(format!(
349            "this machine cannot enforce every part, so {unknown}"
350        ));
351    }
352    (!clauses.is_empty()).then(|| clauses.join("; and "))
353}
354
355/// The caged command for `argv` in `cwd` with exactly `env`.
356fn command(argv: &[String], cwd: &Path, env: &[(String, String)]) -> Option<Command> {
357    let (program, args) = argv.split_first()?;
358    let mut cmd = Command::new(program);
359    cmd.args(args)
360        .current_dir(cwd)
361        .env_clear()
362        .envs(env.iter().map(|(k, v)| (k, v)))
363        .envs(foreground_git(
364            env.iter()
365                .find(|(k, _)| k == "GIT_CONFIG_COUNT")
366                .map(|(_, v)| v.as_str()),
367        ));
368    Some(cmd)
369}
370
371/// `gc.autoDetach=false` after the `count` git settings the environment
372/// already passes: git's automatic `gc` detaches with `setsid`, which the
373/// cage refuses (a run's processes end with it), and would print "setsid
374/// failed".
375fn foreground_git(count: Option<&str>) -> [(String, String); 3] {
376    let n: usize = count.and_then(|c| c.parse().ok()).unwrap_or(0);
377    [
378        (format!("GIT_CONFIG_KEY_{n}"), "gc.autoDetach".to_owned()),
379        (format!("GIT_CONFIG_VALUE_{n}"), "false".to_owned()),
380        ("GIT_CONFIG_COUNT".to_owned(), (n + 1).to_string()),
381    ]
382}
383
384/// Runs `inv` caged by `profile` to completion or deadline. `inv.env`
385/// already holds the profile's `TMPDIR`.
386#[must_use]
387pub fn run(profile: &Profile, inv: &Invocation) -> Outcome {
388    let started = SystemTime::now();
389    let clock = Instant::now();
390    let failed = |e: String| Outcome {
391        exit_code: None,
392        timed_out: false,
393        spawn_error: Some(e),
394        started,
395        duration: clock.elapsed(),
396        stdout: Captured::default(),
397        stderr: Captured::default(),
398        sandbox: Default::default(),
399    };
400    // A cage that cannot be built now is this command's failure, never an
401    // unconfined run. Reporting what it denies changes no rule; where it
402    // cannot start, the command runs confined all the same.
403    let cage = match cage_with(profile, &options(profile, None).with_violations()) {
404        Ok(c) => c,
405        Err(e) => return failed(format!("the sandbox could not confine the command: {e}")),
406    };
407    let Some(mut cmd) = command(&inv.argv, &inv.cwd, &inv.env) else {
408        return failed("empty argv".to_owned());
409    };
410    cmd.stdin(Stdio::null())
411        .stdout(Stdio::piped())
412        .stderr(Stdio::piped());
413    let program = &inv.argv[0];
414    let child = match cage.spawn(&mut cmd, &Grants::new()) {
415        Ok(c) => c,
416        Err(e) => return failed(format!("could not start `{program}` in the sandbox: {e}")),
417    };
418    let mut caged = Caged(child);
419    let mut outcome = supervise(&mut caged, inv, started, clock);
420    // Read only after a failure: `supervise` ended the run, which completed
421    // the record, so reading it costs nothing more.
422    if outcome.exit_code != Some(0) {
423        outcome.sandbox = super::denials::from_record(&caged.0.violations());
424    }
425    outcome
426}
427
428/// A caged child: procage's handle kills every process the command started
429/// when the run ends, wherever it went.
430struct Caged(procage::native::Child);
431
432impl crate::verify::process::Supervised for Caged {
433    fn child(&mut self) -> &mut std::process::Child {
434        &mut self.0
435    }
436
437    fn kill(&mut self) {
438        let _ = self.0.kill_all();
439    }
440}
441
442/// The helper (`speccoder __sandbox <profile> -- argv…`): confines this
443/// process with `profile` and becomes the command, keeping this process's
444/// pid, standard streams and environment, leading a session of its own
445/// that the command's processes cannot leave (the caller ends it with
446/// [`crate::session::end`]); returns only on failure, with the reason.
447pub fn exec(profile: &Profile, argv: &[String]) -> String {
448    let cage = match cage(profile) {
449        Ok(c) => c,
450        Err(e) => return format!("the sandbox could not confine the command: {e}"),
451    };
452    let Some((program, args)) = argv.split_first() else {
453        return "nothing to run".to_owned();
454    };
455    let mut cmd = Command::new(program);
456    // The environment speccoder gave the helper, which it already scrubbed.
457    cmd.args(args)
458        .envs(std::env::vars_os())
459        .envs(foreground_git(
460            std::env::var("GIT_CONFIG_COUNT").ok().as_deref(),
461        ));
462    let err = cage.exec_session(&mut cmd, &Grants::new());
463    format!("could not start `{program}` in the sandbox: {err}")
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use procage::policy::{CapabilityGap, GapKind, Target, Tier};
470
471    fn relaxed(relaxation: Relaxation, subject: Option<&str>, lift: Lift) -> Relaxed {
472        Relaxed {
473            gap: CapabilityGap {
474                construct: relaxation.construct(),
475                target: Target::Linux {
476                    tier: Tier::T2,
477                    landlock_abi: 5,
478                },
479                kind: GapKind::Unsupported,
480                subject: subject.map(str::to_owned),
481            },
482            relaxation,
483            lift,
484        }
485    }
486
487    #[test]
488    fn caged_git_configuration_keeps_what_the_environment_set() {
489        assert_eq!(
490            foreground_git(Some("2")),
491            [
492                ("GIT_CONFIG_KEY_2".to_owned(), "gc.autoDetach".to_owned()),
493                ("GIT_CONFIG_VALUE_2".to_owned(), "false".to_owned()),
494                ("GIT_CONFIG_COUNT".to_owned(), "3".to_owned()),
495            ]
496        );
497        assert_eq!(foreground_git(None)[2].1, "1");
498    }
499
500    /// Run, the helper and fit share one policy, on the base that keeps
501    /// benign probes out of the record and allows nothing more than V2.
502    #[test]
503    fn the_policy_is_on_base_v3() {
504        let p = Profile::new(Path::new("/w"), &super::super::Config::default(), None);
505        assert_eq!(policy(&p).unwrap().base, Base::V3);
506    }
507
508    const T1: Lift = Lift {
509        tier1: true,
510        landlock_abi: None,
511    };
512
513    fn profile() -> Profile {
514        let mut p = Profile::new(Path::new("/w"), &super::super::Config::default(), None);
515        p.read_only.push("/w/.git".into());
516        p.hidden.push("/w/.env".into());
517        p
518    }
519
520    /// Each relaxation procage reports is recorded in the profile, so the
521    /// helper's cage is the fitted one.
522    #[test]
523    fn each_relaxation_is_recorded_in_the_profile() {
524        let mut p = profile();
525        let protect = relaxed(Relaxation::LeaveOutProtect, Some("/w/.git"), T1);
526        assert!(adopt(&mut p, &protect).unwrap().contains("`.git`"));
527        assert!(p.read_only.is_empty() && p.hidden == [Path::new("/w/.env")]);
528        adopt(
529            &mut p,
530            &relaxed(Relaxation::LeaveOutHide, Some("/w/.env"), T1),
531        )
532        .unwrap();
533        assert!(p.hidden.is_empty());
534        let handoff = Relaxation::Waive(HardDenyClass::TrustHandoff);
535        assert!(
536            adopt(&mut p, &relaxed(handoff, None, T1))
537                .unwrap()
538                .contains("git hooks")
539        );
540        adopt(&mut p, &relaxed(Relaxation::NoUnixSockets, None, T1)).unwrap();
541        adopt(
542            &mut p,
543            &relaxed(Relaxation::SignalsSameUser, None, Lift::default()),
544        )
545        .unwrap();
546        assert!(p.unenforced.trust_handoff && p.unenforced.workspace_sockets);
547        assert!(p.unenforced.signal_scope);
548        // The translation follows the profile.
549        let policy = policy(&p).unwrap();
550        assert!(policy.waive.contains(&HardDenyClass::TrustHandoff));
551        assert!(policy.ipc.unix_sockets.is_empty());
552        assert_eq!(policy.process.signals, Signals::SameUser);
553        assert!(
554            !policy
555                .fs
556                .iter()
557                .any(|r| matches!(r.access, Access::Protect | Access::Hide))
558        );
559    }
560
561    /// What speccoder did not accept, or a path it does not know, fails.
562    #[test]
563    fn other_relaxations_fail() {
564        let mut p = profile();
565        for r in [
566            relaxed(Relaxation::Waive(HardDenyClass::Secrets), None, T1),
567            relaxed(Relaxation::NoLoopback, None, T1),
568            relaxed(Relaxation::LeaveOutHide, Some("/w/other"), T1),
569        ] {
570            assert!(adopt(&mut p, &r).is_err(), "{r}");
571        }
572        assert_eq!(p, profile());
573        assert_eq!(relax().accepted().count(), 5);
574    }
575
576    /// The cause is what procage says would lift it; each cause gets its own
577    /// clause, so each fix is clear.
578    #[test]
579    fn the_notice_names_each_cause() {
580        assert_eq!(cause(&T1), Cause::UserNamespaces);
581        let abi6 = Lift {
582            tier1: false,
583            landlock_abi: Some(6),
584        };
585        assert_eq!(cause(&abi6), Cause::Kernel(6));
586        assert_eq!(cause(&Lift::default()), Cause::Unknown);
587        let mut notes = Vec::new();
588        note(&mut notes, Cause::UserNamespaces, "sockets");
589        assert_eq!(
590            notice(&notes, Some(8)).unwrap(),
591            "this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so sockets"
592        );
593        let mut kernel = Vec::new();
594        note(&mut kernel, Cause::Kernel(6), "signals");
595        let n = notice(&kernel, Some(5)).unwrap();
596        assert_eq!(
597            n,
598            "this kernel is too old (Landlock ABI 5), so signals (Linux 6.12 lifts this)"
599        );
600        notes.extend(kernel);
601        note(&mut notes, Cause::Unknown, "other");
602        let all = notice(&notes, Some(5)).unwrap();
603        assert!(
604            all.starts_with("this machine does not allow user namespaces")
605                && all.contains("; and this kernel is too old (Landlock ABI 5), so signals")
606                && all.ends_with("; and this machine cannot enforce every part, so other"),
607            "{all}"
608        );
609        let mut new = Vec::new();
610        note(&mut new, Cause::Kernel(12), "x");
611        assert!(
612            notice(&new, None)
613                .unwrap()
614                .ends_with("x (Landlock ABI 12 lifts this)")
615        );
616        assert_eq!(notice(&[], Some(5)), None);
617    }
618
619    /// On older kernels every command is still caged; only the signal scope
620    /// (below ABI 3 also truncation by path, below ABI 2 moves between
621    /// directories) is given up, and the notice says so. At the kernel's own ABI the scope holds.
622    #[cfg(target_os = "linux")]
623    #[test]
624    fn older_kernels_keep_commands_caged() {
625        let dir = tempfile::tempdir().unwrap();
626        let base = std::fs::canonicalize(dir.path()).unwrap();
627        let (root, home, outside) = (base.join("w"), base.join("home"), base.join("o"));
628        for d in [&root, &home.join(".ssh"), &outside] {
629            std::fs::create_dir_all(d).unwrap();
630        }
631        std::fs::write(home.join(".ssh/id"), "secret").unwrap();
632        let mut sleeper = Command::new("sleep").arg("30").spawn().unwrap();
633        let profile = Profile::new(&root, &super::super::Config::default(), Some(&home));
634        let kernel = procage::linux::probe()
635            .landlock_abi
636            .min(procage::linux::BACKEND_MAX_ABI);
637        let ok = |p: &Profile, cap: Option<u8>, script: &str| -> bool {
638            let cage = cage_at(p, cap).unwrap();
639            let mut cmd = Command::new("/bin/sh");
640            cmd.args(["-c", script])
641                .env_clear()
642                .env("PATH", "/usr/bin:/bin")
643                .stdout(Stdio::null())
644                .stderr(Stdio::null());
645            let mut child = cage.spawn(&mut cmd, &Grants::new()).unwrap();
646            child.wait().unwrap().success()
647        };
648        let signal = format!("kill -0 {}", sleeper.id());
649        // `os.rename`, not `mv`: `mv` copies where a rename fails.
650        let moves = format!(
651            "cd {} && mkdir -p a b && echo x > a/x && python3 -I -c \
652             'import os; os.rename(\"a/x\", \"b/x\"); os.rename(\"b/x\", \"a/x\")'",
653            root.display()
654        );
655        for cap in [1, 2, 5].into_iter().filter(|&c| c < kernel) {
656            let (fitted, notice) = fit_at(&profile, Some(cap)).unwrap();
657            assert!(fitted.unenforced.signal_scope, "ABI {cap}");
658            let notice = notice.unwrap();
659            assert!(
660                notice.contains(&format!("this kernel is too old (Landlock ABI {cap})"))
661                    && notice.contains("commands can signal your other processes"),
662                "{notice}"
663            );
664            assert_eq!(
665                notice.contains("truncating a file by path"),
666                cap < 3,
667                "{notice}"
668            );
669            assert_eq!(
670                notice.contains("files cannot move between directories"),
671                cap < 2,
672                "{notice}"
673            );
674            let at = Some(cap);
675            assert_eq!(ok(&fitted, at, &moves), cap >= 2, "ABI {cap}");
676            assert!(
677                ok(&fitted, at, &format!("echo x > {}/f", root.display())),
678                "ABI {cap}"
679            );
680            assert!(
681                !ok(&fitted, at, &format!("echo x > {}/f", outside.display())),
682                "ABI {cap}"
683            );
684            assert!(
685                !ok(&fitted, at, &format!("cat {}/.ssh/id", home.display())),
686                "ABI {cap}"
687            );
688            assert!(ok(&fitted, at, &signal), "ABI {cap}");
689        }
690        if kernel >= 6 {
691            let (fitted, _) = fit_at(&profile, None).unwrap();
692            assert!(!fitted.unenforced.signal_scope);
693            assert!(!ok(&fitted, None, &signal));
694        }
695        assert!(!outside.join("f").exists());
696        let _ = sleeper.kill();
697        let _ = sleeper.wait();
698    }
699
700    /// With the network on a command still reaches only the workspace's
701    /// Unix sockets, and none where they cannot be kept apart (fitted).
702    #[test]
703    fn the_network_on_keeps_the_unix_socket_rule() {
704        let dir = tempfile::tempdir().unwrap();
705        let mut p = Profile::new(dir.path(), &super::super::Config::default(), None);
706        let sockets = policy(&p).unwrap().ipc.unix_sockets;
707        assert!(!sockets.is_empty());
708        p.network = Network::On;
709        let on = policy(&p).unwrap();
710        assert_eq!((on.net, on.ipc.unix_sockets), (Net::Ip, sockets));
711        p.unenforced.workspace_sockets = true;
712        assert!(policy(&p).unwrap().ipc.unix_sockets.is_empty());
713    }
714}