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}