Skip to main content

boundgraph_check/
sandbox.rs

1//! Where a checker runs something it did not write.
2//!
3//! # Why this boundary is declared here
4//!
5//! Feature 09 owns the sandbox: what a capability is, how a filesystem grant
6//! becomes a preopened descriptor, how a network grant becomes an egress proxy.
7//! None of that exists yet, and the compile checker (US-0804) and the test-run
8//! checker (US-0805) need exactly one thing from it — somewhere to run a
9//! command and get its exit status back.
10//!
11//! So the boundary is declared here, as narrowly as it can be, exactly as
12//! feature 07 declares `Store` for the content store it does not own and the
13//! runtime declares `LedgerSink` for the log it does not own. Feature 09's
14//! `Sandbox` will satisfy [`Sandbox`] unchanged.
15//!
16//! # The sandbox is handed the grant, and must refuse what it cannot enforce
17//!
18//! [`Sandbox::run`] takes a [`GrantSet`] as well as a [`Command`]. That is not
19//! decoration: it is the whole contract. An implementation that cannot confine
20//! a child to the granted authority must return
21//! [`SandboxError::Unenforceable`] rather than run it anyway.
22//!
23//! The alternative — a sandbox that accepts any grant and enforces whichever
24//! parts it happens to implement — produces a system where the compiler proved
25//! a property that the runtime silently does not hold. Refusal keeps the
26//! failure loud and local.
27//!
28//! # [`Unconfined`] is not a sandbox, and says so
29//!
30//! It runs the command as this process's user, on this machine, with this
31//! machine's filesystem. It enforces a working directory, a timeout, and a
32//! cleared environment, and it enforces *nothing else*. It is named for what it
33//! is so that no flow definition can contain it by accident, and it is behind
34//! the `unconfined` feature so that a deployment can make it impossible to
35//! build.
36//!
37//! What it does do is refuse honestly: any grant beyond reading the workspace
38//! and executing the named program is [`SandboxError::Unenforceable`], because
39//! those are the only two it can even pretend to. That makes it useful for M3
40//! — the compile and test checkers need nothing else — without it ever
41//! claiming containment it does not provide.
42
43use std::fmt;
44use std::path::{Path, PathBuf};
45use std::time::Duration;
46
47use async_trait::async_trait;
48use boundgraph_core::{GrantAtom, GrantSet};
49
50/// How long a checker's command may run before it is killed.
51///
52/// A timeout is not a tuning knob here, it is what stops a hung compiler
53/// becoming a hung commit. Every [`Command`] carries one.
54pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
55
56/// How much of a command's output is kept.
57///
58/// A compiler having a bad day can emit tens of megabytes. Everything past this
59/// is dropped, and the outcome says so.
60pub const MAX_OUTPUT: usize = 256 * 1024;
61
62/// What to run, and under what limits.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Command {
65    /// The program, as the sandbox will resolve it.
66    pub program: String,
67    /// Its arguments, in order.
68    pub args: Vec<String>,
69    /// The directory to run it in, which is also the only directory a checker
70    /// has any business reading.
71    pub workspace: PathBuf,
72    /// How long it may run before it is killed.
73    pub timeout: Duration,
74}
75
76impl Command {
77    /// A command in `workspace`, with the default timeout.
78    pub fn new(
79        program: impl Into<String>,
80        args: impl IntoIterator<Item = impl Into<String>>,
81        workspace: impl Into<PathBuf>,
82    ) -> Self {
83        Self {
84            program: program.into(),
85            args: args.into_iter().map(Into::into).collect(),
86            workspace: workspace.into(),
87            timeout: DEFAULT_TIMEOUT,
88        }
89    }
90
91    /// Kill it after `timeout` rather than after [`DEFAULT_TIMEOUT`].
92    #[must_use]
93    pub fn within(mut self, timeout: Duration) -> Self {
94        self.timeout = timeout;
95        self
96    }
97
98    /// The command as it would be typed, for a diagnostic.
99    pub fn rendered(&self) -> String {
100        std::iter::once(self.program.clone())
101            .chain(self.args.iter().cloned())
102            .collect::<Vec<_>>()
103            .join(" ")
104    }
105}
106
107/// What running a command produced.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Outcome {
110    /// The exit status, or `None` where the process was killed and never
111    /// returned one.
112    pub exit: Option<i32>,
113    /// Standard output, truncated to [`MAX_OUTPUT`].
114    pub stdout: String,
115    /// Standard error, truncated to [`MAX_OUTPUT`].
116    pub stderr: String,
117    /// Whether it was killed for running past its timeout.
118    ///
119    /// Separate from `exit` being `None`, because "we killed it at the deadline"
120    /// and "it died for some other reason" are different facts and only one of
121    /// them is the checker's own doing.
122    pub timed_out: bool,
123    /// Whether output was dropped to fit [`MAX_OUTPUT`].
124    pub truncated: bool,
125    /// How long it ran.
126    pub duration: Duration,
127}
128
129impl Outcome {
130    /// Whether the command reported success.
131    pub fn succeeded(&self) -> bool {
132        self.exit == Some(0) && !self.timed_out
133    }
134
135    /// Standard error and standard output together, in that order.
136    ///
137    /// Diagnostics go to stderr for most tools and to stdout for some, and a
138    /// checker that read only one of them would report "the build failed, no
139    /// output" for half the ecosystem.
140    pub fn diagnostics(&self) -> String {
141        match (self.stderr.trim(), self.stdout.trim()) {
142            ("", "") => String::new(),
143            (err, "") => err.to_string(),
144            ("", out) => out.to_string(),
145            (err, out) => format!("{err}\n{out}"),
146        }
147    }
148}
149
150/// The command could not be run under the authority it was given.
151#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
152#[non_exhaustive]
153pub enum SandboxError {
154    /// This sandbox cannot enforce part of the grant, so it refuses to run.
155    ///
156    /// The important variant. See the module documentation: a sandbox that ran
157    /// the command anyway would make the compiler's proof false at run time,
158    /// silently.
159    #[error("this sandbox cannot enforce `{atom}`, so it will not run {command}")]
160    Unenforceable {
161        /// The grant it could not honour.
162        atom: String,
163        /// What it declined to run.
164        command: String,
165    },
166
167    /// The program could not be started.
168    #[error("{command} could not be started: {detail}")]
169    Spawn {
170        /// What could not be started.
171        command: String,
172        /// Why.
173        detail: String,
174    },
175
176    /// The sandbox itself is not available.
177    #[error("the sandbox is unavailable: {0}")]
178    Unavailable(String),
179}
180
181/// Runs a command under a grant, or refuses to.
182///
183/// Feature 09 will implement this with real isolation. Until then
184/// [`Unconfined`] implements it with none, and says so.
185#[async_trait]
186pub trait Sandbox: Send + Sync + fmt::Debug {
187    /// Run `command`, confined to `grant`.
188    ///
189    /// # Errors
190    ///
191    /// [`SandboxError::Unenforceable`] if this sandbox cannot confine the
192    /// command to the grant — which it must return rather than run the command
193    /// with more authority than was granted. [`SandboxError::Spawn`] if the
194    /// program could not be started.
195    ///
196    /// A command that ran and failed is **not** an error: that is an
197    /// [`Outcome`] with a non-zero exit, and deciding what it means is the
198    /// checker's job.
199    async fn run(&self, command: &Command, grant: &GrantSet) -> Result<Outcome, SandboxError>;
200}
201
202/// Truncate to [`MAX_OUTPUT`], keeping the start.
203///
204/// The start, because a compiler's first error is the one that caused the rest
205/// — later errors are usually consequences of it. A test runner is the other
206/// way round, which is why the test checker keeps the tail of what it is given
207/// rather than relying on this.
208#[cfg(any(feature = "unconfined", test))]
209fn cap(bytes: Vec<u8>) -> (String, bool) {
210    let truncated = bytes.len() > MAX_OUTPUT;
211    let kept = if truncated {
212        // On a character boundary, so the result is still valid UTF-8 text.
213        // A UTF-8 continuation byte is `10xxxxxx`; back up off any of them.
214        let mut end = MAX_OUTPUT;
215        while end > 0 && (bytes[end] & 0xc0) == 0x80 {
216            end -= 1;
217        }
218        &bytes[..end]
219    } else {
220        &bytes[..]
221    };
222    (String::from_utf8_lossy(kept).into_owned(), truncated)
223}
224
225/// What [`Unconfined`] is willing to pretend it enforces.
226///
227/// Reading and listing under the command's own workspace, and executing a
228/// program. Nothing else — no writes, no network, no secrets, no access outside
229/// the workspace.
230#[cfg(any(feature = "unconfined", test))]
231fn enforceable(atom: &GrantAtom, workspace: &Path) -> bool {
232    match (atom.domain.as_str(), atom.action.as_str()) {
233        ("proc", "exec") => true,
234        ("fs", "read" | "list" | "stat") => {
235            let root = normalise(workspace);
236            let resource = atom.resource.replace('\\', "/");
237            let resource = resource
238                .strip_suffix("**")
239                .unwrap_or(&resource)
240                .trim_end_matches('/');
241
242            // On a segment boundary, not a byte prefix. A plain `starts_with`
243            // accepts `/work/repository` for a workspace of `/work/repo`, which
244            // is a different directory that this would then claim to confine.
245            resource == root || resource.starts_with(&format!("{root}/"))
246        }
247        _ => false,
248    }
249}
250
251/// A path as a grant resource writes it: forward slashes, no trailing one.
252///
253/// Backslashes matter. A resource pattern is split on `/`, so a Windows path
254/// left as `D:\work\**` is a single segment containing a `*`, which falls
255/// outside the decidable fragment and makes every containment question
256/// `Unknown` — and `Unknown` is a refusal. Normalising here is what keeps the
257/// same grant decidable on every platform.
258pub fn normalise(path: &Path) -> String {
259    let text = path.to_string_lossy().replace('\\', "/");
260    text.strip_suffix('/').unwrap_or(&text).to_string()
261}
262
263/// The grant a checker needs to run `program` over `workspace`, and no more.
264///
265/// Read and list under the workspace, and execute exactly that one program.
266/// No write anywhere, nothing outside the workspace, and nothing that could
267/// reach a network or a secret — so it is comfortably inside
268/// [`checker_ceiling`](crate::checker_ceiling).
269pub fn workspace_grant(workspace: &Path, program: &str) -> GrantSet {
270    let root = normalise(workspace);
271    GrantSet::new([
272        GrantAtom::new("fs", "read", format!("{root}/**")),
273        GrantAtom::new("fs", "list", format!("{root}/**")),
274        GrantAtom::new("proc", "exec", program),
275    ])
276}
277
278#[cfg(feature = "unconfined")]
279pub use unconfined::Unconfined;
280
281#[cfg(feature = "unconfined")]
282mod unconfined {
283    use super::*;
284
285    /// Runs commands on this machine, with no isolation whatsoever.
286    ///
287    /// # Read this before using it
288    ///
289    /// This is **not a sandbox**. A command run through it has whatever access
290    /// the BoundGraph process itself has: the whole filesystem, the network, the
291    /// environment of whatever started it. If the command decides to delete the
292    /// workspace, it will.
293    ///
294    /// What it actually enforces is three things:
295    ///
296    /// - the working directory is the command's workspace,
297    /// - the process is killed at the timeout,
298    /// - the child's environment is cleared except for the few variables a
299    ///   toolchain cannot start without.
300    ///
301    /// And what it refuses is everything else: any grant beyond reading under
302    /// the workspace and executing the named program is
303    /// [`SandboxError::Unenforceable`]. It cannot make a `net:egress` grant
304    /// true, so it will not accept one.
305    ///
306    /// It exists because the compile and test checkers are useful now and
307    /// feature 09 is not written yet, and it is named [`Unconfined`] so that
308    /// nobody reads a flow definition containing it and thinks otherwise.
309    #[derive(Debug, Clone, Default)]
310    pub struct Unconfined {
311        keep_env: Vec<String>,
312    }
313
314    /// Environment variables a toolchain genuinely cannot start without.
315    ///
316    /// Deliberately short. Everything else is cleared, because an inherited
317    /// environment is how a credential reaches a child that was never granted
318    /// one.
319    const ESSENTIAL_ENV: &[&str] = &[
320        "PATH",
321        "HOME",
322        "SystemRoot",
323        "SYSTEMROOT",
324        "TEMP",
325        "TMP",
326        "USERPROFILE",
327        "LANG",
328        "LC_ALL",
329    ];
330
331    impl Unconfined {
332        /// An unconfined runner.
333        pub fn new() -> Self {
334            Self::default()
335        }
336
337        /// Also pass `name` through to the child.
338        ///
339        /// For a toolchain that needs one — `CARGO_HOME`, `RUSTUP_HOME`,
340        /// `VIRTUAL_ENV`. Every addition is a way for something the checker was
341        /// not granted to reach the child, so add them one at a time and
342        /// knowingly.
343        #[must_use]
344        pub fn passing_through(mut self, name: impl Into<String>) -> Self {
345            self.keep_env.push(name.into());
346            self
347        }
348
349        pub(super) fn keeps(&self, name: &str) -> bool {
350            ESSENTIAL_ENV.contains(&name) || self.keep_env.iter().any(|k| k == name)
351        }
352    }
353
354    #[async_trait]
355    impl Sandbox for Unconfined {
356        async fn run(&self, command: &Command, grant: &GrantSet) -> Result<Outcome, SandboxError> {
357            // Refuse before running, not after: a grant this cannot honour must
358            // never reach a child process.
359            if let Some(atom) = grant
360                .iter()
361                .find(|atom| !enforceable(atom, &command.workspace))
362            {
363                return Err(SandboxError::Unenforceable {
364                    atom: atom.to_string(),
365                    command: command.rendered(),
366                });
367            }
368
369            let mut child = tokio::process::Command::new(&command.program);
370            child
371                .args(&command.args)
372                .current_dir(&command.workspace)
373                .stdin(std::process::Stdio::null())
374                .stdout(std::process::Stdio::piped())
375                .stderr(std::process::Stdio::piped())
376                .kill_on_drop(true)
377                .env_clear();
378
379            for (name, value) in std::env::vars() {
380                if self.keeps(&name) {
381                    child.env(name, value);
382                }
383            }
384
385            let started = std::time::Instant::now();
386
387            let spawned = child.spawn().map_err(|e| SandboxError::Spawn {
388                command: command.rendered(),
389                detail: e.to_string(),
390            })?;
391
392            let finished = tokio::time::timeout(command.timeout, spawned.wait_with_output()).await;
393
394            let duration = started.elapsed();
395
396            match finished {
397                Ok(Ok(output)) => {
398                    let (stdout, out_cut) = cap(output.stdout);
399                    let (stderr, err_cut) = cap(output.stderr);
400                    Ok(Outcome {
401                        exit: output.status.code(),
402                        stdout,
403                        stderr,
404                        timed_out: false,
405                        truncated: out_cut || err_cut,
406                        duration,
407                    })
408                }
409                Ok(Err(e)) => Err(SandboxError::Spawn {
410                    command: command.rendered(),
411                    detail: e.to_string(),
412                }),
413                // `kill_on_drop` means dropping the future kills the child, so
414                // the deadline is a real deadline rather than a hope.
415                Err(_elapsed) => Ok(Outcome {
416                    exit: None,
417                    stdout: String::new(),
418                    stderr: String::new(),
419                    timed_out: true,
420                    truncated: false,
421                    duration,
422                }),
423            }
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn workspace() -> PathBuf {
433        PathBuf::from("/work/repo")
434    }
435
436    #[test]
437    fn a_command_renders_as_it_would_be_typed() {
438        let c = Command::new("cargo", ["check", "--all-targets"], workspace());
439        assert_eq!(c.rendered(), "cargo check --all-targets");
440        assert_eq!(c.timeout, DEFAULT_TIMEOUT);
441    }
442
443    #[test]
444    fn a_timeout_can_be_narrowed() {
445        let c = Command::new("cargo", ["check"], workspace()).within(Duration::from_secs(5));
446        assert_eq!(c.timeout, Duration::from_secs(5));
447    }
448
449    #[test]
450    fn a_workspace_grant_reads_the_workspace_and_execs_one_program() {
451        let grant = workspace_grant(&workspace(), "cargo");
452
453        assert_eq!(grant.len(), 3);
454        assert!(grant.iter().all(|a| a.action != "write"));
455        assert!(grant.iter().any(|a| a.resource == "/work/repo/**"));
456        assert!(
457            grant
458                .iter()
459                .any(|a| a.domain == "proc" && a.resource == "cargo")
460        );
461    }
462
463    /// The grant has to be inside what any checker may hold, or the trait
464    /// refuses the checker before it runs.
465    #[test]
466    fn a_workspace_grant_is_within_the_checker_ceiling() {
467        use boundgraph_grant::{Tri, set_leq};
468
469        let grant = workspace_grant(&workspace(), "/usr/bin/cargo");
470        assert_eq!(set_leq(&grant, &crate::checker_ceiling()), Tri::Yes);
471    }
472
473    /// A Windows path left with backslashes is one segment containing a `*`,
474    /// which is outside the decidable fragment — and undecidable is a refusal.
475    #[test]
476    fn a_windows_path_still_yields_a_decidable_grant() {
477        use boundgraph_grant::{Tri, set_leq};
478
479        let grant = workspace_grant(Path::new(r"D:\work\repo"), "cargo.exe");
480        assert!(grant.iter().any(|a| a.resource == "D:/work/repo/**"));
481        assert_eq!(set_leq(&grant, &crate::checker_ceiling()), Tri::Yes);
482    }
483
484    #[test]
485    fn normalising_drops_a_trailing_separator() {
486        assert_eq!(normalise(Path::new("/work/repo/")), "/work/repo");
487        assert_eq!(normalise(Path::new(r"D:\work\repo\")), "D:/work/repo");
488    }
489
490    // ── what an unconfined runner will and will not accept ──────────────────
491
492    #[test]
493    fn reading_under_the_workspace_and_execing_are_enforceable() {
494        let root = workspace();
495        for atom in [
496            "fs:read:/work/repo/**",
497            "fs:list:/work/repo/src/**",
498            "fs:stat:/work/repo/Cargo.toml",
499            "proc:exec:cargo",
500        ] {
501            let atom: GrantAtom = atom.parse().unwrap();
502            assert!(enforceable(&atom, &root), "{atom} should be accepted");
503        }
504    }
505
506    /// The point of the whole boundary: what it cannot enforce, it refuses.
507    #[test]
508    fn anything_it_cannot_enforce_is_refused() {
509        let root = workspace();
510        for atom in [
511            "fs:write:/work/repo/**",
512            "fs:read:/etc/**",
513            "fs:read:/work/other/**",
514            // A byte-prefix check would accept this: a different directory
515            // whose name begins with the workspace's.
516            "fs:read:/work/repository/**",
517            "fs:read:/work/repo-backup/secrets",
518            "net:egress:api.example.com:443",
519            "secret:use:stripe_key",
520            "env:read:AWS_SECRET_ACCESS_KEY",
521        ] {
522            let atom: GrantAtom = atom.parse().unwrap();
523            assert!(!enforceable(&atom, &root), "{atom} should be refused");
524        }
525    }
526
527    #[test]
528    fn output_is_capped_on_a_character_boundary() {
529        let long = "é".repeat(MAX_OUTPUT);
530        let (kept, truncated) = cap(long.into_bytes());
531
532        assert!(truncated);
533        assert!(kept.len() <= MAX_OUTPUT);
534        assert!(!kept.contains('\u{fffd}'), "cut mid-character");
535    }
536
537    #[test]
538    fn short_output_is_kept_whole_and_not_flagged() {
539        let (kept, truncated) = cap(b"error: expected `;`".to_vec());
540        assert_eq!(kept, "error: expected `;`");
541        assert!(!truncated);
542    }
543
544    #[test]
545    fn diagnostics_read_both_streams() {
546        let outcome = |out: &str, err: &str| Outcome {
547            exit: Some(1),
548            stdout: out.into(),
549            stderr: err.into(),
550            timed_out: false,
551            truncated: false,
552            duration: Duration::ZERO,
553        };
554
555        assert_eq!(outcome("", "on stderr").diagnostics(), "on stderr");
556        assert_eq!(outcome("on stdout", "").diagnostics(), "on stdout");
557        assert_eq!(outcome("out", "err").diagnostics(), "err\nout");
558        assert!(outcome("", "").diagnostics().is_empty());
559    }
560
561    #[test]
562    fn a_timed_out_command_did_not_succeed_whatever_its_exit_status() {
563        let killed = Outcome {
564            exit: Some(0),
565            stdout: String::new(),
566            stderr: String::new(),
567            timed_out: true,
568            truncated: false,
569            duration: Duration::from_secs(300),
570        };
571        assert!(!killed.succeeded());
572    }
573
574    #[cfg(feature = "unconfined")]
575    mod unconfined {
576        use super::*;
577
578        /// Two programs that exist everywhere, one succeeding and one failing.
579        fn command(succeed: bool) -> Command {
580            let workspace = std::env::temp_dir();
581            if cfg!(windows) {
582                let code = if succeed { "exit 0" } else { "exit 3" };
583                Command::new("cmd", ["/C", code], workspace)
584            } else {
585                let code = if succeed { "0" } else { "3" };
586                Command::new("sh", ["-c", &format!("exit {code}")], workspace)
587            }
588        }
589
590        fn grant(command: &Command) -> GrantSet {
591            workspace_grant(&command.workspace, &command.program)
592        }
593
594        #[tokio::test]
595        async fn a_successful_command_reports_a_zero_exit() {
596            let c = command(true);
597            let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
598
599            assert_eq!(outcome.exit, Some(0));
600            assert!(outcome.succeeded());
601            assert!(!outcome.timed_out);
602        }
603
604        /// A command that ran and failed is an outcome, not an error. Deciding
605        /// what a non-zero exit means is the checker's job, not the sandbox's.
606        #[tokio::test]
607        async fn a_failing_command_is_an_outcome_rather_than_an_error() {
608            let c = command(false);
609            let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
610
611            assert_eq!(outcome.exit, Some(3));
612            assert!(!outcome.succeeded());
613        }
614
615        #[tokio::test]
616        async fn output_comes_back_on_the_stream_it_was_written_to() {
617            let workspace = std::env::temp_dir();
618            let c = if cfg!(windows) {
619                Command::new("cmd", ["/C", "echo hello"], workspace)
620            } else {
621                Command::new("sh", ["-c", "echo hello"], workspace)
622            };
623
624            let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
625            assert!(outcome.stdout.contains("hello"), "{outcome:?}");
626        }
627
628        /// The criterion behind both checkers that use this: a hung tool must
629        /// not become a hung commit.
630        #[tokio::test]
631        async fn a_command_that_outlives_its_timeout_is_killed_and_says_so() {
632            let workspace = std::env::temp_dir();
633            // A busy loop rather than a sleep on Windows: `timeout` refuses a
634            // redirected stdin and `ping` is not always on PATH, and neither
635            // failure would be about what this test is checking.
636            let c = if cfg!(windows) {
637                Command::new(
638                    "cmd",
639                    ["/C", "for /L %i in (1,1,200000000) do @rem"],
640                    workspace,
641                )
642            } else {
643                Command::new("sh", ["-c", "sleep 30"], workspace)
644            }
645            .within(Duration::from_millis(250));
646
647            let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
648
649            assert!(outcome.timed_out);
650            assert_eq!(outcome.exit, None);
651            assert!(!outcome.succeeded());
652            assert!(outcome.duration < Duration::from_secs(10));
653        }
654
655        /// It cannot make a `net:egress` grant true, so it will not accept one.
656        #[tokio::test]
657        async fn a_grant_it_cannot_enforce_is_refused_before_anything_runs() {
658            let c = command(true);
659            let over = GrantSet::parse(["net:egress:api.example.com:443"]).unwrap();
660
661            let err = Unconfined::new().run(&c, &over).await.unwrap_err();
662            assert!(matches!(err, SandboxError::Unenforceable { .. }), "{err:?}");
663        }
664
665        #[tokio::test]
666        async fn a_program_that_does_not_exist_is_a_spawn_error() {
667            let c = Command::new("boundgraph-no-such-program", [""; 0], std::env::temp_dir());
668            let err = Unconfined::new().run(&c, &grant(&c)).await.unwrap_err();
669
670            assert!(matches!(err, SandboxError::Spawn { .. }), "{err:?}");
671        }
672
673        /// A variable this process has that `Unconfined` does not keep, if the
674        /// environment offers one. `unsafe_code` is forbidden here, so the test
675        /// cannot plant one and works with what is already there.
676        fn a_non_essential_variable() -> Option<(String, String)> {
677            let runner = Unconfined::new();
678            std::env::vars().find(|(name, value)| {
679                !runner.keeps(name)
680                    && !value.is_empty()
681                    && value.len() > 3
682                    && value.chars().all(|c| c.is_ascii_alphanumeric())
683            })
684        }
685
686        fn echoing(name: &str) -> Command {
687            let workspace = std::env::temp_dir();
688            if cfg!(windows) {
689                Command::new(
690                    "cmd",
691                    ["/C".to_string(), format!("echo [%{name}%]")],
692                    workspace,
693                )
694            } else {
695                Command::new(
696                    "sh",
697                    ["-c".to_string(), format!("echo [${name}]")],
698                    workspace,
699                )
700            }
701        }
702
703        /// An inherited environment is how a credential reaches a child that was
704        /// never granted one.
705        #[tokio::test]
706        async fn the_child_environment_is_cleared_except_for_the_essentials() {
707            let Some((name, value)) = a_non_essential_variable() else {
708                return; // Nothing to prove with; see above.
709            };
710
711            let c = echoing(&name);
712            let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
713
714            assert!(
715                !outcome.stdout.contains(&value),
716                "the child inherited `{name}`: {outcome:?}"
717            );
718        }
719
720        #[tokio::test]
721        async fn a_variable_can_be_passed_through_deliberately() {
722            let Some((name, value)) = a_non_essential_variable() else {
723                return;
724            };
725
726            let c = echoing(&name);
727            let outcome = Unconfined::new()
728                .passing_through(&name)
729                .run(&c, &grant(&c))
730                .await
731                .unwrap();
732
733            assert!(outcome.stdout.contains(&value), "{outcome:?}");
734        }
735
736        /// The essentials really are passed through, or no toolchain starts.
737        #[tokio::test]
738        async fn the_essentials_reach_the_child() {
739            let c = echoing("PATH");
740            let outcome = Unconfined::new().run(&c, &grant(&c)).await.unwrap();
741
742            assert!(outcome.stdout.trim().len() > 3, "{outcome:?}");
743            assert!(!outcome.stdout.contains("[]"), "{outcome:?}");
744        }
745    }
746}