Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/hand/src/exec.rs

414 KiB, 1 run

created by r2519314175:913, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Running a command: `argv` only, explicit environment, streamed, killable.
2//!
3//! The app's existing `Executor` hands a string to `sh -c`, waits, and returns
4//! one whole `CommandOutput`. Three things are wrong with that here, and this
5//! module is the answer to all three.
6//!
7//! * **The shell is the injection surface.** A string given to `sh` has to be
8//! defended against `;`, `$(…)`, backticks, `|`, `eval`, `base64 -d | sh`,
9//! `find -exec` and `tar --to-command`, and that defence does not exist -- a
10//! fence made of string matching is not a fence. Passing the argument vector
11//! *removes* the surface rather than guarding it. There is no `sh -c`
12//! anywhere in this file, tests included, and that absence is the design.
13//! * **Waiting is not watching.** An agent driving `cargo test` needs to see
14//! progress, so both streams are read concurrently and emitted as
15//! [`wire::Resp::Chunk`] as they arrive, bounded by [`CHUNK_MAX`], with a
16//! monotonic per-stream sequence so a dropped frame is detectable.
17//! * **A run must be reachable while it runs.** Every live command is in a
18//! registry keyed by the caller's identifier, so [`Runner::signal`] can reach
19//! one, and a hard wall-clock timeout can end one that will not stop.
20//!
21//! Two smaller decisions worth naming. The environment is *cleared* and then
22//! rebuilt from the pairs the caller gave, because the hand inherits whatever
23//! launched the browser and that includes credentials nobody meant to lend a
24//! command. And the child is put in its own process group, so killing it kills
25//! the compilers and test binaries it spawned rather than orphaning them.
26//!
27//! # Nothing runs until the fence is on it
28//!
29//! Landlock restricts *the calling thread* and is inherited across `execve`.
30//! There is no call that hands a ruleset to somebody else's child, and the one
31//! hook Rust offers for running code between `fork` and `exec` --
32//! `CommandExt::pre_exec` -- is `unsafe`, which this project does not write.
33//!
34//! So the hand does not spawn the command. It spawns **itself**, as a launcher:
35//! a second copy of this binary that reads a plan, applies it to itself while it
36//! is still a small single-purpose process that has opened nothing, and then
37//! calls [`std::os::unix::process::CommandExt::exec`] -- which is safe -- to
38//! *become* the command. The fence carries across the `exec`, which
39//! `fence::tests::the_fence_is_inherited_by_a_real_program` proves against a
40//! real kernel.
41//!
42//! Everything the launcher needs travels down its **standard input**, and that
43//! is a decision rather than an accident. See [`LAUNCH_ARG`] for why not argv,
44//! why not the environment, and why not a spare descriptor.
45//!
46//! # Every command is given somewhere to write
47//!
48//! A real build writes temporary files, and it writes them where `TMPDIR` says
49//! -- which, unset, is `/tmp`. `/tmp` is shared with every other process the
50//! user runs, so it is outside every fence this hand builds, and a fenced
51//! `cargo test` therefore died forty seconds into a compile with `couldn't
52//! create a temp dir: Permission denied (os error 13) at path
53//! "/tmp/rustcOHkDBV"`. That is the worst shape a failure can take here: late,
54//! obscure, and about a path nobody asked for.
55//!
56//! So [`Scratch`] makes one private directory per run, adds it to that run's
57//! fence as a writable root, and points `TMPDIR`, `TMP` and `TEMP` at it. It is
58//! unconditional and it is not configurable by the caller, because a caller that
59//! could name `TMPDIR` would be choosing where a command writes -- and the
60//! environment is not the caller's to set for exactly that reason. It lives
61//! under the hand's own data directory rather than in the Diamond's workspace,
62//! since a build's temporary files are not the user's work, and it is removed
63//! when the run ends however the run ended.
64
65use crate::{
66 fence::{
67 Fence,
68 Grant,
69 Level,
70 Listing,
71 Plan,
72 Reach,
73 SysBase,
74 Unfenced,
75 },
76 seccomp::{
77 Seccomp,
78 Spec as SysSpec,
79 },
80 wire::{
81 Capture,
82 FenceSpec,
83 FileOp,
84 Req,
85 Resp,
86 Run,
87 RunState,
88 Sig,
89 Stream,
90 CHUNK_MAX,
91 RUNS_MAX,
92 RUN_WHAT_MAX,
93 FILE_TEXT_MAX,
94 SEARCH_ANSWER_MAX,
95 SEARCH_CONTEXT_LINES,
96 },
97};
98
99use oxedyne_fe2o3_core::prelude::*;
100use oxedyne_fe2o3_core::rand::Rand;
101// The SAME matchers the page compiles, from the same source text. Two engines that agree
102// today is not a property worth resting a search on: the hand's pass decides which files are
103// worth carrying and the page's decides what the reader sees, so a hand that matched less
104// than the page would hide files the page would have reported and nothing would say so.
105use oxedyne_fe2o3_text::glob::Glob;
106use oxedyne_fe2o3_text::regex::Regex;
107
108use std::{
109 collections::HashMap,
110 path::{
111 Path,
112 PathBuf,
113 },
114 process::Stdio,
115 sync::{
116 atomic::{
117 AtomicBool,
118 AtomicU64,
119 Ordering,
120 },
121 Arc,
122 Mutex,
123 OnceLock,
124 },
125 time::Duration,
126};
127
128use tokio::{
129 io::{
130 AsyncRead,
131 AsyncReadExt,
132 AsyncWriteExt,
133 },
134 process::{
135 Child,
136 Command,
137 },
138 sync::mpsc::{
139 Sender,
140 UnboundedReceiver,
141 UnboundedSender,
142 },
143 task::JoinHandle,
144 time::timeout,
145};
146
147// ┌───────────────────────────────────────────────────────────────┐
148// │ Limits │
149// └───────────────────────────────────────────────────────────────┘
150
151/// The wall-clock limit applied when the caller asks for none.
152///
153/// Zero is read as "no preference", not as "no limit": a command with no
154/// ceiling is a command that can hold a slot for ever.
155pub const DEFAULT_TIMEOUT_MS: u64 = 120_000;
156
157/// The longest wall-clock limit the hand will honour, whatever was asked for.
158pub const TIMEOUT_MAX_MS: u64 = 24 * 60 * 60 * 1_000;
159
160/// How long the readers are given to drain after the child has exited.
161///
162/// A grandchild that survived the group kill can hold the write end of a pipe
163/// open indefinitely; after this the readers are abandoned so that
164/// [`wire::Resp::Ended`] is not held up behind them.
165pub const DRAIN_GRACE_MS: u64 = 2_000;
166
167/// How long a group-signal helper is given before it is given up on.
168const SIGNAL_GRACE_MS: u64 = 2_000;
169
170/// How long a signalled process group is given to empty before it is looked at.
171///
172/// A signal is delivered before it is acted on: between the `kill` returning and
173/// the target running its exit path there is a scheduler tick, and a probe taken
174/// inside it sees a process that is already dying. Short for the same reason --
175/// a tick is all there is between a `KILL` and an empty group, and a longer wait
176/// would only make a `TERM` that is being obeyed slowly look more like one that
177/// is being ignored, which is a judgement this code deliberately does not make.
178///
179/// A member already reaped needs no wait at all: [`counts_as_member`] does not
180/// count a zombie, so the group reads as empty the moment the last of it has
181/// exited rather than the moment the kernel gets round to collecting it.
182const STOP_SETTLE_MS: u64 = 250;
183
184/// Bytes taken from a pipe in one read.
185///
186/// Below [`CHUNK_MAX`] by the most a held-back partial character can add, so an
187/// ordinary text read never has to be split.
188const READ_MAX: usize = CHUNK_MAX - 4;
189
190/// The most output one run will forward, across both streams together.
191///
192/// [`CHUNK_MAX`] bounds a single frame and nothing bounded the total, which is
193/// not the same guarantee at all: `yes` under a three-second timeout delivered
194/// 3,406,442,688 bytes in 52,067 frames. Memory was never at risk -- the pipe
195/// is drained as it fills -- but the journal, the extension's message pipe and
196/// the page's own buffers all absorbed every byte of it.
197///
198/// Twenty megabytes is roughly two hundred times the largest `cargo test` output
199/// seen in practice, and small enough that the whole of it can sit in a page.
200pub const OUTPUT_TOTAL_MAX: u64 = 20 * 1024 * 1024;
201
202/// The argument that tells a copy of this binary it is a launcher.
203///
204/// # Why the plan does not travel here, and does not travel in the environment
205///
206/// A command must not be able to read its own fence. Knowing exactly which
207/// paths are granted, which are carved and which are denied is a map of where to
208/// probe, and it names paths -- a Diamond's directory, an attachment, the
209/// journal -- that the command was never told about.
210///
211/// `argv` and the environment both fail that test while the launcher lives, and
212/// the window is not the point: `/proc/<pid>/cmdline` and `/proc/<pid>/environ`
213/// are readable by every process of the same user, `ps` shows the one and `ps e`
214/// the other, and any of it may be captured by an unrelated monitor. After the
215/// `exec` both are replaced by the command's own -- so the leak would be a race
216/// rather than a certainty, which is worse, not better: it would pass every test
217/// and fail in the field.
218///
219/// The obvious alternative is a spare descriptor, say fd 3. It cannot be done
220/// here: handing a child a descriptor above 2 needs either `pre_exec` or an
221/// `fcntl` to clear `FD_CLOEXEC`, and both are `unsafe`.
222///
223/// So the plan travels on **standard input**, length-prefixed, and the
224/// command's own standard input follows immediately behind it in the same pipe.
225/// The launcher reads exactly the plan's bytes and not one more, leaving the
226/// remainder for the command it becomes. Nothing is ever visible in `argv`, in
227/// the environment, or on disc.
228pub const LAUNCH_ARG: &str = "--daimond-hand-launch";
229
230/// The launcher could not read a plan on its standard input.
231pub const EXIT_NO_PLAN: i32 = 125;
232
233/// The launcher read a plan and could not apply it, so it did not exec.
234///
235/// The one exit code that must never be confused with a command's own: it means
236/// the fence was not in force, and therefore that the command did not run.
237pub const EXIT_FENCE_FAILED: i32 = 126;
238
239/// The fence was applied and the command could not then be started.
240pub const EXIT_EXEC_FAILED: i32 = 127;
241
242/// The `PATH` used to resolve a bare program name when the caller named none.
243///
244/// Deliberately short and absolute. The caller may supply its own `PATH`, and
245/// whatever it supplies is used only to *find* a candidate: the candidate is
246/// then resolved and checked against the fence like any other path, so a `PATH`
247/// pointing somewhere unfenced finds a program the command is not allowed to
248/// run and is refused by name.
249const PATH_FALLBACK: &str = "/usr/local/bin:/usr/bin:/bin";
250
251// ┌───────────────────────────────────────────────────────────────┐
252// │ Outcomes the caller distinguishes │
253// └───────────────────────────────────────────────────────────────┘
254
255/// What became of a request to start a command.
256///
257/// An enum rather than an error, because a refusal is not a failure: the hand
258/// declined, said why in a whole sentence, and the model can recover from that.
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260pub enum Launch {
261 /// The command is running under this process id, which is also its group.
262 Started(u32),
263 /// The hand declined; the sentence has already gone out as
264 /// [`wire::Resp::Refused`].
265 Refused,
266}
267
268/// What became of a request to signal a command.
269///
270/// Three arms and not two, because the missing one was the defect. A signal
271/// that was attempted and did not take used to answer `Finished`, which reads as
272/// "it had already stopped" -- so a page told its command was gone had no way to
273/// learn that it was not.
274#[derive(Clone, Debug, Eq, PartialEq)]
275pub enum Signalled {
276 Sent, // handed to the supervisor, or sent to a standing group
277 Finished, // no such run; it had already gone. Not an error
278 Failed(String), // the signal was attempted and did not take; the sentence says what happened
279}
280
281/// The result of vetting a working directory against a fence.
282pub(crate) enum Vetted {
283 /// The resolved, absolute, in-fence directory.
284 Ok(PathBuf),
285 /// The whole sentence explaining what was refused and why.
286 Refused(String),
287}
288
289/// The result of vetting the program a caller asked to run.
290pub(crate) enum Vetted0 {
291 /// The resolved, absolute, in-fence program.
292 Ok(PathBuf),
293 /// The whole sentence explaining what was refused and why.
294 Refused(String),
295}
296
297/// Which program is re-executed to become the launcher.
298///
299/// An enum with two arms rather than a path with a default, because the two arms
300/// are not interchangeable and the difference should be readable at the call
301/// site. [`Launcher::SelfExe`] is the only one the hand uses.
302///
303/// The test arm exists because the launcher cannot be exercised any other way
304/// from inside a test binary: `/proc/self/exe` there is the *test* binary, whose
305/// `main` is libtest's and which will not dispatch [`LAUNCH_ARG`]. Pointing it
306/// back at the test binary with libtest's own arguments makes a chosen test
307/// function the launcher entry, so the real [`launch_main`] runs, applies a real
308/// fence and really `exec`s -- which is the only way to prove the join between
309/// this module and `fence`.
310#[derive(Clone, Debug, Eq, PartialEq)]
311pub enum Launcher {
312 /// This binary, through `/proc/self/exe`, run with [`LAUNCH_ARG`].
313 ///
314 /// Never `argv[0]`: that is whatever the caller of `execve` chose to put
315 /// there, and on a Linux system it is trivially a lie. `/proc/self/exe` is
316 /// the kernel's own answer to "which file is this process running".
317 ///
318 /// The path is handed to `execve` *as itself* rather than resolved first,
319 /// which matters twice over. A binary that has been replaced or unlinked
320 /// since the hand started still runs the code the hand is running, so an
321 /// upgrade mid-session cannot silently change what the launcher does; and
322 /// there is no window between resolving a name and executing it in which
323 /// something else could take that name.
324 SelfExe,
325 /// A named program, for tests that cannot re-enter this binary's `main`.
326 Explicit {
327 /// The program to run.
328 prog: PathBuf,
329 /// Its arguments, in place of [`LAUNCH_ARG`].
330 args: Vec<String>,
331 /// The launcher's own environment, which the `exec` then replaces.
332 env: Vec<(String, String)>,
333 },
334}
335
336impl Launcher {
337
338 /// The program to spawn, resolved.
339 pub fn prog(&self) -> Outcome<PathBuf> {
340 match self {
341 // Read once to find out whether it is there at all, so that a
342 // machine with no /proc says so in a sentence rather than through
343 // a failed spawn; the path handed back is the literal one.
344 Self::SelfExe => match std::fs::read_link("/proc/self/exe") {
345 Ok(_) => Ok(PathBuf::from("/proc/self/exe")),
346 Err(e) => Err(err!(e,
347 "The hand cannot find its own binary through /proc/self/exe, \
348 so it cannot start the launcher that applies the fence. No \
349 command was run.";
350 IO, Path, Security)),
351 },
352 Self::Explicit { prog, .. } => Ok(prog.clone()),
353 }
354 }
355
356 /// The arguments the launcher is started with.
357 pub fn args(&self) -> Vec<String> {
358 match self {
359 Self::SelfExe => vec![fmt!("{}", LAUNCH_ARG)],
360 Self::Explicit { args, .. } => args.clone(),
361 }
362 }
363
364 /// The launcher's own environment, which `exec` replaces with the command's.
365 pub fn env(&self) -> Vec<(String, String)> {
366 match self {
367 Self::SelfExe => Vec::new(),
368 Self::Explicit { env, .. } => env.clone(),
369 }
370 }
371}
372
373// ┌───────────────────────────────────────────────────────────────┐
374// │ The registry │
375// └───────────────────────────────────────────────────────────────┘
376
377/// One live run, as the registry holds it.
378struct Live {
379 pid: u32, // the child's process id, which is also its process group
380 sigtx: UnboundedSender<Sig>, // to the supervisor, which owns the child and does the killing
381 what: String, // the command line, for the listing
382 since: std::time::Instant, // when it started
383}
384
385// ── A run that ended and left its process group standing ────────────────────
386//
387// A command may start something that outlives it. `bash dev/world.sh 3 --up`
388// starts a dev server and a mock provider in the background and returns; the
389// direct child is reaped and the two servers go on holding their ports, in the
390// process group the launcher made for the run.
391//
392// Until this existed the hand forgot them at that moment, and nothing else could
393// reach them. The fence scopes signals to the Landlock domain that sent them, so
394// a LATER command's `kill` answers "Operation not permitted"; `/proc` is outside
395// every fence, so the pid cannot be found either. A daimon that brought up a
396// world could not take it down, and a person had to clear the ports from outside
397// the app. That is a leak the app creates and then forbids fixing, which is
398// worse than either half.
399//
400// So a run whose group is not empty is kept, and kept whole: the group, so it can
401// be signalled; the command line, so a listing means something; and the SCRATCH
402// DIRECTORY, because `TMPDIR` still points into it and the survivors are still
403// writing there. Removing it at the moment the direct child exited was pulling
404// the ground out from under a process the hand knew about.
405//
406// Keyed by the run's identifier, which is what [`Runner::signal`] already takes.
407// A pid would be a second way in and the wrong one -- a caller that could name a
408// number could name any number, and the whole guarantee here is that only a group
409// this hand's own launcher created can be named at all.
410
411/// A run whose command has ended and whose process group has not emptied.
412struct Left {
413 pgid: u32, // the ended child's pid, which named the group
414 what: String, // the command line, for the listing
415 since: std::time::Instant, // when the command itself ended
416 scratch: Option<Scratch>, // held until the group goes: TMPDIR still points into it
417}
418
419/// Starts commands, streams what they say, and keeps them reachable.
420///
421/// Cheap to clone: every clone shares one registry of live runs. Nothing here
422/// blocks -- [`Runner::spawn`] returns as soon as the child exists, and the
423/// waiting, reading and killing all happen in tasks.
424#[derive(Clone)]
425pub struct Runner {
426 live: Arc<Mutex<HashMap<String, Live>>>, // runs whose command is still going
427 left: Arc<Mutex<HashMap<String, Left>>>, // runs that ended and left a group standing
428 launcher: Arc<Launcher>, // what is re-executed to apply the fence
429}
430
431impl Default for Runner {
432 fn default() -> Self {
433 Self::new()
434 }
435}
436
437impl Runner {
438
439 /// Creates an empty runner that fences through this binary.
440 pub fn new() -> Self {
441 Self::with_launcher(Launcher::SelfExe)
442 }
443
444 /// Creates an empty runner with a stated launcher.
445 ///
446 /// # Arguments
447 /// * `launcher` - What to re-execute in order to apply the fence.
448 pub fn with_launcher(launcher: Launcher) -> Self {
449 Self {
450 live: Arc::new(Mutex::new(HashMap::new())),
451 left: Arc::new(Mutex::new(HashMap::new())),
452 launcher: Arc::new(launcher),
453 }
454 }
455
456 /// Starts a command and returns as soon as it exists.
457 ///
458 /// [`wire::Resp::Started`] is sent before this returns; every
459 /// [`wire::Resp::Chunk`] and the closing [`wire::Resp::Ended`] follow on
460 /// `tx` from a task, in that order. A refusal goes out as
461 /// [`wire::Resp::Refused`] and comes back as [`Launch::Refused`]; an `Err`
462 /// means the machine would not start the process at all, which is a
463 /// different thing and the caller should say so differently.
464 ///
465 /// # Arguments
466 /// * `req` - A [`wire::Req::Exec`]; any other variant is a caller bug.
467 /// * `tx` - Where every response about this run is sent.
468 ///
469 /// # Returns
470 /// [`Launch::Started`] with the child's process id, or [`Launch::Refused`].
471 pub async fn spawn(&self, req: Req, tx: Sender<Resp>) -> Outcome<Launch> {
472 let (id, argv, cwd, mut env, stdin, timeout_ms, capture, mut fence) = match req {
473 // `toolkits` is spent before the request gets here: `Desk::exec` clamps the fence
474 // against it, and what survives that is a fence of absolute paths the runner needs no
475 // grant to interpret. Named rather than swept up by `..`, so a field added later has
476 // to be looked at here too.
477 Req::Exec { id, argv, cwd, env, stdin, timeout_ms, capture, fence, toolkits: _ } =>
478 (id, argv, cwd, env, stdin, timeout_ms, capture, fence),
479 other => return Err(err!(
480 "Runner::spawn was given {:?}, which is not an Exec request.", other;
481 Bug, Invalid, Input)),
482 };
483
484 // Nothing to run. // Checked before the fence, because it is cheaper and
485 // the sentence is more useful.
486 if argv.is_empty() {
487 return self.refuse(&id, &tx, fmt!(
488 "Refused: a command was asked for with no program to run. The first element of \
489 argv is the program and the rest are its arguments -- there is no shell here to \
490 take a string apart for you.")).await;
491 }
492
493 // A caller-chosen identifier that is already in use would leave one
494 // registry slot for two children: the survivor becomes unkillable and
495 // invisible to `Bye`, and the first to finish removes the other's entry,
496 // after which `signal` answers `Finished` for a process that is still
497 // running. There is no repair for that after the fact, so it is refused
498 // before there is a second child. The answer is taken out of the lock
499 // before it is used, so that no guard is held across the `await` that
500 // sends the refusal.
501 //
502 // A LEFTOVER counts too, and for the same reason. A run that ended and
503 // left its process group standing is still reachable by that identifier
504 // and by no other means at all, so letting a second run take the name
505 // would put the first beyond the only door there is.
506 let already = {
507 let g = lock_mutex!(self.live);
508 g.contains_key(&id)
509 };
510 if already {
511 return self.refuse(&id, &tx, fmt!(
512 "Refused: '{}' is already the identifier of a command that is still running. \
513 Identifiers are how a run is signalled and how its output is recognised, so two \
514 runs cannot share one. Give this command a different id, or signal the one \
515 already running.", id)).await;
516 }
517 let standing = {
518 let g = lock_mutex!(self.left);
519 g.contains_key(&id)
520 };
521 if standing {
522 return self.refuse(&id, &tx, fmt!(
523 "Refused: '{}' named a command that has finished and left processes of its own \
524 still running -- a server it started, most likely. That identifier is the only \
525 way anything can reach them, so it cannot be given to a second command. Ask what \
526 is running, stop that one, or give this command a different id.", id)).await;
527 }
528
529 if let Some(s) = screen_env(&env) {
530 return self.refuse(&id, &tx, s).await;
531 }
532
533 if let Some(s) = screen_scratch(&env) {
534 return self.refuse(&id, &tx, s).await;
535 }
536
537 // A push that could destroy work at the far end, refused HERE as well as in the page --
538 // because a repository holding credentials of its own needs nothing from Daimond to make
539 // one, and that is the case the page cannot see. See the section comment on
540 // [`screen_git_push`] for which of the page's rules this repeats and which it does not.
541 // Before the scratch directory is made, so a refusal costs no filesystem work.
542 if let Some(s) = screen_git_push(&argv) {
543 return self.refuse(&id, &tx, s).await;
544 }
545
546 // Against the fence the caller sent, before the hand widens it. The
547 // scratch is a root the caller did not ask for, and a spec that grants
548 // nothing must still read as granting nothing.
549 let dir = match vet_cwd(&cwd, &fence) {
550 Vetted::Ok(p) => p,
551 Vetted::Refused(s) => return self.refuse(&id, &tx, s).await,
552 };
553
554 // Somewhere to write. Made before the plan rather than after, because
555 // the fence is applied by the launcher from a plan it cannot add to: a
556 // directory granted after the plan was made is a directory the command
557 // cannot open. A failure here is a refusal, not a warning -- a command
558 // run without one fails forty seconds into a compile with a permission
559 // error about a path nobody asked for.
560 let scratch = match Scratch::make(&id) {
561 Ok(s) => s,
562 Err(e) => return self.refuse(&id, &tx, fmt!(
563 "Refused: this command could not be given a private directory to write temporary \
564 files in, and the hand will not run one without. {} ", e.msgs().join(" "))).await,
565 };
566 fence.rw.push(fmt!("{}", scratch.dir().display()));
567 // Appended after the caller's pairs, so that the hand's answer is the
568 // last word even if one of these names ever reached this far.
569 for k in TMP_VARS {
570 env.push((fmt!("{}", k), fmt!("{}", scratch.dir().display())));
571 }
572 // And the two the hand fills in only where the request said nothing. The
573 // opposite rule to the three above, and the section on [`add_defaults`]
574 // says why each of the two is there and why the rest are not.
575 add_defaults(&mut env);
576
577 // The fence is decided here, in the hand, where a failure can still
578 // become a sentence the page shows. The launcher only applies it: by the
579 // time the plan is in the launcher's hands the only remaining move is to
580 // die, so a spec that cannot be honoured must fail on this side of the
581 // line. Release gate 1 is this call -- an unfenceable command is refused,
582 // never run and mentioned.
583 let plan = match detected_fence().plan(&fence, &Unfenced::Refuse) {
584 Ok(p) => p,
585 Err(e) => return self.refuse(&id, &tx, fmt!(
586 "Refused: {}", e.msgs().join(" "))).await,
587 };
588
589 // The other half of the compartment, and gate 1 applies to it identically.
590 //
591 // Landlock governs opening a file. It has no access right covering `chmod`,
592 // `chown`, `utimensat` or `setxattr`, and it does not govern `connect()` to a
593 // pathname unix socket below ABI 9 -- so on this kernel a fenced command could
594 // world-write a file inside the denied subtree, and could reach the session bus
595 // and start a process outside the fence entirely. Both were measured; neither is
596 // something `fence.rs` can express.
597 //
598 // Asked here rather than only in the launcher because a machine that cannot
599 // filter must produce a *sentence the page shows*, not an exit code and a line of
600 // stderr. The launcher asks again and dies if the answer changed, because being
601 // wrong there is unrecoverable.
602 if let Err(e) = detected_seccomp().plan(&SysSpec::for_command()) {
603 return self.refuse(&id, &tx, fmt!(
604 "Refused: {}", e.msgs().join(" "))).await;
605 }
606
607 // `argv[0]` is the one caller value that decides which *code* runs, and
608 // it was never checked. An absolute path outside the fence ran; so did
609 // `../outside/evil`; and so did a bare name resolved through a `PATH`
610 // the caller wrote, because `env_clear` then `execvp` resolves against
611 // the child's environment. The program is therefore resolved here, once,
612 // to an absolute path, checked against the fence, and handed to the
613 // launcher already resolved so that nothing resolves it a second time.
614 let prog = match vet_program(&argv[0], &dir, &env, &plan) {
615 Vetted0::Ok(p) => p,
616 Vetted0::Refused(s) => return self.refuse(&id, &tx, s).await,
617 };
618
619 let mut cmd = Command::new(res!(self.launcher.prog()));
620 cmd.args(self.launcher.args());
621 cmd.current_dir(&dir);
622
623 // The launcher's own environment, which is empty for the real launcher
624 // and which `exec` replaces with the command's in any case. The
625 // command's environment travels down the pipe, not through here.
626 cmd.env_clear();
627 for (k, v) in self.launcher.env() {
628 cmd.env(k, v);
629 }
630
631 // Standard input is the launcher's channel: the plan first, then the
632 // command's own input behind it. Where the caller sent no input the
633 // write end is closed after the plan, so the command reads end-of-file
634 // immediately -- which is what `Stdio::null()` used to provide, without
635 // needing `/dev/null` to be inside the fence.
636 cmd.stdin(Stdio::piped());
637 match capture {
638 Capture::Both => { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); },
639 Capture::Out => { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::null()); },
640 Capture::Err => { cmd.stdout(Stdio::null()); cmd.stderr(Stdio::piped()); },
641 Capture::None => { cmd.stdout(Stdio::null()); cmd.stderr(Stdio::null()); },
642 }
643
644 cmd.kill_on_drop(true);
645
646 // Its own process group, so the kill reaches the compilers and test
647 // binaries the command spawned rather than orphaning them. `setpgid`
648 // is done by the child between fork and exec, and `0` means "become
649 // your own leader", so the group id is the child's own process id.
650 #[cfg(unix)]
651 cmd.process_group(0);
652
653 // The whole of what the launcher will do, encoded before there is a
654 // launcher to send it to, so that an encoding failure is a refusal
655 // rather than a process waiting on a pipe that will never fill.
656 let payload = res!(encode_payload(&Payload {
657 prog: prog.clone(),
658 argv: argv.clone(),
659 env: env.clone(),
660 plan: plan.clone(),
661 tty: false,
662 act: Act::Exec,
663 }));
664
665 let mut child = res!(cmd.spawn()
666 .map_err(|e| err!(e,
667 "The hand could not start the launcher that fences '{}' in '{}'.",
668 prog.display(), dir.display();
669 IO, Init)));
670
671 let pid = match child.id() {
672 Some(p) => p,
673 None => return Err(err!(
674 "The child exited before the hand could learn its process id."; IO, Unexpected)),
675 };
676
677 // Written from a task: the plan can exceed a pipe buffer, and a caller
678 // sending more input than a pipe holds would otherwise deadlock against
679 // its own output.
680 if let Some(mut w) = child.stdin.take() {
681 tokio::spawn(async move {
682 if w.write_all(&payload).await.is_err() {
683 return; // The launcher died; `Ended` will carry its code.
684 }
685 if let Some(text) = stdin {
686 let _ = w.write_all(text.as_bytes()).await;
687 }
688 let _ = w.shutdown().await; // Closes the pipe.
689 });
690 }
691
692 let what = cut_to(&argv.join(" "), RUN_WHAT_MAX);
693 let (sigtx, sigrx) = tokio::sync::mpsc::unbounded_channel::<Sig>();
694 {
695 let mut g = lock_mutex!(self.live);
696 g.insert(id.clone(), Live {
697 pid,
698 sigtx: sigtx.clone(),
699 what: what.clone(),
700 since: std::time::Instant::now(),
701 });
702 }
703
704 if tx.send(Resp::Started { id: id.clone(), pid }).await.is_err() {
705 // The registry entry was made before the announcement and must not
706 // outlive it. Left behind, it is permanent: no signal reaches it,
707 // because there is no supervisor to receive one, and `live_count`
708 // over-reports for the life of the hand. The child dies with `cmd`,
709 // which was built with `kill_on_drop`.
710 {
711 let mut g = lock_mutex!(self.live);
712 g.remove(&id);
713 }
714 return Err(err!(
715 "The page stopped listening before '{}' could be announced.", id;
716 Channel, IO));
717 }
718
719 let job = Job {
720 id: id.clone(),
721 pgid: pid,
722 what,
723 dur: Duration::from_millis(clamp_timeout(timeout_ms)),
724 live: Arc::clone(&self.live),
725 left: Arc::clone(&self.left),
726 tx: tx.clone(),
727 scratch: Some(scratch),
728 };
729 tokio::spawn(async move {
730 let jid = job.id.clone();
731 let jtx = job.tx.clone();
732 if let Err(e) = supervise(job, child, sigrx, sigtx).await {
733 let _ = jtx.send(Resp::Error {
734 id: Some(jid),
735 message: fmt!("{}", e),
736 }).await;
737 }
738 });
739
740 Ok(Launch::Started(pid))
741 }
742
743 /// Sends a signal to a run this hand started, live or standing.
744 ///
745 /// Two paths, and the second is the one that was missing. A live run is
746 /// signalled through its supervisor, which owns the child. A run that has
747 /// ENDED and left its process group standing has no supervisor, so the group
748 /// is signalled directly -- and it can be, because the hand is not the fenced
749 /// thing. Nothing else on the machine can: Landlock scopes signals to the
750 /// domain that sent them, so a later command's `kill` answers "Operation not
751 /// permitted", which is what left two servers holding ports with no route to
752 /// them.
753 ///
754 /// **Only by identifier, and only one this hand issued.** There is no arm
755 /// here that takes a pid, a name or a pattern. The guard is not a check on
756 /// the argument; it is that the argument cannot express anything else.
757 ///
758 /// Signalling a run that has already gone is not an error and answers
759 /// [`Signalled::Finished`]. A signal that was attempted and did not take
760 /// answers [`Signalled::Failed`] and never `Finished`.
761 ///
762 /// # Arguments
763 /// * `id` - The identifier given at [`wire::Req::Exec`].
764 /// * `sig` - Which signal.
765 pub async fn signal(&self, id: &str, sig: Sig) -> Outcome<Signalled> {
766 let line = {
767 let g = lock_mutex!(self.live);
768 g.get(id).map(|l| l.sigtx.clone())
769 };
770 if let Some(t) = line {
771 if t.send(sig).is_ok() {
772 return Ok(Signalled::Sent);
773 }
774 // The supervisor has gone, which means the run ended between the
775 // lookup and the send. Fall through: it may be standing.
776 }
777 let pgid = {
778 let g = lock_mutex!(self.left);
779 g.get(id).map(|l| l.pgid)
780 };
781 let pgid = match pgid {
782 Some(p) => p,
783 None => return Ok(Signalled::Finished),
784 };
785 let said = match timeout(
786 Duration::from_millis(SIGNAL_GRACE_MS),
787 signal_group(pgid, sig)).await
788 {
789 Ok(Signalling::Sent) => None,
790 Ok(Signalling::Degraded(w)) => Some(w),
791 Ok(Signalling::Unavailable(w)) => Some(w),
792 Err(_) => Some(fmt!(
793 "The kill helper did not finish within {} ms and was given up on.",
794 SIGNAL_GRACE_MS)),
795 };
796 // Then ask the machine, rather than believe the bookkeeping. A group is
797 // not emptied the instant the signal is delivered -- the leader has to be
798 // reaped -- so the probe waits first, and the wait is short because the
799 // only thing between a KILL and an empty group is a scheduler tick.
800 tokio::time::sleep(Duration::from_millis(STOP_SETTLE_MS)).await;
801 let still = group_standing(pgid).await;
802 res!(self.reap().await);
803 Ok(signalled(&id, pgid, said, still))
804 }
805
806 /// Forgets every standing group that has emptied, and clears its scratch.
807 ///
808 /// A listing that still holds a group nobody is in is a listing that lies,
809 /// and it lies in the direction that matters: a reader would go on trying to
810 /// stop something already gone rather than looking for what is not. A group
811 /// the machine will not answer about is KEPT, because "I cannot tell" is not
812 /// "it is gone".
813 pub async fn reap(&self) -> Outcome<usize> {
814 let asking = {
815 let g = lock_mutex!(self.left);
816 g.iter().map(|(k, l)| (k.clone(), l.pgid)).collect::<Vec<_>>()
817 };
818 let mut gone = Vec::new();
819 for (id, pgid) in asking {
820 if group_standing(pgid).await == Some(false) {
821 gone.push(id);
822 }
823 }
824 let mut taken = Vec::new();
825 {
826 let mut g = lock_mutex!(self.left);
827 for id in &gone {
828 if let Some(mut l) = g.remove(id) {
829 if let Some(sc) = l.scratch.take() {
830 taken.push(sc);
831 }
832 }
833 }
834 }
835 // Removed outside the lock: a scrub of a tree the command built is not
836 // work to do while every other run is waiting to look at the registry.
837 for mut sc in taken {
838 let _ = sc.remove();
839 }
840 Ok(gone.len())
841 }
842
843 /// What this hand is still running, standing groups included.
844 ///
845 /// Measured rather than remembered: [`Runner::reap`] runs first, so a group
846 /// that has emptied since anyone last looked is gone from the answer rather
847 /// than reported and then found missing.
848 ///
849 /// Standing runs come first, oldest first, because they are the ones nothing
850 /// else can reach and therefore the ones a reader is looking for.
851 pub async fn runs(&self) -> Outcome<(Vec<Run>, u32)> {
852 res!(self.reap().await);
853 let now = std::time::Instant::now();
854 let mut out = Vec::new();
855 {
856 let g = lock_mutex!(self.left);
857 for (id, l) in g.iter() {
858 out.push(Run {
859 id: id.clone(),
860 pid: l.pgid,
861 what: l.what.clone(),
862 state: RunState::Standing,
863 secs: now.saturating_duration_since(l.since).as_secs().min(u32::MAX as u64)
864 as u32,
865 });
866 }
867 }
868 out.sort_by(|a, b| b.secs.cmp(&a.secs).then(a.id.cmp(&b.id)));
869 let mut live = Vec::new();
870 {
871 let g = lock_mutex!(self.live);
872 for (id, l) in g.iter() {
873 live.push(Run {
874 id: id.clone(),
875 pid: l.pid,
876 what: l.what.clone(),
877 state: RunState::Running,
878 secs: now.saturating_duration_since(l.since).as_secs().min(u32::MAX as u64)
879 as u32,
880 });
881 }
882 }
883 live.sort_by(|a, b| b.secs.cmp(&a.secs).then(a.id.cmp(&b.id)));
884 out.extend(live);
885 let more = out.len().saturating_sub(RUNS_MAX) as u32;
886 out.truncate(RUNS_MAX);
887 Ok((out, more))
888 }
889
890 /// Stops everything this hand started, for [`wire::Req::Bye`].
891 ///
892 /// **Standing groups included, and that is a decision rather than tidiness.**
893 /// A dev server a run left behind is reachable through this hand and through
894 /// nothing else; if the hand exits without stopping it, it holds its port
895 /// until somebody finds it from outside the app, which is the incident this
896 /// whole arrangement is a repair for. A server outliving the page that asked
897 /// for it is a thing nobody asked for either.
898 ///
899 /// # Returns
900 /// How many runs were signalled, live and standing together.
901 pub async fn stop_all(&self) -> Outcome<usize> {
902 let lines = {
903 let g = lock_mutex!(self.live);
904 g.values().map(|l| l.sigtx.clone()).collect::<Vec<_>>()
905 };
906 let mut n = 0;
907 for t in lines {
908 if t.send(Sig::Kill).is_ok() {
909 n += 1;
910 }
911 }
912 let standing = {
913 let g = lock_mutex!(self.left);
914 g.values().map(|l| l.pgid).collect::<Vec<_>>()
915 };
916 for pgid in standing {
917 // Awaited, unlike the line above: there is no supervisor here to do
918 // the killing after this function returns, and the process this one
919 // is in is about to exit.
920 if let Ok(Signalling::Sent) = timeout(
921 Duration::from_millis(SIGNAL_GRACE_MS),
922 signal_group(pgid, Sig::Kill)).await
923 {
924 n += 1;
925 }
926 }
927 res!(self.reap().await);
928 Ok(n)
929 }
930
931 /// The process id of a live run, or `None` if it has ended.
932 ///
933 /// # Arguments
934 /// * `id` - The identifier given at [`wire::Req::Exec`].
935 pub fn pid_of(&self, id: &str) -> Outcome<Option<u32>> {
936 let g = lock_mutex!(self.live);
937 Ok(g.get(id).map(|l| l.pid))
938 }
939
940 /// How many runs are live.
941 pub fn live_count(&self) -> Outcome<usize> {
942 let g = lock_mutex!(self.live);
943 Ok(g.len())
944 }
945
946 /// How many ended runs are still holding a process group.
947 ///
948 /// Remembered rather than measured, unlike [`Runner::runs`]: a caller that
949 /// wants the honest picture asks for the listing, and this is the cheap
950 /// question a test or a shutdown path asks.
951 pub fn standing_count(&self) -> Outcome<usize> {
952 let g = lock_mutex!(self.left);
953 Ok(g.len())
954 }
955
956 /// Sends a refusal and reports it, so the caller does not repeat itself.
957 ///
958 /// # Arguments
959 /// * `id` - The run the refusal concerns.
960 /// * `tx` - Where the refusal is sent.
961 /// * `reason` - The whole sentence.
962 async fn refuse(&self, id: &str, tx: &Sender<Resp>, reason: String) -> Outcome<Launch> {
963 if tx.send(Resp::Refused { id: fmt!("{}", id), reason }).await.is_err() {
964 return Err(err!(
965 "The page stopped listening before the refusal for '{}' could be sent.", id;
966 Channel, IO));
967 }
968 Ok(Launch::Refused)
969 }
970}
971
972// ┌───────────────────────────────────────────────────────────────┐
973// │ The file door │
974// └───────────────────────────────────────────────────────────────┘
975
976/// How long one file operation is given.
977///
978/// A file op is not a build. It opens a file, reads or writes it and exits, so a minute is
979/// already generous -- and a launcher that has not answered in a minute is one that will not,
980/// which is a better thing to say than to wait for.
981const FILE_TIMEOUT_MS: u64 = 60_000;
982
983/// Carries out one [`Req::File`], behind the fence a command would run behind.
984///
985/// **The whole of what this type adds over [`Runner`] is that nothing is exec'd.** The
986/// gates are the same gates in the same order -- the working directory is vetted against the
987/// fence the caller sent, the plan is made here where a failure can still become a sentence,
988/// release gate 1 refuses an unfenceable request rather than running it and mentioning it,
989/// and the system-call filter is proved buildable before a child exists. Then the same
990/// launcher is started with the same plan, and it applies the same ruleset before it opens
991/// anything.
992///
993/// It holds no registry. A file op cannot be signalled, cannot leave a process group
994/// standing and cannot outlive its own answer, so there is nothing for a caller to reach
995/// afterwards and nothing to record for them to reach it by.
996#[derive(Clone)]
997pub struct Files {
998 launcher: Arc<Launcher>,
999}
1000
1001impl Default for Files {
1002 fn default() -> Self {
1003 Self::new()
1004 }
1005}
1006
1007impl Files {
1008
1009 /// A door that fences through this binary.
1010 pub fn new() -> Self {
1011 Self::with_launcher(Launcher::SelfExe)
1012 }
1013
1014 /// A door with a stated launcher, which is how a test reaches the real [`launch_main`].
1015 pub fn with_launcher(launcher: Launcher) -> Self {
1016 Self { launcher: Arc::new(launcher) }
1017 }
1018
1019 /// Does the operation and answers, or says why it did not.
1020 ///
1021 /// # Arguments
1022 /// * `req` - The [`Req::File`].
1023 /// * `tx` - Where the answer goes.
1024 pub async fn apply(&self, req: Req, tx: Sender<Resp>) -> Outcome<()> {
1025 let (id, op, cwd, fence) = match req {
1026 // Named rather than swept up by `..`, so a field added later has to be looked at
1027 // here too. `toolkits` is spent before the request gets here, exactly as it is for
1028 // an `Exec`: `Desk::exec` clamps the fence against it.
1029 Req::File { id, op, cwd, fence, toolkits: _ } => (id, op, cwd, fence),
1030 other => return Err(err!(
1031 "Files::apply was given {:?}, which is not a File request.", other;
1032 Bug, Invalid, Input)),
1033 };
1034
1035 let dir = match vet_cwd(&cwd, &fence) {
1036 Vetted::Ok(p) => p,
1037 Vetted::Refused(s) => return self.refuse(&id, &tx, s).await,
1038 };
1039
1040 // Release gate 1, word for word as `Runner::spawn` meets it: the fence is decided in
1041 // the hand, where a failure can still be a sentence the page shows, and an
1042 // unfenceable request is refused rather than run and mentioned afterwards.
1043 let plan = match detected_fence().plan(&fence, &Unfenced::Refuse) {
1044 Ok(p) => p,
1045 Err(e) => return self.refuse(&id, &tx, fmt!(
1046 "Refused: {}", e.msgs().join(" "))).await,
1047 };
1048
1049 // The other half of the compartment. Landlock does not govern `chmod`, `chown`,
1050 // `utimensat` or `setxattr`, so a fence without the filter is not a compartment on
1051 // this kernel -- and a file op is precisely a thing that would use them.
1052 if let Err(e) = detected_seccomp().plan(&SysSpec::for_command()) {
1053 return self.refuse(&id, &tx, fmt!("Refused: {}", e.msgs().join(" "))).await;
1054 }
1055
1056 let payload = res!(encode_payload(&Payload {
1057 prog: PathBuf::new(),
1058 argv: Vec::new(),
1059 env: Vec::new(),
1060 plan: plan.clone(),
1061 tty: false,
1062 act: Act::File(op.clone()),
1063 }));
1064
1065 let mut cmd = Command::new(res!(self.launcher.prog()));
1066 cmd.args(self.launcher.args());
1067 cmd.current_dir(&dir);
1068 cmd.env_clear();
1069 for (k, v) in self.launcher.env() {
1070 cmd.env(k, v);
1071 }
1072 cmd.stdin(Stdio::piped());
1073 cmd.stdout(Stdio::piped());
1074 cmd.stderr(Stdio::piped());
1075 cmd.kill_on_drop(true);
1076 #[cfg(unix)]
1077 cmd.process_group(0);
1078
1079 let mut child = res!(cmd.spawn().map_err(|e| err!(e,
1080 "The hand could not start the launcher that fences a file operation in '{}'.",
1081 dir.display(); IO, Init)));
1082
1083 // Written from a task for the reason `Runner::spawn` gives: a plan can exceed a pipe
1084 // buffer, and a writer that blocks against its own unread output deadlocks.
1085 if let Some(mut w) = child.stdin.take() {
1086 tokio::spawn(async move {
1087 let _ = w.write_all(&payload).await;
1088 let _ = w.shutdown().await;
1089 });
1090 }
1091
1092 let waited = tokio::time::timeout(
1093 Duration::from_millis(FILE_TIMEOUT_MS),
1094 child.wait_with_output()).await;
1095 let out = match waited {
1096 Ok(Ok(o)) => o,
1097 Ok(Err(e)) => return self.refuse(&id, &tx, fmt!(
1098 "Refused: the fenced child that was to {} '{}' could not be waited on ({}). \
1099 Do not assume nothing changed.", op.word(), op.path(), e)).await,
1100 Err(_) => return self.refuse(&id, &tx, fmt!(
1101 "Refused: the fenced child that was to {} '{}' did not answer within {} \
1102 seconds and was stopped. Do not assume nothing changed.",
1103 op.word(), op.path(), FILE_TIMEOUT_MS / 1000)).await,
1104 };
1105
1106 // A non-zero exit is the launcher's own, and every one of its codes means the fence
1107 // was NOT in force and therefore that nothing was done. Its sentence is on standard
1108 // error and is written for a reader; it is passed through rather than summarised.
1109 if !out.status.success() {
1110 let why = String::from_utf8_lossy(&out.stderr).trim().to_string();
1111 return self.refuse(&id, &tx, fmt!(
1112 "Refused: the fence could not be put on the process that was to {} '{}', so \
1113 nothing was done. {}", op.word(), op.path(), why)).await;
1114 }
1115
1116 let (ok, text) = match out.stdout.split_first() {
1117 Some((b, rest)) => (*b != 0, String::from_utf8_lossy(rest).to_string()),
1118 None => return self.refuse(&id, &tx, fmt!(
1119 "Refused: the fenced child that was to {} '{}' exited without saying what it \
1120 did. Do not assume nothing changed.", op.word(), op.path())).await,
1121 };
1122
1123 if tx.send(Resp::Filed { id: id.clone(), ok, text }).await.is_err() {
1124 return Err(err!(
1125 "The page stopped listening before the answer for '{}' could be sent.", id;
1126 Channel, IO));
1127 }
1128 Ok(())
1129 }
1130
1131 /// Sends a refusal and says so.
1132 async fn refuse(&self, id: &str, tx: &Sender<Resp>, reason: String) -> Outcome<()> {
1133 if tx.send(Resp::Refused { id: fmt!("{}", id), reason }).await.is_err() {
1134 return Err(err!(
1135 "The page stopped listening before the refusal for '{}' could be sent.", id;
1136 Channel, IO));
1137 }
1138 Ok(())
1139 }
1140}
1141
1142// ┌───────────────────────────────────────────────────────────────┐
1143// │ Supervision │
1144// └───────────────────────────────────────────────────────────────┘
1145
1146/// Everything the supervisor needs that is not the child itself.
1147struct Job {
1148 id: String, // the caller's identifier
1149 pgid: u32, // the child's process group, which is its process id
1150 what: String, // the command line, kept for a listing if the group outlives the command
1151 dur: Duration, // the hard wall-clock limit
1152 live: Arc<Mutex<HashMap<String, Live>>>, // so the run can forget itself when it ends
1153 left: Arc<Mutex<HashMap<String, Left>>>, // and remember itself where its group has not
1154 tx: Sender<Resp>,
1155 /// The command's private temporary directory.
1156 ///
1157 /// Held here so that it is removed by the one piece of code that sees every
1158 /// way a run can end, and taken out of the option there so that `Drop` --
1159 /// which covers the ways a run can end before this struct exists -- has
1160 /// nothing left to do.
1161 scratch: Option<Scratch>,
1162}
1163
1164/// Watches one child to its end and sends the closing [`wire::Resp::Ended`].
1165///
1166/// # Arguments
1167/// * `job` - The run's identity, limit and outputs.
1168/// * `child` - The spawned child, whose pipes are taken here.
1169/// * `sigrx` - Signals arriving from [`Runner::signal`].
1170/// * `sigtx` - A live sender kept so the receiver never reports closure.
1171async fn supervise(
1172 mut job: Job,
1173 mut child: Child,
1174 mut sigrx: UnboundedReceiver<Sig>,
1175 sigtx: UnboundedSender<Sig>,
1176)
1177 -> Outcome<()>
1178{
1179 let _keepalive = sigtx; // Holding this keeps `sigrx.recv()` from ending.
1180
1181 let out_n = Arc::new(AtomicU64::new(0));
1182 let err_n = Arc::new(AtomicU64::new(0));
1183 // One budget across both streams, and one marker for the pair: a command
1184 // that floods stderr must not be allowed a second helping on stdout, and the
1185 // page should be told once rather than twice.
1186 let budget = Budget {
1187 spent: Arc::new(AtomicU64::new(0)),
1188 noted: Arc::new(AtomicBool::new(false)),
1189 };
1190 let mut pumps: Vec<JoinHandle<()>> = Vec::new();
1191
1192 if let Some(r) = child.stdout.take() {
1193 pumps.push(tokio::spawn(pump(
1194 r, job.id.clone(), Stream::Out, job.tx.clone(), Arc::clone(&out_n),
1195 budget.clone())));
1196 }
1197 if let Some(r) = child.stderr.take() {
1198 pumps.push(tokio::spawn(pump(
1199 r, job.id.clone(), Stream::Err, job.tx.clone(), Arc::clone(&err_n),
1200 budget.clone())));
1201 }
1202
1203 let mut timed_out = false;
1204 let mut killed = false;
1205
1206 let deadline = tokio::time::sleep(job.dur);
1207 tokio::pin!(deadline);
1208
1209 // What the group signal did, kept rather than discarded. Both call sites
1210 // used to throw it away with `let _ =`, so on a system whose `kill` rejects
1211 // the form used here -- BusyBox does, and Alpine is a realistic Cloud-tier
1212 // host -- only the direct child was signalled while the page was told
1213 // `Ended{killed:true}`. The grandchildren the group kill exists for survived
1214 // and nothing said so.
1215 let mut degraded: Option<String> = None;
1216 let status = loop {
1217 tokio::select! {
1218 r = child.wait() => break r,
1219 _ = &mut deadline, if !timed_out => {
1220 timed_out = true;
1221 note_signalling(
1222 &mut degraded,
1223 timeout(
1224 Duration::from_millis(SIGNAL_GRACE_MS),
1225 signal_group(job.pgid, Sig::Kill)).await);
1226 let _ = child.start_kill();
1227 },
1228 m = sigrx.recv() => {
1229 if let Some(s) = m {
1230 killed = true;
1231 note_signalling(
1232 &mut degraded,
1233 timeout(
1234 Duration::from_millis(SIGNAL_GRACE_MS),
1235 signal_group(job.pgid, s)).await);
1236 // Where the group signal did not take, the direct child is
1237 // killed whatever was asked for: a `Term` that reached
1238 // nothing leaves a run the page believes it has stopped.
1239 if s == Sig::Kill || degraded.is_some() {
1240 let _ = child.start_kill();
1241 }
1242 }
1243 },
1244 }
1245 };
1246
1247 if let Some(why) = &degraded {
1248 let _ = job.tx.send(Resp::Error {
1249 id: Some(job.id.clone()),
1250 message: fmt!(
1251 "The signal reached the command itself but not the process group \
1252 it leads, so anything it had started may still be running. {}",
1253 why),
1254 }).await;
1255 }
1256
1257 // Let the readers finish, but not for ever: a surviving grandchild holding
1258 // the write end open must not keep the page waiting.
1259 for mut h in pumps {
1260 if timeout(Duration::from_millis(DRAIN_GRACE_MS), &mut h).await.is_err() {
1261 h.abort();
1262 }
1263 }
1264
1265 // Forget the run before announcing its end, so a signal that arrives after
1266 // the announcement is answered `Finished` rather than sent nowhere.
1267 {
1268 let mut g = lock_mutex!(job.live);
1269 g.remove(&job.id);
1270 }
1271
1272 // Did the command leave anything of its own behind? The direct child is
1273 // reaped; its process GROUP may not be empty, and `bash x.sh --up` starting
1274 // a server in the background is the ordinary way that happens. Asked before
1275 // the scratch is removed, because the answer decides whether removing it
1276 // would pull the ground out from under a process that is still writing
1277 // there.
1278 //
1279 // ASKED EVEN WHERE THE GROUP WAS SIGNALLED, and the first draft skipped that
1280 // case as empty by construction. It is not: `killed` is set by any of the
1281 // three signals, and a `Term` the group declined to obey leaves exactly the
1282 // thing this record exists for -- unrecorded, because the skip looked safe.
1283 // What makes asking here safe is that a zombie does not count as standing;
1284 // see `counts_as_member`.
1285 let standing = group_standing(job.pgid).await;
1286
1287 // And take the scratch away before announcing it too, so that a page told a
1288 // run has ended can never look and still find it. This is the ordinary path;
1289 // every other way out of this function drops the guard instead, which does
1290 // the same thing without being able to say that it failed.
1291 //
1292 // The exception is a group still standing, where the scratch goes into the
1293 // leftover record instead and is removed when the group finally is.
1294 if standing == Some(true) {
1295 let s = job.scratch.take();
1296 {
1297 let mut g = lock_mutex!(job.left);
1298 g.insert(job.id.clone(), Left {
1299 pgid: job.pgid,
1300 what: job.what.clone(),
1301 since: std::time::Instant::now(),
1302 scratch: s,
1303 });
1304 }
1305 // Said before `Ended`, because the page attaches a note to a run before
1306 // it settles it and this has to reach the model that started the thing.
1307 // Naming the identifier is the whole of the message: it is the only way
1308 // anything can reach that group, since the fence scopes signals to the
1309 // domain that sent them and a later command's `kill` answers "Operation
1310 // not permitted".
1311 let _ = job.tx.send(Resp::Error {
1312 id: Some(job.id.clone()),
1313 message: fmt!(
1314 "'{}' has finished and the processes it started are still running, in process \
1315 group {}. Nothing you can RUN will stop them -- each command is fenced into a \
1316 domain of its own and cannot signal another one's -- so the hand keeps them \
1317 reachable by this identifier. Ask it what is running, and stop '{}' when you are \
1318 done with them. They are stopped for you when the page goes away.",
1319 job.id, job.pgid, job.id),
1320 }).await;
1321 } else if let Some(mut s) = job.scratch.take() {
1322 if let Err(e) = s.remove() {
1323 let _ = job.tx.send(Resp::Error {
1324 id: Some(job.id.clone()),
1325 message: fmt!(
1326 "The command's private temporary directory could not be \
1327 removed, so what it wrote there is still on this machine. \
1328 {}", e.msgs().join(" ")),
1329 }).await;
1330 }
1331 }
1332
1333 let exit = match &status {
1334 Ok(st) => match st.code() {
1335 Some(c) => c,
1336 None => -1, // Ended by a signal, which has no exit code.
1337 },
1338 Err(_) => -1,
1339 };
1340
1341 let ended = Resp::Ended {
1342 id: job.id.clone(),
1343 exit,
1344 timed_out,
1345 killed,
1346 out_bytes: out_n.load(Ordering::Relaxed),
1347 err_bytes: err_n.load(Ordering::Relaxed),
1348 };
1349 if job.tx.send(ended).await.is_err() {
1350 return Err(err!(
1351 "The page stopped listening before '{}' could be closed off.", job.id;
1352 Channel, IO));
1353 }
1354
1355 if let Err(e) = status {
1356 return Err(err!(e, "Waiting on '{}' failed.", job.id; IO));
1357 }
1358 Ok(())
1359}
1360
1361/// How much output one run has been allowed to forward, shared by both streams.
1362#[derive(Clone)]
1363struct Budget {
1364 /// Bytes forwarded so far, across both streams.
1365 spent: Arc<AtomicU64>,
1366 /// Whether the truncation marker has already gone out.
1367 noted: Arc<AtomicBool>,
1368}
1369
1370impl Budget {
1371
1372 /// Whether `len` more bytes may be forwarded, taking them if so.
1373 ///
1374 /// # Arguments
1375 /// * `len` - The size of the chunk about to be sent.
1376 fn take(&self, len: usize) -> bool {
1377 let before = self.spent.fetch_add(len as u64, Ordering::Relaxed);
1378 before < OUTPUT_TOTAL_MAX
1379 }
1380
1381 /// The marker, once, or `None` if it has already been sent.
1382 fn marker(&self) -> Option<String> {
1383 if self.noted.swap(true, Ordering::Relaxed) {
1384 return None;
1385 }
1386 Some(fmt!(
1387 "\n[The hand stopped forwarding this command's output after {} \
1388 bytes. The command is still running and the rest of what it says is \
1389 being read and discarded, so it will not block. The byte counts in \
1390 the closing message are the true totals.]\n",
1391 OUTPUT_TOTAL_MAX))
1392 }
1393}
1394
1395/// Reads one stream to its end, emitting bounded, sequenced chunks.
1396///
1397/// Invalid UTF-8 is replaced rather than rejected, but a *partial* character at
1398/// the end of a read is held back and joined to the next one, so a chunk
1399/// boundary landing mid-character does not corrupt the text.
1400///
1401/// Past [`OUTPUT_TOTAL_MAX`] the stream is still *read* -- stopping would block
1402/// the command on a full pipe, which is a different failure and a worse one --
1403/// but nothing more is forwarded, and one marker says so.
1404///
1405/// # Arguments
1406/// * `r` - The pipe to read.
1407/// * `id` - The caller's identifier.
1408/// * `stream` - Which stream this is.
1409/// * `tx` - Where chunks are sent.
1410/// * `n` - Running count of bytes read from this stream.
1411/// * `budget` - How much more this run may forward, shared with the other stream.
1412async fn pump<R>(
1413 mut r: R,
1414 id: String,
1415 stream: Stream,
1416 tx: Sender<Resp>,
1417 n: Arc<AtomicU64>,
1418 budget: Budget,
1419)
1420where
1421 R: AsyncRead + Unpin + Send + 'static,
1422{
1423 let mut buf = vec![0u8; READ_MAX];
1424 let mut carry = Vec::<u8>::new(); // A partial character, at most three bytes.
1425 let mut seq = 0u64;
1426
1427 loop {
1428 let got = match r.read(&mut buf).await {
1429 Ok(0) => break,
1430 Ok(k) => k,
1431 Err(_) => break,
1432 };
1433 n.fetch_add(got as u64, Ordering::Relaxed);
1434
1435 let mut data = Vec::with_capacity(carry.len() + got);
1436 data.extend_from_slice(&carry);
1437 data.extend_from_slice(&buf[..got]);
1438 carry.clear();
1439
1440 // Hold back a trailing sequence that is incomplete rather than wrong.
1441 let cut = match std::str::from_utf8(&data) {
1442 Ok(_) => data.len(),
1443 Err(e) => match e.error_len() {
1444 None => e.valid_up_to(),
1445 Some(_) => data.len(),
1446 },
1447 };
1448 if cut < data.len() {
1449 carry.extend_from_slice(&data[cut..]);
1450 data.truncate(cut);
1451 }
1452 if data.is_empty() {
1453 continue;
1454 }
1455
1456 let text = String::from_utf8_lossy(&data).to_string();
1457 if !forward(&tx, &id, stream, &mut seq, &text, &budget).await {
1458 return;
1459 }
1460 }
1461
1462 if !carry.is_empty() {
1463 let text = String::from_utf8_lossy(&carry).to_string();
1464 let _ = forward(&tx, &id, stream, &mut seq, &text, &budget).await;
1465 }
1466}
1467
1468/// Sends `text` if the run's output budget still allows it, marking the point at
1469/// which it stopped.
1470///
1471/// # Arguments
1472/// * `tx` - Where chunks are sent.
1473/// * `id` - The caller's identifier.
1474/// * `stream` - Which stream this is.
1475/// * `seq` - The stream's sequence counter.
1476/// * `text` - The run of output.
1477/// * `budget` - How much more this run may forward.
1478///
1479/// # Returns
1480/// False once the page has stopped listening. A run over budget still returns
1481/// true, because the stream must go on being drained.
1482async fn forward(
1483 tx: &Sender<Resp>,
1484 id: &str,
1485 stream: Stream,
1486 seq: &mut u64,
1487 text: &str,
1488 budget: &Budget,
1489)
1490 -> bool
1491{
1492 if budget.take(text.len()) {
1493 return emit(tx, id, stream, seq, text).await;
1494 }
1495 match budget.marker() {
1496 Some(m) => emit(tx, id, stream, seq, &m).await,
1497 None => true,
1498 }
1499}
1500
1501/// Sends `text` as one or more chunks, none larger than [`CHUNK_MAX`].
1502///
1503/// # Arguments
1504/// * `tx` - Where chunks are sent.
1505/// * `id` - The caller's identifier.
1506/// * `stream` - Which stream this is.
1507/// * `seq` - The stream's sequence counter, advanced once per chunk.
1508/// * `text` - The run of output.
1509///
1510/// # Returns
1511/// False once the page has stopped listening.
1512async fn emit(
1513 tx: &Sender<Resp>,
1514 id: &str,
1515 stream: Stream,
1516 seq: &mut u64,
1517 text: &str,
1518)
1519 -> bool
1520{
1521 for part in split_chunks(text) {
1522 let msg = Resp::Chunk {
1523 id: fmt!("{}", id),
1524 stream,
1525 seq: *seq,
1526 data: fmt!("{}", part),
1527 };
1528 if tx.send(msg).await.is_err() {
1529 return false;
1530 }
1531 *seq += 1;
1532 }
1533 true
1534}
1535
1536/// Splits `s` into runs of at most [`CHUNK_MAX`] bytes, never inside a character.
1537///
1538/// Replacing invalid bytes can treble their length, so text that fitted a read
1539/// buffer need not fit a chunk; this is where that is made true again.
1540///
1541/// # Arguments
1542/// * `s` - The text to split.
1543fn split_chunks(s: &str) -> Vec<&str> {
1544 if s.len() <= CHUNK_MAX {
1545 return vec![s];
1546 }
1547 let mut parts = Vec::new();
1548 let mut start = 0usize;
1549 while start < s.len() {
1550 let mut end = std::cmp::min(start + CHUNK_MAX, s.len());
1551 while end > start && !s.is_char_boundary(end) {
1552 end -= 1;
1553 }
1554 parts.push(&s[start..end]);
1555 start = end;
1556 }
1557 parts
1558}
1559
1560// ┌───────────────────────────────────────────────────────────────┐
1561// │ Signalling a process group │
1562// └───────────────────────────────────────────────────────────────┘
1563
1564/// What became of an attempt to signal a whole process group.
1565///
1566/// A three-armed answer rather than a boolean, because the two failures need
1567/// different sentences: a `kill` that would not take the arguments is a
1568/// portability problem the operator can act on, and no `kill` at all is a
1569/// missing system.
1570#[derive(Clone, Debug, Eq, PartialEq)]
1571pub enum Signalling {
1572 /// A `kill` ran and reported success.
1573 Sent,
1574 /// A `kill` ran and refused, so only the direct child was reached.
1575 Degraded(String),
1576 /// No `kill` could be run at all.
1577 Unavailable(String),
1578}
1579
1580/// The programs tried, in order, when a process group has to be signalled.
1581///
1582/// Two spellings, because the binary sits in different places on different
1583/// systems and neither is worth failing over.
1584const KILL_PROGS: &[&str] = &["/bin/kill", "/usr/bin/kill"];
1585
1586/// How a particular `kill` wants a process group named.
1587///
1588/// The operand is `-1234`, a negative number, and a negative number is
1589/// indistinguishable from an option unless something says otherwise. The two
1590/// arms are the two answers systems give to that, and there is no third:
1591/// procps-ng needs the POSIX `--` and BusyBox has never implemented it.
1592#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1593enum Operand {
1594 /// `-s TERM -- -1234`, which is what POSIX says and what procps-ng requires.
1595 Separated,
1596 /// `-s TERM -1234`, for a `kill` that treats `--` as a malformed pid.
1597 Bare,
1598}
1599
1600/// Asks a `kill` which spelling it takes, by signalling nothing to this process.
1601///
1602/// Signal 0 is the null signal: it validates the arguments and delivers nothing.
1603/// Sent to the hand's own process it cannot fail for want of permission, so a
1604/// non-zero exit means the arguments -- which is exactly the question being
1605/// asked, and the only question this probe can answer wrongly.
1606///
1607/// # Why this is a probe and not a fallback
1608///
1609/// The obvious design is to send with `--` and retry without it, and it is worse
1610/// in a way that is easy to miss. Measured here on BusyBox 1.37,
1611/// `kill -s KILL -- -<pgid>` prints `kill: invalid number '--'`, **exits 1, and
1612/// kills the group anyway**: the unreadable operand is counted as an error and
1613/// the loop carries on to the one after it. A retry therefore sends a second
1614/// signal to a group the first has already emptied, and whether that reports
1615/// success turns on whether the group's leader has been reaped -- `kill` reaches
1616/// a zombie and fails with `ESRCH` once it is gone. That makes the answer the
1617/// page is given depend on the caller's bookkeeping rather than on what happened
1618/// to the command, and `Degraded` is the sentence that tells a user their build
1619/// may still be running.
1620///
1621/// Asking first costs one extra process on the path that kills a run, sends the
1622/// signal exactly once, and leaves the exit status meaning what it says on both
1623/// systems.
1624///
1625/// # Arguments
1626/// * `prog` - The `kill` to ask.
1627///
1628/// # Returns
1629/// Which spelling to use, or `None` where the program could not be run at all.
1630#[cfg(unix)]
1631async fn operand_form(prog: &str) -> Option<Operand> {
1632 let mut c = Command::new(prog);
1633 c.arg("-s").arg("0").arg("--").arg(fmt!("{}", std::process::id()))
1634 .env_clear()
1635 .stdin(Stdio::null())
1636 .kill_on_drop(true);
1637 match c.output().await {
1638 Ok(out) if out.status.success() => Some(Operand::Separated),
1639 Ok(_) => Some(Operand::Bare),
1640 Err(_) => None,
1641 }
1642}
1643
1644/// Sends `sig` to the whole of process group `pgid`.
1645///
1646/// The negative-pid form of `kill` is what reaches a *group*, and there is no
1647/// safe standard-library call for it: `std` can signal a direct child only, and
1648/// `libc::kill` would be an `unsafe` call in a crate that forbids them. So the
1649/// signal goes through the system's own `kill`, invoked as argv like everything
1650/// else here. Killing the group rather than the child is the point -- a
1651/// `cargo test` that spawned compilers must not leave them behind.
1652///
1653/// Every candidate is tried and the first *success* wins, not the first that
1654/// merely started: returning on the first program that spawned meant a working
1655/// `/usr/bin/kill` sitting behind a broken `/bin/kill` was never reached.
1656///
1657/// # Arguments
1658/// * `pgid` - The group, which is the process id of the child that leads it.
1659/// * `sig` - Which signal.
1660///
1661/// # Returns
1662/// What happened, in a form the caller has to look at.
1663pub(crate) async fn signal_group(pgid: u32, sig: Sig) -> Signalling {
1664 signal_group_with(KILL_PROGS, pgid, sig).await
1665}
1666
1667/// [`signal_group`], with the candidate programs named.
1668///
1669/// Split out so a test can put a real BusyBox `kill` in front of this code path
1670/// rather than reason about what one would do (`REVIEW.md` §3.10).
1671///
1672/// # Arguments
1673/// * `progs` - The `kill` binaries to try, in order.
1674/// * `pgid` - The group, which is the process id of the child that leads it.
1675/// * `sig` - Which signal.
1676pub(crate) async fn signal_group_with(progs: &[&str], pgid: u32, sig: Sig) -> Signalling {
1677 let name = match sig {
1678 Sig::Term => "TERM",
1679 Sig::Kill => "KILL",
1680 Sig::Int => "INT",
1681 };
1682 signal_group_named(progs, pgid, name).await
1683}
1684
1685/// [`signal_group_with`], with the signal named as `kill -s` spells it.
1686///
1687/// Split out for the null signal. `0` is not a [`wire::Sig`] and must not
1688/// become one -- the wire's three are the signals a page may SEND -- but it is
1689/// how the hand asks whether a group still has anybody in it, which is the same
1690/// question in the same words to the same program.
1691///
1692/// # Arguments
1693/// * `progs` - The `kill` binaries to try, in order.
1694/// * `pgid` - The group, which is the process id of the child that led it.
1695/// * `name` - The signal, as `kill -s` spells it.
1696async fn signal_group_named(progs: &[&str], pgid: u32, name: &str) -> Signalling {
1697 #[cfg(unix)]
1698 {
1699 let mut said = Vec::<String>::new();
1700 for prog in progs {
1701 if !Path::new(prog).exists() {
1702 continue;
1703 }
1704 let form = match operand_form(prog).await {
1705 Some(f) => f,
1706 None => {
1707 said.push(fmt!("{} could not be run at all", prog));
1708 continue;
1709 },
1710 };
1711 let mut c = Command::new(prog);
1712 c.arg("-s").arg(name);
1713 if form == Operand::Separated {
1714 c.arg("--");
1715 }
1716 c.arg(fmt!("-{}", pgid))
1717 .env_clear()
1718 .stdin(Stdio::null())
1719 .kill_on_drop(true);
1720 match c.output().await {
1721 Ok(out) if out.status.success() => return Signalling::Sent,
1722 Ok(out) => said.push(fmt!(
1723 "{} exited {} ({})",
1724 prog,
1725 match out.status.code() {
1726 Some(c) => fmt!("{}", c),
1727 None => fmt!("on a signal"),
1728 },
1729 String::from_utf8_lossy(&out.stderr).trim())),
1730 Err(e) => said.push(fmt!("{} could not be run ({})", prog, e)),
1731 }
1732 }
1733 if said.is_empty() {
1734 return Signalling::Unavailable(fmt!(
1735 "None of {} exists on this machine, so there is no way to signal \
1736 a process group from a program that writes no unsafe code.",
1737 progs.join(" or ")));
1738 }
1739 Signalling::Degraded(said.join("; "))
1740 }
1741 #[cfg(not(unix))]
1742 {
1743 let _ = (progs, pgid, name);
1744 Signalling::Unavailable(fmt!(
1745 "Signalling a process group is a POSIX idea and this is not a POSIX \
1746 platform."))
1747 }
1748}
1749
1750/// Whether one `/proc/<pid>/stat` line describes a process still holding the
1751/// group `pgid` open.
1752///
1753/// Two things are easy to get wrong here and both are why this is its own
1754/// function with its own test.
1755///
1756/// **Where the fields are.** The second field is the executable's name in
1757/// brackets, and a file name may contain brackets, spaces and anything else a
1758/// file name may. So the fields are counted from after the LAST `)`, which is
1759/// what `pty::session_groups` already does; the three after it are state, parent
1760/// and group.
1761///
1762/// **A zombie is not standing.** It holds no port, writes no file and will do
1763/// nothing further; it is an exit status waiting to be collected. Counting one
1764/// would make every killed run look as though it had left something behind for
1765/// as long as the kernel took to reap it -- a false alarm about the one subject
1766/// this has to be believed on -- and would hold the run's scratch directory open
1767/// for a process that no longer exists.
1768///
1769/// # Arguments
1770/// * `stat` - The contents of one `/proc/<pid>/stat`.
1771/// * `pgid` - The group being asked about.
1772fn counts_as_member(stat: &str, pgid: u32) -> bool {
1773 let tail = match stat.rsplit_once(')') {
1774 Some((_, t)) => t,
1775 None => return false,
1776 };
1777 let mut f = tail.split_whitespace();
1778 let state = match f.next() {
1779 Some(s) => s,
1780 None => return false,
1781 };
1782 if state == "Z" {
1783 return false;
1784 }
1785 // Past the parent, to the group.
1786 match f.nth(1).and_then(|v| v.parse::<u32>().ok()) {
1787 Some(g) => g == pgid,
1788 None => false,
1789 }
1790}
1791
1792/// Whether any process is still in the group `pgid`.
1793///
1794/// `/proc` first, because the hand is not the fenced thing and may read it, and
1795/// because it answers without starting a process. Where there is no `/proc`,
1796/// the null signal: `kill -s 0` validates its arguments, delivers nothing, and
1797/// succeeds only where there is somebody to deliver to.
1798///
1799/// `None` is neither yes nor no, and a caller must not read it as either. It
1800/// means the machine would not answer, which is why the listing goes on showing
1801/// a run it cannot ask about rather than quietly forgetting one.
1802///
1803/// # Arguments
1804/// * `pgid` - The group, which is the process id of the child that led it.
1805async fn group_standing(pgid: u32) -> Option<bool> {
1806 if let Ok(dir) = std::fs::read_dir("/proc") {
1807 for entry in dir.flatten() {
1808 let name = entry.file_name();
1809 if !name.to_string_lossy().chars().all(|c| c.is_ascii_digit()) {
1810 continue;
1811 }
1812 let stat = match std::fs::read_to_string(entry.path().join("stat")) {
1813 Ok(s) => s,
1814 Err(_) => continue, // It ended while we were looking at it.
1815 };
1816 if counts_as_member(&stat, pgid) {
1817 return Some(true);
1818 }
1819 }
1820 return Some(false);
1821 }
1822 match signal_group_named(KILL_PROGS, pgid, "0").await {
1823 Signalling::Sent => Some(true),
1824 Signalling::Degraded(_) => Some(false),
1825 Signalling::Unavailable(_) => None,
1826 }
1827}
1828
1829/// What to tell the caller about a signal, given what `kill` said and what the
1830/// machine then showed.
1831///
1832/// Separate from [`Runner::signal`] so that the one judgement it makes can be
1833/// put under a test, because getting it wrong is the defect this whole
1834/// arrangement exists to repair: a signal that did not take, reported as a stop.
1835/// The rule has no arm that can do that.
1836///
1837/// * The group is gone -- **finished**, whatever `kill` printed. BusyBox exits 1
1838/// on the POSIX spelling and empties the group anyway (`REVIEW.md` §3.10), so
1839/// the exit status is the weaker witness and the probe is the stronger one.
1840/// * The group is still standing and `kill` refused -- **failed**, and the
1841/// sentence names the group so a person can find it from outside.
1842/// * The group is still standing and `kill` was accepted -- **sent**, which says
1843/// the signal went and does not say the command stopped. A `TERM` is a
1844/// request, and something part-way through shutting down is not a failure.
1845/// * The machine would not say -- **sent**, with the same reading. "I cannot
1846/// tell" is not "it failed", and the run stays in the listing so the question
1847/// can be asked again.
1848///
1849/// # Arguments
1850/// * `id` - The run, for the sentence.
1851/// * `pgid` - Its process group, for the sentence.
1852/// * `said` - What went wrong with the `kill`, or `None` if nothing did.
1853/// * `still` - Whether the group was still standing afterwards, where the
1854/// machine would answer.
1855fn signalled(id: &str, pgid: u32, said: Option<String>, still: Option<bool>) -> Signalled {
1856 if still == Some(false) {
1857 return Signalled::Finished;
1858 }
1859 match said {
1860 None => Signalled::Sent,
1861 Some(w) => Signalled::Failed(fmt!(
1862 "'{}' was signalled and the signal did not take, so anything it started is still \
1863 running as process group {}. Nothing inside a fence can reach it -- that is what \
1864 the refusal below is -- so it has to be stopped from outside the app. {}",
1865 id, pgid, w)),
1866 }
1867}
1868
1869/// Keeps the first thing that went wrong with a group signal, if anything did.
1870///
1871/// # Arguments
1872/// * `slot` - Where the explanation is kept.
1873/// * `got` - What the attempt returned, or `Err` if it ran out of time.
1874fn note_signalling(
1875 slot: &mut Option<String>,
1876 got: Result<Signalling, tokio::time::error::Elapsed>,
1877) {
1878 if slot.is_some() {
1879 return;
1880 }
1881 *slot = match got {
1882 Ok(Signalling::Sent) => None,
1883 Ok(Signalling::Degraded(why)) => Some(why),
1884 Ok(Signalling::Unavailable(why))=> Some(why),
1885 Err(_) => Some(fmt!(
1886 "The kill helper did not finish within {} ms and was given up on.",
1887 SIGNAL_GRACE_MS)),
1888 };
1889}
1890
1891// ┌───────────────────────────────────────────────────────────────┐
1892// │ Private scratch space │
1893// └───────────────────────────────────────────────────────────────┘
1894
1895/// Where the per-run scratch directories are kept, for an operator who needs
1896/// them on a different volume.
1897///
1898/// Named after [`crate::journal::default_dir`]'s own variable and read the same
1899/// way, because the two answer the same question about the same machine. A
1900/// build's intermediate objects can be large, and the platform data directory is
1901/// not always where an operator wants them.
1902pub const SCRATCH_DIR_VAR: &str = "DAIMOND_HAND_SCRATCH_DIR";
1903
1904/// The environment names that tell a program where to write temporary files.
1905///
1906/// Three rather than one. `TMPDIR` is the POSIX spelling and is what Rust,
1907/// `cargo`, `cc`, `ld` and coreutils read; `TMP` and `TEMP` are Windows'
1908/// spellings, and enough cross-platform toolchains consult them on Unix too that
1909/// setting only the first leaves a portable build writing somewhere else. All
1910/// three are set to the same directory, so there is no case in which a program
1911/// finds one of them and gets a different answer.
1912pub(crate) const TMP_VARS: &[&str] = &["TMPDIR", "TMP", "TEMP"];
1913
1914// ── What a command is given that nobody asked for ────────────────────────────
1915//
1916// The command's environment is the caller's pairs and nothing else, cleared by
1917// the launcher and rebuilt from the plan. That is right, and it was too narrow
1918// by two names.
1919//
1920// **`env_clear` is not the thing to change.** There are two clears in this file
1921// and they answer different questions. `Runner::spawn`'s clears the LAUNCHER's
1922// environment -- and the launcher's environment is replaced wholesale by
1923// `execve`, so nothing added there ever reaches the command. `launch_inner`'s
1924// clears the command's, and rebuilds it from the pairs that travelled down the
1925// pipe. A default therefore has to be added to the PAIRS, here, which is also
1926// the only place it can be journalled and screened like any other pair.
1927//
1928// **The two that are added, and why each of them and not more.**
1929//
1930// * `HOME`. A shell script under `set -u` dies on its first line without one --
1931// `HOME: unbound variable` -- and nearly every script in this repository's
1932// `dev/` reads it. The value is the hand's own, which is the same path the
1933// page is already told in `caps` as `home:`; a hand that advertises where home
1934// is and then hides it from the command is telling two stories. It POINTS and
1935// it does not GRANT: what a command can open is the fence's decision, so a tool
1936// that follows `HOME` somewhere ungranted meets a refusal rather than a file.
1937// That is `tools.rs`'s own argument for setting it for a git grant, and it is
1938// the same argument.
1939// * `PATH`. [`PATH_FALLBACK`] is already the hand's answer to "where do programs
1940// live" -- `vet_program` resolves a bare `argv[0]` through it when the caller
1941// names none. Handing that program an environment in which it cannot find
1942// `node`, `grep` or `curl` is the same answer given twice and differently. It
1943// grants nothing either: the fence decides what may be executed, and everything
1944// on this list is in the read-only system base already.
1945//
1946// **The ones deliberately refused, because an environment is an input.**
1947// Everything passed is something a command can be steered by, so the list is
1948// short and each absence is a decision:
1949//
1950// * `USER` and `LOGNAME`. Nothing needs them. The kernel already knows who the
1951// process is; `id`, `whoami` and git's own author fallback go through
1952// `getpwuid` and never read these. Nothing in this repository's `dev/` reads
1953// them either. A name a program is TOLD is a name the kernel would contradict.
1954// * `LANG`, `LC_ALL` and the rest of the locale family. A locale changes what a
1955// program PRINTS -- collation, the decimal separator, the language of an error
1956// -- and the reader here is a model. An absent locale is the C locale, which
1957// is the deterministic one; the user's desktop setting was chosen for their
1958// screen and not for this. A caller that needs one can send it.
1959// * `SHELL`. There is no shell here by design, and a variable naming one is an
1960// invitation to find it.
1961// * `TERM`. A command run down a pipe has no terminal, and one that believes it
1962// has writes escape sequences into captured output. A pty session is the
1963// exception and sets it itself; see `pty::TERM`.
1964// * `TMPDIR`, `TMP` and `TEMP`. Set already, unconditionally, and refused from
1965// the caller by [`screen_scratch`] -- the one case where the hand's answer is
1966// the last word rather than a default. A caller that could name them would be
1967// choosing where a command writes.
1968//
1969// The rule for the two that are added is the opposite of the scratch's: the
1970// caller's pair WINS. A default is a floor under a caller that said nothing, not
1971// a correction of one that spoke -- and the app does speak, setting `HOME` for a
1972// git grant and `PATH` for every toolkit.
1973
1974/// The names the hand fills in where the request named none.
1975pub(crate) const ENV_DEFAULTED: &[&str] = &["HOME", "PATH"];
1976
1977/// Where the hand's own home directory is, where it has one.
1978///
1979/// Absolute or nothing: a relative `HOME` is not a home directory, and passing
1980/// one on would put a command's configuration wherever it happened to be
1981/// standing.
1982pub fn home_dir() -> Option<String> {
1983 match std::env::var("HOME") {
1984 Ok(h) if h.starts_with('/') => Some(h),
1985 _ => None,
1986 }
1987}
1988
1989/// What the hand would set a defaulted name to, where it has an answer.
1990///
1991/// # Arguments
1992/// * `name` - One of [`ENV_DEFAULTED`].
1993pub(crate) fn default_env(name: &str) -> Option<String> {
1994 match name {
1995 "HOME" => home_dir(),
1996 "PATH" => Some(fmt!("{}", PATH_FALLBACK)),
1997 // A name in the list with no answer here would be a name silently never
1998 // set, so the two are kept together and this arm cannot be reached.
1999 _ => None,
2000 }
2001}
2002
2003/// Adds the defaults the request left unsaid.
2004///
2005/// # Arguments
2006/// * `env` - The caller's pairs, appended to in place.
2007pub(crate) fn add_defaults(env: &mut Vec<(String, String)>) {
2008 for name in ENV_DEFAULTED {
2009 if env.iter().any(|(k, _)| k == name) {
2010 continue;
2011 }
2012 if let Some(v) = default_env(name) {
2013 env.push((fmt!("{}", name), v));
2014 }
2015 }
2016}
2017
2018/// How much of a run's identifier reaches the directory name.
2019///
2020/// The identifier is caller-chosen and unbounded; the name only has to make a
2021/// directory recognisable to a person reading a listing, and the unguessable
2022/// half is what makes it unique.
2023const SCRATCH_SLUG_MAX: usize = 48;
2024
2025/// How deep the removal will descend when a command has left a directory it did
2026/// not leave writable.
2027///
2028/// A limit rather than a promise of completeness: an unbounded recursion over a
2029/// tree the fenced command built is a stack the fenced command chose the depth
2030/// of.
2031const SCRUB_DEPTH_MAX: u32 = 64;
2032
2033/// One command's private directory for temporary files, and its removal.
2034///
2035/// The removal is the whole of why this is a type rather than two function
2036/// calls. A scratch that outlives its command is a disk leak on every run and a
2037/// data leak on the interesting ones -- half a compile's worth of somebody's
2038/// source, sitting in a directory nobody will ever look in -- so it is tied to a
2039/// value whose `Drop` removes it. [`Runner::spawn`] holds it until the child
2040/// exists and the supervisor holds it after that, which means a refusal, a
2041/// failure to spawn, an ordinary exit, a timeout and a kill all end the same
2042/// way, without any of them having to remember to.
2043pub struct Scratch {
2044 /// The directory, absolute and canonical.
2045 dir: PathBuf,
2046 /// Whether it has already been removed, so a second attempt is silent.
2047 gone: bool,
2048}
2049
2050impl Scratch {
2051
2052 /// Makes a directory this run alone can name, or says why it could not.
2053 ///
2054 /// Two runs never collide and one cannot guess another's: the name carries
2055 /// the run's identifier so a person can read a listing, and 128 bits from
2056 /// the operating system's own source so nobody can predict one. Guessing is
2057 /// not idle worry -- the directory holding them all carries no rule in any
2058 /// fence, so a name is the only thing a command would need.
2059 ///
2060 /// # Arguments
2061 /// * `id` - The caller's identifier for the run.
2062 pub fn make(id: &str) -> Outcome<Self> {
2063 let base = res!(scratch_base());
2064
2065 // Before anything is created, and not after: a scratch base that
2066 // contained the journal would hand every command a writable root over
2067 // the record of what it was refused, which `Journal::check_fence`
2068 // refuses and which is not a thing to create first and discover second.
2069 if let Ok(journal) = crate::journal::default_dir() {
2070 res!(clear_of_journal(&base, &journal));
2071 }
2072
2073 res!(std::fs::create_dir_all(&base).map_err(|e| err!(e,
2074 "The scratch directory '{}' could not be made.", base.display();
2075 IO, File, Path)));
2076 // Resolved once the directory exists, so that the path put into the
2077 // fence and the path put into TMPDIR are the same path the kernel will
2078 // see. `fence::canonical` resolves its roots; an unresolved TMPDIR would
2079 // name the same place by a spelling the plan never mentioned.
2080 let base = res!(base.canonicalize().map_err(|e| err!(e,
2081 "The scratch directory '{}' could not be resolved.", base.display();
2082 IO, File, Path)));
2083
2084 let dir = base.join(scratch_name(id));
2085 let mut mk = std::fs::DirBuilder::new();
2086 #[cfg(unix)]
2087 {
2088 use std::os::unix::fs::DirBuilderExt;
2089 // Created at 0700 rather than created and then tightened: the gap
2090 // between the two is a window at whatever the umask happens to be.
2091 mk.mode(0o700);
2092 }
2093 res!(mk.create(&dir).map_err(|e| err!(e,
2094 "The private temporary directory '{}' could not be made.", dir.display();
2095 IO, File, Path)));
2096
2097 Ok(Self { dir, gone: false })
2098 }
2099
2100 /// The directory, which is what `TMPDIR` will name.
2101 pub fn dir(&self) -> &Path {
2102 &self.dir
2103 }
2104
2105 /// Removes it, and says so if it could not.
2106 ///
2107 /// Idempotent, because it is called once by the supervisor -- before the
2108 /// page is told the run ended, so that "ended" and "gone" cannot be observed
2109 /// in the wrong order -- and again by `Drop` on every other path.
2110 pub fn remove(&mut self) -> Outcome<()> {
2111 if self.gone {
2112 return Ok(());
2113 }
2114 self.gone = true;
2115 if wipe(&self.dir).is_ok() {
2116 return Ok(());
2117 }
2118 // A command can leave behind a directory it did not leave itself able to
2119 // enter -- an installer that chmods its output, a test fixture with a
2120 // read-only tree in it -- and the hand is not fenced, so it can put that
2121 // right. Only then is a failure worth reporting.
2122 scrub(&self.dir, 0);
2123 res!(wipe(&self.dir).map_err(|e| err!(e,
2124 "The private temporary directory '{}' could not be removed, so \
2125 whatever the command left in it is still on the disc.",
2126 self.dir.display();
2127 IO, File, Path)));
2128 Ok(())
2129 }
2130}
2131
2132/// Removes a tree, counting one that is already absent as removed.
2133///
2134/// # Arguments
2135/// * `dir` - What to remove.
2136fn wipe(dir: &Path) -> std::io::Result<()> {
2137 match std::fs::remove_dir_all(dir) {
2138 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
2139 other => other,
2140 }
2141}
2142
2143impl Drop for Scratch {
2144
2145 /// The backstop for every path that is not the supervisor's.
2146 fn drop(&mut self) {
2147 if let Err(e) = self.remove() {
2148 eprintln!("daimond-hand: {}", e);
2149 }
2150 }
2151}
2152
2153/// Where the per-run directories are made.
2154///
2155/// The platform rules are [`crate::journal::default_dir`]'s, with `scratch` in
2156/// place of `journal`, so the hand keeps everything it owns in one place and a
2157/// person looking for either finds both. It is deliberately *not* derived from
2158/// the journal's directory: an operator who moves the journal to a log volume
2159/// has said where a record goes, not where a compiler's intermediate objects go.
2160fn scratch_base() -> Outcome<PathBuf> {
2161 if let Ok(v) = std::env::var(SCRATCH_DIR_VAR) {
2162 if !v.is_empty() {
2163 return Ok(PathBuf::from(v));
2164 }
2165 }
2166 let tail = Path::new("daimond").join("hand").join("scratch");
2167 match crate::os() {
2168 "macos" => match std::env::var("HOME") {
2169 Ok(h) if !h.is_empty() => Ok(Path::new(&h)
2170 .join("Library")
2171 .join("Application Support")
2172 .join(&tail)),
2173 _ => Err(err!(
2174 "HOME is not set, so the hand cannot say where a command's \
2175 temporary files belong. Set {}.", SCRATCH_DIR_VAR;
2176 Missing, Configuration, Path)),
2177 },
2178 "windows" => match std::env::var("APPDATA") {
2179 Ok(a) if !a.is_empty() => Ok(Path::new(&a).join(&tail)),
2180 _ => Err(err!(
2181 "APPDATA is not set, so the hand cannot say where a command's \
2182 temporary files belong. Set {}.", SCRATCH_DIR_VAR;
2183 Missing, Configuration, Path)),
2184 },
2185 _ => {
2186 if let Ok(x) = std::env::var("XDG_DATA_HOME") {
2187 if !x.is_empty() {
2188 return Ok(Path::new(&x).join(&tail));
2189 }
2190 }
2191 match std::env::var("HOME") {
2192 Ok(h) if !h.is_empty() => Ok(Path::new(&h)
2193 .join(".local")
2194 .join("share")
2195 .join(&tail)),
2196 _ => Err(err!(
2197 "Neither XDG_DATA_HOME nor HOME is set, so the hand cannot \
2198 say where a command's temporary files belong. Set {}.",
2199 SCRATCH_DIR_VAR;
2200 Missing, Configuration, Path)),
2201 }
2202 },
2203 }
2204}
2205
2206/// Refuses a scratch base that a fence over it would carry the journal with.
2207///
2208/// The scratch is the one root the *hand* adds to a fence, so it is the one root
2209/// nobody else can be relied on to check. `main` checks the granted folder
2210/// against the journal at startup and [`crate::journal::Journal::check_fence`]
2211/// checks the caller's spec on every command; neither sees this one, because it
2212/// is added after both.
2213///
2214/// # Arguments
2215/// * `base` - Where the per-run directories would be made.
2216/// * `journal` - Where the record lives.
2217fn clear_of_journal(base: &Path, journal: &Path) -> Outcome<()> {
2218 let spec = FenceSpec {
2219 rw: vec![fmt!("{}", base.display())],
2220 ro: Vec::new(),
2221 deny: Vec::new(),
2222 net: false,
2223 };
2224 match crate::journal::check_fence_at(journal, &spec) {
2225 Ok(()) => Ok(()),
2226 Err(e) => Err(err!(e,
2227 "The hand gives every command a writable directory under '{}', and \
2228 the journal at '{}' would be inside it. A command that can reach its \
2229 own record can delete the entry that says it was refused, so no \
2230 command was run. Set {} to a directory that does not contain the \
2231 journal.", base.display(), journal.display(), SCRATCH_DIR_VAR;
2232 Invalid, Configuration, Path, Security)),
2233 }
2234}
2235
2236/// The name of one run's directory: readable, then unguessable.
2237///
2238/// # Arguments
2239/// * `id` - The caller's identifier for the run.
2240fn scratch_name(id: &str) -> String {
2241 let mut slug = String::new();
2242 for c in id.chars() {
2243 if slug.len() >= SCRATCH_SLUG_MAX {
2244 break;
2245 }
2246 // A deliberately short alphabet, and no full stop in it: a name made of
2247 // these cannot be `.` or `..`, cannot be hidden, and cannot carry a
2248 // separator into a path.
2249 match c {
2250 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => slug.push(c),
2251 _ => slug.push('_'),
2252 }
2253 }
2254 if slug.is_empty() {
2255 slug.push_str("run");
2256 }
2257 fmt!("{}-{:016x}{:016x}", slug, Rand::rand_u64(), Rand::rand_u64())
2258}
2259
2260/// Makes a tree the hand can remove, where the command left one it could not.
2261///
2262/// Best effort by design: it is called only after an ordinary removal has
2263/// already failed, and anything it cannot mend is reported by the second
2264/// attempt rather than here. Symbolic links are stepped over rather than
2265/// followed -- a link in the scratch can point anywhere, and this runs unfenced.
2266///
2267/// # Arguments
2268/// * `p` - What to make removable.
2269/// * `depth` - How far down this already is.
2270#[cfg(unix)]
2271fn scrub(p: &Path, depth: u32) {
2272 use std::os::unix::fs::PermissionsExt;
2273
2274 if depth > SCRUB_DEPTH_MAX {
2275 return;
2276 }
2277 let md = match std::fs::symlink_metadata(p) {
2278 Ok(md) => md,
2279 Err(_) => return,
2280 };
2281 if md.file_type().is_symlink() || !md.is_dir() {
2282 return;
2283 }
2284 let mut perm = md.permissions();
2285 perm.set_mode(0o700);
2286 let _ = std::fs::set_permissions(p, perm);
2287 let rd = match std::fs::read_dir(p) {
2288 Ok(rd) => rd,
2289 Err(_) => return,
2290 };
2291 for ent in rd.flatten() {
2292 scrub(&ent.path(), depth + 1);
2293 }
2294}
2295
2296/// Makes a tree the hand can remove, where the platform has the notion.
2297///
2298/// # Arguments
2299/// * `p` - What to make removable.
2300/// * `depth` - How far down this already is.
2301#[cfg(not(unix))]
2302fn scrub(p: &Path, depth: u32) {
2303 let _ = (p, depth);
2304}
2305
2306// ┌───────────────────────────────────────────────────────────────┐
2307// │ Vetting │
2308// └───────────────────────────────────────────────────────────────┘
2309
2310/// What this machine can fence with, asked once.
2311///
2312/// [`Fence::detect`] spawns a thread and asks the kernel, which is cheap but not
2313/// free, and the answer cannot change while the process lives. Asking once also
2314/// means every run in a session is planned against the same answer, so a
2315/// capability reported to the page in the opening `hello` is the one every later
2316/// command was actually held to.
2317pub(crate) fn detected_fence() -> &'static Fence {
2318 static FENCE: OnceLock<Fence> = OnceLock::new();
2319 FENCE.get_or_init(Fence::detect)
2320}
2321
2322/// The same machine, planned for a TERMINAL, where a carved directory may be listed.
2323///
2324/// [`Listing::Sealed`] is right for a command and wrong for a terminal, and the difference is
2325/// who is at the other end. A carved directory cannot be listed at all under `Sealed` -- that is
2326/// the documented cost of it -- and a terminal's working directory is now the granted root, which
2327/// always holds `.daimond`. So `ls` in the folder the terminal opens in failed, with nothing on
2328/// screen to say why: measured on 2026-08-26, `ls` answering "cannot open directory '.'" in a
2329/// terminal whose fence granted that very directory read and write.
2330///
2331/// [`Listing::Names`] costs exactly what the enum says it costs: entry NAMES inside the denied
2332/// subtree become visible, never contents, because reading a file still needs `READ_FILE`. The
2333/// person typing owns those names -- it is their machine and their workspace -- and no daimon can
2334/// reach this surface: no tool opens a terminal or types into one, which is the same reason
2335/// `terminal_toolkit_bounds` may lend it an ssh key and `toolkit_bounds` may not.
2336///
2337/// A command keeps [`detected_fence`] and its seal. The choice is the hand's, never the page's:
2338/// a caller that could name its own listing could name the laxer one.
2339pub fn detected_terminal_fence() -> &'static Fence {
2340 static FENCE: OnceLock<Fence> = OnceLock::new();
2341 FENCE.get_or_init(|| Fence::detect_with(Listing::Names, SysBase::Minimal))
2342}
2343
2344/// What this machine can refuse at the system-call layer, asked once.
2345///
2346/// Cached for the same reason [`detected_fence`] is, and with one extra reason:
2347/// [`Seccomp::detect`] answers by *installing* a throwaway filter on a thread of
2348/// its own, which is the only honest probe and not one to repeat per command.
2349pub(crate) fn detected_seccomp() -> &'static Seccomp {
2350 static SYS: OnceLock<Seccomp> = OnceLock::new();
2351 SYS.get_or_init(Seccomp::detect)
2352}
2353
2354/// Whether `p` is `prefix` or lies beneath it.
2355///
2356/// Compared component by component, so `/workshop` is not inside `/work`.
2357///
2358/// An empty or relative prefix is *nobody's* ancestor, and saying so here is not
2359/// pedantry: `Path::new("/etc/ssh").starts_with("")` is true, so a fence whose
2360/// root was the empty string granted the whole filesystem while passing every
2361/// guard that only counted roots. A spec of that shape reached the hand from
2362/// the app, which read the granted root out of a status message and checked only
2363/// that the key was present.
2364///
2365/// # Arguments
2366/// * `p` - The candidate path, already absolute.
2367/// * `prefix` - The root it might sit under.
2368fn under(p: &Path, prefix: &Path) -> bool {
2369 if prefix.as_os_str().is_empty() || !prefix.is_absolute() {
2370 return false;
2371 }
2372 p.starts_with(prefix)
2373}
2374
2375/// Environment variables the caller may not set, whatever else it may set.
2376///
2377/// `README.md` gives this as the reason the environment is not the model's: a
2378/// caller that can name a variable can name `LD_PRELOAD`, and a library loaded
2379/// into every fenced command is a way to make that command do something else.
2380/// The fence does not stop it -- the injected object is read through the same
2381/// grants the program itself is read through -- so it has to be refused here.
2382///
2383/// Refused rather than dropped: a command that silently did not get the
2384/// environment it asked for fails somewhere further along, for a reason nobody
2385/// can see, and the caller is entitled to be told which name it may not use.
2386///
2387/// This is a floor and not a ceiling. Every interpreter has its own version --
2388/// `PYTHONPATH`, `RUBYOPT`, `NODE_OPTIONS` -- and the general answer to those is
2389/// that the fence bounds what any of them can reach. The four families here are
2390/// different in kind: they act on the *dynamic loader*, before any program's own
2391/// code runs, so they apply to a program that has no interpreter at all.
2392const ENV_REFUSED: &[&str] = &[
2393 "GCONV_PATH", // Loads a conversion module of the caller's choosing.
2394 "BASH_ENV", // Sourced by a non-interactive bash before anything else.
2395 "ENV", // The POSIX shell's spelling of the same idea.
2396 "SHELLOPTS", // Turns on shell behaviour a caller was not given.
2397];
2398
2399/// Refuses an environment carrying a name that decides what code loads.
2400///
2401/// # Arguments
2402/// * `env` - The pairs the caller asked for.
2403///
2404/// # Returns
2405/// The refusal sentence, or `None` if every pair is ordinary.
2406pub(crate) fn screen_env(env: &[(String, String)]) -> Option<String> {
2407 for (k, _) in env {
2408 if k.is_empty() || k.contains('=') || k.contains('\0') {
2409 return Some(fmt!(
2410 "Refused: {:?} is not a usable environment variable name. A name cannot be empty \
2411 and cannot contain '=' or a null byte, because nothing downstream could tell \
2412 where it ended.", k));
2413 }
2414 // The dynamic loader's whole family, not `LD_PRELOAD` alone: `LD_AUDIT`
2415 // loads a library the same way, and `LD_LIBRARY_PATH` chooses which
2416 // copy of a library a program gets.
2417 if k.starts_with("LD_") || ENV_REFUSED.contains(&k.as_str()) {
2418 return Some(fmt!(
2419 "Refused: this command asked to run with {} set. That variable decides what code \
2420 is loaded into the program before the program's own code runs, so it is a way to \
2421 make any command do something else -- and the fence cannot tell the difference, \
2422 because the injected code is read through the same grants the program is. Set \
2423 what the command needs some other way.", k));
2424 }
2425 }
2426 None
2427}
2428
2429/// Refuses an environment that tries to say where a command's temporary files go.
2430///
2431/// Refused rather than dropped, for the reason [`screen_env`] gives: a caller
2432/// whose setting silently did not take effect has no way to find that out. And
2433/// refused rather than honoured because `TMPDIR` decides *where a command
2434/// writes*, which is the one thing the fence exists to decide -- a caller able
2435/// to set it could point a compiler's output at any granted path and call it
2436/// temporary.
2437///
2438/// Compared without regard to case, because a name differing only in case would
2439/// be a second answer to the same question on the platforms where `TMP` and
2440/// `TEMP` come from.
2441///
2442/// # Arguments
2443/// * `env` - The pairs the caller asked for.
2444///
2445/// # Returns
2446/// The refusal sentence, or `None` if the caller left the question alone.
2447pub(crate) fn screen_scratch(env: &[(String, String)]) -> Option<String> {
2448 for (k, _) in env {
2449 if TMP_VARS.iter().any(|n| k.eq_ignore_ascii_case(n)) {
2450 return Some(fmt!(
2451 "Refused: this command asked to run with {} set. The hand makes every command a \
2452 private directory of its own for temporary files and points TMPDIR, TMP and TEMP \
2453 at it, so there is nothing to configure -- and a command that could name that \
2454 directory would be choosing where it writes, which is the fence's decision and \
2455 not the caller's. Ask again without it.", k));
2456 }
2457 }
2458 None
2459}
2460
2461// ── A push this repository could authenticate on its own ─────────────────────
2462//
2463// The app guards `git push` inside the page (`src/tools.rs`, `git_guard`), and for a push carrying
2464// DAIMOND's credential that guard is the whole decision: an `argv` that does not pass it gets no
2465// environment, so the push cannot authenticate however it is spelled. What it cannot cover is a
2466// repository that already holds working credentials of its OWN -- an HTTPS remote with a token in
2467// `.git/config`, or a `.git-credentials` file inside the granted root. There a `--force` succeeds
2468// with nothing from Daimond in it at all, and the page has no way to know. The hand is where that
2469// refusal can still be made, so it is made again here.
2470//
2471// # How much of the page's list is duplicated, and why not the rest
2472//
2473// Two copies of a list drift, and drift is its own hazard, so the line is drawn by REASON rather
2474// than by copying. The page's rules have two different reasons behind them and only one of them
2475// survives the journey:
2476//
2477// * **Work that cannot be got back.** `--force`, `--force-with-lease`, `--force-if-includes`,
2478// `--delete`, `--mirror`, `--prune`, a `+` or `:` refspec, and `f` or `d` in a short cluster.
2479// `--no-verify`, because a pre-push hook is a check the user installed. `--receive-pack` and
2480// `--exec`, because they name a program to run at the far end. None of these depends on whose
2481// credential authenticates the push, so every one is duplicated here.
2482// * **Daimond's credential is scoped to one host.** "Only `origin`", "not a URL", `--repo`. Those
2483// exist because a push aimed anywhere else could not authenticate with the app's token anyway,
2484// and saying so turns a confusing failure into a sentence. NONE of them is duplicated. The
2485// premise here is a repository with its own credentials, so `git push upstream main` is an
2486// ordinary push and refusing it would be a rule with no harm behind it -- while a fenced command
2487// that wanted to send the workspace somewhere would reach for `curl` and not for git. Refusing
2488// destruction on EVERY remote is broader where it matters and narrower where it does not.
2489//
2490// `-c`, `--config-env` and `--exec-path` before the subcommand are duplicated, for a reason of
2491// their own rather than the page's: `remote.<name>.push` is a refspec, so
2492// `-c remote.origin.push=+main:main` is `--force` spelled in configuration, and `--exec-path`
2493// chooses which `git-*` programs run.
2494//
2495// # What this does not close, and is not pretended to
2496//
2497// * **The same refspec written into the repository's own `.git/config`.** That file is inside the
2498// fence and the model may write it, so `git push origin` on its own can be a forced push and
2499// neither guard sees anything in `argv`. Closing it means reading the repository's
2500// configuration, and that job has real corners -- `.git` can be a file, the repository can be
2501// above `cwd`, `-C` and `--git-dir` move it -- so a half-built version would be counted as done
2502// and would be worse than none. Written down rather than attempted.
2503// * **Another spelling of the program.** `argv[0]` is matched by basename, so `/usr/bin/git` is
2504// caught and `sh -c 'git push -f'` is not. The page can shrug that off because no credential is
2505// attached to a command whose `argv[0]` is not `git`; here the harm needs no credential of ours,
2506// so the shrug does not transfer. It is the same shape as `REVIEW.md` §3.13 and it is not
2507// closed.
2508// * **A pty session.** `Pty::open` runs an interactive shell; `argv[0]` is that shell, and what is
2509// typed into it never passes through here.
2510
2511/// Git's own options, before the subcommand, that take their value as a separate argument.
2512///
2513/// Needed for one thing: finding where the subcommand is. `git -C /somewhere push` has `push`
2514/// third and `git --no-pager push` has it second, and a parser that could not tell them apart would
2515/// read `/somewhere` as the subcommand and stop guarding.
2516const GIT_OPT_VALUE: &[&str] = &[
2517 "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env",
2518 "--super-prefix", "--attr-source",
2519];
2520
2521/// Long options to `git push` that are refused, and what each of them does.
2522///
2523/// Matched on the name with any `=value` removed, so `--force-with-lease=main` is caught and
2524/// `--no-force-with-lease` -- which turns forcing OFF -- is not.
2525const PUSH_LONG_REFUSED: &[(&str, &str)] = &[
2526 ("--force", "overwrites whatever is on the remote"),
2527 ("--force-with-lease", "overwrites the remote when it looks unchanged, which is still an \
2528 overwrite"),
2529 ("--force-if-includes", "is part of the force-with-lease family"),
2530 ("--delete", "removes a branch or tag from the remote"),
2531 ("--mirror", "makes the remote match this repository exactly, deleting every ref \
2532 that is not here"),
2533 ("--prune", "deletes remote branches that are not here"),
2534 ("--no-verify", "skips the hooks that run before a push"),
2535 ("--receive-pack", "names the program that runs at the far end"),
2536 ("--exec", "names the program that runs at the far end"),
2537];
2538
2539/// The refusal a push that could lose work gets.
2540///
2541/// One shape for all of them, because they are one decision. It names the hand, so that a reader
2542/// looking at two guards can tell which one spoke; and it says the rule is a rule, so that the
2543/// model reworks the request rather than the spelling.
2544///
2545/// # Arguments
2546/// * `spelling` - The option as it was written, so the model can see which one was meant.
2547/// * `does` - What that option does, in a clause.
2548fn push_refusal(spelling: &str, does: &str) -> String {
2549 fmt!(
2550 "Refused: '{}' {}. A push from inside Daimond only ever fast-forwards, because a \
2551 fast-forward push cannot destroy a commit that exists nowhere else and every other kind \
2552 can. The machine hand refuses this as well as the page does, because a repository holding \
2553 credentials of its own could make that push without anything from Daimond in it. It is a \
2554 rule and not a fault in the command, so do not try another spelling of it: '-f', \
2555 '--force-with-lease', a '+' in front of the refspec and '--delete' are all refused \
2556 together. If the history really has to be rewritten, say so and let the user push it \
2557 themselves.", spelling, does)
2558}
2559
2560/// Whether one of git's own options, before the subcommand, is refused on a push.
2561///
2562/// # Arguments
2563/// * `a` - One argument, exactly as it was written.
2564fn git_opt_refused(a: &str) -> Option<&'static str> {
2565 // `-c k=v` and `-ck=v` are both git; `-C` is a different option and case matters.
2566 if a.starts_with("-c") && !a.starts_with("--") {
2567 return Some("-c");
2568 }
2569 match a.split('=').next().unwrap_or(a) {
2570 "--config-env" => Some("--config-env"),
2571 "--exec-path" => Some("--exec-path"),
2572 _ => None,
2573 }
2574}
2575
2576/// Refuses a `git push` that could destroy work at the far end.
2577///
2578/// Pure, so the whole decision is testable without a repository, a remote or a credential. See the
2579/// section comment above for which of the app's rules are duplicated here, which are not, and what
2580/// is left open.
2581///
2582/// # Arguments
2583/// * `argv` - The command, exactly as the caller sent it.
2584///
2585/// # Returns
2586/// The refusal sentence, or `None` where this is not a push or is one that only fast-forwards.
2587pub(crate) fn screen_git_push(argv: &[String]) -> Option<String> {
2588 let first = match argv.first() {
2589 Some(a) => a.as_str(),
2590 None => return None,
2591 };
2592 // Basename, so `/usr/bin/git` is caught. Split on '/' rather than through `Path`, so that this
2593 // reads argument text the same way the page's guard does.
2594 if first.rsplit('/').next().unwrap_or(first) != "git" {
2595 return None;
2596 }
2597 // Git's own options, then the subcommand. `pre` is kept because some of those options are
2598 // refused on a push, and finding the subcommand at all needs the same walk.
2599 let mut i = 1;
2600 let mut pre: Vec<&str> = Vec::new();
2601 let mut sub: Option<&str> = None;
2602 while i < argv.len() {
2603 let a = argv[i].as_str();
2604 if !a.starts_with('-') {
2605 sub = Some(a);
2606 i += 1;
2607 break;
2608 }
2609 pre.push(a);
2610 i += if GIT_OPT_VALUE.contains(&a) { 2 } else { 1 };
2611 }
2612 // `git`, `git --version`: nothing to guard.
2613 let sub = match sub {
2614 Some(s) => s,
2615 None => return None,
2616 };
2617 if sub != "push" {
2618 return None;
2619 }
2620 for a in &pre {
2621 if let Some(name) = git_opt_refused(a) {
2622 return Some(fmt!(
2623 "Refused: '{}' before 'push' adds configuration to this one command, and \
2624 'remote.<name>.push' is a refspec -- so a forced push can be written there \
2625 instead of on the command line, and '--exec-path' chooses which git programs run \
2626 at all. The machine hand refuses it for the same reason the page does. Run the \
2627 push without it.", name));
2628 }
2629 }
2630 let rest: Vec<&str> = argv.get(i..).unwrap_or(&[]).iter().map(|s| s.as_str()).collect();
2631 // The push's own arguments. Positionals are collected rather than checked in place, because
2632 // which one is the remote depends on how many options ate a value first.
2633 let mut positional: Vec<&str> = Vec::new();
2634 let mut j = 0;
2635 let mut ended = false;
2636 while j < rest.len() {
2637 let a = rest[j];
2638 if ended {
2639 positional.push(a);
2640 j += 1;
2641 continue;
2642 }
2643 if a == "--" {
2644 ended = true;
2645 j += 1;
2646 continue;
2647 }
2648 if a.starts_with("--") {
2649 let name = a.split('=').next().unwrap_or(a);
2650 if let Some((sp, does)) = PUSH_LONG_REFUSED.iter().find(|(n, _)| *n == name) {
2651 return Some(push_refusal(sp, does));
2652 }
2653 // The one permitted long option that takes a separate value; the rest either take none
2654 // or are refused above.
2655 j += if name == "--push-option" && !a.contains('=') { 2 } else { 1 };
2656 continue;
2657 }
2658 if a.starts_with('-') && a.len() > 1 {
2659 // Short options are CLUSTERS: `-uf`, `-fq` and `-qfu` all carry the `f`.
2660 let flags = &a[1..];
2661 if flags.contains('f') {
2662 return Some(push_cluster_refusal(a, 'f', "forces the push"));
2663 }
2664 if flags.contains('d') {
2665 return Some(push_cluster_refusal(a, 'd', "deletes the ref on the remote"));
2666 }
2667 // `-o` is `--push-option`, and its value follows unless it is stuck to the cluster.
2668 j += if flags.ends_with('o') { 2 } else { 1 };
2669 continue;
2670 }
2671 positional.push(a);
2672 j += 1;
2673 }
2674 // The first positional is the remote, which this deliberately does not judge; every one after
2675 // it is a refspec, and two ordinary-looking spellings destroy work.
2676 for spec in positional.iter().skip(1) {
2677 if spec.starts_with('+') {
2678 return Some(push_refusal(spec,
2679 "is a forced refspec -- the leading '+' means the same as --force"));
2680 }
2681 if spec.starts_with(':') {
2682 return Some(push_refusal(spec,
2683 "is a delete refspec -- an empty source side means the same as --delete"));
2684 }
2685 }
2686 None
2687}
2688
2689/// The refusal a short-option cluster gets, naming the letter rather than the cluster.
2690///
2691/// `-uf` is refused for its `f`, and a model told only that `-uf` was refused would reasonably try
2692/// `-u -f`.
2693///
2694/// # Arguments
2695/// * `cluster` - The argument as written.
2696/// * `letter` - The letter that decided it.
2697/// * `does` - What that letter does, in a clause.
2698fn push_cluster_refusal(cluster: &str, letter: char, does: &str) -> String {
2699 push_refusal(
2700 &fmt!("-{}", letter),
2701 &fmt!("{} -- it is in '{}', and a short option carries every letter written after the \
2702 dash", does, cluster))
2703}
2704
2705/// A string cut to at most `max` bytes on a character boundary, marked where it
2706/// was cut.
2707///
2708/// The mark is not decoration: a command line a reader takes for whole is one
2709/// they will try to run again, and the argument that went missing is usually the
2710/// one that mattered.
2711///
2712/// # Arguments
2713/// * `s` - The text.
2714/// * `max` - The most bytes the result may take, including the mark.
2715fn cut_to(s: &str, max: usize) -> String {
2716 if s.len() <= max {
2717 return fmt!("{}", s);
2718 }
2719 const MARK: &str = " …";
2720 let room = max.saturating_sub(MARK.len());
2721 let mut end = room;
2722 while end > 0 && !s.is_char_boundary(end) {
2723 end -= 1;
2724 }
2725 fmt!("{}{}", &s[..end], MARK)
2726}
2727
2728/// Resolves `prefix` where it exists, so both sides of a containment test agree.
2729///
2730/// # Arguments
2731/// * `prefix` - A fence root as the caller spelled it.
2732fn resolve(prefix: &str) -> PathBuf {
2733 match std::fs::canonicalize(prefix) {
2734 Ok(p) => p,
2735 Err(_) => PathBuf::from(prefix), // Not there yet; compare as written.
2736 }
2737}
2738
2739/// The home-relative paths a granted toolchain is allowed to name.
2740///
2741/// # Why this table exists here as well as in the app
2742///
2743/// `REVIEW.md` §1.5. The fence is computed inside the page and the page is not
2744/// trusted, so the hand has to decide for itself whether an arriving fence is
2745/// one its grant could have produced. "Everything under the granted root" is
2746/// the obvious rule and it is wrong: a toolchain does not live in the workspace.
2747/// `cargo` is under `~/.cargo`, `node` under `~/.nvm`, and a rule that refused
2748/// them would refuse every real build -- and a security check that breaks
2749/// `cargo` is a security check somebody switches off.
2750///
2751/// So the question the clamp asks is not "is this path under that path" but "is
2752/// this a root the grant could imply". The set is closed and knowable: the
2753/// workspace, the hand's own scratch, and these -- the same tails
2754/// `Toolkit::grants` names in `src/tools.rs`, at the level of the directory each
2755/// toolchain owns rather than each individual grant, so that the app adding a
2756/// path *within* a toolchain does not need a change here.
2757///
2758/// The two copies can drift, and the drift fails safe and loud: a path this list
2759/// does not know is refused with a sentence naming it and naming this constant,
2760/// so the failure is one line to fix rather than a hole to find. The reverse
2761/// drift -- the app dropping a toolkit -- costs nothing, because a ceiling that
2762/// is never reached grants nothing.
2763/// # Why it names a toolkit and a level, and not merely a folder
2764///
2765/// It used to be a flat list of folders, allowed to every fence at either level
2766/// whether or not any toolkit had been granted. That was two mistakes in one
2767/// line, and the second is the dangerous one:
2768///
2769/// * **Unconditional.** A fence naming `~/.cargo/registry` was accepted from a
2770/// turn that had been granted no toolchain at all.
2771/// * **Level-blind.** `~/.local/bin` is a READ grant in the app's own table --
2772/// it holds the console scripts `pip install --user` writes -- and the clamp
2773/// accepted it as writable. `~/.local/bin` is first on `PATH`, so a file
2774/// called `ls` or `git` written there is unfenced execution as the user on the
2775/// next shell command. Measured against the release binary with no toolkit in
2776/// play: `rw:[<workspace>, ~/.local/bin]` was accepted, the shim was written,
2777/// and `chmod 755` succeeded because making a file executable is not
2778/// "loosening" and the metadata filter permits it.
2779///
2780/// So each entry names the toolkit that implies it and whether the fence may
2781/// name it WRITABLE, and both are checked. A `ro` root may sit under any tail of
2782/// a granted toolkit; an `rw` root only under one marked writable.
2783/// Which of the three doors a gated request came in by.
2784///
2785/// Lives here rather than beside the dispatcher because the clamp reads it: one toolkit,
2786/// [`TOOLKIT_ROOTS`]'s `remote` rows, is granted to a terminal the user opened and to nothing
2787/// a daimon can reach. An enum rather than a boolean so that a fourth door cannot be added
2788/// without the compiler asking what it means here.
2789#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2790pub enum Door {
2791 Command,
2792 Terminal,
2793 File,
2794}
2795
2796impl Door {
2797 /// Is this the surface a person opened and is typing into?
2798 pub fn is_terminal(&self) -> bool {
2799 matches!(self, Self::Terminal)
2800 }
2801}
2802
2803const TOOLKIT_ROOTS: &[KitRoot] = &[
2804 // rust
2805 KitRoot { kit: "rust", tail: ".cargo/bin", write: false, term: false },
2806 KitRoot { kit: "rust", tail: ".rustup", write: false, term: false },
2807 KitRoot { kit: "rust", tail: ".cargo/registry", write: true, term: false },
2808 KitRoot { kit: "rust", tail: ".cargo/git", write: true, term: false },
2809 KitRoot { kit: "rust", tail: ".cargo/.package-cache", write: true, term: false },
2810 // The target directory this repository's own convention puts a build in, one
2811 // named subdirectory per agent slot. Without the row the clamp refuses the
2812 // whole command, so the grant the app now sends makes a fenced build WORSE
2813 // than none at all. `~/.cache` is never granted: only this tail is, and only
2814 // to a request that named the Rust toolkit.
2815 KitRoot { kit: "rust", tail: ".cache/cargo-targets", write: true, term: false },
2816 // node
2817 KitRoot { kit: "node", tail: ".nvm", write: false, term: false },
2818 KitRoot { kit: "node", tail: ".npm", write: true, term: false },
2819 // Where a world's dev server and mock provider keep their pid files, their
2820 // output and their scratch root. Same reasoning as the row above, and the
2821 // same narrowness: the tail and not `~/.cache`.
2822 KitRoot { kit: "node", tail: ".cache/daimond", write: true, term: false },
2823 // python
2824 KitRoot { kit: "python", tail: ".pyenv", write: false, term: false },
2825 KitRoot { kit: "python", tail: ".local/bin", write: false, term: false },
2826 KitRoot { kit: "python", tail: ".local/lib", write: false, term: false },
2827 KitRoot { kit: "python", tail: ".cache/pip", write: true, term: false },
2828 // go
2829 KitRoot { kit: "go", tail: "sdk", write: false, term: false },
2830 KitRoot { kit: "go", tail: "go/bin", write: false, term: false },
2831 KitRoot { kit: "go", tail: "go/pkg/mod", write: true, term: false },
2832 KitRoot { kit: "go", tail: ".cache/go-build", write: true, term: false },
2833 // git -- the CONFIGURATION and not the program, which is in `/usr/bin` and therefore already in
2834 // this hand's own read-only base. Without these two rows the app's Git toolkit cannot be
2835 // granted at all: `vet_roots` refuses the fence, loudly and safely, and a fenced git then runs
2836 // with no `core.hooksPath` -- which on this machine is the whole of `~/.gitconfig`, and is the
2837 // pre-commit hook that reads every staged line looking for a credential. An unreadable hooks
2838 // directory is indistinguishable from an empty one, so refusing `--no-verify` while the hook
2839 // cannot run would be a guard protecting nothing.
2840 KitRoot { kit: "git", tail: ".gitconfig", write: false, term: false },
2841 KitRoot { kit: "git", tail: ".config/git", write: false, term: false },
2842 // remote -- THE ONE TOOLKIT A COMMAND CANNOT HAVE, whatever the page says it was granted.
2843 //
2844 // An `ssh` that reaches another machine reaches it UNFENCED: the shell at the far end is
2845 // sshd's child, under nothing this binary applies. So a fenced command able to run it
2846 // would not be fenced at all, and `term: true` is what makes that a property of the HAND
2847 // rather than of the page -- the app drops the grant for a command (`toolkit_bounds` in
2848 // `src/tools.rs`), and this refuses it even if a page were made to send it anyway.
2849 //
2850 // Read-only, all three, with one exception: the host list ssh writes when it learns a
2851 // host. None of them is the user's own `~/.ssh`, which is denied by name in the app's
2852 // table and is never lent to anything.
2853 KitRoot { kit: "remote", tail: ".config/oxedyne/daimond-hand/bin",
2854 write: false, term: true },
2855 KitRoot { kit: "remote", tail: ".config/oxedyne/daimond-hand/ssh",
2856 write: false, term: true },
2857 KitRoot { kit: "remote", tail: ".config/oxedyne/daimond-hand/known_hosts",
2858 write: true, term: true },
2859];
2860
2861/// Has the user set Daimond's own ssh up on this computer?
2862///
2863/// The Remote toolchain's posture, and it is READ rather than stored. `install.sh --remote`
2864/// makes the key and writes the wrapper; a machine where nobody ran it has neither. So there
2865/// is no setting anywhere that could disagree with what it describes, and nothing new to
2866/// forget, migrate or leave switched on -- the answer IS the two files ssh cannot work
2867/// without. A machine that never had ssh set up therefore never gains it by itself, which is
2868/// the whole of why the posture is not a default.
2869///
2870/// Both files, not either. A wrapper with no key behind it is an `ssh` on `PATH` that
2871/// connects to nothing; a key with no wrapper is a key nothing would ever pass to OpenSSH,
2872/// which takes its home directory from the passwd entry and not from `HOME`. Announcing
2873/// "ready" for half of it would put a grant in a terminal's fence for a toolchain that cannot
2874/// work.
2875///
2876/// Said to the page in `hello`'s `caps` as `remote:ready`, beside `home:`, `host:` and
2877/// `shell:`, and for the same reason as all three: the page cannot read this machine.
2878pub fn remote_ready() -> bool {
2879 match home_dir() {
2880 Some(h) => remote_ready_at(Path::new(&h)),
2881 None => false,
2882 }
2883}
2884
2885/// As [`remote_ready`], against a named home directory.
2886///
2887/// Split out so the answer can be tested without touching the environment: `HOME` is process
2888/// state and a test that set it would decide what every other test on the same process saw.
2889///
2890/// # Arguments
2891/// * `home` - The home directory to look under.
2892fn remote_ready_at(home: &Path) -> bool {
2893 let base = home.join(".config/oxedyne/daimond-hand");
2894 base.join("bin/ssh").is_file() && base.join("ssh/id_daimond").is_file()
2895}
2896
2897/// One folder a named toolchain may reach, and at what level.
2898///
2899/// Mirrors one row of `Toolkit::grants` in the app's `src/tools.rs`. The
2900/// `Level::Deny` rows there have no entry here on purpose: a deny only ever takes
2901/// access away, and this is a ceiling on what may be granted.
2902#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2903pub struct KitRoot {
2904 /// The toolkit that implies it, as the app's `Toolkit::name` spells it.
2905 pub kit: &'static str,
2906 /// The folder, relative to the home directory.
2907 pub tail: &'static str,
2908 /// Whether a fence may name it as WRITABLE, and not merely readable.
2909 pub write: bool,
2910 /// Whether only a terminal the user opened may name it.
2911 pub term: bool,
2912}
2913
2914/// Whether an arriving fence names only roots this hand's grant could imply.
2915///
2916/// This is the answer to `REVIEW.md` §1.5, and the finding is worth restating
2917/// because the shape of it is easy to lose: the fence travels **through the
2918/// page**, and a page is not the app. The hand was honouring whatever roots
2919/// arrived. Measured against the release binary before this existed, an exec
2920/// carrying `fence:{rw:["/etc"]}` with `cwd:"/etc"` ran `/bin/ls /etc/ssh` and
2921/// returned the listing -- with the fence fully in force, doing exactly what it
2922/// was told. `rw:["/"]` *was* refused, but only because the journal happens to
2923/// sit somewhere under `/` and `Journal::check_fence` refuses a fence that
2924/// reaches the record. That is a coincidence, not a boundary, and it stops
2925/// holding the moment somebody moves the journal.
2926///
2927/// Three sources of a legitimate root, and there is no fourth:
2928///
2929/// * **The granted root**, which is the workspace the user handed over.
2930/// * **The hand's own scratch**, which the hand appends to every fence itself --
2931/// allowed here so that a fence echoed back to the hand is not refused for
2932/// carrying something the hand put in it.
2933/// * **A toolchain the request SAYS was granted**, from [`TOOLKIT_ROOTS`], at the
2934/// level that table gives it.
2935///
2936/// That third source is conditional and level-aware, and both halves were once
2937/// missing -- see [`TOOLKIT_ROOTS`] for what that cost. A request naming no
2938/// toolkit reaches no toolchain folder at all, which is the ordinary case: a
2939/// toolkit is a grant, the user makes it per Diamond, and most turns have none.
2940///
2941/// `deny` is not clamped and must not be: a deny only ever takes access away, so
2942/// a caller naming one outside the grant has narrowed its own fence and harmed
2943/// nobody.
2944///
2945/// # Arguments
2946/// * `root` - The folder this hand was granted.
2947/// * `fence` - The fence as it arrived, before the hand adds anything.
2948/// * `kits` - The toolkit names the request carried. Never read from `argv`: a
2949/// fence that widened itself to fit the requested binary would be a fence the
2950/// model chooses.
2951/// * `door` - Which surface the request came in by. A [`TOOLKIT_ROOTS`] row marked `term`
2952/// is reachable from a terminal the user opened and from nothing else, however the request
2953/// spells its grant.
2954///
2955/// # Returns
2956/// The refusal, or `None` where every root is one the grant could imply.
2957/// Which folder an arriving fence is checked against: the terminal's ceiling, or the grant.
2958///
2959/// A terminal is the user at a keyboard and a command is a daimon, so the two are allowed
2960/// different sizes. The ceiling is written on the machine by `hand/install/install.sh` and
2961/// never by a page, so checking a terminal against it is still the hand holding an arriving
2962/// fence to a grant the page could not have chosen -- which is the whole of what
2963/// [`vet_roots`] is for.
2964///
2965/// # Arguments
2966/// * `root` - The folder this hand was granted.
2967/// * `ceiling` - The widest a terminal may reach, where the installer named one.
2968/// * `door` - Which surface the request came in by.
2969pub fn vet_against<'a>(root: &'a Path, ceiling: Option<&'a Path>, door: Door) -> &'a Path {
2970 match (ceiling, door.is_terminal()) {
2971 (Some(c), true) => c,
2972 _ => root,
2973 }
2974}
2975
2976pub fn vet_roots(root: &Path, fence: &FenceSpec, kits: &[String], door: Door) -> Option<String> {
2977 // Allowed at either level: the workspace, and the hand's own scratch, which the hand appends
2978 // to every fence itself and must therefore accept back.
2979 let mut any: Vec<PathBuf> = vec![root.to_path_buf()];
2980 if let Ok(s) = scratch_base() {
2981 any.push(resolve(&fmt!("{}", s.display())));
2982 }
2983 // The toolchain folders, split by level, and only for the toolkits this request named. A name
2984 // this build does not know contributes nothing rather than being refused: the app may record a
2985 // toolkit a later build expresses, and the safe reading of "I do not know what that grants" is
2986 // "it grants nothing".
2987 let mut ro_only: Vec<PathBuf> = Vec::new();
2988 let mut writable: Vec<PathBuf> = Vec::new();
2989 // Folders a granted toolkit names that THIS door may not have. Kept rather than
2990 // discarded so the refusal can say which of the two mistakes it is: "nobody granted
2991 // that" is false here and would send the reader to the wrong fix.
2992 let mut term_only: Vec<PathBuf> = Vec::new();
2993 if let Ok(h) = std::env::var("HOME") {
2994 if !h.is_empty() && Path::new(&h).is_absolute() {
2995 for k in TOOLKIT_ROOTS {
2996 if !kits.iter().any(|n| n == k.kit) {
2997 continue;
2998 }
2999 // The trust boundary, enforced at the hand and not merely at the page: a
3000 // grant marked for the terminal is not available to a command or to a file
3001 // operation, which are the two doors a daimon reaches.
3002 let p = Path::new(&h).join(k.tail);
3003 if k.term && !door.is_terminal() {
3004 term_only.push(p);
3005 continue;
3006 }
3007 if k.write { writable.push(p); } else { ro_only.push(p); }
3008 }
3009 }
3010 }
3011 for (which, roots) in [("rw", &fence.rw), ("ro", &fence.ro)] {
3012 let writing = which == "rw";
3013 for r in roots.iter() {
3014 let p = resolve(r);
3015 if any.iter().any(|a| under(&p, a)) {
3016 continue;
3017 }
3018 if writable.iter().any(|a| under(&p, a)) {
3019 continue;
3020 }
3021 // A readable grant may sit under a folder the toolkit writes as well as one it only
3022 // reads; a writable grant may not sit under one it only reads.
3023 if !writing && ro_only.iter().any(|a| under(&p, a)) {
3024 continue;
3025 }
3026 // Said apart, because the two are different mistakes and the fix for each is
3027 // different: one is a fence naming somewhere nobody granted, the other is a fence
3028 // asking to WRITE a toolchain folder that is lent for reading.
3029 if writing && ro_only.iter().any(|a| under(&p, a)) {
3030 return Some(fmt!(
3031 "Refused: this command's fence asks to WRITE '{}', which is part of a granted \
3032 toolchain and is lent for reading. The compiler, the interpreter and what a \
3033 package manager installed for the user are not a command's to edit -- a file \
3034 written there runs the next time anything on this machine calls that name. \
3035 The caches a build genuinely has to write are granted separately and by name.",
3036 r));
3037 }
3038 // Named by a toolkit the request really did carry, and refused all the same for
3039 // the door it came in by. The whole of the Remote grant's reason is in the
3040 // sentence, because the reader's next move depends on believing it.
3041 if term_only.iter().any(|a| under(&p, a)) {
3042 return Some(fmt!(
3043 "Refused: this command's fence names '{}', which is part of the Remote \
3044 toolchain -- an ssh key of Daimond's own and the host list that goes with \
3045 it. That grant is lent to a terminal the user opened by hand and to \
3046 nothing else, because an ssh reaches a shell on another machine that no \
3047 fence on this one binds. A command does not get it, whatever this Diamond \
3048 was granted.", r));
3049 }
3050 return Some(fmt!(
3051 "Refused: this command's fence grants '{}' access to '{}', which is not inside \
3052 the folder this hand was granted ('{}'), is not this hand's own temporary \
3053 directory, and is not a folder one of the toolkits this request named ({}) \
3054 reaches. The fence is computed in the page and the page is not the app, so the \
3055 hand checks that an arriving fence is one its grant could have produced -- and \
3056 refuses rather than fencing a command around somewhere nobody granted. If this is \
3057 a toolchain the hand should know about, it belongs in TOOLKIT_ROOTS in exec.rs.",
3058 which, r, root.display(),
3059 if kits.is_empty() { fmt!("none") } else { kits.join(", ") }));
3060 }
3061 }
3062 None
3063}
3064
3065/// The user's own files a terminal they opened is lent, read-only, by name.
3066///
3067/// **Three, and no more.** A shell started under the fence could not read a single one of
3068/// them, so the terminal opened on `bash: /home/…/.bashrc: Permission denied` and then on a
3069/// prompt belonging to nobody -- no aliases, no functions, no history search, none of the key
3070/// bindings that are in every other terminal on the machine. A person opening a terminal
3071/// expects their own terminal.
3072///
3073/// * `.bashrc` -- what an interactive shell reads: the prompt, the aliases, the functions.
3074/// * `.profile` -- what a login shell reads, and on many machines the file that reaches
3075/// `.bashrc` at all.
3076/// * `.inputrc` -- readline's, so the keys do what the user's fingers expect.
3077///
3078/// **Read-only, named one at a time, and never the home directory.** That is the difference
3079/// between lending three files and granting `$HOME`, which holds `.ssh`, `.aws`, `.netrc` and
3080/// every browser profile. A file `.bashrc` sources that is not in this list is refused and
3081/// says so on the screen, which is honest and is a line the user can act on.
3082pub const USER_DOTFILES: &[&str] = &[".bashrc", ".profile", ".inputrc"];
3083
3084/// Lends those files to a terminal, and to nothing else.
3085///
3086/// # Which end decides, and why it is this one
3087///
3088/// The page composes the fence and this does not change that: what the page cannot do is
3089/// know which of the three files EXIST. `fence::canonical` refuses a root it cannot resolve
3090/// -- correctly, since a marked folder that has gone is a fence that would not cover what the
3091/// user marked -- so a page naming `~/.inputrc` on a machine without one would take the whole
3092/// terminal down with it. The same argument [`grant_git_hooks`] is here for.
3093///
3094/// A denial already in the fence is left alone. A deny is a decision somebody made, and
3095/// widening one from here would be the fence quietly disagreeing with it.
3096///
3097/// # Arguments
3098/// * `fence` - The fence as it arrived; the files that are there are added read-only.
3099/// * `door` - Which surface the request came in by. Nothing is added for a command or a file
3100/// operation, which are the two doors a daimon reaches: the user's own shell configuration
3101/// runs code, and a `.bashrc` read by a program the model chose is a program that ran the
3102/// user's aliases with the model's arguments.
3103///
3104/// # Returns
3105/// What was lent, so a caller that wants to say so can.
3106pub fn grant_user_dotfiles(fence: &mut FenceSpec, door: Door) -> Vec<String> {
3107 if !door.is_terminal() {
3108 return Vec::new();
3109 }
3110 let home = match home_dir() {
3111 Some(h) => PathBuf::from(h),
3112 None => return Vec::new(),
3113 };
3114 let mut lent = Vec::new();
3115 for tail in USER_DOTFILES {
3116 let p = home.join(tail);
3117 if !p.is_file() {
3118 continue;
3119 }
3120 if fence.deny.iter().any(|d| under(&p, &resolve(d))) {
3121 continue;
3122 }
3123 if fence.rw.iter().chain(fence.ro.iter()).any(|r| under(&p, &resolve(r))) {
3124 continue;
3125 }
3126 let named = fmt!("{}", p.display());
3127 fence.ro.push(named.clone());
3128 lent.push(named);
3129 }
3130 lent
3131}
3132
3133/// Drops the toolchain folders this machine does not have.
3134///
3135/// A toolkit grant is a list of paths the APP expands from one name the user
3136/// ticked, and the machine may simply not have all of them: `~/.config/git` and
3137/// `~/.nvm` and `~/.pyenv` are absent here, and each of them is an ordinary
3138/// arrangement rather than a fault. [`fence::canonical`] refuses an `rw` or `ro`
3139/// root it cannot resolve, and rightly -- so before this existed, ticking the Git
3140/// toolkit refused EVERY command the Diamond ran, with a sentence about a path
3141/// the user had never named.
3142///
3143/// Skipping is the safe direction: a path that is not there grants nothing, so
3144/// dropping it tightens the fence. It is also the rule `fence::resolve` already
3145/// applies to the read-only system base, and for the same reason -- these are
3146/// paths nobody asked for by name.
3147///
3148/// **A WORKSPACE root is not touched, and that is the whole of the care here.**
3149/// A marked folder that cannot be resolved is a fence that would silently not
3150/// cover what the user marked, which is the opposite case and must keep
3151/// refusing. So only a root under a [`TOOLKIT_ROOTS`] tail of a toolkit this
3152/// request NAMED is eligible, and it is dropped only when the filesystem says it
3153/// is not there.
3154///
3155/// # Arguments
3156/// * `fence` - The fence as it arrived; the absent toolchain roots are removed.
3157/// * `kits` - The toolkit names the request carried.
3158///
3159/// # Returns
3160/// What was dropped, so a caller that wants to say so can.
3161pub fn drop_absent_kit_roots(fence: &mut FenceSpec, kits: &[String]) -> Vec<String> {
3162 let home = match home_dir() {
3163 Some(h) => PathBuf::from(h),
3164 None => return Vec::new(),
3165 };
3166 let tails = TOOLKIT_ROOTS.iter()
3167 .filter(|k| kits.iter().any(|n| n == k.kit))
3168 .map(|k| home.join(k.tail))
3169 .collect::<Vec<_>>();
3170 if tails.is_empty() {
3171 return Vec::new();
3172 }
3173 let mut gone = Vec::new();
3174 for roots in [&mut fence.rw, &mut fence.ro] {
3175 roots.retain(|r| {
3176 let p = resolve(r);
3177 // Under a toolkit tail AND not there. Either half alone keeps it: a
3178 // workspace root that is missing still refuses, and a toolchain
3179 // folder that exists is granted as before.
3180 if tails.iter().any(|t| under(&p, t)) && !p.exists() {
3181 gone.push(fmt!("{}", r));
3182 return false;
3183 }
3184 true
3185 });
3186 }
3187 gone
3188}
3189
3190// ┌───────────────────────────────────────────────────────────────┐
3191// │ The credential scanner a fence switches off in silence │
3192// └───────────────────────────────────────────────────────────────┘
3193//
3194// `core.hooksPath` names the directory git runs `pre-commit` from, and on the machine this
3195// was written on it names a credential scanner -- added after a live key reached a public
3196// repository and was used by somebody else nine days later.
3197//
3198// A fenced git loses that hook in either of two ways, and only one of them is loud.
3199//
3200// * **The hooks directory is unreachable but git knows where it is.** Git tries to run
3201// the hook, `execve` answers EACCES, and the commit fails with `cannot exec ...
3202// Permission denied`. Loud, and already fail-closed.
3203// * **Git cannot read the configuration that names it.** Nothing grants `~/.gitconfig`
3204// unless the user ticked the Git toolkit, and git needs no toolkit to commit -- so the
3205// ordinary case is a git that never learns `core.hooksPath` exists, looks in
3206// `.git/hooks`, finds nothing, and commits. Exit 0, no message, no scanner. Measured
3207// 2026-08-24: a fenced commit put AWS's published example key into a repository one
3208// after the same hook had refused the same bytes outside the fence.
3209//
3210// The second is the one this section closes, and the shape of the answer is the shape of
3211// the fault: what is missing is the CONFIGURATION, so what is checked is whether git will
3212// be able to read it. The hand can always read it -- the hand is not fenced -- so it reads
3213// the user's own global configuration itself and then asks the plan whether the fenced git
3214// could have.
3215//
3216// Two halves, closing different failures. [`grant_git_hooks`] puts the hooks directory
3217// into the fence read-only, which carries execute, so a Diamond that granted the Git
3218// toolchain runs the hook -- the normal case working. [`git_hooks_refusal`] refuses a
3219// commit that would run without it, naming what is missing -- an unreachable hook made
3220// loud instead of invisible. A refusal costs one call; the silence costs a credential.
3221//
3222// # Only the user's own GLOBAL value is read, and that is the security of it
3223//
3224// A repository's `.git/config` is inside the fence and a command can write it, so a
3225// `core.hooksPath` read from there would let a turn choose which directory the fence lends
3226// it: `~/.ssh` is an exfiltration path, and an empty folder in the workspace is the scanner
3227// disabled without a word. So the grant follows the user's own configuration and nothing
3228// else, and a repository that overrides it is refused by name rather than obeyed.
3229
3230/// The git verbs that run a hook the user could be relying on.
3231///
3232/// `push` is absent on purpose: a Daimond push runs with `core.hooksPath` pointed at a
3233/// denied directory deliberately, so that a `pre-push` script in a repository a model can
3234/// write does not run with a credential in its environment. That decision is argued where
3235/// it is made, in `src/tools.rs` beside `PushCred::git_env`.
3236const HOOKED_VERBS: &[&str] = &[
3237 "commit",
3238 "merge",
3239 "rebase",
3240 "am",
3241 "cherry-pick",
3242 "revert",
3243];
3244
3245/// Where the user's own global configuration says hooks live, and which file said so.
3246///
3247/// Asked of git rather than parsed out of `~/.gitconfig`, because `include.path` and
3248/// `includeIf` mean the file is not the answer -- and a parser that missed one of those
3249/// would report "no hooks configured" for a machine that has them, which is the silence
3250/// this whole section exists to end.
3251///
3252/// Read with the HAND's environment and not the command's. The command's environment
3253/// arrives from the page, and a `HOME` chosen there would decide which configuration counts
3254/// as the user's own.
3255///
3256/// # Arguments
3257/// * `env` - Overrides laid over the hand's own environment. Empty in the hand; a test
3258/// passes `GIT_CONFIG_GLOBAL` so that it never touches the real configuration.
3259///
3260/// # Returns
3261/// The hooks directory and the configuration file naming it, both resolved, or `None`
3262/// where the user configured none or named somewhere that is not there -- in which case
3263/// there is no hook to lose, fenced or not.
3264fn user_hooks_dir(env: &[(String, String)]) -> Option<(PathBuf, PathBuf)> {
3265 let out = git_config_read(env, None, &["--global", "--show-origin"])?;
3266 let (origin, value) = out.split_once('\t')?;
3267 let file = PathBuf::from(origin.strip_prefix("file:")?).canonicalize().ok()?;
3268 let dir = hooks_dir_of(value, None, env)?;
3269 Some((dir, file))
3270}
3271
3272/// What `core.hooksPath` reads as, or `None` where it is unset and where git failed.
3273///
3274/// # Arguments
3275/// * `env` - Overrides laid over the hand's own environment.
3276/// * `cwd` - Where to ask from, which decides whether a repository's own configuration is
3277/// in the answer. `None` asks from wherever the hand is.
3278/// * `flags` - Extra arguments to `git config`, before `--get`.
3279fn git_config_read(
3280 env: &[(String, String)],
3281 cwd: Option<&Path>,
3282 flags: &[&str],
3283)
3284 -> Option<String>
3285{
3286 let mut cmd = std::process::Command::new("git");
3287 cmd.arg("config").args(flags).args(["--get", "core.hooksPath"]);
3288 if let Some(d) = cwd {
3289 cmd.current_dir(d);
3290 }
3291 for (k, v) in env {
3292 cmd.env(k, v);
3293 }
3294 // A prompt here would hang the hand rather than fail. `config --get` should never ask,
3295 // and this makes sure of it.
3296 cmd.env("GIT_TERMINAL_PROMPT", "0");
3297 let out = cmd.output().ok()?;
3298 if !out.status.success() {
3299 return None;
3300 }
3301 let s = String::from_utf8_lossy(&out.stdout).trim_end_matches('\n').to_string();
3302 if s.trim().is_empty() { None } else { Some(s) }
3303}
3304
3305/// The directory a `core.hooksPath` value names on this machine, if it is one.
3306///
3307/// A value naming somewhere that is not there is not a fence problem: git would run no hook
3308/// with or without a fence, so there is nothing here to lose and nothing to refuse.
3309///
3310/// # Arguments
3311/// * `value` - What git said.
3312/// * `cwd` - What a relative value is relative to, which is git's own rule: the directory
3313/// the hooks are run from, meaning the top of the working tree.
3314/// * `env` - Where `HOME` comes from, for a value written with a leading `~`.
3315fn hooks_dir_of(value: &str, cwd: Option<&Path>, env: &[(String, String)]) -> Option<PathBuf> {
3316 let v = value.trim();
3317 if v.is_empty() {
3318 return None;
3319 }
3320 let home = env.iter().find(|(k, _)| k == "HOME").map(|(_, v)| v.clone())
3321 .or_else(home_dir);
3322 let raw = if v == "~" {
3323 PathBuf::from(home?)
3324 } else if let Some(rest) = v.strip_prefix("~/") {
3325 PathBuf::from(home?).join(rest)
3326 } else {
3327 let p = PathBuf::from(v);
3328 if p.is_absolute() { p } else { cwd?.join(p) }
3329 };
3330 let real = raw.canonicalize().ok()?;
3331 if real.is_dir() { Some(real) } else { None }
3332}
3333
3334/// Puts the user's own hooks directory into the fence, read-only.
3335///
3336/// Read-only and never writable, for the reason every other configuration grant is:
3337/// a hooks directory a command can write is a directory that decides what runs on the
3338/// user's next commit, in their own shell, outside all of this. A read-only grant carries
3339/// execute, which is what a hook needs.
3340///
3341/// Called after [`vet_roots`], which checks that the roots the PAGE named are ones its
3342/// grant could have produced. This root is not one of those: it is read here, on the
3343/// machine, from configuration the model cannot reach and the page cannot see.
3344///
3345/// # Arguments
3346/// * `fence` - The fence as it arrived; the directory is added to its read-only roots.
3347/// * `kits` - The toolkit names the request carried. Nothing happens without `git`,
3348/// because without it the fenced git cannot read `~/.gitconfig` either, and a hooks
3349/// directory git will never be told about is a widening that buys nothing.
3350/// * `env` - Overrides for reading the user's configuration; empty in the hand.
3351///
3352/// # Returns
3353/// The directory added, so a caller that wants to say so can.
3354pub fn grant_git_hooks(
3355 fence: &mut FenceSpec,
3356 kits: &[String],
3357 env: &[(String, String)],
3358)
3359 -> Option<PathBuf>
3360{
3361 if !kits.iter().any(|k| k == "git") {
3362 return None;
3363 }
3364 let (dir, _) = user_hooks_dir(env)?;
3365 // A denial is a decision somebody made, and widening one from here would be the fence
3366 // quietly disagreeing with it. `.config/oxedyne` is denied to every toolkit on purpose.
3367 if fence.deny.iter().any(|d| under(&dir, &resolve(d))) {
3368 return None;
3369 }
3370 if fence.rw.iter().chain(fence.ro.iter()).any(|r| under(&dir, &resolve(r))) {
3371 return None;
3372 }
3373 fence.ro.push(fmt!("{}", dir.display()));
3374 Some(dir)
3375}
3376
3377/// Refuses a git command whose hooks would not run, naming what is missing.
3378///
3379/// The fail-closed half, and the one that catches the silent case: git needs no toolkit to
3380/// commit, so the ordinary fenced commit is one that cannot read `~/.gitconfig`, never
3381/// learns `core.hooksPath` exists, and commits with no scanner and no message.
3382///
3383/// Three ways that happens, and each gets its own sentence, because the fix for each is
3384/// different:
3385///
3386/// * the configuration naming the hooks is outside the fence -- grant the Git toolchain;
3387/// * the hooks directory is outside the fence -- attach it read-only;
3388/// * the repository's own `.git/config`, which is inside the fence and which a command can
3389/// write, points `core.hooksPath` somewhere else.
3390///
3391/// # What this does not reach
3392///
3393/// Only a command whose `argv[0]` is git is checked, so `sh -c 'git commit'` is not. With
3394/// the Git toolchain granted that spelling is covered anyway, because [`grant_git_hooks`]
3395/// puts the directory in the fence and git reads its own configuration; without it, a
3396/// shell-wrapped commit still runs unscanned. Written down rather than papered over: the
3397/// honest boundary of this guard is the command it can see.
3398///
3399/// And it fails OPEN where the hand cannot run `git config` at all -- no git on the hand's
3400/// own `PATH`, or a `git config` that exits non-zero -- because it then cannot tell a
3401/// machine with no `core.hooksPath` from a machine it could not ask. Refusing both would
3402/// refuse every commit on every ordinary machine, which is a guard nobody would keep. The
3403/// case is narrow: a hand that cannot find git is a hand whose fenced git will not run
3404/// either.
3405///
3406/// # Arguments
3407/// * `plan` - The fence as it will be enforced, which is the only honest thing to ask
3408/// "could git have read this" of.
3409/// * `argv` - The command.
3410/// * `cwd` - Where it will run, which decides which repository's configuration is in play.
3411/// * `env` - Overrides for reading the user's configuration; empty in the hand.
3412pub fn git_hooks_refusal(
3413 plan: &Plan,
3414 argv: &[String],
3415 cwd: &str,
3416 env: &[(String, String)],
3417)
3418 -> Option<String>
3419{
3420 let prog = argv.first()?;
3421 if Path::new(prog).file_name().map(|n| n != "git").unwrap_or(true) {
3422 return None;
3423 }
3424 // The verb is the first argument that is not an option. `git -C x commit` takes an
3425 // argument after `-C`, so a lone `-C` swallows the next word rather than the verb.
3426 let mut verb: Option<&str> = None;
3427 let mut skip = false;
3428 for a in argv.iter().skip(1) {
3429 if skip { skip = false; continue; }
3430 if a == "-C" || a == "-c" || a == "--git-dir" || a == "--work-tree" {
3431 skip = true;
3432 continue;
3433 }
3434 if a.starts_with('-') { continue; }
3435 verb = Some(a.as_str());
3436 break;
3437 }
3438 if !HOOKED_VERBS.contains(&verb?) {
3439 return None;
3440 }
3441 // Nothing configured is nothing to lose: git's own default is `.git/hooks`, inside the
3442 // repository, inside the folder the fence was built around.
3443 let (want, from) = user_hooks_dir(env)?;
3444 if !plan.permits(&from, Level::Ro) {
3445 return Some(fmt!(
3446 "Refused: this would commit without the hooks the user configured. Their git \
3447 configuration at {} points core.hooksPath at {}, and this command's fence does \
3448 not reach that configuration -- so git would never learn the directory exists, \
3449 would look in .git/hooks, would find nothing, and would commit. That is how a \
3450 credential-scanning pre-commit hook stops running without saying so, which is \
3451 why this is refused rather than run. Grant this Diamond the Git toolchain, \
3452 which lends git the user's own configuration, and ask again.",
3453 from.display(), want.display()));
3454 }
3455 if !plan.permits(&want, Level::Ro) {
3456 return Some(fmt!(
3457 "Refused: git would run its hooks from {}, and this command's fence does not \
3458 reach it. Attach that directory to the Diamond read-only -- a read-only grant \
3459 carries execute, which is what a hook needs -- or take core.hooksPath out of \
3460 the git configuration if the hooks are not wanted.", want.display()));
3461 }
3462 let here = Path::new(cwd);
3463 let effective = git_config_read(env, Some(here), &[])
3464 .and_then(|v| hooks_dir_of(&v, Some(here), env));
3465 if effective.as_deref() != Some(want.as_path()) {
3466 return Some(fmt!(
3467 "Refused: this repository's own configuration points core.hooksPath at {}, and \
3468 the user's points it at {}. A repository's .git/config is inside the fence and \
3469 a command can write it, so a hooks directory named there is one this turn chose \
3470 -- and choosing an empty one is how the credential-scanning pre-commit hook \
3471 stops running without saying so. Take core.hooksPath out of .git/config.",
3472 effective.map(|p| fmt!("{}", p.display())).unwrap_or_else(|| fmt!("nothing")),
3473 want.display()));
3474 }
3475 None
3476}
3477
3478/// Checks a working directory against a fence, in the app's own refusing voice.
3479///
3480/// Symbolic links are resolved before the comparison, because a link inside the
3481/// fence pointing out of it is otherwise a way past the whole arrangement.
3482///
3483/// # Arguments
3484/// * `cwd` - The absolute working directory the caller asked for.
3485/// * `fence` - What the command may touch.
3486pub(crate) fn vet_cwd(cwd: &str, fence: &FenceSpec) -> Vetted {
3487 let raw = Path::new(cwd);
3488 if !raw.is_absolute() {
3489 return Vetted::Refused(fmt!(
3490 "Refused: '{}' is not an absolute path, and the hand does not guess what it is \
3491 relative to. Give the working directory in full.", cwd));
3492 }
3493 if fence.rw.is_empty() && fence.ro.is_empty() {
3494 return Vetted::Refused(fmt!(
3495 "Refused: this command arrived with an empty fence, which grants nothing at all. Name \
3496 the roots it may work under before asking for it to run."));
3497 }
3498 // Counting roots is not the same as having any. A root of "" is a root the
3499 // guard above counts and that every containment test then answers `true`
3500 // for, so `FenceSpec{rw:[""]}` ran a command in /etc/ssh and reported
3501 // success. The roots are therefore checked for meaning, not for presence.
3502 for (which, roots) in [("rw", &fence.rw), ("ro", &fence.ro), ("deny", &fence.deny)] {
3503 for r in roots.iter() {
3504 if r.is_empty() {
3505 return Vetted::Refused(fmt!(
3506 "Refused: this command's fence lists an empty path among its '{}' roots. An \
3507 empty root is not a root: every path on the machine sits under it, so a fence \
3508 holding one grants everything. Name the directory in full.", which));
3509 }
3510 if !Path::new(r).is_absolute() {
3511 return Vetted::Refused(fmt!(
3512 "Refused: this command's fence lists '{}' among its '{}' roots, which is not \
3513 an absolute path. The hand does not guess what a fence root is relative to; \
3514 whatever resolved the workspace should send the result.", r, which));
3515 }
3516 }
3517 }
3518
3519 let dir = match std::fs::canonicalize(raw) {
3520 Ok(p) => p,
3521 Err(_) => return Vetted::Refused(fmt!(
3522 "Refused: '{}' cannot be resolved to a directory on this machine. A command's working \
3523 directory has to exist before it can run in it.", cwd)),
3524 };
3525 // A FILE RESOLVES PERFECTLY WELL, and every check below it passes: it is absolute, it exists,
3526 // and it sits inside the fence. The failure then surfaces at the spawn as `Os { code: 20,
3527 // kind: NotADirectory }` wrapped in two error layers, which names no path the caller chose and
3528 // tells the user nothing they can act on. On 2026-08-26 a terminal opened for a Diamond whose
3529 // one attachment was `writing_spec.md` produced exactly that.
3530 //
3531 // Refused rather than climbed: the parent of a file is a directory the caller did not ask for,
3532 // and a working directory quietly widened by one level is not a thing a fence should do on its
3533 // own. The sentence names the folder, so the fix is a copy and paste away.
3534 if !dir.is_dir() {
3535 let parent = dir.parent()
3536 .map(|p| p.display().to_string())
3537 .unwrap_or_else(|| fmt!("the folder it is in"));
3538 return Vetted::Refused(fmt!(
3539 "Refused: '{}' is a file, not a folder, and nothing can be run inside a file. Ask for \
3540 '{}' instead.", cwd, parent));
3541 }
3542
3543 for d in &fence.deny {
3544 if under(&dir, &resolve(d)) {
3545 return Vetted::Refused(fmt!(
3546 "Refused: '{}' is inside '{}', which this command is denied outright. That subtree \
3547 is fenced off whatever else the fence allows.", cwd, d));
3548 }
3549 }
3550
3551 let inside = fence.rw.iter().chain(fence.ro.iter())
3552 .any(|r| under(&dir, &resolve(r)));
3553 if !inside {
3554 let roots = fence.rw.iter().chain(fence.ro.iter())
3555 .map(|r| fmt!("'{}'", r))
3556 .collect::<Vec<_>>()
3557 .join(", ");
3558 return Vetted::Refused(fmt!(
3559 "Refused: '{}' is outside this command's fence, which reaches {} and nowhere else. \
3560 Run it somewhere inside the fence, or say what you would need and let the user widen \
3561 it.", cwd, roots));
3562 }
3563
3564 Vetted::Ok(dir)
3565}
3566
3567/// Resolves the program a caller named and checks it against the fence.
3568///
3569/// Three ways in were open and all three are closed here. An absolute path
3570/// outside the fence ran, because nothing compared it to anything.
3571/// `../outside/evil` ran, because a relative program name was handed to `execvp`
3572/// and resolved against the working directory afterwards. And a bare name
3573/// resolved through a `PATH` the *caller* supplied, because the environment
3574/// `execvp` searches is the child's, which is exactly what the caller writes --
3575/// and with no `PATH` at all, glibc falls back to `confstr(_CS_PATH)` and finds
3576/// `/bin:/usr/bin` anyway.
3577///
3578/// So the answer is always an absolute, canonical path, checked against the plan
3579/// before anything is spawned and handed to the launcher already resolved. The
3580/// caller's `PATH` still *finds* candidates -- refusing it outright would break
3581/// every ordinary call -- but finding is not permission, and what it finds is
3582/// checked like anything else.
3583///
3584/// # Arguments
3585/// * `argv0` - The program as the caller spelled it.
3586/// * `cwd` - The vetted working directory, for a relative spelling.
3587/// * `env` - The caller's environment, consulted for `PATH` only.
3588/// * `plan` - The fence this command will run behind.
3589pub(crate) fn vet_program(argv0: &str, cwd: &Path, env: &[(String, String)], plan: &Plan) -> Vetted0 {
3590 if argv0.is_empty() {
3591 return Vetted0::Refused(fmt!(
3592 "Refused: the program to run is an empty string. The first element of argv names the \
3593 program; there is no shell here to turn an empty word into something else."));
3594 }
3595
3596 let mut tried = Vec::<PathBuf>::new();
3597 if argv0.contains('/') {
3598 let p = Path::new(argv0);
3599 tried.push(if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) });
3600 } else {
3601 let path = match env.iter().find(|(k, _)| k == "PATH") {
3602 Some((_, v)) => v.as_str(),
3603 None => PATH_FALLBACK,
3604 };
3605 for dir in path.split(':') {
3606 // A relative or empty `PATH` element means "the working directory"
3607 // to a shell. It is skipped rather than honoured: a program found
3608 // that way is chosen by whatever last wrote to the directory the
3609 // command happens to be sitting in.
3610 if dir.is_empty() || !Path::new(dir).is_absolute() {
3611 continue;
3612 }
3613 tried.push(Path::new(dir).join(argv0));
3614 }
3615 }
3616
3617 let mut found: Option<PathBuf> = None;
3618 for cand in &tried {
3619 let real = match std::fs::canonicalize(cand) {
3620 Ok(r) => r,
3621 Err(_) => continue,
3622 };
3623 if !is_runnable(&real) {
3624 continue;
3625 }
3626 found = Some(real);
3627 break;
3628 }
3629 let real = match found {
3630 Some(r) => r,
3631 None => return Vetted0::Refused(fmt!(
3632 "Refused: '{}' is not a program this machine can run. Nothing of that name resolved \
3633 to an executable file{}.", argv0,
3634 if argv0.contains('/') {
3635 fmt!("")
3636 } else {
3637 fmt!(" on the PATH this command was given")
3638 })),
3639 };
3640
3641 // Execute is part of the read set in Landlock's vocabulary: a program the
3642 // fence will not let the command read is a program it will not let it run.
3643 if !plan.permits(&real, Level::Ro) {
3644 return Vetted0::Refused(fmt!(
3645 "Refused: '{}' is the program at {}, which is outside this command's fence. A command \
3646 cannot run something the fence would not let it read, so it was refused here rather \
3647 than left to fail with a permission error nobody could interpret.", argv0,
3648 real.display()));
3649 }
3650 Vetted0::Ok(real)
3651}
3652
3653/// Whether `p` is a regular file with an execute bit set.
3654///
3655/// # Arguments
3656/// * `p` - An already-canonical path.
3657fn is_runnable(p: &Path) -> bool {
3658 let md = match std::fs::metadata(p) {
3659 Ok(md) => md,
3660 Err(_) => return false,
3661 };
3662 if !md.is_file() {
3663 return false;
3664 }
3665 #[cfg(unix)]
3666 {
3667 use std::os::unix::fs::PermissionsExt;
3668 md.permissions().mode() & 0o111 != 0
3669 }
3670 #[cfg(not(unix))]
3671 {
3672 true
3673 }
3674}
3675
3676/// Brings a caller's wall-clock limit inside what the hand will honour.
3677///
3678/// # Arguments
3679/// * `ms` - The limit asked for; zero means no preference.
3680fn clamp_timeout(ms: u64) -> u64 {
3681 if ms == 0 {
3682 DEFAULT_TIMEOUT_MS
3683 } else {
3684 std::cmp::min(ms, TIMEOUT_MAX_MS)
3685 }
3686}
3687
3688// ┌───────────────────────────────────────────────────────────────┐
3689// │ The launcher │
3690// └───────────────────────────────────────────────────────────────┘
3691
3692/// What the launcher does once the fence is in force.
3693///
3694/// **The fence is applied before this is looked at, and that is the point.** A file op run
3695/// any other way would be a second compartment to keep in step with the first; run here it
3696/// is the same Landlock ruleset and the same seccomp filter as a command's, built from the
3697/// same [`Plan`], in a child of the same launcher. A path the fence does not reach fails
3698/// with the kernel's own refusal.
3699#[derive(Clone, Debug, Eq, PartialEq)]
3700pub enum Act {
3701 /// Become the command.
3702 Exec,
3703 /// Carry out one file operation and answer on standard output.
3704 File(FileOp),
3705}
3706
3707/// Everything the launcher is told, and everything it is allowed to decide.
3708///
3709/// It decides nothing. The program is already resolved, the fence is already
3710/// planned, and the environment is already screened; the launcher's whole job is
3711/// to put the fence on itself and become the command. That is deliberate:
3712/// every judgement is made where a failure can still be turned into a sentence
3713/// the page shows, and none is made in a process whose only remaining move is to
3714/// die.
3715#[derive(Clone, Debug, Eq, PartialEq)]
3716pub struct Payload {
3717 /// The program, absolute and canonical, already checked against the fence.
3718 pub prog: PathBuf,
3719 /// The argument vector as the caller wrote it.
3720 pub argv: Vec<String>,
3721 /// The command's environment, in full.
3722 pub env: Vec<(String, String)>,
3723 /// The fence to apply before the command exists.
3724 pub plan: Plan,
3725 /// What to do once the fence is on.
3726 ///
3727 /// Two arms and not a flag, because they are not two shapes of one thing: [`Act::Exec`]
3728 /// ends in `execve` and never returns, and [`Act::File`] ends in a write to standard
3729 /// output and an exit. What they share is everything above them -- the same plan, the
3730 /// same ruleset, the same filter, applied in the same order -- and that sharing is the
3731 /// whole guarantee the file door rests on.
3732 pub act: Act,
3733 /// Whether this command is to have a controlling terminal.
3734 ///
3735 /// A terminal is adopted BEFORE the fence, because Landlock's ABI 5 governs `ioctl` on a
3736 /// device opened after the ruleset is in force -- and `TIOCSCTTY` is exactly that ioctl.
3737 /// Ordered the other way, a session would fence itself out of its own terminal.
3738 pub tty: bool,
3739}
3740
3741/// Becomes the command, behind its fence, and never returns.
3742///
3743/// `main` dispatches here on [`LAUNCH_ARG`], as its first act and before any
3744/// runtime is started. The signature says `!` on purpose: there is no way to
3745/// call this and carry on, so there is no way for a later edit to reach the
3746/// spawn path with the fence half-applied. **Failing open is the one outcome
3747/// that must be impossible here**, and the type system is a better guarantee of
3748/// that than a comment.
3749///
3750/// The order matters and is not negotiable: read the plan, apply the plan, then
3751/// `exec`. Anything that goes wrong before the `exec` exits non-zero without
3752/// exec'ing; nothing runs behind half a fence.
3753pub fn launch_main() -> ! {
3754 #[cfg(unix)]
3755 {
3756 let (code, why) = launch_inner();
3757 // Standard error is the command's own, so this reaches the page as the
3758 // run's stderr -- unless the caller asked for `Capture::None` or
3759 // `Capture::Out`, in which case the exit code is the whole of the
3760 // answer. That is why the codes are distinct and documented.
3761 eprintln!("daimond-hand launcher: {}", why);
3762 std::process::exit(code)
3763 }
3764 #[cfg(not(unix))]
3765 {
3766 eprintln!(
3767 "daimond-hand launcher: this platform has no exec, so a fence \
3768 cannot be applied to a command by becoming it. Nothing was run.");
3769 std::process::exit(EXIT_FENCE_FAILED)
3770 }
3771}
3772
3773/// The launcher's body, which returns only when something has gone wrong.
3774///
3775/// # Returns
3776/// The exit code to use and the sentence explaining it.
3777#[cfg(unix)]
3778fn launch_inner() -> (i32, String) {
3779 use std::os::unix::process::CommandExt;
3780
3781 let mut payload = match read_payload() {
3782 Ok(p) => p,
3783 Err(e) => return (EXIT_NO_PLAN, fmt!(
3784 "no usable plan arrived on standard input, so there is no fence to \
3785 apply and nothing was run. {}", e.msgs().join(" "))),
3786 };
3787
3788 // A launcher always wants every thread. It has none of its own, but a
3789 // runtime started before the dispatch might, and a fence binding only the
3790 // calling thread would leave a sibling able to fork and exec outside it.
3791 payload.plan.reach = Reach::Process;
3792
3793 // The last gate, and the cheapest. The hand screened this already; the
3794 // launcher is the one place where being wrong is unrecoverable.
3795 if let Some(s) = screen_env(&payload.env) {
3796 return (EXIT_FENCE_FAILED, s);
3797 }
3798
3799 // The terminal is adopted BEFORE the fence, and the order is not a preference.
3800 // Landlock's ABI 5 governs `ioctl` on a device file opened after the ruleset is in
3801 // force, and `TIOCSCTTY` is exactly that ioctl -- so a session that fenced first would
3802 // fence itself out of its own terminal, and fail in a way that reads like a pty bug.
3803 let mut tty_in: Option<std::process::Stdio> = None;
3804 if payload.tty {
3805 match crate::pty::adopt_terminal() {
3806 Ok(s) => tty_in = Some(s),
3807 Err(e) => return (EXIT_FENCE_FAILED, fmt!(
3808 "the terminal could not be adopted, so the command was not run. {}",
3809 e.msgs().join(" "))),
3810 }
3811 }
3812
3813 let applied = match payload.plan.apply() {
3814 Ok(a) => a,
3815 Err(e) => return (EXIT_FENCE_FAILED, fmt!(
3816 "the fence could not be applied, so the command was not run. {}",
3817 e.msgs().join(" "))),
3818 };
3819 if !applied.fenced && applied.waiver.is_none() {
3820 return (EXIT_FENCE_FAILED, fmt!(
3821 "the fence reported that it was not applied and no waiver was \
3822 recorded. The command was not run."));
3823 }
3824
3825 // The system-call filter, and its place in the order is the whole of this comment.
3826 //
3827 // Three things have to happen before the `exec` and they cannot be reordered:
3828 //
3829 // 1. **The terminal, first.** Landlock's ABI 5 governs `ioctl` on a device file
3830 // opened after the ruleset is in force, and `TIOCSCTTY` is exactly that ioctl.
3831 // 2. **The fence, second.** Applying it *opens every granted path* -- `PathFd` per
3832 // rule -- so Landlock still has real work to do after its own rules take hold. A
3833 // filter installed underneath that work would, if its deny-list ever named
3834 // something the `landlock` crate needed, break the fence rather than the command:
3835 // the wrong failure, in the wrong layer, for a reason nobody could read.
3836 // 3. **The filter, last.** It needs nothing after itself except `execve`, which it
3837 // permits. Being last also means it is the layer nearest the command, so the last
3838 // thing to happen before the command exists is the narrowest.
3839 //
3840 // Both restrictions are irreversible and both survive `execve`, so the order is not
3841 // about undoing anything -- it is about what each still needs to do after it is
3842 // installed. Both also require `no_new_privs`; `Plan::apply` sets it and hard-errors,
3843 // and `Filter::apply` sets it again rather than assuming, since it is idempotent.
3844 let spec = SysSpec::for_command();
3845 let filter = match Seccomp::detect().plan(&spec) {
3846 Ok(f) => f,
3847 Err(e) => return (EXIT_FENCE_FAILED, fmt!(
3848 "the system-call filter could not be built, so the command was not run. \
3849 Without it the fence is not a compartment on this kernel. {}",
3850 e.msgs().join(" "))),
3851 };
3852 if let Err(e) = filter.apply() {
3853 return (EXIT_FENCE_FAILED, fmt!(
3854 "the system-call filter could not be installed, so the command was not \
3855 run. {}", e.msgs().join(" ")));
3856 }
3857
3858 // The file door, and it is HERE and not one line earlier for the one reason this
3859 // whole arrangement exists: everything above has already happened to this process --
3860 // the ruleset is on, the filter is on, `no_new_privs` is set -- so the `open` below is
3861 // governed by exactly the rules the command on the other branch would have met. There
3862 // is no second check and there is deliberately nowhere to put one.
3863 if let Act::File(op) = &payload.act {
3864 let (ok, text) = do_file(op);
3865 // A byte and then the bytes. No JSON, nothing escaped, nothing to parse wrongly:
3866 // the parent reads this and the text may be anything a file holds, including the
3867 // quotes and backslashes an encoding would have had to survive. That is the same
3868 // sentence the request is built on, one layer down.
3869 let mut out: Vec<u8> = Vec::with_capacity(text.len() + 1);
3870 out.push(u8::from(ok));
3871 out.extend_from_slice(text.as_bytes());
3872 use std::io::Write;
3873 let mut sink = std::io::stdout();
3874 if sink.write_all(&out).is_err() || sink.flush().is_err() {
3875 return (EXIT_EXEC_FAILED, fmt!(
3876 "the fence was applied, the file operation was carried out and its answer \
3877 could not be written back. Do not assume nothing changed."));
3878 }
3879 std::process::exit(0)
3880 }
3881
3882 let mut cmd = std::process::Command::new(&payload.prog);
3883 // Exec the RESOLVED binary, but under the name the caller asked for.
3884 //
3885 // A multi-call binary decides what it is from `argv[0]`: `~/.cargo/bin/cargo` is a
3886 // symlink to `rustup`, and busybox is a dozen tools in one file. Exec'ing the resolved
3887 // path with the resolved name told rustup it had been invoked AS rustup, so
3888 // `cargo test --offline` came back "unexpected argument '--offline'" from a usage
3889 // message for a different program. A shell preserves the requested name; so does this.
3890 //
3891 // `arg0` is safe -- it is `CommandExt`, not `pre_exec` -- so the rule against `unsafe`
3892 // costs nothing here.
3893 if let Some(asked) = payload.argv.first() {
3894 cmd.arg0(asked);
3895 }
3896 cmd.args(payload.argv.iter().skip(1));
3897 // An allow-list, not an inheritance. The launcher's own environment holds
3898 // nothing worth keeping; the command's arrived down the pipe.
3899 cmd.env_clear();
3900 for (k, v) in &payload.env {
3901 cmd.env(k, v);
3902 }
3903 // Standard input is left as it is: the plan was read from it and the
3904 // command's own input, if any, is the remainder of the same pipe. Where the
3905 // caller sent none the write end is already closed, so the first read
3906 // answers end-of-file.
3907 //
3908 // A terminal session is the exception: its input is the terminal, not the pipe the
3909 // plan arrived down, so the adopted tty replaces stdin here.
3910 if let Some(s) = tty_in {
3911 cmd.stdin(s);
3912 }
3913
3914 // `exec` returns only on failure.
3915 let e = cmd.exec();
3916 (EXIT_EXEC_FAILED, fmt!(
3917 "the fence was applied and then {} could not be started ({}). Nothing \
3918 ran behind the fence.", payload.prog.display(), e))
3919}
3920
3921// ── The file operations themselves, run behind the fence ────────────
3922//
3923// Everything in this section executes in the launcher, AFTER `Plan::apply` and after the
3924// seccomp filter, and it is written on the assumption that the kernel is the guard. There
3925// is no path check here beyond "is it absolute", on purpose: a check written here would be
3926// a second opinion about what the fence allows, it would drift from the first, and the day
3927// it disagreed the laxer of the two would be the one that ran. What this code does with a
3928// path the fence does not reach is exactly what any other program does -- it gets EACCES
3929// from `open` -- and the only value added is that the sentence says so in words.
3930
3931/// What the launcher answers a [`FileOp`] with: whether it was done, and what to say.
3932///
3933/// # Arguments
3934/// * `op` - The operation, whose paths must all be absolute.
3935#[cfg(unix)]
3936fn do_file(op: &FileOp) -> (bool, String) {
3937 // EVERY path, not the first. A walk names several and an empty list names none, and both
3938 // were reachable before `paths()` existed.
3939 let named = op.paths();
3940 if named.is_empty() {
3941 return (false, fmt!("A {} was asked for with no path to work on.", op.word()));
3942 }
3943 for p in named {
3944 if !Path::new(p).is_absolute() {
3945 return (false, fmt!(
3946 "'{}' is not an absolute path, and the hand does not guess what a path is \
3947 relative to.", p));
3948 }
3949 }
3950 match op {
3951 FileOp::Read { path, offset, limit } => read_op(path, *offset, *limit),
3952 FileOp::Write { path, content } => write_op(path, content),
3953 FileOp::Edit { path, old, new } => edit_op(path, old, new),
3954 FileOp::Move { path, to } => move_op(path, to),
3955 FileOp::List { path } => list_op(path),
3956 FileOp::MkDir { path } => mkdir_op(path),
3957 FileOp::Search { paths, query, ci, glob, base, skip, budget, cap } =>
3958 search_op(paths, query, *ci, glob, base, skip, *budget as usize, *cap as u64),
3959 FileOp::Glob { paths, pattern, base, skip, budget } =>
3960 glob_op(paths, pattern, base, skip, *budget as usize),
3961 }
3962}
3963
3964/// What one filesystem error means, in the words the model has to act on.
3965///
3966/// **A refusal and an absence are different answers and only one of them is true.** A path
3967/// the fence does not reach comes back from the kernel as `PermissionDenied`, which read
3968/// bare says nothing about the fence at all -- and a model that reads it as "the file is
3969/// protected" goes looking for `chmod` instead of asking the user to mark the folder in.
3970///
3971/// # Arguments
3972/// * `what` - The verb, for the opening clause.
3973/// * `path` - The path as the caller wrote it.
3974/// * `e` - What the operating system said.
3975#[cfg(unix)]
3976fn fs_said(what: &str, path: &str, e: &std::io::Error) -> String {
3977 match e.kind() {
3978 std::io::ErrorKind::PermissionDenied => fmt!(
3979 "The kernel refused to let this turn {} '{}'. That is the fence, not the file's \
3980 own permissions: a file tool reaches exactly the folders a command reaches, and \
3981 this path is outside them. Ask the user to mark the folder in.", what, path),
3982 std::io::ErrorKind::NotFound => fmt!(
3983 "There is no '{}' on this machine.", path),
3984 // A DIRECTORY, SAID AS A DIRECTORY. The browser-storage arm of `file_read` has named
3985 // `file_list` here since 2026-08-24, when a daimon spent three calls working out what
3986 // the browser's `TypeMismatchError` meant; the machine arm answered `Is a directory
3987 // (os error 21)` on its first measured run, 2026-08-25, and cost a call to the same
3988 // question. The two doors say the same sentence or they are two doors.
3989 std::io::ErrorKind::IsADirectory => fmt!(
3990 "'{}' is a directory, not a file. file_list answers what is in it, and \
3991 file_search looks inside everything under it.", path),
3992 _ => fmt!("Could not {} '{}': {}.", what, path, e),
3993 }
3994}
3995
3996/// The text of a file, or the sentence saying why not.
3997///
3998/// The answer is three tab-separated numbers, a newline, and then the lines asked for: the
3999/// lines the WHOLE file holds, the whole file's length in bytes, and how many lines follow
4000/// here. A private convention between two halves of one binary, and it exists because the
4001/// caller pages a read and cannot say "lines 40-60 of 812" without being told the 812 by
4002/// whoever held the whole file.
4003///
4004/// **The third number is `dev/BLOCKERS.md` B18.** The answer used to carry the line count
4005/// alone and then be cut to [`FILE_TEXT_MAX`] as one string, so a caller that asked for a
4006/// 1.2 MB file was handed 512 KiB of it and counted the cut: `src/tools.rs` read as 9,304
4007/// lines of 21,276, with the offset to continue from twelve thousand lines short of the end.
4008/// Cutting on a line boundary and SAYING how many lines went is what makes the caller's
4009/// arithmetic about the rest come out right.
4010///
4011/// # Arguments
4012/// * `offset` - The 1-based line to start at; 0 is read as 1.
4013/// * `limit` - How many lines to take; 0 means every line from `offset`.
4014#[cfg(unix)]
4015fn read_op(path: &str, offset: u32, limit: u32) -> (bool, String) {
4016 let bytes = match std::fs::read(path) {
4017 Ok(b) => b,
4018 Err(e) => return (false, fs_said("read", path, &e)),
4019 };
4020 let text = String::from_utf8_lossy(&bytes).to_string();
4021 let lines: Vec<&str> = text.split('\n').collect();
4022 // A file ending in a newline splits to a final empty piece that is not a line, and an
4023 // empty file splits to one such piece and holds no lines at all.
4024 let total = match lines.last() {
4025 Some(&"") if lines.len() > 1 => lines.len() - 1,
4026 Some(&"") => 0,
4027 _ => lines.len(),
4028 };
4029 let from = (offset.max(1) as usize) - 1;
4030 let take = match limit {
4031 0 => total.saturating_sub(from),
4032 n => n as usize,
4033 };
4034 let mut sent = String::new();
4035 let mut kept = 0usize;
4036 for line in lines.iter().skip(from).take(take) {
4037 // The newline that ends it, counted before it is spent, so the frame's ceiling is a
4038 // ceiling on what is actually built.
4039 if sent.len() + line.len() + 1 > FILE_TEXT_MAX {
4040 break;
4041 }
4042 sent.push_str(line);
4043 // EVERY line ends with one, the last of them included. Joining with newlines instead
4044 // makes "a\n" mean either one line or two -- and a window whose last line is blank is
4045 // then one line shorter than it says it is, which the caller checks and refuses.
4046 sent.push('\n');
4047 kept += 1;
4048 }
4049 // ONE LINE LONGER THAN THE WHOLE FRAME, which the loop above would answer with nothing at
4050 // all. A cut line is worth more than an empty answer, so it goes with the marker that
4051 // says it is cut -- and it is the only place the hand puts words of its own among a
4052 // file's characters.
4053 if kept == 0 && from < total {
4054 let line = lines[from];
4055 let mut end = FILE_TEXT_MAX.min(line.len());
4056 while end > 0 && !line.is_char_boundary(end) {
4057 end -= 1;
4058 }
4059 sent.push_str(&line[..end]);
4060 sent.push_str(&fmt!(
4061 " …[{} further bytes on this line were not returned]\n", line.len() - end));
4062 kept = 1;
4063 }
4064 let mut out = fmt!("{}\t{}\t{}\n", total, bytes.len(), kept);
4065 out.push_str(&sent);
4066 (true, out)
4067}
4068
4069/// The whole of `path` replaced by `content`, with any parent it needs made first.
4070#[cfg(unix)]
4071fn write_op(path: &str, content: &str) -> (bool, String) {
4072 if let Some(dir) = Path::new(path).parent() {
4073 if let Err(e) = std::fs::create_dir_all(dir) {
4074 return (false, fs_said("write", path, &e));
4075 }
4076 }
4077 match std::fs::write(path, content.as_bytes()) {
4078 Ok(()) => (true, String::new()),
4079 Err(e) => (false, fs_said("write", path, &e)),
4080 }
4081}
4082
4083/// `old` replaced by `new` in `path`, exactly once.
4084///
4085/// The count is the answer on both failures, because "which of my six edits landed" is the
4086/// question a caller cannot ask afterwards and the one that cost 71 calls.
4087#[cfg(unix)]
4088fn edit_op(path: &str, old: &str, new: &str) -> (bool, String) {
4089 let bytes = match std::fs::read(path) {
4090 Ok(b) => b,
4091 Err(e) => return (false, fs_said("edit", path, &e)),
4092 };
4093 let data = match String::from_utf8(bytes) {
4094 Ok(t) => t,
4095 Err(_) => return (false, fmt!(
4096 "'{}' is not UTF-8 text, so replacing a string in it would rewrite the bytes it \
4097 is not made of. Nothing was changed.", path)),
4098 };
4099 let count = data.matches(old).count();
4100 if count == 0 {
4101 return (false, fmt!("old_string was not found in '{}'. Nothing was changed.{}",
4102 path, near_miss(&data, old)));
4103 }
4104 if count > 1 {
4105 return (false, fmt!(
4106 "old_string appears {} times in '{}'; make it unique. Nothing was changed.",
4107 count, path));
4108 }
4109 let updated = data.replacen(old, new, 1);
4110 match std::fs::write(path, updated.as_bytes()) {
4111 Ok(()) => (true, String::new()),
4112 Err(e) => (false, fs_said("edit", path, &e)),
4113 }
4114}
4115
4116/// Where a failed `old_string` nearly matched, as the lines to copy instead.
4117///
4118/// **"Not found" says what is not there and nothing about what is, and a caller that
4119/// mistyped one character has no way to converge.** Measured on the second live run of this
4120/// door, 2026-08-25: a daimon building an `old_string` out of a `sed -n` slice wrote a
4121/// straight `"` where `de.js` has a typographic one, met "was not found" four times,
4122/// concluded the tool did not work, and went back to `sed -i` -- where it spent the next
4123/// forty-eight calls on French quoting, which is the very failure `dev/BLOCKERS.md` B2 is
4124/// measured on. The refusal was honest and it was a dead end.
4125///
4126/// So the LONGEST PREFIX of `old_string` that is in the file is found, and the answer is
4127/// where it is and what the file actually holds from that line -- which is the text to copy,
4128/// exactly, with nothing to guess at. A prefix and not the first line, because the character
4129/// that was got wrong is as often in the first line as anywhere; the German quote that
4130/// started this was.
4131///
4132/// Binary search is sound here and worth saying why: a prefix of length k is present only if
4133/// every shorter prefix is, since each is a prefix of it, so presence is monotone in k.
4134///
4135/// # Arguments
4136/// * `data` - The file's whole text.
4137/// * `old` - The string that was not found.
4138#[cfg(unix)]
4139fn near_miss(data: &str, old: &str) -> String {
4140 // Below this a "near miss" is a coincidence: any file holds a tab and a quote somewhere,
4141 // and pointing at one would be worse than saying nothing.
4142 const LEAST: usize = 12;
4143
4144 let bytes = old.as_bytes();
4145 let (mut lo, mut hi) = (0usize, bytes.len());
4146 while lo < hi {
4147 let mid = (lo + hi + 1) / 2;
4148 let mut k = mid;
4149 while k > 0 && !old.is_char_boundary(k) {
4150 k -= 1;
4151 }
4152 if k <= lo {
4153 break;
4154 }
4155 if data.contains(&old[..k]) { lo = k; } else { hi = k - 1; }
4156 }
4157 if lo < LEAST {
4158 return fmt!(
4159 " No part of it is in the file, so this is not a near miss: read the file and \
4160 copy the text from what the read returns.");
4161 }
4162 let at = match data.find(&old[..lo]) {
4163 Some(i) => i,
4164 None => return String::new(), // unreachable while `lo` came from `contains`
4165 };
4166 let line = data[..at].matches('\n').count() + 1;
4167 let want = old.split('\n').count().max(1) + 1;
4168 let shown: Vec<String> = data.split('\n').skip(line - 1).take(want)
4169 .enumerate()
4170 .map(|(i, t)| fmt!("{}\t{}", line + i, t))
4171 .collect();
4172 fmt!(
4173 " Its first {} characters ARE there, at line {}, and it stops matching after them. \
4174 The file holds this from that line, which is the text to copy exactly:\n{}",
4175 lo, line, shown.join("\n"))
4176}
4177
4178/// `path` renamed to `to`, which must not already be something.
4179#[cfg(unix)]
4180fn move_op(path: &str, to: &str) -> (bool, String) {
4181 if !Path::new(to).is_absolute() {
4182 return (false, fmt!(
4183 "'{}' is not an absolute path, and the hand does not guess what a path is \
4184 relative to.", to));
4185 }
4186 if Path::new(to).symlink_metadata().is_ok() {
4187 return (false, fmt!("'{}' already exists; nothing was moved.", to));
4188 }
4189 if let Some(dir) = Path::new(to).parent() {
4190 if let Err(e) = std::fs::create_dir_all(dir) {
4191 return (false, fs_said("move", to, &e));
4192 }
4193 }
4194 match std::fs::rename(path, to) {
4195 Ok(()) => (true, String::new()),
4196 Err(e) => (false, fs_said("move", path, &e)),
4197 }
4198}
4199
4200/// What is in the directory `path`, one entry a line, a directory marked with a slash.
4201///
4202/// A listing too big for one frame stops on a whole name and says how many of the directory's
4203/// entries it is showing. It used to be cut as one string with the note *"ask for the rest by
4204/// line range"*, which is `read`'s advice: `list` takes no range, and a caller acting on that
4205/// sentence has nowhere to go.
4206#[cfg(unix)]
4207fn list_op(path: &str) -> (bool, String) {
4208 let rd = match std::fs::read_dir(path) {
4209 Ok(r) => r,
4210 Err(e) => return (false, fs_said("list", path, &e)),
4211 };
4212 let mut names: Vec<String> = Vec::new();
4213 for ent in rd {
4214 let ent = match ent {
4215 Ok(e) => e,
4216 Err(e) => return (false, fs_said("list", path, &e)),
4217 };
4218 let name = ent.file_name().to_string_lossy().to_string();
4219 let dir = matches!(ent.file_type(), Ok(t) if t.is_dir());
4220 names.push(match dir {
4221 true => fmt!("{}/", name),
4222 false => name,
4223 });
4224 }
4225 names.sort();
4226 let total = names.len();
4227 let mut out = String::new();
4228 let mut shown = 0usize;
4229 for name in &names {
4230 if out.len() + name.len() + 1 > FILE_TEXT_MAX {
4231 break;
4232 }
4233 if shown > 0 {
4234 out.push('\n');
4235 }
4236 out.push_str(name);
4237 shown += 1;
4238 }
4239 if shown < total {
4240 out.push_str(&fmt!(
4241 "\n[file_list] {} of {} entries; the rest would not fit in one message. A listing \
4242 takes no range to page it with, so name what is wanted instead: file_glob with a \
4243 pattern under this directory answers the same question for a fraction of it.",
4244 shown, total));
4245 }
4246 (true, out)
4247}
4248
4249/// `path` made, with any parent it needs.
4250#[cfg(unix)]
4251fn mkdir_op(path: &str) -> (bool, String) {
4252 match std::fs::create_dir_all(path) {
4253 Ok(()) => (true, String::new()),
4254 Err(e) => (false, fs_said("create", path, &e)),
4255 }
4256}
4257
4258// ── The two walks ───────────────────────────────────────────────────
4259//
4260// Both run in the launcher, behind the same ruleset as everything else in this section, and
4261// both are bounded by an ENTRY budget rather than by a depth or a file count. A search that
4262// matches nothing looks at every entry there is, and on a large enough folder it never comes
4263// back; the page has had that budget since `WalkBudget` and it is the page that sets it here,
4264// so the two ends cannot come to disagree about what a walk costs.
4265//
4266// **What comes back is deliberately not an answer.** The regex here is a FILTER -- it decides
4267// which files are worth carrying -- and the page then runs its own scan over the ones it is
4268// handed. Both compile the same `fe2o3_text` pattern from the same source, so the filter
4269// cannot be narrower than the answer; and everything a reader actually sees, the context
4270// lines and the paging and the notes, is composed in exactly one place.
4271
4272/// A directory entry, in the order a walk must see it.
4273///
4274/// Sorted by name, so a walk is a repeatable pre-order and the page's `offset` can page it
4275/// honestly. Unsorted, two calls with the same arguments report different pages of the same
4276/// tree and a reader paging through one of them silently skips files.
4277#[cfg(unix)]
4278fn sorted_entries(dir: &Path) -> Option<Vec<(String, PathBuf, bool)>> {
4279 // `None`, NOT an empty listing. A directory the walk cannot open is not a directory with
4280 // nothing in it, and `Err(_) => Vec::new()` is exactly the shape `dev/BLOCKERS.md` B1 is
4281 // about: the walk answers about a place it never looked and nothing says so. Behind a
4282 // fence it is the commonest case there is -- it is what the kernel's refusal looks like
4283 // from in here -- so it is counted and named rather than swallowed.
4284 let rd = match std::fs::read_dir(dir) {
4285 Ok(r) => r,
4286 Err(_) => return None,
4287 };
4288 let mut out: Vec<(String, PathBuf, bool)> = Vec::new();
4289 for ent in rd.flatten() {
4290 let name = ent.file_name().to_string_lossy().to_string();
4291 let is_dir = matches!(ent.file_type(), Ok(t) if t.is_dir());
4292 out.push((name, ent.path(), is_dir));
4293 }
4294 out.sort_by(|a, b| a.0.cmp(&b.0));
4295 Some(out)
4296}
4297
4298/// What a walk did as well as what it found, so the page can say what was NOT looked at.
4299///
4300/// Every field is a count the page already has a sentence for; they travel as one line rather
4301/// than as a shape, because the only reader is the other half of this build.
4302#[cfg(unix)]
4303#[derive(Default)]
4304struct Walked {
4305 spent: usize, // entries charged
4306 stop: String, // the directory the budget ran out in, empty if it did not
4307 queued: usize, // directories still waiting when it did
4308 skipped: usize, // directories passed over by name
4309 filtered:usize, // files the glob excluded
4310 too_big: usize, // files past the size cap
4311 binary: usize, // files whose bytes are not text
4312 files: usize, // files actually read and matched against
4313 left: usize, // files that matched and did not fit in the answer
4314 denied: usize, // directories the walk could not open at all
4315}
4316
4317impl Walked {
4318 /// The header line every walk answers with, before whatever it found.
4319 fn line(&self) -> String {
4320 fmt!("{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
4321 self.spent, self.stop, self.queued, self.skipped, self.filtered,
4322 self.too_big, self.binary, self.files, self.left, self.denied)
4323 }
4324}
4325
4326/// Charge one entry, answering whether the walk may look at it.
4327///
4328/// The first refusal records where the walk had got to and later ones are free, exactly as the
4329/// page's own budget behaves -- a caller may keep asking without the record moving.
4330#[cfg(unix)]
4331fn afford(w: &mut Walked, budget: usize, here: &str) -> bool {
4332 if w.spent >= budget {
4333 if w.stop.is_empty() {
4334 w.stop = here.to_string();
4335 }
4336 return false;
4337 }
4338 w.spent += 1;
4339 true
4340}
4341
4342/// Every file under `paths` the pattern matches in, with the lines it matched on.
4343///
4344/// The answer is the header line, then for each file a line of `<path>\t<byte length>` and
4345/// exactly that many bytes of its matching lines. Length-prefixed and not delimited, because
4346/// a line of a file holds every character a delimiter could have been.
4347///
4348/// **The lines rather than the file: `dev/BLOCKERS.md` B17.** See [`matching_lines`].
4349///
4350/// # Arguments
4351/// * `query` - The regex source, already quoted by the caller where a literal was asked for.
4352/// * `skip` - Directory names to pass over, decided by the page from its own rule.
4353/// * `budget` - Entries the walk may look at.
4354/// * `cap` - The largest file worth opening, in bytes.
4355#[cfg(unix)]
4356fn search_op(
4357 paths: &[String],
4358 query: &str,
4359 ci: bool,
4360 glob: &str,
4361 base: &str,
4362 skip: &[String],
4363 budget: usize,
4364 cap: u64,
4365)
4366 -> (bool, String)
4367{
4368 let re = match Regex::with_case(query, ci) {
4369 Ok(r) => r,
4370 Err(e) => return (false, fmt!(
4371 "The search pattern could not be read by the hand: {}. Nothing was searched.",
4372 e.msgs().join(" "))),
4373 };
4374 let filter = match glob.is_empty() {
4375 true => None,
4376 false => match Glob::new(glob) {
4377 Ok(g) => Some(g),
4378 Err(e) => return (false, fmt!(
4379 "The search's glob could not be read by the hand: {}. Nothing was searched.",
4380 e.msgs().join(" "))),
4381 },
4382 };
4383 let mut w = Walked::default();
4384 let mut out = String::new();
4385 // Reversed so the first path is popped first: a walk over several marks should reach the
4386 // one the caller named first before it spends its budget on the others.
4387 let mut stack: Vec<PathBuf> = paths.iter().rev().map(PathBuf::from).collect();
4388 'walk: while let Some(dir) = stack.pop() {
4389 let here = fmt!("{}", dir.display());
4390 let entries = match sorted_entries(&dir) {
4391 Some(e) => e,
4392 None => { w.denied += 1; continue; },
4393 };
4394 // Pushed in reverse so they pop in name order, which is what makes the whole walk a
4395 // repeatable pre-order.
4396 for (name, p, is_dir) in entries.iter().rev() {
4397 if !*is_dir {
4398 continue;
4399 }
4400 // Charged before the skip test, and so charged for every entry the walk lays eyes
4401 // on: what costs is reading the entry, not deciding to descend into it.
4402 if !afford(&mut w, budget, &here) {
4403 break;
4404 }
4405 if skip.iter().any(|d| d == name) {
4406 w.skipped += 1;
4407 continue;
4408 }
4409 stack.push(p.clone());
4410 }
4411 for (_, p, is_dir) in &entries {
4412 if *is_dir {
4413 continue;
4414 }
4415 if !afford(&mut w, budget, &here) {
4416 break 'walk;
4417 }
4418 let disp = fmt!("{}", p.display());
4419 if let Some(g) = &filter {
4420 // Matched against the path AS THE CALLER SPELLS IT. See `GLOB_BASE_DOC`: a
4421 // glob written `www/i18n/en.js` matched against `/home/.../repo/www/i18n/en.js`
4422 // excludes every file there is, and says so in a note nobody acts on.
4423 if !g.matches(under_base(&disp, base)) {
4424 w.filtered += 1;
4425 continue;
4426 }
4427 }
4428 match std::fs::metadata(p) {
4429 Ok(m) if m.len() > cap => { w.too_big += 1; continue; },
4430 Ok(_) => (),
4431 Err(_) => continue,
4432 }
4433 let bytes = match std::fs::read(p) {
4434 Ok(b) => b,
4435 Err(_) => continue,
4436 };
4437 // Lossy-decoding a binary file lets its bytes match and be quoted back as though
4438 // they were source. The page makes the same test and would drop it anyway; making
4439 // it here is what stops the bytes crossing at all.
4440 if looks_binary(&bytes) {
4441 w.binary += 1;
4442 continue;
4443 }
4444 w.files += 1;
4445 let text = String::from_utf8_lossy(&bytes).to_string();
4446 let block = match matching_lines(&text, &re) {
4447 Some(b) => b,
4448 None => continue,
4449 };
4450 if out.len() + block.len() > SEARCH_ANSWER_MAX {
4451 w.left += 1;
4452 continue;
4453 }
4454 out.push_str(&fmt!("{}\t{}\n", disp, block.len()));
4455 out.push_str(&block);
4456 }
4457 }
4458 w.queued = stack.len();
4459 (true, fmt!("{}\n{}", w.line(), out))
4460}
4461
4462/// The lines of `text` a search has to carry back, each with the number it has in the file.
4463///
4464/// **A search answers about LINES, and the hand used to send whole FILES.** The page's answer
4465/// is `path:line:text` with context around it, so what it needs is the lines that matched and
4466/// their neighbours; sending the file made the answer's size a function of how big the file was
4467/// rather than of how much of it matched, and a file over [`SEARCH_ANSWER_MAX`] could not be
4468/// searched at any narrowing -- `dev/BLOCKERS.md` B17, measured on this repository's own
4469/// `src/tools.rs` at 1,211,990 bytes against a 384 KiB ceiling, and answered *"No matches"*
4470/// eight times in one turn for a name that is in it.
4471///
4472/// [`SEARCH_CONTEXT_LINES`] neighbours go with each match because the page's `before` and
4473/// `after` reach that far and no further, so every line the page could be asked to print is
4474/// here. A line the matcher could not decide goes back too: the page counts those and names
4475/// them, and dropping one here would have it counted as a line that did not match.
4476///
4477/// Each line is written as its 1-based number, a tab, and the line; `None` means the pattern
4478/// matched nothing in this file at all.
4479///
4480/// # Arguments
4481/// * `re` - The pattern, compiled from the same source the page compiled it from.
4482#[cfg(unix)]
4483fn matching_lines(text: &str, re: &Regex) -> Option<String> {
4484 let lines: Vec<&str> = text.lines().collect();
4485 let mut want = vec![false; lines.len()];
4486 let mut any = false;
4487 for (i, l) in lines.iter().enumerate() {
4488 let hit = match re.is_match(l) {
4489 Ok(b) => b,
4490 // Undecided is not "no". It travels, and the page is what says so in words.
4491 Err(_) => true,
4492 };
4493 if !hit {
4494 continue;
4495 }
4496 any = true;
4497 let lo = i.saturating_sub(SEARCH_CONTEXT_LINES);
4498 let hi = (i + SEARCH_CONTEXT_LINES).min(lines.len().saturating_sub(1));
4499 for w in want.iter_mut().take(hi + 1).skip(lo) {
4500 *w = true;
4501 }
4502 }
4503 if !any {
4504 return None;
4505 }
4506 let mut out = String::new();
4507 for (i, l) in lines.iter().enumerate() {
4508 if want[i] {
4509 out.push_str(&fmt!("{}\t{}\n", i + 1, l));
4510 }
4511 }
4512 Some(out)
4513}
4514
4515/// Every path under `paths` matching `pattern`, and when it was last written.
4516///
4517/// The answer is the header line, then one line per hit: the path, a tab, and the modification
4518/// time in nanoseconds since the epoch -- or `-` where the platform will not say, which the
4519/// page reports as an absence rather than as 1970.
4520#[cfg(unix)]
4521fn glob_op(paths: &[String], pattern: &str, base: &str, skip: &[String], budget: usize)
4522 -> (bool, String)
4523{
4524 let g = match Glob::new(pattern) {
4525 Ok(g) => g,
4526 Err(e) => return (false, fmt!(
4527 "The glob could not be read by the hand: {}. Nothing was walked.",
4528 e.msgs().join(" "))),
4529 };
4530 let mut w = Walked::default();
4531 let mut out = String::new();
4532 let mut stack: Vec<PathBuf> = paths.iter().rev().map(PathBuf::from).collect();
4533 'walk: while let Some(dir) = stack.pop() {
4534 let here = fmt!("{}", dir.display());
4535 let entries = match sorted_entries(&dir) {
4536 Some(e) => e,
4537 None => { w.denied += 1; continue; },
4538 };
4539 for (name, p, is_dir) in entries {
4540 if !afford(&mut w, budget, &here) {
4541 break 'walk;
4542 }
4543 if is_dir {
4544 if skip.iter().any(|d| *d == name) {
4545 w.skipped += 1;
4546 continue;
4547 }
4548 stack.push(p);
4549 continue;
4550 }
4551 let disp = fmt!("{}", p.display());
4552 if !g.matches(under_base(&disp, base)) {
4553 continue;
4554 }
4555 w.files += 1;
4556 let when = std::fs::metadata(&p).ok()
4557 .and_then(|m| m.modified().ok())
4558 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
4559 .map(|d| d.as_nanos() as u64);
4560 let stamp = match when {
4561 Some(n) => fmt!("{}", n),
4562 None => fmt!("-"),
4563 };
4564 if out.len() + disp.len() + 24 > SEARCH_ANSWER_MAX {
4565 w.left += 1;
4566 continue;
4567 }
4568 out.push_str(&fmt!("{}\t{}\n", disp, stamp));
4569 }
4570 }
4571 w.queued = stack.len();
4572 (true, fmt!("{}\n{}", w.line(), out))
4573}
4574
4575/// A path with the caller's own prefix taken off, which is the spelling its glob is written in.
4576///
4577/// Returns the path WHOLE where it is not under the prefix, because a file silently excluded is
4578/// the failure this exists to end: matching too much is a file the caller then sees and can
4579/// ignore, and matching too little is a file nobody knows was there.
4580///
4581/// # Arguments
4582/// * `abs` - The path as this walk found it.
4583/// * `base` - The absolute prefix the caller strips, or empty for none.
4584#[cfg(unix)]
4585fn under_base<'a>(abs: &'a str, base: &str) -> &'a str {
4586 if base.is_empty() {
4587 return abs;
4588 }
4589 let cut = base.trim_end_matches('/');
4590 match abs.strip_prefix(cut).and_then(|r| r.strip_prefix('/')) {
4591 Some(r) => r,
4592 None => abs,
4593 }
4594}
4595
4596/// Does this look like a file of bytes rather than a file of text?
4597///
4598/// A NUL in the first few kilobytes, which is what every tool that has to make this decision
4599/// without a type uses. The page makes the same test with the same rule; making it here as
4600/// well is not a second opinion but a way of not carrying the bytes at all.
4601#[cfg(unix)]
4602fn looks_binary(data: &[u8]) -> bool {
4603 data.iter().take(8000).any(|b| *b == 0)
4604}
4605
4606/// Reads one length-prefixed payload from standard input, and not one byte more.
4607///
4608/// Unbuffered, and that is the whole difficulty. `std::io::stdin()` is a
4609/// `BufReader`, and a buffered four-byte read pulls eight kilobytes out of the
4610/// pipe -- which here would swallow the beginning of the command's own standard
4611/// input, since that is what follows the plan in the same pipe. Duplicating the
4612/// descriptor into a `File` gives a handle whose every `read` is exactly the
4613/// syscall that was asked for. The duplicate is closed on return; descriptor
4614/// zero itself is untouched and passes to the command.
4615#[cfg(unix)]
4616fn read_payload() -> Outcome<Payload> {
4617 use std::io::Read;
4618 use std::os::fd::AsFd;
4619
4620 let dup = res!(std::io::stdin().as_fd().try_clone_to_owned()
4621 .map_err(|e| err!(e,
4622 "The launcher could not take an unbuffered handle on its own \
4623 standard input."; IO)));
4624 let mut src = std::fs::File::from(dup);
4625
4626 let mut len = [0u8; 4];
4627 res!(src.read_exact(&mut len).map_err(|e| err!(e,
4628 "The launcher could not read the plan's length prefix."; IO, Input)));
4629 let n = u32::from_le_bytes(len) as usize;
4630 if n > PAYLOAD_MAX {
4631 return Err(err!(
4632 "The launcher was offered a plan of {} bytes, and {} is the most it \
4633 will read. Nothing was run.", n, PAYLOAD_MAX;
4634 Excessive, Input));
4635 }
4636 let mut body = vec![0u8; n];
4637 res!(src.read_exact(&mut body).map_err(|e| err!(e,
4638 "The launcher read a plan {} bytes long and then could not read the \
4639 plan.", n; IO, Input)));
4640 decode_payload(&body)
4641}
4642
4643/// The most the launcher will read as a plan.
4644///
4645/// A carved workspace produces one rule per child, so a large plan is ordinary;
4646/// a plan of megabytes is a mistake or a lie, and reading it would be a way to
4647/// make the launcher allocate on somebody else's say-so.
4648const PAYLOAD_MAX: usize = 4 * 1024 * 1024;
4649
4650/// Writes a payload as the launcher expects to read it.
4651///
4652/// A private encoding rather than the wire's JSON, and for once that is not
4653/// laziness: both ends of this pipe are the same binary from the same build, the
4654/// content is a `Plan` rather than anything the protocol describes, and every
4655/// field is length-prefixed, so there is nothing to escape and no place for a
4656/// value to be mistaken for a delimiter.
4657///
4658/// # Arguments
4659/// * `p` - What the launcher is to be told.
4660pub(crate) fn encode_payload(p: &Payload) -> Outcome<Vec<u8>> {
4661 let mut e = Enc { out: Vec::new() };
4662 res!(e.path(&p.prog));
4663 res!(e.len(p.argv.len()));
4664 for a in &p.argv {
4665 res!(e.text(a));
4666 }
4667 res!(e.len(p.env.len()));
4668 for (k, v) in &p.env {
4669 res!(e.text(k));
4670 res!(e.text(v));
4671 }
4672
4673 res!(e.len(p.plan.abi.level() as usize));
4674 e.byte(match p.plan.listing {
4675 Listing::Sealed => 0,
4676 Listing::Names => 1,
4677 });
4678 e.byte(match p.plan.base {
4679 SysBase::Bare => 0,
4680 SysBase::Minimal => 1,
4681 });
4682 e.byte(match p.plan.reach {
4683 Reach::Thread => 0,
4684 Reach::Process => 1,
4685 });
4686 e.byte(u8::from(p.plan.net));
4687 e.byte(u8::from(p.tty));
4688 match &p.plan.waiver {
4689 None => e.byte(0),
4690 Some(w) => {
4691 e.byte(1);
4692 res!(e.text(w));
4693 },
4694 }
4695 res!(e.len(p.plan.grants.len()));
4696 for g in &p.plan.grants {
4697 res!(e.path(&g.path));
4698 e.byte(match g.level {
4699 Level::Deny => 0,
4700 Level::Ro => 1,
4701 Level::Rw => 2,
4702 });
4703 }
4704 res!(e.len(p.plan.sealed.len()));
4705 for s in &p.plan.sealed {
4706 res!(e.path(s));
4707 }
4708 res!(e.len(p.plan.dropped.len()));
4709 for d in &p.plan.dropped {
4710 res!(e.path(d));
4711 }
4712 // The act, last, so that a reader of this function meets the fence before the thing the
4713 // fence is for. Its own tag byte and then only the fields that arm has: an `Exec` costs
4714 // one byte, which is what it should cost.
4715 match &p.act {
4716 Act::Exec => e.byte(0),
4717 Act::File(op) => {
4718 e.byte(1);
4719 e.byte(match op {
4720 FileOp::Read { .. } => 0,
4721 FileOp::Write { .. } => 1,
4722 FileOp::Edit { .. } => 2,
4723 FileOp::Move { .. } => 3,
4724 FileOp::List { .. } => 4,
4725 FileOp::MkDir { .. } => 5,
4726 FileOp::Search { .. } => 6,
4727 FileOp::Glob { .. } => 7,
4728 });
4729 // The walks carry a LIST of starts, so the single path every other op has is
4730 // written as a one-element list and read back as one. Written this way rather than
4731 // as two shapes, because a plan whose first field means two things is a plan a
4732 // later edit reads wrongly.
4733 let named = op.paths();
4734 res!(e.len(named.len()));
4735 for p in &named {
4736 res!(e.text(p));
4737 }
4738 match op {
4739 FileOp::Read { offset, limit, .. } => {
4740 res!(e.len(*offset as usize));
4741 res!(e.len(*limit as usize));
4742 },
4743 FileOp::Write { content, .. } => res!(e.text(content)),
4744 FileOp::Edit { old, new, .. } => {
4745 res!(e.text(old));
4746 res!(e.text(new));
4747 },
4748 // `to` already travelled in the path list above.
4749 FileOp::Move { .. } => (),
4750 FileOp::List { .. } | FileOp::MkDir { .. } => (),
4751 FileOp::Search { query, ci, glob, base, skip, budget, cap, .. } => {
4752 res!(e.text(query));
4753 e.byte(u8::from(*ci));
4754 res!(e.text(glob));
4755 res!(e.text(base));
4756 res!(e.len(skip.len()));
4757 for d in skip {
4758 res!(e.text(d));
4759 }
4760 res!(e.len(*budget as usize));
4761 res!(e.len(*cap as usize));
4762 },
4763 FileOp::Glob { pattern, base, skip, budget, .. } => {
4764 res!(e.text(pattern));
4765 res!(e.text(base));
4766 res!(e.len(skip.len()));
4767 for d in skip {
4768 res!(e.text(d));
4769 }
4770 res!(e.len(*budget as usize));
4771 },
4772 }
4773 },
4774 }
4775
4776 let body = e.out;
4777 if body.len() > PAYLOAD_MAX {
4778 return Err(err!(
4779 "This command's fence needs {} bytes to describe and the launcher \
4780 will read {}. The fence is too finely divided to apply; a spec with \
4781 fewer carved directories would fit.", body.len(), PAYLOAD_MAX;
4782 Excessive, Size));
4783 }
4784 let mut out = Vec::with_capacity(body.len() + 4);
4785 out.extend_from_slice(&(body.len() as u32).to_le_bytes());
4786 out.extend_from_slice(&body);
4787 Ok(out)
4788}
4789
4790/// Reads back what [`encode_payload`] wrote.
4791///
4792/// # Arguments
4793/// * `b` - The body, without its length prefix.
4794fn decode_payload(b: &[u8]) -> Outcome<Payload> {
4795 let mut d = Dec { b, at: 0 };
4796 let prog = res!(d.path());
4797 let mut argv = Vec::new();
4798 for _ in 0..res!(d.len()) {
4799 argv.push(res!(d.text()));
4800 }
4801 let mut env = Vec::new();
4802 for _ in 0..res!(d.len()) {
4803 let k = res!(d.text());
4804 let v = res!(d.text());
4805 env.push((k, v));
4806 }
4807
4808 let abi = crate::fence::Abi::of_level(res!(d.len()) as u32);
4809 let listing = match res!(d.byte()) {
4810 0 => Listing::Sealed,
4811 1 => Listing::Names,
4812 n => return Err(err!("The plan names listing mode {}, which does not exist.", n;
4813 Invalid, Input)),
4814 };
4815 let base = match res!(d.byte()) {
4816 0 => SysBase::Bare,
4817 1 => SysBase::Minimal,
4818 n => return Err(err!("The plan names system base {}, which does not exist.", n;
4819 Invalid, Input)),
4820 };
4821 let reach = match res!(d.byte()) {
4822 0 => Reach::Thread,
4823 1 => Reach::Process,
4824 n => return Err(err!("The plan names reach {}, which does not exist.", n;
4825 Invalid, Input)),
4826 };
4827 let net = res!(d.byte()) != 0;
4828 let tty = res!(d.byte()) != 0;
4829 let waiver = match res!(d.byte()) {
4830 0 => None,
4831 1 => Some(res!(d.text())),
4832 n => return Err(err!("The plan's waiver flag is {}, which is neither 0 nor 1.", n;
4833 Invalid, Input)),
4834 };
4835 let mut grants = Vec::new();
4836 for _ in 0..res!(d.len()) {
4837 let path = res!(d.path());
4838 let level = match res!(d.byte()) {
4839 0 => Level::Deny,
4840 1 => Level::Ro,
4841 2 => Level::Rw,
4842 n => return Err(err!("The plan grants level {}, which does not exist.", n;
4843 Invalid, Input)),
4844 };
4845 grants.push(Grant { path, level });
4846 }
4847 let mut sealed = Vec::new();
4848 for _ in 0..res!(d.len()) {
4849 sealed.push(res!(d.path()));
4850 }
4851 let mut dropped = Vec::new();
4852 for _ in 0..res!(d.len()) {
4853 dropped.push(res!(d.path()));
4854 }
4855 let act = match res!(d.byte()) {
4856 0 => Act::Exec,
4857 1 => {
4858 let kind = res!(d.byte());
4859 let mut named: Vec<String> = Vec::new();
4860 for _ in 0..res!(d.len()) {
4861 named.push(res!(d.text()));
4862 }
4863 // One path where the op has one, and a refusal rather than a guess where the plan
4864 // carried none: a `read` of nowhere is a plan this build did not write.
4865 let one = |v: &Vec<String>| -> Outcome<String> {
4866 match v.first() {
4867 Some(p) => Ok(p.clone()),
4868 None => Err(err!(
4869 "The plan names a file operation with no path at all."; Invalid, Input)),
4870 }
4871 };
4872 let names = |d: &mut Dec| -> Outcome<Vec<String>> {
4873 let mut out = Vec::new();
4874 for _ in 0..res!(d.len()) {
4875 out.push(res!(d.text()));
4876 }
4877 Ok(out)
4878 };
4879 Act::File(match kind {
4880 0 => FileOp::Read {
4881 path: res!(one(&named)),
4882 offset: res!(d.len()) as u32,
4883 limit: res!(d.len()) as u32,
4884 },
4885 1 => FileOp::Write { path: res!(one(&named)), content: res!(d.text()) },
4886 2 => FileOp::Edit {
4887 path: res!(one(&named)),
4888 old: res!(d.text()),
4889 new: res!(d.text()),
4890 },
4891 3 => FileOp::Move {
4892 path: res!(one(&named)),
4893 to: match named.get(1) {
4894 Some(t) => t.clone(),
4895 None => return Err(err!(
4896 "The plan names a move with nowhere to move to."; Invalid, Input)),
4897 },
4898 },
4899 4 => FileOp::List { path: res!(one(&named)) },
4900 5 => FileOp::MkDir { path: res!(one(&named)) },
4901 6 => FileOp::Search {
4902 paths: named,
4903 query: res!(d.text()),
4904 ci: res!(d.byte()) != 0,
4905 glob: res!(d.text()),
4906 base: res!(d.text()),
4907 skip: res!(names(&mut d)),
4908 budget: res!(d.len()) as u32,
4909 cap: res!(d.len()) as u32,
4910 },
4911 7 => FileOp::Glob {
4912 paths: named,
4913 pattern: res!(d.text()),
4914 base: res!(d.text()),
4915 skip: res!(names(&mut d)),
4916 budget: res!(d.len()) as u32,
4917 },
4918 n => return Err(err!("The plan names file operation {}, which does not exist.", n;
4919 Invalid, Input)),
4920 })
4921 },
4922 n => return Err(err!("The plan names act {}, which does not exist.", n;
4923 Invalid, Input)),
4924 };
4925 res!(d.done());
4926
4927 Ok(Payload {
4928 prog,
4929 argv,
4930 env,
4931 plan: Plan { abi, listing, base, reach, grants, sealed, dropped, net, waiver },
4932 tty,
4933 act,
4934 })
4935}
4936
4937/// Builds the launcher's payload, one length-prefixed field at a time.
4938struct Enc {
4939 /// What has been written so far.
4940 out: Vec<u8>,
4941}
4942
4943impl Enc {
4944
4945 /// Writes one byte.
4946 ///
4947 /// # Arguments
4948 /// * `v` - The byte.
4949 fn byte(&mut self, v: u8) {
4950 self.out.push(v);
4951 }
4952
4953 /// Writes a count or a length.
4954 ///
4955 /// # Arguments
4956 /// * `v` - The number, which must fit in 32 bits.
4957 fn len(&mut self, v: usize) -> Outcome<()> {
4958 if v > u32::MAX as usize {
4959 return Err(err!(
4960 "A fence plan cannot carry {} of anything.", v; Excessive, Size));
4961 }
4962 self.out.extend_from_slice(&(v as u32).to_le_bytes());
4963 Ok(())
4964 }
4965
4966 /// Writes a length-prefixed run of bytes.
4967 ///
4968 /// # Arguments
4969 /// * `b` - The bytes.
4970 fn bytes(&mut self, b: &[u8]) -> Outcome<()> {
4971 res!(self.len(b.len()));
4972 self.out.extend_from_slice(b);
4973 Ok(())
4974 }
4975
4976 /// Writes a length-prefixed string.
4977 ///
4978 /// # Arguments
4979 /// * `s` - The text.
4980 fn text(&mut self, s: &str) -> Outcome<()> {
4981 self.bytes(s.as_bytes())
4982 }
4983
4984 /// Writes a length-prefixed path, byte for byte.
4985 ///
4986 /// # Arguments
4987 /// * `p` - The path.
4988 fn path(&mut self, p: &Path) -> Outcome<()> {
4989 self.bytes(&path_bytes(p))
4990 }
4991}
4992
4993/// Reads the launcher's payload back, refusing anything that does not fit.
4994struct Dec<'a> {
4995 /// The body being read.
4996 b: &'a [u8],
4997 /// How far in.
4998 at: usize,
4999}
5000
5001impl<'a> Dec<'a> {
5002
5003 /// Takes `n` bytes, or says how far short the payload fell.
5004 ///
5005 /// # Arguments
5006 /// * `n` - How many bytes are wanted.
5007 fn take(&mut self, n: usize) -> Outcome<&'a [u8]> {
5008 let end = match self.at.checked_add(n) {
5009 Some(e) => e,
5010 None => return Err(err!(
5011 "A field in the plan claims a length that cannot be counted.";
5012 Invalid, Input)),
5013 };
5014 if end > self.b.len() {
5015 return Err(err!(
5016 "The plan ended after {} bytes with {} still to read.",
5017 self.b.len(), end - self.b.len();
5018 Invalid, Input, Size));
5019 }
5020 let out = &self.b[self.at..end];
5021 self.at = end;
5022 Ok(out)
5023 }
5024
5025 /// Takes one byte.
5026 fn byte(&mut self) -> Outcome<u8> {
5027 let b = res!(self.take(1));
5028 Ok(b[0])
5029 }
5030
5031 /// Takes a count or a length.
5032 fn len(&mut self) -> Outcome<usize> {
5033 let b = res!(self.take(4));
5034 let mut v = [0u8; 4];
5035 v.copy_from_slice(b);
5036 Ok(u32::from_le_bytes(v) as usize)
5037 }
5038
5039 /// Takes a length-prefixed run of bytes.
5040 fn bytes(&mut self) -> Outcome<Vec<u8>> {
5041 let n = res!(self.len());
5042 Ok(res!(self.take(n)).to_vec())
5043 }
5044
5045 /// Takes a length-prefixed string.
5046 fn text(&mut self) -> Outcome<String> {
5047 let b = res!(self.bytes());
5048 match String::from_utf8(b) {
5049 Ok(s) => Ok(s),
5050 Err(e) => Err(err!(e,
5051 "A string in the plan is not valid UTF-8."; Invalid, Input, String)),
5052 }
5053 }
5054
5055 /// Takes a length-prefixed path.
5056 fn path(&mut self) -> Outcome<PathBuf> {
5057 let b = res!(self.bytes());
5058 Ok(bytes_path(b))
5059 }
5060
5061 /// Refuses a payload with anything left over.
5062 ///
5063 /// Trailing bytes mean the two ends disagree about the shape of a plan, and
5064 /// a launcher that applied the part it understood would be applying a fence
5065 /// nobody wrote.
5066 fn done(&self) -> Outcome<()> {
5067 if self.at != self.b.len() {
5068 return Err(err!(
5069 "The plan carried {} bytes beyond its last field, so the launcher \
5070 and the hand disagree about what a plan is.", self.b.len() - self.at;
5071 Invalid, Input, Mismatch));
5072 }
5073 Ok(())
5074 }
5075}
5076
5077/// A path as the bytes the operating system holds it as.
5078///
5079/// # Arguments
5080/// * `p` - The path.
5081fn path_bytes(p: &Path) -> Vec<u8> {
5082 #[cfg(unix)]
5083 {
5084 use std::os::unix::ffi::OsStrExt;
5085 p.as_os_str().as_bytes().to_vec()
5086 }
5087 #[cfg(not(unix))]
5088 {
5089 p.to_string_lossy().as_bytes().to_vec()
5090 }
5091}
5092
5093/// The path those bytes name.
5094///
5095/// # Arguments
5096/// * `b` - The bytes.
5097fn bytes_path(b: Vec<u8>) -> PathBuf {
5098 #[cfg(unix)]
5099 {
5100 use std::os::unix::ffi::OsStringExt;
5101 PathBuf::from(std::ffi::OsString::from_vec(b))
5102 }
5103 #[cfg(not(unix))]
5104 {
5105 PathBuf::from(String::from_utf8_lossy(&b).to_string())
5106 }
5107}
5108
5109// ┌───────────────────────────────────────────────────────────────┐
5110// │ Tests │
5111// └───────────────────────────────────────────────────────────────┘
5112
5113#[cfg(test)]
5114mod tests {
5115 use super::*;
5116
5117 use tokio::sync::mpsc::Receiver;
5118
5119 // ── Becoming the launcher ───────────────────────────────────────
5120 //
5121 // Every command in these tests really is fenced, by the real `launch_main`,
5122 // in a real second process. That is possible only because the test binary
5123 // can be made to re-enter itself: `/proc/self/exe` here is libtest, whose
5124 // `main` will not dispatch `LAUNCH_ARG`, so the launcher is invoked as
5125 // "run exactly the test named below" and that test calls `launch_main`.
5126 //
5127 // The one artefact is that libtest announces itself on standard output
5128 // before reaching the test, so sixteen known bytes precede every command's
5129 // own output. They are removed by name rather than tolerated, and a test
5130 // asserting an exact byte count adds them explicitly, so that a change in
5131 // the harness fails a test instead of quietly moving a number.
5132
5133 /// The environment name that turns a copy of the test binary into a launcher.
5134 const LAUNCH_CHILD: &str = "DAIMOND_HAND_TEST_LAUNCHER";
5135
5136 /// What libtest writes to standard output before it reaches a test.
5137 ///
5138 /// Two lines rather than one: under `--nocapture` the name is printed
5139 /// *before* the test runs, so that a test's own output appears after it.
5140 /// Fixed text, because the test named is fixed.
5141 const HARNESS_NOISE: &str =
5142 "\nrunning 1 test\ntest exec::tests::launcher_child_entry ... ";
5143
5144 /// The launcher entry point, reached only in a re-executed test binary.
5145 ///
5146 /// Ordinary runs of the suite see the variable unset and return at once, so
5147 /// this costs nothing except when it is the point.
5148 #[test]
5149 fn launcher_child_entry() {
5150 if std::env::var(LAUNCH_CHILD).is_err() {
5151 return;
5152 }
5153 launch_main()
5154 }
5155
5156 /// A launcher that re-enters this test binary at [`launcher_child_entry`].
5157 fn test_launcher() -> Outcome<Launcher> {
5158 let exe = res!(std::env::current_exe().map_err(|e| err!(e,
5159 "The launcher tests need to know their own binary."; Test, IO)));
5160 Ok(Launcher::Explicit {
5161 prog: exe,
5162 args: vec![
5163 fmt!("exec::tests::launcher_child_entry"),
5164 fmt!("--exact"),
5165 fmt!("--nocapture"),
5166 fmt!("--test-threads=1"),
5167 ],
5168 env: vec![(fmt!("{}", LAUNCH_CHILD), fmt!("1"))],
5169 })
5170 }
5171
5172 /// A runner whose launcher is this test binary.
5173 fn runner() -> Outcome<Runner> {
5174 Ok(Runner::with_launcher(res!(test_launcher())))
5175 }
5176
5177 /// A directory that certainly exists and that the tests never write to.
5178 fn root() -> String {
5179 fmt!("{}", env!("CARGO_MANIFEST_DIR"))
5180 }
5181
5182 /// A fence that permits the crate's own directory and nothing else.
5183 fn fence_here() -> FenceSpec {
5184 FenceSpec { rw: vec![root()], ro: Vec::new(), deny: Vec::new(), net: false }
5185 }
5186
5187 /// A workspace with something in it, and something outside it.
5188 ///
5189 /// Under the home cache and never `/tmp`: that is a tmpfs here, and filling
5190 /// it has taken this machine down before.
5191 ///
5192 /// # Arguments
5193 /// * `name` - A name unique to the calling test.
5194 fn fixture(name: &str) -> Outcome<PathBuf> {
5195 let home = match std::env::var("HOME") {
5196 Ok(h) => h,
5197 Err(e) => return Err(err!(e,
5198 "The exec tests need HOME to know where to put fixtures."; Test, Configuration)),
5199 };
5200 let base = PathBuf::from(home).join(".cache/daimond-hand-exec-tests").join(name);
5201 let _ = std::fs::remove_dir_all(&base);
5202 res!(std::fs::create_dir_all(base.join("ws")));
5203 res!(std::fs::create_dir_all(base.join("outside")));
5204 res!(std::fs::write(base.join("ws/inside.txt"), "inside"));
5205 res!(std::fs::write(base.join("outside/other.txt"), "other"));
5206 Ok(res!(base.canonicalize()))
5207 }
5208
5209 /// How long a file this process has just written is given to stop being busy.
5210 ///
5211 /// Generous by more than two orders of magnitude: every wait measured here
5212 /// cleared inside three attempts and fifteen milliseconds. A file still busy
5213 /// after this is not the race below.
5214 const FRESH_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
5215
5216 /// A program this process has just written, run once the kernel will let it.
5217 ///
5218 /// `std::fs::copy` opens the destination for writing, and for as long as that
5219 /// descriptor is open any OTHER thread of this process that starts a command
5220 /// forks a child whose file descriptor table is a copy of ours -- so the child
5221 /// carries a duplicate of it until its own `execve` closes it. For those few
5222 /// milliseconds the file has a writer, and Linux answers `execve` on a file
5223 /// with a writer with `ETXTBSY`. Nothing has leaked: the child is a launcher
5224 /// this suite meant to start, the descriptor is not one it knows it holds, and
5225 /// it goes the moment the child execs.
5226 ///
5227 /// Measured on this tree, which runs its tests in parallel and starts a real
5228 /// second process for nearly every one of them. At 32 threads the failure
5229 /// appeared in 3 runs of 20; a scan of this process's own children at the
5230 /// instant of the error caught the holder twice, by pid and by descriptor
5231 /// number; and with this wait in place it appeared in 7 runs of 40 and cleared
5232 /// every time inside three attempts and fifteen milliseconds. Run serially it
5233 /// never appeared in 20 runs, and run on its own never in 300 -- which is the
5234 /// same statement from the other side, since neither has anything else
5235 /// forking.
5236 ///
5237 /// So this waits, briefly, and only for that one error. Every other failure is
5238 /// returned at once, and a file still busy at the end of [`FRESH_WAIT`] is a
5239 /// descriptor somebody really did leak rather than this race, and says so.
5240 ///
5241 /// # Arguments
5242 /// * `prog` - The program, which this process wrote a moment ago.
5243 /// * `args` - Its arguments.
5244 fn run_fresh(prog: &Path, args: &[&str]) -> Outcome<std::process::Output> {
5245 let began = std::time::Instant::now();
5246 let mut tries = 0u32;
5247 loop {
5248 tries += 1;
5249 match std::process::Command::new(prog).args(args).output() {
5250 Ok(out) => return Ok(out),
5251 Err(e) => {
5252 if e.kind() != std::io::ErrorKind::ExecutableFileBusy {
5253 return Err(err!(e,
5254 "{} would not run.", prog.display(); Test, IO));
5255 }
5256 if began.elapsed() >= FRESH_WAIT {
5257 return Err(err!(e,
5258 "{} was still busy after {} attempts over {:?}. A file this \
5259 process wrote and then closed goes un-busy as soon as the \
5260 children that forked while it was open have exec'd, which \
5261 takes milliseconds; {:?} means a descriptor on it is genuinely \
5262 held open somewhere, and that leak is the thing to fix rather \
5263 than this wait.",
5264 prog.display(), tries, began.elapsed(), FRESH_WAIT; Test, IO));
5265 }
5266 std::thread::sleep(std::time::Duration::from_millis(2));
5267 },
5268 }
5269 }
5270 }
5271
5272 /// A request with the fields the tests vary and sensible rest.
5273 fn exec(id: &str, argv: &[&str]) -> Req {
5274 Req::Exec {
5275 id: fmt!("{}", id),
5276 argv: argv.iter().map(|a| fmt!("{}", a)).collect(),
5277 cwd: root(),
5278 env: Vec::new(),
5279 stdin: None,
5280 timeout_ms: 10_000,
5281 capture: Capture::Both,
5282 fence: fence_here(),
5283 toolkits: Vec::new(),
5284 }
5285 }
5286
5287 /// The process group of `pid`, read straight out of `/proc` by the test.
5288 ///
5289 /// Deliberately not [`group_standing`]: a test that measured the machine with
5290 /// the code under test would agree with it whatever either of them did.
5291 fn proc_pgrp(pid: u32) -> Option<u32> {
5292 let stat = match std::fs::read_to_string(fmt!("/proc/{}/stat", pid)) {
5293 Ok(s) => s,
5294 Err(_) => return None,
5295 };
5296 let tail = match stat.rsplit_once(')') {
5297 Some((_, t)) => t,
5298 None => return None,
5299 };
5300 tail.split_whitespace().nth(2).and_then(|f| f.parse::<u32>().ok())
5301 }
5302
5303 /// A request that runs in a named directory, with that directory as the
5304 /// whole of its fence.
5305 fn exec_at(id: &str, argv: &[&str], dir: &Path) -> Req {
5306 Req::Exec {
5307 id: fmt!("{}", id),
5308 argv: argv.iter().map(|a| fmt!("{}", a)).collect(),
5309 cwd: fmt!("{}", dir.display()),
5310 env: Vec::new(),
5311 stdin: None,
5312 timeout_ms: 30_000,
5313 capture: Capture::Both,
5314 fence: FenceSpec {
5315 rw: vec![fmt!("{}", dir.display())],
5316 ro: Vec::new(),
5317 deny: Vec::new(),
5318 net: false,
5319 },
5320 toolkits: Vec::new(),
5321 }
5322 }
5323
5324 /// Collects responses until the run closes.
5325 async fn collect(rx: &mut Receiver<Resp>) -> Vec<Resp> {
5326 let mut v = Vec::new();
5327 while let Some(r) = rx.recv().await {
5328 let done = matches!(r, Resp::Ended { .. } | Resp::Refused { .. });
5329 v.push(r);
5330 if done {
5331 break;
5332 }
5333 }
5334 v
5335 }
5336
5337 /// Everything one stream said, in order, less the harness's own announcement.
5338 fn text_of(rs: &[Resp], want: Stream) -> String {
5339 let mut s = String::new();
5340 for r in rs {
5341 if let Resp::Chunk { stream, data, .. } = r {
5342 if *stream == want {
5343 s.push_str(data);
5344 }
5345 }
5346 }
5347 match s.strip_prefix(HARNESS_NOISE) {
5348 Some(rest) => fmt!("{}", rest),
5349 None => s,
5350 }
5351 }
5352
5353 /// The sequence numbers seen on one stream, in arrival order.
5354 fn seqs_of(rs: &[Resp], want: Stream) -> Vec<u64> {
5355 let mut v = Vec::new();
5356 for r in rs {
5357 if let Resp::Chunk { stream, seq, .. } = r {
5358 if *stream == want {
5359 v.push(*seq);
5360 }
5361 }
5362 }
5363 v
5364 }
5365
5366 /// The closing message.
5367 fn ended(rs: &[Resp]) -> Option<(i32, bool, bool, u64)> {
5368 for r in rs {
5369 if let Resp::Ended { exit, timed_out, killed, out_bytes, .. } = r {
5370 return Some((*exit, *timed_out, *killed, *out_bytes));
5371 }
5372 }
5373 None
5374 }
5375
5376 /// Runs one request to completion and hands back everything it said.
5377 async fn run(req: Req) -> Outcome<Vec<Resp>> {
5378 let runner = res!(runner());
5379 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(4096);
5380 res!(runner.spawn(req, tx).await);
5381 Ok(collect(&mut rx).await)
5382 }
5383
5384 /// The refusal sentence, or an error naming what came instead.
5385 ///
5386 /// # Arguments
5387 /// * `rs` - Everything the run said.
5388 fn refusal(rs: &[Resp]) -> Outcome<String> {
5389 match rs.first() {
5390 Some(Resp::Refused { reason, .. }) => {
5391 assert!(reason.starts_with("Refused: "),
5392 "a refusal did not read as one: {}", reason);
5393 Ok(fmt!("{}", reason))
5394 },
5395 other => Err(err!(
5396 "Expected a refusal, got {:?}.", other; Test, Mismatch)),
5397 }
5398 }
5399
5400 #[tokio::test]
5401 async fn test_echo_returns_its_output_and_exit_zero() -> Outcome<()> {
5402 let rs = res!(run(exec("e1", &["/bin/echo", "hello"])).await);
5403 assert_eq!(text_of(&rs, Stream::Out), "hello\n");
5404 let (exit, timed_out, killed, out_bytes) = match ended(&rs) {
5405 Some(e) => e,
5406 None => return Err(err!("No Ended was sent."; Test, Missing)),
5407 };
5408 assert_eq!(exit, 0);
5409 assert!(!timed_out);
5410 assert!(!killed);
5411 // The command wrote six bytes; the harness that became its launcher
5412 // wrote the rest. Counted explicitly rather than loosened to `>=`, so
5413 // that a harness change fails here instead of hiding a lost byte.
5414 assert_eq!(out_bytes, (HARNESS_NOISE.len() + 6) as u64);
5415 Ok(())
5416 }
5417
5418 #[tokio::test]
5419 async fn test_non_zero_exit_is_reported() -> Outcome<()> {
5420 let rs = res!(run(exec("e2", &["/bin/false"])).await);
5421 let (exit, timed_out, ..) = match ended(&rs) {
5422 Some(e) => e,
5423 None => return Err(err!("No Ended was sent."; Test, Missing)),
5424 };
5425 assert_ne!(exit, 0);
5426 assert!(!timed_out);
5427 Ok(())
5428 }
5429
5430 #[tokio::test]
5431 async fn test_timeout_kills_and_reports_it() -> Outcome<()> {
5432 let req = match exec("e3", &["/bin/sleep", "30"]) {
5433 Req::Exec { id, argv, cwd, env, stdin, capture, fence, .. } =>
5434 Req::Exec { id, argv, cwd, env, stdin, timeout_ms: 300, capture, fence, toolkits: Vec::new() },
5435 other => other,
5436 };
5437 let rs = res!(run(req).await);
5438 let (_, timed_out, ..) = match ended(&rs) {
5439 Some(e) => e,
5440 None => return Err(err!("No Ended was sent."; Test, Missing)),
5441 };
5442 assert!(timed_out);
5443 Ok(())
5444 }
5445
5446 #[tokio::test]
5447 async fn test_stdin_is_delivered() -> Outcome<()> {
5448 let req = match exec("e4", &["/bin/cat"]) {
5449 Req::Exec { id, argv, cwd, env, timeout_ms, capture, fence, .. } =>
5450 Req::Exec {
5451 id, argv, cwd, env,
5452 stdin: Some(fmt!("through the pipe\n")),
5453 timeout_ms, capture, fence,
5454 toolkits: Vec::new(),
5455 },
5456 other => other,
5457 };
5458 let rs = res!(run(req).await);
5459 assert_eq!(text_of(&rs, Stream::Out), "through the pipe\n");
5460 Ok(())
5461 }
5462
5463 #[tokio::test]
5464 async fn test_large_output_arrives_as_several_sequenced_chunks() -> Outcome<()> {
5465 // Comfortably more than CHUNK_MAX, sent in and read back out.
5466 let big = "a".repeat(CHUNK_MAX * 3);
5467 let req = match exec("e5", &["/bin/cat"]) {
5468 Req::Exec { id, argv, cwd, env, timeout_ms, capture, fence, .. } =>
5469 Req::Exec {
5470 id, argv, cwd, env,
5471 stdin: Some(big.clone()),
5472 timeout_ms, capture, fence,
5473 toolkits: Vec::new(),
5474 },
5475 other => other,
5476 };
5477 let rs = res!(run(req).await);
5478
5479 assert_eq!(text_of(&rs, Stream::Out), big);
5480
5481 let seqs = seqs_of(&rs, Stream::Out);
5482 assert!(seqs.len() > 1, "expected several chunks, got {}", seqs.len());
5483 for (i, s) in seqs.iter().enumerate() {
5484 assert_eq!(*s, i as u64, "sequence is not monotonic from zero");
5485 }
5486 for r in &rs {
5487 if let Resp::Chunk { data, .. } = r {
5488 assert!(data.len() <= CHUNK_MAX, "a chunk exceeded CHUNK_MAX");
5489 }
5490 }
5491 Ok(())
5492 }
5493
5494 /// The command sees the pairs it was given, the three the hand adds, and
5495 /// nothing else at all.
5496 ///
5497 /// `PATH` is named on purpose. It is certainly in the hand's own
5498 /// environment, an inherited environment is how a credential the user never
5499 /// meant to lend reaches a command, and it is now DEFAULTED as well -- so the
5500 /// test is that the command holds [`PATH_FALLBACK`] and not the hand's, which
5501 /// is the difference between a default and an inheritance.
5502 #[tokio::test]
5503 async fn test_environment_really_is_cleared() -> Outcome<()> {
5504 let req = match exec("e6", &["/usr/bin/env"]) {
5505 Req::Exec { id, argv, cwd, stdin, timeout_ms, capture, fence, .. } =>
5506 Req::Exec {
5507 id, argv, cwd, stdin, timeout_ms, capture, fence,
5508 env: vec![
5509 (fmt!("ONLY"), fmt!("this")),
5510 (fmt!("AND"), fmt!("that")),
5511 ],
5512 toolkits: Vec::new(),
5513 },
5514 other => other,
5515 };
5516 let rs = res!(run(req).await);
5517
5518 let mut lines = text_of(&rs, Stream::Out)
5519 .lines()
5520 .map(|l| fmt!("{}", l))
5521 .filter(|l| !l.is_empty())
5522 .collect::<Vec<_>>();
5523 lines.sort();
5524 // A default and an inheritance look alike from here, and the way to tell
5525 // them apart is the value. The hand's own PATH is whatever launched the
5526 // browser and is nothing like the fixed list.
5527 if let Ok(mine) = std::env::var("PATH") {
5528 assert!(!lines.iter().any(|l| *l == fmt!("PATH={}", mine)) || mine == PATH_FALLBACK,
5529 "the hand's own PATH reached the command");
5530 }
5531 for l in &lines {
5532 if let Some(rest) = l.strip_prefix("PATH=") {
5533 assert_eq!(rest, PATH_FALLBACK, "the command's PATH is not the fixed list");
5534 }
5535 }
5536
5537 // The three the hand adds are the same directory, and that directory is
5538 // under the scratch base rather than /tmp.
5539 let base = res!(scratch_base());
5540 let mut tmp = Vec::<String>::new();
5541 for name in TMP_VARS {
5542 let want = fmt!("{}=", name);
5543 match lines.iter().find(|l| l.starts_with(&want)) {
5544 Some(l) => tmp.push(fmt!("{}", &l[want.len()..])),
5545 None => return Err(err!(
5546 "{} was not set for the command.", name; Test, Missing)),
5547 }
5548 }
5549 assert_eq!(tmp[0], tmp[1], "TMPDIR and TMP name different directories");
5550 assert_eq!(tmp[1], tmp[2], "TMP and TEMP name different directories");
5551 assert!(Path::new(&tmp[0]).starts_with(&base),
5552 "{} is not under the scratch base {}", tmp[0], base.display());
5553
5554 let mut want = vec![fmt!("AND=that"), fmt!("ONLY=this")];
5555 for name in TMP_VARS {
5556 want.push(fmt!("{}={}", name, tmp[0]));
5557 }
5558 for name in ENV_DEFAULTED {
5559 if let Some(v) = default_env(name) {
5560 want.push(fmt!("{}={}", name, v));
5561 }
5562 }
5563 want.sort();
5564 assert_eq!(lines, want);
5565 Ok(())
5566 }
5567
5568 /// The central design decision, stated as a test.
5569 ///
5570 /// Each of these strings is a shell instruction, and every one of them
5571 /// arrives at the program as literal text. There is no quoting to get
5572 /// right and no metacharacter to escape, because no shell is involved --
5573 /// which is why `argv` is not a preference here but the whole defence.
5574 #[tokio::test]
5575 async fn test_shell_metacharacters_are_passed_through_literally() -> Outcome<()> {
5576 let hostile = [
5577 "a;b",
5578 "$(whoami)",
5579 "`whoami`",
5580 "x|y",
5581 "&& rm -rf /",
5582 "$HOME",
5583 "*",
5584 ">out.txt",
5585 ];
5586 let mut argv = vec!["/bin/echo"];
5587 argv.extend_from_slice(&hostile);
5588
5589 let rs = res!(run(exec("e7", &argv)).await);
5590 let got = text_of(&rs, Stream::Out);
5591
5592 assert_eq!(got, fmt!("{}\n", hostile.join(" ")));
5593 // Named individually, because each is a different way in.
5594 assert!(got.contains("a;b"), "a semicolon was interpreted");
5595 assert!(got.contains("$(whoami)"), "a substitution was interpreted");
5596 assert!(got.contains("`whoami`"), "a backquote was interpreted");
5597 assert!(got.contains("x|y"), "a pipe was interpreted");
5598 assert!(got.contains("&& rm -rf /"), "a conjunction was interpreted");
5599 assert!(got.contains("$HOME"), "a variable was expanded");
5600 assert!(got.contains(" * "), "a glob was expanded");
5601 assert!(got.contains(">out.txt"), "a redirection was interpreted");
5602 Ok(())
5603 }
5604
5605 #[tokio::test]
5606 async fn test_cwd_outside_the_fence_is_refused() -> Outcome<()> {
5607 let req = match exec("e8", &["/bin/echo", "hi"]) {
5608 Req::Exec { id, argv, env, stdin, timeout_ms, capture, fence, .. } =>
5609 Req::Exec {
5610 id, argv, env, stdin, timeout_ms, capture, fence,
5611 cwd: fmt!("/"),
5612 toolkits: Vec::new(),
5613 },
5614 other => other,
5615 };
5616 let rs = res!(run(req).await);
5617 match rs.first() {
5618 Some(Resp::Refused { reason, .. }) => {
5619 assert!(reason.starts_with("Refused: "));
5620 assert!(reason.contains("outside this command's fence"));
5621 },
5622 other => return Err(err!(
5623 "Expected a refusal, got {:?}.", other; Test, Mismatch)),
5624 }
5625 Ok(())
5626 }
5627
5628 /// A working directory that names a FILE is refused, and the refusal names the folder.
5629 ///
5630 /// A file passes every other test in `vet_cwd` -- absolute, resolvable, inside the fence --
5631 /// so before this the failure surfaced at the spawn as `Os { code: 20, kind: NotADirectory }`
5632 /// wrapped in two error layers, naming a path the caller never wrote. The assertion is on the
5633 /// SENTENCE and on the parent it offers, because "it refused" was already true of the broken
5634 /// version by accident, two layers further down and in words nobody could act on.
5635 #[tokio::test]
5636 async fn a_cwd_that_names_a_file_is_refused_and_the_folder_is_named() -> Outcome<()> {
5637 let file = fmt!("{}/Cargo.toml", root());
5638 let req = match exec("e10", &["/bin/echo", "hi"]) {
5639 Req::Exec { id, argv, env, stdin, timeout_ms, capture, fence, .. } =>
5640 Req::Exec {
5641 id, argv, env, stdin, timeout_ms, capture, fence,
5642 cwd: file.clone(),
5643 toolkits: Vec::new(),
5644 },
5645 other => other,
5646 };
5647 let rs = res!(run(req).await);
5648 match rs.first() {
5649 Some(Resp::Refused { reason, .. }) => {
5650 assert!(reason.contains("is a file, not a folder"),
5651 "the refusal did not say what was wrong: {}", reason);
5652 assert!(reason.contains(&root()),
5653 "the refusal did not name the folder to use instead: {}", reason);
5654 assert!(!reason.contains("NotADirectory"),
5655 "a system error reached the user: {}", reason);
5656 },
5657 other => return Err(err!(
5658 "Expected a refusal, got {:?}.", other; Test, Mismatch)),
5659 }
5660 Ok(())
5661 }
5662
5663 /// **A terminal is vetted against its ceiling; a command never is.**
5664 ///
5665 /// The ceiling is the whole of the owner's ruling of 2026-08-26: a terminal is the user at
5666 /// a keyboard and may reach further than a daimon's command. Both halves are asserted,
5667 /// because only asserting the first would pass on code that gave the wider folder to
5668 /// everything -- which is the mistake this split exists to avoid.
5669 #[test]
5670 fn a_terminal_is_vetted_against_its_ceiling_and_a_command_is_not() -> Outcome<()> {
5671 let root = Path::new("/home/u/usr");
5672 let ceiling = Path::new("/home/u");
5673 assert_eq!(ceiling, vet_against(root, Some(ceiling), Door::Terminal),
5674 "a terminal was not vetted against its ceiling");
5675 assert_eq!(root, vet_against(root, Some(ceiling), Door::Command),
5676 "a COMMAND was given the terminal's ceiling, which is the whole thing this splits");
5677 assert_eq!(root, vet_against(root, Some(ceiling), Door::File),
5678 "a file operation was given the terminal's ceiling");
5679 // No ceiling is every build before this one, and it must behave as one.
5680 assert_eq!(root, vet_against(root, None, Door::Terminal),
5681 "with no ceiling a terminal should get the granted root, as it always did");
5682 Ok(())
5683 }
5684
5685 #[tokio::test]
5686 async fn test_relative_cwd_is_refused() -> Outcome<()> {
5687 let req = match exec("e9", &["/bin/echo", "hi"]) {
5688 Req::Exec { id, argv, env, stdin, timeout_ms, capture, fence, .. } =>
5689 Req::Exec {
5690 id, argv, env, stdin, timeout_ms, capture, fence,
5691 cwd: fmt!("relative/place"),
5692 toolkits: Vec::new(),
5693 },
5694 other => other,
5695 };
5696 let rs = res!(run(req).await);
5697 assert!(matches!(rs.first(), Some(Resp::Refused { .. })));
5698 Ok(())
5699 }
5700
5701 #[tokio::test]
5702 async fn test_empty_argv_is_refused() -> Outcome<()> {
5703 let rs = res!(run(exec("e10", &[])).await);
5704 assert!(matches!(rs.first(), Some(Resp::Refused { .. })));
5705 Ok(())
5706 }
5707
5708 #[tokio::test]
5709 async fn test_capture_none_yields_no_chunks() -> Outcome<()> {
5710 let req = match exec("e11", &["/bin/echo", "quiet"]) {
5711 Req::Exec { id, argv, cwd, env, stdin, timeout_ms, fence, .. } =>
5712 Req::Exec {
5713 id, argv, cwd, env, stdin, timeout_ms, fence,
5714 capture: Capture::None,
5715 toolkits: Vec::new(),
5716 },
5717 other => other,
5718 };
5719 let rs = res!(run(req).await);
5720 assert!(seqs_of(&rs, Stream::Out).is_empty());
5721 let (exit, ..) = match ended(&rs) {
5722 Some(e) => e,
5723 None => return Err(err!("No Ended was sent."; Test, Missing)),
5724 };
5725 assert_eq!(exit, 0);
5726 Ok(())
5727 }
5728
5729 #[tokio::test]
5730 async fn test_signal_reaches_a_running_command() -> Outcome<()> {
5731 let runner = res!(runner());
5732 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
5733 res!(runner.spawn(exec("s1", &["/bin/sleep", "30"]), tx).await);
5734
5735 // Wait for it to be announced, then stop it.
5736 match rx.recv().await {
5737 Some(Resp::Started { .. }) => {},
5738 other => return Err(err!("Expected Started, got {:?}.", other; Test, Mismatch)),
5739 }
5740 assert_eq!(res!(runner.signal("s1", Sig::Term).await), Signalled::Sent);
5741
5742 let rs = collect(&mut rx).await;
5743 let (_, timed_out, killed, _) = match ended(&rs) {
5744 Some(e) => e,
5745 None => return Err(err!("No Ended was sent."; Test, Missing)),
5746 };
5747 assert!(killed);
5748 assert!(!timed_out);
5749 assert_eq!(res!(runner.live_count()), 0);
5750 Ok(())
5751 }
5752
5753 #[tokio::test]
5754 async fn test_signalling_a_finished_run_is_not_an_error() -> Outcome<()> {
5755 let runner = res!(runner());
5756 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
5757 res!(runner.spawn(exec("s2", &["/bin/echo", "done"]), tx).await);
5758 let _ = collect(&mut rx).await;
5759
5760 assert_eq!(res!(runner.signal("s2", Sig::Kill).await), Signalled::Finished);
5761 assert_eq!(res!(runner.signal("never-existed", Sig::Term).await), Signalled::Finished);
5762 Ok(())
5763 }
5764
5765 /// A grandchild must not outlive the kill that took its parent.
5766 ///
5767 /// `xargs` forks the program it was given and waits on it, and -- unlike
5768 /// `timeout`, which arranges its child's death itself -- it takes no steps
5769 /// to bring that child down with it. So killing the `xargs` process alone
5770 /// demonstrably leaves the `sleep` running, and only signalling the *group*
5771 /// takes both. That is the whole difference between this and killing the
5772 /// child, and it is what a fenced `cargo test` full of compilers needs.
5773 #[cfg(target_os = "linux")]
5774 #[tokio::test]
5775 async fn test_group_kill_reaps_grandchildren() -> Outcome<()> {
5776 let runner = res!(runner());
5777 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
5778 let req = match exec("g1", &["/usr/bin/xargs", "/bin/sleep"]) {
5779 Req::Exec { id, argv, cwd, env, timeout_ms, capture, fence, .. } =>
5780 Req::Exec {
5781 id, argv, cwd, env, timeout_ms, capture, fence,
5782 stdin: Some(fmt!("47\n")), // The sleep's argument, via the pipe.
5783 toolkits: Vec::new(),
5784 },
5785 other => other,
5786 };
5787 res!(runner.spawn(req, tx).await);
5788
5789 let pid = match rx.recv().await {
5790 Some(Resp::Started { pid, .. }) => pid,
5791 other => return Err(err!("Expected Started, got {:?}.", other; Test, Mismatch)),
5792 };
5793 // Give the grandchild time to exist.
5794 tokio::time::sleep(Duration::from_millis(600)).await;
5795 assert!(group_members(pid) >= 2, "the grandchild never appeared");
5796
5797 assert_eq!(res!(runner.signal("g1", Sig::Kill).await), Signalled::Sent);
5798 let _ = collect(&mut rx).await;
5799 tokio::time::sleep(Duration::from_millis(600)).await;
5800
5801 let left = group_members(pid);
5802 if left != 0 {
5803 // Do not leave the survivor behind for the next run to trip over.
5804 let _ = signal_group(pid, Sig::Kill).await;
5805 }
5806 assert_eq!(left, 0, "something survived the group kill");
5807 Ok(())
5808 }
5809
5810 // ── BusyBox, in front of the code path ──────────────────────────
5811
5812 /// Where a BusyBox multi-call binary is looked for.
5813 ///
5814 /// The last of the three is the one Debian and Ubuntu ship in the initramfs
5815 /// tools, which is present on a great many machines that have never
5816 /// installed BusyBox on purpose.
5817 #[cfg(target_os = "linux")]
5818 const BUSYBOX_PATHS: &[&str] = &[
5819 "/usr/bin/busybox",
5820 "/bin/busybox",
5821 "/usr/lib/initramfs-tools/bin/busybox",
5822 ];
5823
5824 /// A BusyBox binary on this machine, where there is one.
5825 #[cfg(target_os = "linux")]
5826 fn busybox() -> Option<&'static str> {
5827 BUSYBOX_PATHS.iter().copied().find(|p| Path::new(p).exists())
5828 }
5829
5830 /// A directory holding one applet link, so `argv[0]` is `kill`.
5831 ///
5832 /// BusyBox dispatches on the name it was invoked as, so a link called `kill`
5833 /// **is** BusyBox's `kill` and not procps'. Nothing is simulated here.
5834 ///
5835 /// # Arguments
5836 /// * `bb` - The BusyBox binary.
5837 /// * `name` - A name unique to the calling test.
5838 #[cfg(target_os = "linux")]
5839 fn busybox_kill(bb: &str, name: &str) -> Outcome<PathBuf> {
5840 let dir = res!(fixture(name)).join("bin");
5841 res!(std::fs::create_dir_all(&dir));
5842 let shim = dir.join("kill");
5843 let _ = std::fs::remove_file(&shim);
5844 res!(std::os::unix::fs::symlink(bb, &shim), IO, File);
5845 Ok(shim)
5846 }
5847
5848 /// A real BusyBox `kill` reaches the whole group, and reports that it did.
5849 ///
5850 /// `REVIEW.md` §3.10, proved rather than reasoned about. A BusyBox binary is
5851 /// linked as `kill`, put in front of [`signal_group_with`], and pointed at a
5852 /// real process group with a real grandchild in it.
5853 ///
5854 /// Three things are asserted, and the first two are the finding itself:
5855 ///
5856 /// 1. This `kill` genuinely rejects `--`, so the fixture is the thing the
5857 /// review named and not a stand-in.
5858 /// 2. Asking it which form it takes answers `Bare`, while the system's own
5859 /// `kill` answers `Separated` -- the two systems disagree, which is why
5860 /// one hard-coded spelling cannot serve both.
5861 /// 3. The group dies, and the answer is `Sent`. Under the previous code the
5862 /// third would have been `Degraded`, and `supervise` would have told the
5863 /// page that anything the command started may still be running.
5864 #[cfg(target_os = "linux")]
5865 #[tokio::test]
5866 async fn a_busybox_kill_reaches_the_group_and_says_so() -> Outcome<()> {
5867 let bb = match busybox() {
5868 Some(p) => p,
5869 // No BusyBox here. Say nothing rather than claim a proof.
5870 None => return Ok(()),
5871 };
5872 let shim = res!(busybox_kill(bb, "busybox-kill"));
5873 let shim = fmt!("{}", shim.display());
5874
5875 // 1. It is the `kill` the review named: `--` is refused, and the same
5876 // command without it is accepted.
5877 let sep = res!(Command::new(&shim)
5878 .arg("-s").arg("0").arg("--").arg(fmt!("{}", std::process::id()))
5879 .env_clear().stdin(Stdio::null()).output().await
5880 .map_err(|e| err!(e, "The BusyBox kill could not be run."; Test, IO)));
5881 assert!(!sep.status.success(),
5882 "this BusyBox accepts '--', so the finding it stands for is gone");
5883 assert!(String::from_utf8_lossy(&sep.stderr).contains("--"),
5884 "expected BusyBox to name the operand it could not read, got {:?}",
5885 String::from_utf8_lossy(&sep.stderr));
5886 let bare = res!(Command::new(&shim)
5887 .arg("-s").arg("0").arg(fmt!("{}", std::process::id()))
5888 .env_clear().stdin(Stdio::null()).output().await
5889 .map_err(|e| err!(e, "The BusyBox kill could not be run."; Test, IO)));
5890 assert!(bare.status.success(), "BusyBox refused the form it is meant to take");
5891
5892 // 2. Which is what the probe reports, and the two systems differ.
5893 assert_eq!(Some(Operand::Bare), operand_form(&shim).await);
5894 for prog in KILL_PROGS {
5895 if Path::new(prog).exists() {
5896 assert_eq!(Some(Operand::Separated), operand_form(prog).await,
5897 "{} was expected to take the POSIX form", prog);
5898 }
5899 }
5900
5901 // 3. A real group, with a grandchild, signalled through BusyBox alone.
5902 let mut child = res!(Command::new("/bin/sh")
5903 .arg("-c").arg("/bin/sleep 30 & /bin/sleep 30")
5904 .env_clear()
5905 .stdin(Stdio::null())
5906 .stdout(Stdio::null())
5907 .stderr(Stdio::null())
5908 .process_group(0)
5909 .kill_on_drop(true)
5910 .spawn()
5911 .map_err(|e| err!(e, "The fixture group could not be started."; Test, IO)));
5912 let pgid = match child.id() {
5913 Some(p) => p,
5914 None => return Err(err!("The fixture group had no pid."; Test, Missing)),
5915 };
5916 tokio::time::sleep(Duration::from_millis(600)).await;
5917 assert!(group_members(pgid) >= 2, "the fixture group never had a grandchild");
5918
5919 let said = signal_group_with(&[&shim], pgid, Sig::Kill).await;
5920 // The leader is reaped first: a zombie is still listed under its group,
5921 // and counting one would say the kill had failed when it had not.
5922 let _ = child.wait().await;
5923 tokio::time::sleep(Duration::from_millis(600)).await;
5924 let left = group_members(pgid);
5925 if left != 0 {
5926 let _ = signal_group(pgid, Sig::Kill).await;
5927 }
5928 assert_eq!(Signalling::Sent, said,
5929 "BusyBox killed the group and the hand called it degraded");
5930 assert_eq!(0, left, "something survived a BusyBox group kill");
5931 Ok(())
5932 }
5933
5934 /// How many live processes are in the group led by `pgid`.
5935 ///
5936 /// # Arguments
5937 /// * `pgid` - The group, which is the leader's process id.
5938 #[cfg(target_os = "linux")]
5939 fn group_members(pgid: u32) -> usize {
5940 let dir = match std::fs::read_dir("/proc") {
5941 Ok(d) => d,
5942 Err(_) => return 0,
5943 };
5944 let mut n = 0;
5945 for ent in dir.flatten() {
5946 let name = ent.file_name();
5947 let name = name.to_string_lossy().to_string();
5948 if name.parse::<u32>().is_err() {
5949 continue;
5950 }
5951 let stat = match std::fs::read_to_string(fmt!("/proc/{}/stat", name)) {
5952 Ok(s) => s,
5953 Err(_) => continue, // It ended between the listing and the read.
5954 };
5955 // The command name is parenthesised and may itself contain spaces,
5956 // so the fields are counted from the last closing bracket: state,
5957 // parent, then group.
5958 let tail = match stat.rfind(')') {
5959 Some(i) => &stat[i + 1..],
5960 None => continue,
5961 };
5962 let f = tail.split_whitespace().collect::<Vec<_>>();
5963 if f.len() < 3 {
5964 continue;
5965 }
5966 if let Ok(g) = f[2].parse::<u32>() {
5967 if g == pgid {
5968 n += 1;
5969 }
5970 }
5971 }
5972 n
5973 }
5974
5975 // ── The launcher, end to end ────────────────────────────────────
5976
5977 /// The filter is *installed*, not merely written.
5978 ///
5979 /// `REVIEW.md` §1.2 and §1.3. `seccomp.rs` was complete, tested and called
5980 /// from nowhere: the module's own unit tests passed the whole time the
5981 /// launcher ran unfiltered, so a unit test on the filter is exactly the
5982 /// evidence that failed here. This runs the escape instead, through the real
5983 /// spawn path and the real launcher.
5984 ///
5985 /// The `chmod` half rather than the session-bus half, because it needs
5986 /// nothing of the machine: no bus, no `systemd-run`, no session at all. Both
5987 /// were run against the release binary over a pipe and both are recorded in
5988 /// `REVIEW.md`; this is the one that can be a test.
5989 #[cfg(target_os = "linux")]
5990 #[tokio::test]
5991 async fn the_filter_is_installed_and_not_merely_written() -> Outcome<()> {
5992 if !matches!(detected_seccomp(), Seccomp::Linux { .. }) {
5993 return Ok(()); // No filter here; the hand refuses every command instead.
5994 }
5995 let base = res!(fixture("filter-installed"));
5996 let ws = base.join("ws");
5997 let mark = ws.join("private.txt");
5998 res!(std::fs::write(&mark, "the private thing"));
5999 res!(set_mode(&mark, 0o600));
6000
6001 // Broken first: unfenced and unfiltered, this is what the review measured.
6002 let bare = res!(std::process::Command::new("/bin/chmod")
6003 .arg("777").arg(&mark).output());
6004 assert!(bare.status.success(), "the control chmod failed");
6005 assert_eq!(0o777, res!(mode_of(&mark)), "the control run changed nothing");
6006 res!(set_mode(&mark, 0o600));
6007
6008 let rs = res!(run(Req::Exec {
6009 id: fmt!("chmod"),
6010 argv: vec![fmt!("/bin/chmod"), fmt!("777"), fmt!("{}", mark.display())],
6011 cwd: fmt!("{}", ws.display()),
6012 env: Vec::new(),
6013 stdin: None,
6014 timeout_ms: 10_000,
6015 capture: Capture::Both,
6016 fence: FenceSpec {
6017 rw: vec![fmt!("{}", ws.display())],
6018 ro: Vec::new(),
6019 deny: Vec::new(),
6020 net: false,
6021 },
6022 toolkits: Vec::new(),
6023 }).await);
6024 let (exit, ..) = match ended(&rs) {
6025 Some(e) => e,
6026 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6027 };
6028 // The file is INSIDE the fence and writable, so Landlock permits every
6029 // part of this. Only the filter refuses it.
6030 assert_ne!(0, exit, "chmod 777 succeeded behind the filter");
6031 assert_eq!(0o600, res!(mode_of(&mark)),
6032 "the mode was changed, so no filter was installed: {}",
6033 text_of(&rs, Stream::Err));
6034 assert!(text_of(&rs, Stream::Err).contains("not permitted"),
6035 "the refusal is not one a build log can explain: {:?}",
6036 text_of(&rs, Stream::Err));
6037
6038 // And the trade is still the documented one: a mode that loosens nothing
6039 // is permitted, because cargo sets 644 on every file it unpacks.
6040 let rs = res!(run(Req::Exec {
6041 id: fmt!("chmod-644"),
6042 argv: vec![fmt!("/bin/chmod"), fmt!("644"), fmt!("{}", mark.display())],
6043 cwd: fmt!("{}", ws.display()),
6044 env: Vec::new(),
6045 stdin: None,
6046 timeout_ms: 10_000,
6047 capture: Capture::Both,
6048 fence: FenceSpec {
6049 rw: vec![fmt!("{}", ws.display())],
6050 ro: Vec::new(),
6051 deny: Vec::new(),
6052 net: false,
6053 },
6054 toolkits: Vec::new(),
6055 }).await);
6056 let (exit, ..) = match ended(&rs) {
6057 Some(e) => e,
6058 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6059 };
6060 assert_eq!(0, exit, "chmod 644 was refused, which breaks cargo: {}",
6061 text_of(&rs, Stream::Err));
6062 assert_eq!(0o644, res!(mode_of(&mark)));
6063 Ok(())
6064 }
6065
6066 /// A file's permission bits, for the filter test.
6067 ///
6068 /// # Arguments
6069 /// * `p` - The file.
6070 #[cfg(unix)]
6071 fn mode_of(p: &Path) -> Outcome<u32> {
6072 use std::os::unix::fs::PermissionsExt;
6073 Ok(res!(std::fs::metadata(p), IO, File).permissions().mode() & 0o7777)
6074 }
6075
6076 /// The whole point, in one test: a real command, really fenced.
6077 ///
6078 /// Both halves are here because either alone proves nothing. The same
6079 /// `cat`, on the same file, unfenced, must succeed -- otherwise the fenced
6080 /// refusal might be a missing file, a permission bit or a launcher that
6081 /// refuses everything. And a file *inside* the fence must still be read,
6082 /// otherwise the fence is simply a wall.
6083 #[tokio::test]
6084 async fn the_launcher_fences_a_real_command() -> Outcome<()> {
6085 let base = res!(fixture("launcher"));
6086 let ws = base.join("ws");
6087 let outside = base.join("outside/other.txt");
6088 let inside = ws.join("inside.txt");
6089
6090 // Broken first: unfenced, this reads the file outside the workspace.
6091 let bare = res!(std::process::Command::new("/bin/cat").arg(&outside).output());
6092 assert!(bare.status.success(),
6093 "the control run could not read {} even unfenced", outside.display());
6094 assert_eq!("other", String::from_utf8_lossy(&bare.stdout));
6095
6096 let fence = FenceSpec {
6097 rw: vec![fmt!("{}", ws.display())],
6098 ro: Vec::new(),
6099 deny: Vec::new(),
6100 net: false,
6101 };
6102 let at = |id: &str, target: &Path| -> Req {
6103 Req::Exec {
6104 id: fmt!("{}", id),
6105 argv: vec![fmt!("/bin/cat"), fmt!("{}", target.display())],
6106 cwd: fmt!("{}", ws.display()),
6107 env: Vec::new(),
6108 stdin: None,
6109 timeout_ms: 10_000,
6110 capture: Capture::Both,
6111 fence: fence.clone(),
6112 toolkits: Vec::new(),
6113 }
6114 };
6115
6116 // Inside the fence: it works, so the fence is a fence and not a wall.
6117 let rs = res!(run(at("in", &inside)).await);
6118 let (exit, ..) = match ended(&rs) {
6119 Some(e) => e,
6120 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6121 };
6122 assert_eq!(exit, 0, "a granted file was refused: {}", text_of(&rs, Stream::Err));
6123 assert_eq!(text_of(&rs, Stream::Out), "inside");
6124
6125 // Outside it: the same program, the same launcher, refused by the
6126 // kernel. This is the sentence the product's claim rests on.
6127 let rs = res!(run(at("out", &outside)).await);
6128 let (exit, ..) = match ended(&rs) {
6129 Some(e) => e,
6130 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6131 };
6132 assert_ne!(exit, 0,
6133 "a fenced command read a file outside its roots: {:?}",
6134 text_of(&rs, Stream::Out));
6135 assert_eq!(text_of(&rs, Stream::Out), "",
6136 "the contents came back anyway");
6137 Ok(())
6138 }
6139
6140 /// The command cannot write outside its fence either, and can inside it.
6141 #[tokio::test]
6142 async fn the_launcher_fences_writing_too() -> Outcome<()> {
6143 let base = res!(fixture("launcher-write"));
6144 let ws = base.join("ws");
6145 let fence = FenceSpec {
6146 rw: vec![fmt!("{}", ws.display())],
6147 ro: Vec::new(),
6148 deny: Vec::new(),
6149 net: false,
6150 };
6151 // `tee` writes the file it is named after and copies to stdout, which
6152 // makes it a writer with no shell redirection anywhere near it.
6153 let write_to = |id: &str, target: &Path| -> Req {
6154 Req::Exec {
6155 id: fmt!("{}", id),
6156 argv: vec![fmt!("/usr/bin/tee"), fmt!("{}", target.display())],
6157 cwd: fmt!("{}", ws.display()),
6158 env: Vec::new(),
6159 stdin: Some(fmt!("written")),
6160 timeout_ms: 10_000,
6161 capture: Capture::Both,
6162 fence: fence.clone(),
6163 toolkits: Vec::new(),
6164 }
6165 };
6166
6167 let outside = base.join("outside/planted.txt");
6168 let inside = ws.join("made.txt");
6169
6170 let rs = res!(run(write_to("w-in", &inside)).await);
6171 assert_eq!(text_of(&rs, Stream::Out), "written");
6172 assert_eq!("written", res!(std::fs::read_to_string(&inside)));
6173
6174 let rs = res!(run(write_to("w-out", &outside)).await);
6175 let (exit, ..) = match ended(&rs) {
6176 Some(e) => e,
6177 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6178 };
6179 assert_ne!(exit, 0, "a fenced command wrote outside its roots");
6180 assert!(!outside.exists(), "the file was created outside the fence");
6181 Ok(())
6182 }
6183
6184 /// The prerequisites named on one line of a cargo dep-info file.
6185 ///
6186 /// Make's escaping, which is what the format is: a backslash makes the
6187 /// character after it ordinary, so a path containing a space survives being
6188 /// split on whitespace.
6189 ///
6190 /// # Arguments
6191 /// * `list` - Everything after the colon on a dep-info line.
6192 fn dep_sources(list: &str) -> Vec<PathBuf> {
6193 let mut out = Vec::new();
6194 let mut cur = String::new();
6195 let mut esc = false;
6196 for c in list.chars() {
6197 if esc {
6198 cur.push(c);
6199 esc = false;
6200 } else if c == '\\' {
6201 esc = true;
6202 } else if c.is_whitespace() {
6203 if !cur.is_empty() {
6204 out.push(PathBuf::from(std::mem::take(&mut cur)));
6205 }
6206 } else {
6207 cur.push(c);
6208 }
6209 }
6210 if !cur.is_empty() {
6211 out.push(PathBuf::from(cur));
6212 }
6213 out
6214 }
6215
6216 /// The shipping binary, refused unless it was built from the source that is
6217 /// in the tree now.
6218 ///
6219 /// `cargo test` never builds `daimond-hand`: not `--lib`, and not the whole
6220 /// suite either, since this crate has no integration test that would make
6221 /// cargo produce the binary for one. Only a separate `cargo build` refreshes
6222 /// it, so a test that execs whatever is lying in `target/debug` is measuring
6223 /// a binary of unknown vintage. That cuts both ways, and the second way is
6224 /// the dangerous one: a stale binary can fail against source that is fine,
6225 /// and it can equally pass against source whose fence has since been broken.
6226 /// `dev/verify_scope.mjs` deletes an inherited `CARGO_TARGET_DIR` for the
6227 /// same reason -- one there once made a security test pass against a binary
6228 /// from before the fix.
6229 ///
6230 /// The oracle is cargo's own dep-info file, `daimond-hand.d`, written beside
6231 /// the binary: it names every source that went into it, this crate's and
6232 /// every fe2o3 crate's, so a change anywhere below the launcher counts.
6233 /// Where the binary is missing, where that record is missing or does not
6234 /// describe it, or where any source it names is newer than the binary, this
6235 /// refuses and says what to run. It does not skip: a fence test that cannot
6236 /// say which code it measured must not report success.
6237 fn shipping_hand() -> Outcome<PathBuf> {
6238 /// What a caller has to run to make this test meaningful again.
6239 const REBUILD: &str = "cargo build --manifest-path hand/Cargo.toml";
6240
6241 let exe = res!(std::env::current_exe().map_err(|e| err!(e,
6242 "The launcher tests need to know their own binary."; Test, IO)));
6243 let dir = match exe.parent().and_then(|p| p.parent()) {
6244 Some(d) => d.to_path_buf(),
6245 None => return Err(err!(
6246 "The test binary is not where cargo puts one."; Test, Path)),
6247 };
6248 let hand = dir.join("daimond-hand");
6249 let record = dir.join("daimond-hand.d");
6250
6251 let built = match std::fs::metadata(&hand).and_then(|md| md.modified()) {
6252 Ok(t) => t,
6253 Err(e) => return Err(err!(e,
6254 "The shipping launcher cannot be tested, because {} is not there to \
6255 test: `cargo test` does not build that binary, neither with `--lib` \
6256 nor as the whole suite, so run `{}` and test again.",
6257 hand.display(), REBUILD; Test, Missing)),
6258 };
6259 let listed = match std::fs::read_to_string(&record) {
6260 Ok(s) => s,
6261 Err(e) => return Err(err!(e,
6262 "The shipping launcher cannot be tested, because {} has no dep-info \
6263 file at {}, so there is no record of what went into it and its \
6264 vintage cannot be established: run `{}` and test again.",
6265 hand.display(), record.display(), REBUILD; Test, Missing)),
6266 };
6267
6268 // Cargo writes one line per artefact, `<artefact>: <source> <source> ...`.
6269 let mut described = false;
6270 let mut newest: Option<(PathBuf, std::time::SystemTime)> = None;
6271 for line in listed.lines() {
6272 let (target, sources) = match line.split_once(':') {
6273 Some(pair) => pair,
6274 None => continue,
6275 };
6276 if Path::new(target.trim()) != hand {
6277 continue;
6278 }
6279 described = true;
6280 for src in dep_sources(sources) {
6281 let at = match std::fs::metadata(&src).and_then(|md| md.modified()) {
6282 Ok(t) => t,
6283 Err(e) => return Err(err!(e,
6284 "The shipping launcher cannot be tested, because {} was built \
6285 from {}, which can no longer be read, so what is inside the \
6286 binary cannot be established: run `{}` and test again.",
6287 hand.display(), src.display(), REBUILD; Test, Missing)),
6288 };
6289 if newest.as_ref().map_or(true, |(_, t)| at > *t) {
6290 newest = Some((src, at));
6291 }
6292 }
6293 }
6294 if !described {
6295 return Err(err!(
6296 "The shipping launcher cannot be tested, because {} says nothing about \
6297 {}, so the binary is not the one this build produced: run `{}` and test \
6298 again.",
6299 record.display(), hand.display(), REBUILD; Test, Mismatch));
6300 }
6301 if let Some((src, at)) = newest {
6302 if at > built {
6303 let by = match at.duration_since(built) {
6304 Ok(d) => d.as_secs(),
6305 Err(_) => 0,
6306 };
6307 return Err(err!(
6308 "The shipping launcher test would have measured a stale binary, which \
6309 proves nothing about the fence in either direction: {} was last changed \
6310 {} second(s) after {} was linked, so that binary is not this source -- \
6311 run `{}` and test again.",
6312 src.display(), by, hand.display(), REBUILD; Test, Mismatch));
6313 }
6314 }
6315 Ok(hand)
6316 }
6317
6318 /// The same thing again, through the binary that actually ships.
6319 ///
6320 /// [`Launcher::SelfExe`] cannot be exercised from a test binary, because
6321 /// `/proc/self/exe` there is libtest. This is as close as a test can stand
6322 /// to it: the real `daimond-hand`, the real [`LAUNCH_ARG`], the real
6323 /// dispatch in `main`. If that arm is ever moved after something that starts
6324 /// a runtime, or removed, this test fails and the other one does not.
6325 ///
6326 /// The binary is reached through [`shipping_hand`], which refuses rather
6327 /// than skips where it is absent or older than the source it was built from.
6328 #[tokio::test]
6329 async fn the_shipping_launcher_fences_a_real_command() -> Outcome<()> {
6330 let hand = res!(shipping_hand());
6331
6332 let base = res!(fixture("launcher-shipping"));
6333 let ws = base.join("ws");
6334 let outside = base.join("outside/other.txt");
6335 let runner = Runner::with_launcher(Launcher::Explicit {
6336 prog: hand,
6337 args: vec![fmt!("{}", LAUNCH_ARG)],
6338 env: Vec::new(),
6339 });
6340 let at = |id: &str, target: &Path| -> Req {
6341 Req::Exec {
6342 id: fmt!("{}", id),
6343 argv: vec![fmt!("/bin/cat"), fmt!("{}", target.display())],
6344 cwd: fmt!("{}", ws.display()),
6345 env: Vec::new(),
6346 stdin: None,
6347 timeout_ms: 10_000,
6348 capture: Capture::Both,
6349 fence: FenceSpec {
6350 rw: vec![fmt!("{}", ws.display())],
6351 ro: Vec::new(),
6352 deny: Vec::new(),
6353 net: false,
6354 },
6355 toolkits: Vec::new(),
6356 }
6357 };
6358
6359 // No harness in the way this time, so the output is exact.
6360 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
6361 res!(runner.spawn(at("ship-in", &ws.join("inside.txt")), tx).await);
6362 let rs = collect(&mut rx).await;
6363 let mut said = String::new();
6364 for r in &rs {
6365 if let Resp::Chunk { stream: Stream::Out, data, .. } = r {
6366 said.push_str(data);
6367 }
6368 }
6369 assert_eq!(said, "inside", "the shipping launcher garbled a granted read");
6370
6371 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
6372 res!(runner.spawn(at("ship-out", &outside), tx).await);
6373 let rs = collect(&mut rx).await;
6374 let (exit, ..) = match ended(&rs) {
6375 Some(e) => e,
6376 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6377 };
6378 assert_ne!(exit, 0,
6379 "the shipping launcher ran a command that read outside its fence");
6380 assert_ne!(exit, EXIT_FENCE_FAILED,
6381 "the fence could not be applied at all, so this proved nothing: {}",
6382 text_of(&rs, Stream::Err));
6383 Ok(())
6384 }
6385
6386 // ── The credential scanner a fence used to switch off in silence ────────
6387
6388 /// **A fenced commit that would run without the user's `pre-commit` hook is refused,
6389 /// and one that can reach it is scanned.**
6390 ///
6391 /// Against a real git behind a real Landlock fence through the shipping binary, because
6392 /// every layer between the configuration and the hook is one that could drop it.
6393 ///
6394 /// # What is silent here and what is not, measured rather than assumed
6395 ///
6396 /// Two of the three ways a fenced commit loses the user's hooks are LOUD on git 2.53,
6397 /// and this test does not claim otherwise. A fence that cannot reach `~/.gitconfig`
6398 /// gives `fatal: unknown error occurred while reading the configuration files`; a fence
6399 /// that reaches the configuration but not the hooks directory gives `fatal: cannot exec
6400 /// '.../pre-commit': Permission denied`, because Landlock does not restrict `access`, so
6401 /// git finds the hook and dies on `execve`. Neither commits anything. They are refused
6402 /// here all the same -- ahead of the command, with a sentence naming the grant that
6403 /// would fix it -- because those two messages describe a broken repository rather than a
6404 /// missing permission, and because "loud" is a property of one git and one kernel: git's
6405 /// own `find_hook` returns NULL on an `EACCES` from `access` and merely warns.
6406 ///
6407 /// The third is genuinely silent, reproduces here, and is the one this test RUNS: a
6408 /// repository whose own `.git/config` sets `core.hooksPath` at a directory of its own.
6409 /// `.git/config` is inside the fence and a command may write it, so a turn can point the
6410 /// hooks at an empty folder it just made -- and the commit then succeeds, exit 0, no
6411 /// message, the example key in the repository, scanner never run. That command
6412 /// is then handed to [`git_hooks_refusal`], which is what now stops it running at all.
6413 ///
6414 /// The last part is the same fence with the Git toolchain granted and no override: the
6415 /// hooks directory goes into the fence, the hook runs, the commit fails with the hook's
6416 /// own words, and nothing is committed. The absence of the commit is the property; an
6417 /// exit code is not, since a hook that refuses after the commit exists is not a scanner.
6418 ///
6419 /// The value the fixture stages is AWS's own published example access key, which is a
6420 /// credential to nothing. It is that one because it is what the machine's real scanner
6421 /// looks for, and a fixture the scanner would ignore would prove nothing.
6422 #[tokio::test]
6423 async fn a_fenced_commit_without_the_users_hooks_is_refused() -> Outcome<()> {
6424 let hand = res!(shipping_hand());
6425 let base = res!(fixture("git-hooks"));
6426 let hooks = base.join("hooks");
6427 let home = base.join("home");
6428 let silent = base.join("ws/silent");
6429 let hooked = base.join("ws/hooked");
6430 for d in [&hooks, &home, &silent, &hooked] {
6431 res!(std::fs::create_dir_all(d));
6432 }
6433
6434 // allowlist secret
6435 let key = "AKIAIOSFODNN7EXAMPLE";
6436 // The scanner, in miniature: it refuses a commit that stages the example key.
6437 let hook = hooks.join("pre-commit");
6438 res!(std::fs::write(&hook, fmt!(
6439 "#!/bin/sh\n\
6440 if grep -rq {} .; then\n\
6441 \techo 'pre-commit: a credential is staged' >&2\n\
6442 \texit 1\n\
6443 fi\n\
6444 exit 0\n", key)));
6445 res!(std::fs::set_permissions(&hook,
6446 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o755)));
6447
6448 // The user's own global configuration, which is where `core.hooksPath` lives on the
6449 // machine this was written on. Reached through `GIT_CONFIG_GLOBAL` so that nothing
6450 // here touches the real one.
6451 let cfg = home.join(".gitconfig");
6452 res!(std::fs::write(&cfg, fmt!(
6453 "[user]\n\tname = Fixture\n\temail = fixture@example.invalid\n\
6454 [core]\n\thooksPath = {}\n", hooks.display())));
6455 // What the hand reads the user's configuration WITH. In the hand this is empty and
6456 // the real configuration answers; here it names the fixture.
6457 let readenv: Vec<(String, String)> = vec![
6458 (fmt!("GIT_CONFIG_GLOBAL"), fmt!("{}", cfg.display())),
6459 ];
6460 // What a command runs with WITHOUT the Git toolchain, which is the ordinary case:
6461 // the app sets `HOME` for that toolkit and for no other, so a fenced git has no
6462 // home to find a global configuration in and never learns `core.hooksPath` exists.
6463 // It does not complain -- git skips a global configuration it cannot NAME in
6464 // silence, and fails loudly only over one it was told to read and could not.
6465 let barenv: Vec<(String, String)> = vec![
6466 (fmt!("PATH"), fmt!("/usr/bin:/bin")),
6467 ];
6468 // And with it: `HOME` set, which is the whole of how git finds the user's own
6469 // configuration.
6470 let cmdenv: Vec<(String, String)> = vec![
6471 (fmt!("HOME"), fmt!("{}", home.display())),
6472 (fmt!("PATH"), fmt!("/usr/bin:/bin")),
6473 ];
6474
6475 // Setting a repository up is done OUTSIDE the fence and stops short of a commit, so
6476 // the only commits in this test are the fenced ones under test.
6477 let git = |at: &Path, args: &[&str]| -> Outcome<()> {
6478 let mut c = std::process::Command::new("git");
6479 c.args(args).current_dir(at);
6480 for (k, v) in &cmdenv { c.env(k, v); }
6481 let out = res!(c.output());
6482 if !out.status.success() {
6483 return Err(err!("git {:?} failed: {}", args,
6484 String::from_utf8_lossy(&out.stderr); Test, IO));
6485 }
6486 Ok(())
6487 };
6488 let leak = fmt!("AWS_ACCESS_KEY_ID={}\n", key);
6489 for repo in [&silent, &hooked] {
6490 res!(git(repo, &["init", "-q", "-b", "main"]));
6491 // A real clone carries its own identity, which is why a fenced commit that can
6492 // read nothing global still had everything it needed to succeed.
6493 res!(git(repo, &["config", "user.name", "Fixture"]));
6494 res!(git(repo, &["config", "user.email", "fixture@example.invalid"]));
6495 res!(std::fs::write(repo.join("config.env"), &leak));
6496 res!(git(repo, &["add", "config.env"]));
6497 }
6498
6499 let runner = Runner::with_launcher(Launcher::Explicit {
6500 prog: hand,
6501 args: vec![fmt!("{}", LAUNCH_ARG)],
6502 env: Vec::new(),
6503 });
6504 let commit = |id: &str, at: &Path, fence: &FenceSpec, kits: &[String],
6505 env: &[(String, String)]| Req::Exec
6506 {
6507 id: fmt!("{}", id),
6508 argv: vec![fmt!("/usr/bin/git"), fmt!("commit"), fmt!("-m"), fmt!("add config")],
6509 cwd: fmt!("{}", at.display()),
6510 env: env.to_vec(),
6511 stdin: None,
6512 timeout_ms: 30_000,
6513 capture: Capture::Both,
6514 fence: fence.clone(),
6515 toolkits: kits.to_vec(),
6516 };
6517 let commits_in = |at: &Path| -> Outcome<String> {
6518 let out = res!(std::process::Command::new("git")
6519 .args(["log", "--oneline"]).current_dir(at).output());
6520 Ok(fmt!("{}", String::from_utf8_lossy(&out.stdout).trim()))
6521 };
6522
6523 // ── The silence, run rather than described ──────────────────────
6524 //
6525 // Everything granted: the Git toolchain, so `~/.gitconfig` is readable, and the
6526 // hooks directory it names. The one thing wrong is inside the fence -- the
6527 // repository's own configuration, which a command may write.
6528 let kits = vec![fmt!("git")];
6529 let mut spec = FenceSpec {
6530 rw: vec![fmt!("{}", base.join("ws").display())],
6531 ro: vec![fmt!("{}", home.display())],
6532 deny: Vec::new(),
6533 net: false,
6534 };
6535 let granted = grant_git_hooks(&mut spec, &kits, &readenv);
6536 assert_eq!(granted.as_deref(), Some(hooks.as_path()),
6537 "the user's own hooks directory was not added to the fence: {:?}", spec.ro);
6538
6539 res!(std::fs::create_dir_all(silent.join("mine")));
6540 res!(git(&silent, &["config", "core.hooksPath",
6541 &fmt!("{}", silent.join("mine").display())]));
6542 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
6543 res!(runner.spawn(commit("silent", &silent, &spec, &kits, &cmdenv), tx).await);
6544 let rs = collect(&mut rx).await;
6545 let (exit, ..) = match ended(&rs) {
6546 Some(e) => e,
6547 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6548 };
6549 assert_ne!(exit, EXIT_FENCE_FAILED,
6550 "the fence could not be applied, so this proved nothing: {}",
6551 text_of(&rs, Stream::Err));
6552 assert_eq!(exit, 0,
6553 "the fenced commit did not succeed, so the silence this test is about did not \
6554 happen here and the refusal below would be measuring nothing: {}",
6555 text_of(&rs, Stream::Err));
6556 assert!(!res!(commits_in(&silent)).is_empty(),
6557 "nothing was committed, so there is no silent commit to refuse");
6558 assert_eq!(text_of(&rs, Stream::Err), "",
6559 "git said something about the hook it did not run, so this was never silent");
6560
6561 // And that is the command the hand now refuses, before it runs, naming the
6562 // directory the repository chose and the one the user configured.
6563 let plan = res!(detected_fence().plan(&spec, &Unfenced::Refuse));
6564 let said = match git_hooks_refusal(&plan,
6565 &[fmt!("/usr/bin/git"), fmt!("commit"), fmt!("-m"), fmt!("x")],
6566 &fmt!("{}", silent.display()), &readenv)
6567 {
6568 Some(s) => s,
6569 None => return Err(err!(
6570 "the command that just committed the example key in silence was \
6571 allowed"; Test, Missing)),
6572 };
6573 assert!(said.starts_with("Refused: "), "the refusal did not read as one: {}", said);
6574 assert!(said.contains("mine"),
6575 "the refusal did not name the directory the repository chose: {}", said);
6576 assert!(said.contains(&fmt!("{}", hooks.display())),
6577 "the refusal did not name the directory the user configured: {}", said);
6578
6579 // The two loud ones are refused as well, ahead of the command and with a better
6580 // sentence than git's own. A fence reaching neither the configuration nor the hooks
6581 // is the ordinary case: git needs no toolkit to commit, and the app sets `HOME` for
6582 // that toolkit alone.
6583 let bare = FenceSpec {
6584 rw: vec![fmt!("{}", base.join("ws").display())],
6585 ro: Vec::new(),
6586 deny: Vec::new(),
6587 net: false,
6588 };
6589 let noconf = res!(detected_fence().plan(&bare, &Unfenced::Refuse));
6590 let said = match git_hooks_refusal(&noconf,
6591 &[fmt!("/usr/bin/git"), fmt!("commit")], &fmt!("{}", silent.display()), &readenv)
6592 {
6593 Some(s) => s,
6594 None => return Err(err!(
6595 "a commit whose fence cannot reach the user's git configuration was \
6596 allowed"; Test, Missing)),
6597 };
6598 assert!(said.contains(&fmt!("{}", cfg.display())),
6599 "the refusal did not name the configuration git could not read: {}", said);
6600 assert!(said.contains("Git toolchain"),
6601 "the refusal did not say what would fix it: {}", said);
6602 // The configuration reachable and the hooks directory not.
6603 let halfway = FenceSpec {
6604 rw: vec![fmt!("{}", base.join("ws").display())],
6605 ro: vec![fmt!("{}", home.display())],
6606 deny: Vec::new(),
6607 net: false,
6608 };
6609 let nohooks = res!(detected_fence().plan(&halfway, &Unfenced::Refuse));
6610 let said = match git_hooks_refusal(&nohooks,
6611 &[fmt!("/usr/bin/git"), fmt!("commit")], &fmt!("{}", hooked.display()), &readenv)
6612 {
6613 Some(s) => s,
6614 None => return Err(err!(
6615 "a commit whose fence cannot reach the hooks directory was allowed";
6616 Test, Missing)),
6617 };
6618 assert!(said.contains(&fmt!("{}", hooks.display())),
6619 "the refusal did not name the hooks directory: {}", said);
6620
6621 // And one of the loud ones is RUN, once, so the paragraph above is a measurement
6622 // rather than a claim: with nothing granted the same command dies inside git and
6623 // leaves the repository where it was.
6624 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
6625 res!(runner.spawn(commit("loud", &hooked, &bare, &[], &barenv), tx).await);
6626 let rs = collect(&mut rx).await;
6627 let (exit, ..) = match ended(&rs) {
6628 Some(e) => e,
6629 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6630 };
6631 assert_ne!(exit, 0,
6632 "a commit whose fence reaches no git configuration succeeded: {}",
6633 text_of(&rs, Stream::Err));
6634 assert_eq!(res!(commits_in(&hooked)), "",
6635 "it committed anyway");
6636
6637 // A command that runs no hook is left alone: this refuses a commit, not git.
6638 for harmless in [vec![fmt!("git"), fmt!("status")], vec![fmt!("git"), fmt!("log")],
6639 vec![fmt!("git"), fmt!("push")], vec![fmt!("cargo"), fmt!("test")]]
6640 {
6641 assert!(git_hooks_refusal(&noconf, &harmless,
6642 &fmt!("{}", silent.display()), &readenv).is_none(),
6643 "{:?} was refused for a hook it does not run", harmless);
6644 }
6645 // `git -C <dir> commit` is still a commit: the verb is not always argv[1].
6646 assert!(git_hooks_refusal(&noconf,
6647 &[fmt!("git"), fmt!("-C"), fmt!("elsewhere"), fmt!("commit")],
6648 &fmt!("{}", silent.display()), &readenv).is_some(),
6649 "a commit spelled with -C was not seen as one");
6650
6651 // ── The same fence, and a repository that leaves the hooks alone ─
6652 assert!(git_hooks_refusal(&plan,
6653 &[fmt!("/usr/bin/git"), fmt!("commit")], &fmt!("{}", hooked.display()), &readenv)
6654 .is_none(),
6655 "a commit that can reach the user's hooks was refused anyway");
6656
6657 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
6658 res!(runner.spawn(commit("hooked", &hooked, &spec, &kits, &cmdenv), tx).await);
6659 let rs = collect(&mut rx).await;
6660 let (exit, ..) = match ended(&rs) {
6661 Some(e) => e,
6662 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
6663 };
6664 let err = text_of(&rs, Stream::Err);
6665 assert_ne!(exit, EXIT_FENCE_FAILED,
6666 "the fence could not be applied, so this proved nothing: {}", err);
6667 assert!(err.contains("a credential is staged"),
6668 "the hook the user configured did not run: exit {}, stderr {:?}", exit, err);
6669 assert_eq!(res!(commits_in(&hooked)), "",
6670 "the credential was committed even though the hook ran");
6671
6672 Ok(())
6673 }
6674
6675 /// The plan reaches the launcher and is not visible to the command.
6676 ///
6677 /// The command's own view of itself is `/proc/self/cmdline` and
6678 /// `/proc/self/environ`; neither may carry the fence. `/proc` is outside
6679 /// every fence this hand builds, so the test grants it read-only on purpose
6680 /// -- proving the property in the one configuration where the command could
6681 /// possibly look.
6682 #[tokio::test]
6683 async fn the_command_cannot_read_its_own_fence() -> Outcome<()> {
6684 let base = res!(fixture("launcher-opaque"));
6685 let ws = base.join("ws");
6686 let req = Req::Exec {
6687 id: fmt!("opaque"),
6688 argv: vec![fmt!("/bin/cat"), fmt!("/proc/self/cmdline")],
6689 cwd: fmt!("{}", ws.display()),
6690 env: Vec::new(),
6691 stdin: None,
6692 timeout_ms: 10_000,
6693 capture: Capture::Both,
6694 fence: FenceSpec {
6695 rw: vec![fmt!("{}", ws.display())],
6696 ro: vec![fmt!("/proc")],
6697 deny: Vec::new(),
6698 net: false,
6699 },
6700 toolkits: Vec::new(),
6701 };
6702 let rs = res!(run(req).await);
6703 let said = text_of(&rs, Stream::Out);
6704 // The program really is `cat`, although not by the name it was asked
6705 // for: this machine's /bin/cat resolves into a multi-call binary, and
6706 // `argv[0]` is the resolved path because that is what actually ran.
6707 assert!(said.contains("cat\0/proc/self/cmdline"),
6708 "the command did not see itself: {:?}", said);
6709 assert!(!said.contains(LAUNCH_ARG),
6710 "the launcher's own argument survived into the command: {:?}", said);
6711 assert!(!said.contains(&fmt!("{}", ws.display())),
6712 "a fence path was readable in the command's own argv: {:?}", said);
6713
6714 // And the same question of the environment, which is the other half of
6715 // what `/proc/self` will answer.
6716 let req = Req::Exec {
6717 id: fmt!("opaque-env"),
6718 argv: vec![fmt!("/bin/cat"), fmt!("/proc/self/environ")],
6719 cwd: fmt!("{}", ws.display()),
6720 env: vec![(fmt!("MARKER"), fmt!("kept"))],
6721 stdin: None,
6722 timeout_ms: 10_000,
6723 capture: Capture::Both,
6724 fence: FenceSpec {
6725 rw: vec![fmt!("{}", ws.display())],
6726 ro: vec![fmt!("/proc")],
6727 deny: Vec::new(),
6728 net: false,
6729 },
6730 toolkits: Vec::new(),
6731 };
6732 let rs = res!(run(req).await);
6733 let said = text_of(&rs, Stream::Out);
6734 // The one path of its own fence a command is told: where to write
6735 // temporary files. It has to be told, or it cannot use it -- and it
6736 // names a directory made for this run alone, not a Diamond's workspace.
6737 let scratch = match said.split('\0').find(|v| v.starts_with("TMPDIR=")) {
6738 Some(v) => fmt!("{}", &v["TMPDIR=".len()..]),
6739 None => return Err(err!(
6740 "The command was not told where to write: {:?}", said; Test, Missing)),
6741 };
6742 let mut pairs = said.split('\0')
6743 .filter(|v| !v.is_empty())
6744 .map(|v| fmt!("{}", v))
6745 .collect::<Vec<_>>();
6746 pairs.sort();
6747 let mut want = vec![fmt!("MARKER=kept")];
6748 for name in TMP_VARS {
6749 want.push(fmt!("{}={}", name, scratch));
6750 }
6751 // The two the hand fills in where the request named neither. Neither
6752 // says anything about the fence: `HOME` is the user's own home and
6753 // `PATH` is the fixed system list, and the assertion below is what holds
6754 // that.
6755 for name in ENV_DEFAULTED {
6756 if let Some(v) = default_env(name) {
6757 want.push(fmt!("{}={}", name, v));
6758 }
6759 }
6760 want.sort();
6761 assert_eq!(pairs, want,
6762 "the command's environment is not exactly what it was given");
6763 assert!(!said.contains(&fmt!("{}", ws.display())),
6764 "a fence path was readable in the command's own environment: {:?}", said);
6765 Ok(())
6766 }
6767
6768 // ── The file door, proved through the kernel ────────────────────────────
6769 //
6770 // Every test below drives the SHIPPING launcher, not a stub, because the claim being
6771 // made is about what the kernel does to a real child. A test that called `do_file`
6772 // directly would prove that the code opens files, which nobody doubted; what is in
6773 // question is whether a file tool reaching this machine is fenced exactly as a command
6774 // is, and only a fenced process can answer that.
6775
6776 /// The answer one file op gives, or the refusal, driven through the real launcher.
6777 #[cfg(unix)]
6778 async fn filed(files: &Files, ws: &Path, op: FileOp) -> Outcome<(bool, String)> {
6779 let req = Req::File {
6780 id: fmt!("f-{}", op.word()),
6781 op,
6782 cwd: fmt!("{}", ws.display()),
6783 fence: FenceSpec {
6784 rw: vec![fmt!("{}", ws.display())],
6785 ro: Vec::new(),
6786 deny: Vec::new(),
6787 net: false,
6788 },
6789 toolkits: Vec::new(),
6790 };
6791 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(16);
6792 res!(files.apply(req, tx).await);
6793 let rs = collect(&mut rx).await;
6794 for r in &rs {
6795 match r {
6796 Resp::Filed { ok, text, .. } => return Ok((*ok, text.clone())),
6797 Resp::Refused { reason, .. } => return Ok((false, reason.clone())),
6798 _ => (),
6799 }
6800 }
6801 Err(err!("Nothing came back from a file request: {:?}", rs; Test, Missing))
6802 }
6803
6804 /// A door onto the real launcher.
6805 #[cfg(unix)]
6806 fn file_door() -> Outcome<Files> {
6807 Ok(Files::with_launcher(Launcher::Explicit {
6808 prog: res!(shipping_hand()),
6809 args: vec![fmt!("{}", LAUNCH_ARG)],
6810 env: Vec::new(),
6811 }))
6812 }
6813
6814 /// A file inside the fence is read, changed and read back, with no command anywhere.
6815 ///
6816 /// This is the whole of what `dev/BLOCKERS.md` B2 says is missing, asserted at the layer
6817 /// that would have to provide it: one exact-string replacement, applied by the hand,
6818 /// with nothing quoted through a shell and nothing to escape.
6819 #[cfg(unix)]
6820 #[tokio::test]
6821 async fn a_file_inside_the_fence_is_edited_without_a_command() -> Outcome<()> {
6822 let base = res!(fixture("file-door-edit"));
6823 let ws = base.join("ws");
6824 let files = res!(file_door());
6825
6826 // The character that cost 71 calls, in the string being written, unescaped.
6827 let target = ws.join("fr.js");
6828 res!(std::fs::write(&target, "a\n'k': 'old',\nb\n"));
6829
6830 let (ok, said) = res!(filed(&files, &ws, FileOp::Edit {
6831 path: fmt!("{}", target.display()),
6832 old: fmt!("'k': 'old',"),
6833 new: fmt!("'k': 'Enregistrer l\u{2019}\u{e9}crit dans {{place}}.',"),
6834 }).await);
6835 assert!(ok, "an edit inside the fence was refused: {}", said);
6836 let after = res!(std::fs::read_to_string(&target).map_err(|e| err!(e,
6837 "the edited file could not be read back"; Test, IO)));
6838 assert!(after.contains("Enregistrer l\u{2019}\u{e9}crit dans {place}."),
6839 "the edit did not land: {:?}", after);
6840
6841 // And the read door answers about the same file, so the two halves agree.
6842 let (ok, text) = res!(filed(&files, &ws, FileOp::Read {
6843 path: fmt!("{}", target.display()),
6844 offset: 2,
6845 limit: 1,
6846 }).await);
6847 assert!(ok, "a read inside the fence was refused: {}", text);
6848 let (head, body) = match text.split_once('\n') {
6849 Some((a, b)) => (a.to_string(), b.to_string()),
6850 None => return Err(err!("A read answered without its numbers: {:?}",
6851 text; Test, Invalid)),
6852 };
6853 let cols: Vec<&str> = head.split('\t').collect();
6854 assert_eq!(cols.len(), 3,
6855 "a read must open with the file's lines, its bytes and the lines sent: {:?}", head);
6856 assert_eq!(cols[0], "3", "the read miscounted the file's lines");
6857 assert_eq!(cols[2], "1", "the read did not say how many lines it was sending");
6858 assert!(body.contains("Enregistrer"), "the read returned the wrong line: {:?}", body);
6859 Ok(())
6860 }
6861
6862 // ── The two answers a big file gets ─────────────────────────────────────
6863 //
6864 // The fixture is this repository's own `src/tools.rs`, and it is the fixture because it is
6865 // what both blockers were measured on: 1.2 MB and 22,000-odd lines, larger than the read
6866 // frame twice over and three times the whole search answer. A file made up for the
6867 // occasion would be the same size only until someone changed the constant.
6868
6869 /// The repository this crate sits in, which holds the file both tests below are about.
6870 #[cfg(unix)]
6871 fn repo_root() -> PathBuf {
6872 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
6873 }
6874
6875 /// **A read of a file bigger than one frame says how big the FILE is.**
6876 ///
6877 /// `dev/BLOCKERS.md` B18, at the end that knows the answer: the hand held the whole file
6878 /// and the page did not, so a count taken after the cut was a count of the cut.
6879 /// `src/tools.rs` came back as *"lines 1-200 of 9304 (524403 bytes)"* -- 524,403 being
6880 /// [`FILE_TEXT_MAX`] and a note, not a file -- and the offset the answer named to continue
6881 /// from was twelve thousand lines short of the end.
6882 #[cfg(unix)]
6883 #[tokio::test]
6884 async fn a_read_of_a_file_bigger_than_one_frame_says_how_big_the_file_is() -> Outcome<()> {
6885 let root = repo_root();
6886 let big = root.join("src/tools.rs");
6887 let text = res!(std::fs::read_to_string(&big).map_err(|e| err!(e,
6888 "this crate's own repository must hold src/tools.rs"; Test, IO)));
6889 assert!(text.len() > FILE_TEXT_MAX * 2,
6890 "the fixture must be bigger than one frame twice over: {} bytes", text.len());
6891 let lines = text.lines().count();
6892 let files = res!(file_door());
6893
6894 let (ok, said) = res!(filed(&files, &root, FileOp::Read {
6895 path: fmt!("{}", big.display()),
6896 offset: 1,
6897 limit: 2_000,
6898 }).await);
6899 assert!(ok, "a read of this repository's own source was refused: {}", said);
6900 let (head, body) = match said.split_once('\n') {
6901 Some(p) => p,
6902 None => return Err(err!("A read answered without its numbers"; Test, Invalid)),
6903 };
6904 let cols: Vec<&str> = head.split('\t').collect();
6905 assert_eq!(cols.len(), 3, "a read must open with three numbers: {:?}", head);
6906 assert_eq!(cols[0], fmt!("{}", lines),
6907 "the read counted the answer's lines rather than the file's: {:?}", head);
6908 assert_eq!(cols[1], fmt!("{}", text.len()),
6909 "the read gave the answer's length as the file's: {:?}", head);
6910 assert_eq!(cols[2], "2000", "2,000 lines were asked for: {:?}", head);
6911 // Split INCLUSIVE, because every line the hand sends ends with its own newline: that is
6912 // what makes a window whose last line is blank countable at all.
6913 assert_eq!(body.split_inclusive('\n').count(), 2_000,
6914 "the answer does not hold the lines it says it holds");
6915
6916 // AND THE END OF THE FILE IS REACHABLE, which is the half that cost the run. Every line
6917 // past the frame used not to exist to be asked for: the whole file was asked for, cut,
6918 // and paged over its own truncation.
6919 let want = 10;
6920 let (ok, said) = res!(filed(&files, &root, FileOp::Read {
6921 path: fmt!("{}", big.display()),
6922 offset: (lines - want + 1) as u32,
6923 limit: want as u32,
6924 }).await);
6925 assert!(ok, "a read of the file's last lines was refused: {}", said);
6926 let (head, body) = match said.split_once('\n') {
6927 Some(p) => p,
6928 None => return Err(err!("A read answered without its numbers"; Test, Invalid)),
6929 };
6930 let cols: Vec<&str> = head.split('\t').collect();
6931 assert_eq!(cols[0], fmt!("{}", lines), "the file's length changed between two reads");
6932 assert_eq!(cols[2], fmt!("{}", want), "the last lines of the file did not come back");
6933 let last = match text.lines().last() {
6934 Some(l) => l,
6935 None => return Err(err!("the fixture has no last line"; Test, Invalid)),
6936 };
6937 assert!(body.ends_with(&fmt!("{}\n", last)),
6938 "the last line of the file is not the last line of a read that asked for it");
6939 Ok(())
6940 }
6941
6942 /// **A listing too big for one frame says how much of the directory it is showing.**
6943 ///
6944 /// The family the two blockers belong to: an answer that was cut has to say so in a number
6945 /// the caller can act on. A listing used to be cut as one string and end with `read`'s
6946 /// advice -- *"ask for the rest by line range"* -- which `list` does not take, so a caller
6947 /// following it had nowhere to go.
6948 ///
6949 /// `list_op` directly, and not through the launcher as the tests above are: what is in
6950 /// question here is a sentence, not a fence, and ten thousand files is a slow way to prove
6951 /// nothing about the kernel.
6952 #[cfg(unix)]
6953 #[test]
6954 fn a_listing_too_big_for_one_frame_says_how_much_of_it_is_shown() -> Outcome<()> {
6955 let base = res!(fixture("file-door-listing"));
6956 let dir = base.join("many");
6957 res!(std::fs::create_dir_all(&dir));
6958 // Enough names to outgrow one frame, each long enough that the count is not the cost.
6959 let each = 60;
6960 let want = (FILE_TEXT_MAX / each) + 200;
6961 for i in 0..want {
6962 res!(std::fs::write(dir.join(fmt!("{:0w$}", i, w = each)), b""));
6963 }
6964 let (ok, said) = list_op(&fmt!("{}", dir.display()));
6965 assert!(ok, "a listing was refused: {}", said);
6966 assert!(said.len() <= FILE_TEXT_MAX + 400,
6967 "the listing outgrew what one frame carries: {} bytes", said.len());
6968 assert!(said.contains(&fmt!("of {} entries", want)),
6969 "the listing does not say how many entries the directory holds: {:?}",
6970 said.chars().rev().take(300).collect::<String>());
6971 assert!(said.contains("file_glob"),
6972 "the listing says it was cut and names nothing that would answer instead");
6973 assert!(!said.contains("line range"),
6974 "the listing offers a range, which file_list does not take");
6975 Ok(())
6976 }
6977
6978 /// **A line the matcher could not decide travels with the lines that matched.**
6979 ///
6980 /// The hand decides what to leave behind now, and "did not match" and "could not be
6981 /// decided" are different answers with the same shape. A line the matcher gave up on has to
6982 /// cross, or the page counts it as a line that did not match and a pattern that decides
6983 /// nothing answers "No matches" with nothing beside it. The page names such lines in its
6984 /// notes, and it can only name what it was sent.
6985 #[cfg(unix)]
6986 #[tokio::test]
6987 async fn a_line_the_matcher_could_not_decide_travels_with_the_rest() -> Outcome<()> {
6988 let base = res!(fixture("file-door-undecided"));
6989 let ws = base.join("ws");
6990 let files = res!(file_door());
6991 res!(std::fs::create_dir_all(&ws));
6992 // A backtracker, so this stays true of a matcher whose limits change.
6993 let hay = fmt!("harmless\n{}c\n", "a".repeat(40));
6994 res!(std::fs::write(ws.join("hard.txt"), &hay));
6995
6996 let (ok, said) = res!(filed(&files, &ws, FileOp::Search {
6997 paths: vec![fmt!("{}", ws.display())],
6998 query: fmt!("(a+)+b"),
6999 ci: false,
7000 glob: String::new(),
7001 base: String::new(),
7002 skip: Vec::new(),
7003 budget: 5_000,
7004 cap: 1_000_000,
7005 }).await);
7006 assert!(ok, "a search was refused: {}", said);
7007 assert!(said.contains("hard.txt"),
7008 "the file holding a line the matcher gave up on was passed over in silence: {:?}",
7009 said);
7010 assert!(said.contains(&"a".repeat(40)),
7011 "the line itself did not travel, so nothing can say its answer is unknown: {:?}",
7012 said);
7013 Ok(())
7014 }
7015
7016 /// **A search finds a name in a file no frame could carry.**
7017 ///
7018 /// `dev/BLOCKERS.md` B17. The answer used to be the whole TEXT of every matching file, so
7019 /// its size was the size of the files rather than of what matched: `src/tools.rs` at
7020 /// 1,211,990 bytes against a 384 KiB ceiling was passed over with the answer still empty,
7021 /// and no `glob` or `path` a caller could write made one file smaller. Eight searches in one
7022 /// turn, every one "No matches", for a name that is in it.
7023 #[cfg(unix)]
7024 #[tokio::test]
7025 async fn a_search_finds_a_name_in_a_file_too_big_to_carry_whole() -> Outcome<()> {
7026 let root = repo_root();
7027 let big = root.join("src/tools.rs");
7028 let text = res!(std::fs::read_to_string(&big).map_err(|e| err!(e,
7029 "this crate's own repository must hold src/tools.rs"; Test, IO)));
7030 assert!(text.len() > SEARCH_ANSWER_MAX,
7031 "the fixture must be bigger than one whole search answer: {} bytes", text.len());
7032 // A name that is in that file and in no other, so finding it is finding THAT file.
7033 let name = "test_the_search_agrees_with_grep_over_this_crates_own_source";
7034 assert!(text.contains(name), "the fixture no longer holds the name it is searched for");
7035 let files = res!(file_door());
7036
7037 let (ok, said) = res!(filed(&files, &root, FileOp::Search {
7038 paths: vec![fmt!("{}", root.join("src").display())],
7039 query: fmt!("{}", name),
7040 ci: false,
7041 glob: String::new(),
7042 base: String::new(),
7043 skip: Vec::new(),
7044 budget: 20_000,
7045 cap: 2_000_000,
7046 }).await);
7047 assert!(ok, "a search of this repository's own source was refused: {}", said);
7048 assert!(said.contains("tools.rs"),
7049 "THE SEARCH PASSED OVER THE ONE FILE HOLDING THE NAME: {:?}",
7050 said.chars().take(400).collect::<String>());
7051 assert!(said.contains(name),
7052 "the search named the file and carried no line of it: {:?}",
7053 said.chars().take(400).collect::<String>());
7054 let cols: Vec<&str> = match said.split('\n').next() {
7055 Some(h) => h.split('\t').collect(),
7056 None => return Err(err!("a search answered nothing at all"; Test, Missing)),
7057 };
7058 assert_eq!(cols[8], "0",
7059 "a file was left out of the answer for want of room, which over a search for one \
7060 name means the lines are not what is being sent: {:?}", cols);
7061 // The whole answer is a fraction of the one file it is about, which is what makes the
7062 // ceiling a ceiling on the ANSWER rather than on the size of a file.
7063 assert!(said.len() < text.len() / 4,
7064 "the answer is {} bytes about a {} byte file, so whole texts are still crossing",
7065 said.len(), text.len());
7066 Ok(())
7067 }
7068
7069 /// A search walks the fence and stops at its edge, and it never carries a byte from outside.
7070 ///
7071 /// The pair is the point. A walk that refused everything would satisfy a refusal on its own,
7072 /// so the file INSIDE the fence must come back in the same call that proves the file outside
7073 /// it does not -- and the one outside holds a nonce, so a leak would be unmistakable rather
7074 /// than inferred from a count.
7075 #[cfg(unix)]
7076 #[tokio::test]
7077 async fn a_search_reaches_the_fence_and_stops_at_its_edge() -> Outcome<()> {
7078 let base = res!(fixture("file-door-search"));
7079 let ws = base.join("ws");
7080 let files = res!(file_door());
7081 res!(std::fs::create_dir_all(ws.join("deep/er")));
7082 res!(std::fs::write(ws.join("deep/er/hit.rs"), "fn main() {}\nlet NEEDLE_ONE = 1;\n"));
7083 res!(std::fs::write(ws.join("deep/miss.rs"), "nothing of interest here\n"));
7084 res!(std::fs::write(base.join("outside/secret.txt"), "NEEDLE_ONE lives here too\n"));
7085
7086 let (ok, text) = res!(filed(&files, &ws, FileOp::Search {
7087 paths: vec![fmt!("{}", ws.display())],
7088 query: fmt!("NEEDLE_ONE"),
7089 ci: false,
7090 glob: String::new(),
7091 base: String::new(),
7092 skip: Vec::new(),
7093 budget: 5_000,
7094 cap: 1_000_000,
7095 }).await);
7096 assert!(ok, "a search inside the fence was refused: {}", text);
7097 assert!(text.contains("deep/er/hit.rs"),
7098 "the search did not find the file it was pointed at: {:?}", text);
7099 assert!(text.contains("let NEEDLE_ONE = 1;"),
7100 "the search returned no text for the file it matched: {:?}", text);
7101 assert!(!text.contains("miss.rs"),
7102 "the search carried a file its pattern does not match: {:?}", text);
7103 // ── AND POINTED STRAIGHT AT THE OUTSIDE, WHICH IS THE REAL CLAIM ──────
7104 //
7105 // The assertions above prove only that the walk starts where it was told: they stay
7106 // green with the fence widened to the parent, which was measured before this block
7107 // was written and is the reason it exists. A walk is fenced only if a walk AIMED at
7108 // the far side comes back with nothing -- and the kernel's refusal, seen from inside
7109 // the launcher, is `read_dir` failing, which is counted rather than swallowed.
7110 let (ok, text) = res!(filed(&files, &ws, FileOp::Search {
7111 paths: vec![fmt!("{}", base.join("outside").display())],
7112 query: fmt!("NEEDLE_ONE"),
7113 ci: false,
7114 glob: String::new(),
7115 base: String::new(),
7116 skip: Vec::new(),
7117 budget: 5_000,
7118 cap: 1_000_000,
7119 }).await);
7120 assert!(ok, "a search aimed outside the fence errored instead of finding nothing: {}",
7121 text);
7122 assert!(!text.contains("lives here too"),
7123 "THE SEARCH READ OUTSIDE ITS FENCE: {:?}", text);
7124 assert!(!text.contains("secret.txt"),
7125 "the search named a path outside its fence: {:?}", text);
7126 let cols: Vec<&str> = match text.split('\n').next() {
7127 Some(h) => h.split('\t').collect(),
7128 None => return Err(err!("a search answered nothing at all"; Test, Missing)),
7129 };
7130 assert_eq!(cols.len(), 10, "the header does not carry ten counts: {:?}", cols);
7131 assert_eq!(cols[9], "1",
7132 "the walk did not record that a directory could not be opened, so a fenced-off \
7133 tree reads as an empty one: {:?}", cols);
7134 Ok(())
7135 }
7136
7137 /// A glob is matched against the path AS THE CALLER SPELLS IT, not against the machine's.
7138 ///
7139 /// The door's first live run, 2026-08-25: three searches in a row answered "No matches" with
7140 /// "804 file(s) the glob excluded" beside them. The glob was `www/i18n/en.js`, written in
7141 /// the workspace's paths; the walk matched it against `/home/.../repo/www/i18n/en.js` and
7142 /// excluded every file there is. The note was right and the filter was upside down.
7143 #[cfg(unix)]
7144 #[tokio::test]
7145 async fn a_glob_is_matched_against_the_path_the_caller_wrote_it_for() -> Outcome<()> {
7146 let base = res!(fixture("file-door-base"));
7147 let ws = base.join("ws");
7148 let files = res!(file_door());
7149 res!(std::fs::create_dir_all(ws.join("www/i18n")));
7150 res!(std::fs::write(ws.join("www/i18n/en.js"), "'files.new_file_hint': 'x',\n"));
7151 res!(std::fs::write(ws.join("www/i18n/de.js"), "'files.new_file_hint': 'y',\n"));
7152
7153 let (ok, text) = res!(filed(&files, &ws, FileOp::Search {
7154 paths: vec![fmt!("{}", ws.display())],
7155 query: fmt!("new_file_hint"),
7156 ci: false,
7157 // The spelling a model writes, which names nothing on this machine.
7158 glob: fmt!("www/i18n/en.js"),
7159 base: fmt!("{}", ws.display()),
7160 skip: Vec::new(),
7161 budget: 5_000,
7162 cap: 1_000_000,
7163 }).await);
7164 assert!(ok, "a globbed search was refused: {}", text);
7165 assert!(text.contains("www/i18n/en.js"),
7166 "the glob excluded the one file it names, which is the 2026-08-25 fault: {:?}", text);
7167 assert!(!text.contains("de.js"),
7168 "the glob let through a file it does not name: {:?}", text);
7169 // And the count of what the glob excluded is REAL, so the note beside a miss is worth
7170 // reading rather than always saying everything.
7171 let cols: Vec<&str> = match text.split('\n').next() {
7172 Some(h) => h.split('\t').collect(),
7173 None => return Err(err!("a search answered nothing"; Test, Missing)),
7174 };
7175 assert_ne!(cols[4], "0",
7176 "nothing was recorded as excluded, so the note beside a miss would say the glob \
7177 did nothing: {:?}", cols);
7178 assert_eq!(cols[7], "1",
7179 "the glob let more than the one file it names be opened: {:?}", cols);
7180 Ok(())
7181 }
7182
7183 /// A walk stops at its entry budget and says where it had got to.
7184 ///
7185 /// A search that ran out and answered "no matches" has established nothing, and the caller
7186 /// cannot tell that from a search that looked everywhere. So the budget's exhaustion is a
7187 /// FACT in the answer, not an inference from a count.
7188 #[cfg(unix)]
7189 #[tokio::test]
7190 async fn a_walk_that_runs_out_of_budget_says_where_it_stopped() -> Outcome<()> {
7191 let base = res!(fixture("file-door-budget"));
7192 let ws = base.join("ws");
7193 let files = res!(file_door());
7194 for i in 0..40 {
7195 res!(std::fs::write(ws.join(fmt!("f{:02}.txt", i)), "nothing\n"));
7196 }
7197 res!(std::fs::write(ws.join("f99.txt"), "NEEDLE_TWO\n"));
7198
7199 let (ok, text) = res!(filed(&files, &ws, FileOp::Search {
7200 paths: vec![fmt!("{}", ws.display())],
7201 query: fmt!("NEEDLE_TWO"),
7202 ci: false,
7203 glob: String::new(),
7204 base: String::new(),
7205 skip: Vec::new(),
7206 budget: 5,
7207 cap: 1_000_000,
7208 }).await);
7209 assert!(ok, "a bounded search was refused: {}", text);
7210 let head = match text.split('\n').next() {
7211 Some(h) => h.to_string(),
7212 None => return Err(err!("a search answered nothing at all"; Test, Missing)),
7213 };
7214 let cols: Vec<&str> = head.split('\t').collect();
7215 assert_eq!(cols.len(), 10, "the header does not carry ten counts: {:?}", head);
7216 assert_eq!(cols[0], "5", "the walk spent more than its budget: {:?}", head);
7217 assert!(!cols[1].is_empty(),
7218 "the walk ran out and did not say where it had got to: {:?}", head);
7219 assert!(!text.contains("NEEDLE_TWO"),
7220 "the walk reached past its budget: {:?}", text);
7221
7222 // The control, without which the assertion above is satisfied by a walk that never
7223 // works at all: the same search with room finds it.
7224 let (ok, text) = res!(filed(&files, &ws, FileOp::Search {
7225 paths: vec![fmt!("{}", ws.display())],
7226 query: fmt!("NEEDLE_TWO"),
7227 ci: false,
7228 glob: String::new(),
7229 base: String::new(),
7230 skip: Vec::new(),
7231 budget: 5_000,
7232 cap: 1_000_000,
7233 }).await);
7234 assert!(ok && text.contains("NEEDLE_TWO"),
7235 "with room, the same search must find it: {:?}", text);
7236 Ok(())
7237 }
7238
7239 /// A glob answers paths and reads nothing, and passes over the names it was told to.
7240 #[cfg(unix)]
7241 #[tokio::test]
7242 async fn a_glob_answers_paths_and_skips_the_names_it_was_given() -> Outcome<()> {
7243 let base = res!(fixture("file-door-glob"));
7244 let ws = base.join("ws");
7245 let files = res!(file_door());
7246 res!(std::fs::create_dir_all(ws.join("src")));
7247 res!(std::fs::create_dir_all(ws.join("target")));
7248 res!(std::fs::write(ws.join("src/lib.rs"), "SECRET_TEXT\n"));
7249 res!(std::fs::write(ws.join("target/built.rs"), "SECRET_TEXT\n"));
7250
7251 let (ok, text) = res!(filed(&files, &ws, FileOp::Glob {
7252 paths: vec![fmt!("{}", ws.display())],
7253 pattern: fmt!("**/*.rs"),
7254 base: fmt!("{}", ws.display()),
7255 skip: vec![fmt!("target")],
7256 budget: 5_000,
7257 }).await);
7258 assert!(ok, "a glob inside the fence was refused: {}", text);
7259 assert!(text.contains("src/lib.rs"), "the glob missed the file: {:?}", text);
7260 assert!(!text.contains("target/built.rs"),
7261 "the glob walked a directory it was told to pass over: {:?}", text);
7262 // It reads nothing, so no file's CONTENT may appear in the answer.
7263 assert!(!text.contains("SECRET_TEXT"),
7264 "a glob returned a file's text, which it has no business opening: {:?}", text);
7265 Ok(())
7266 }
7267
7268 /// An `old_string` that nearly matched is answered with the text to copy.
7269 ///
7270 /// The measurement is in `near_miss`'s own doc comment: four honest "was not found"
7271 /// refusals in a row, over one typographic quote, and the daimon abandoned the file tools
7272 /// for `sed` and spent forty-eight further calls there. A refusal that cannot be
7273 /// converged on is a refusal that costs the run.
7274 #[cfg(unix)]
7275 #[tokio::test]
7276 async fn an_old_string_that_nearly_matched_is_told_where_and_what_to_copy() -> Outcome<()> {
7277 let base = res!(fixture("file-door-nearmiss"));
7278 let ws = base.join("ws");
7279 let files = res!(file_door());
7280 let target = ws.join("de.js");
7281 // The real line, with the typographic quotes `de.js` really carries.
7282 res!(std::fs::write(&target,
7283 "a\nb\n\t'files.no_match': 'Nichts passt zu \u{201e}{filter}\u{201c}.',\n\t'files.new_file_hint': 'x',\n"));
7284
7285 // What the daimon actually sent: a straight quote where the file has \u{201c}.
7286 let (ok, said) = res!(filed(&files, &ws, FileOp::Edit {
7287 path: fmt!("{}", target.display()),
7288 old: fmt!("\t'files.no_match': 'Nichts passt zu \u{201e}{{filter}}\".',\n\t'files.new_file_hint': 'x',"),
7289 new: fmt!("replaced"),
7290 }).await);
7291 assert!(!ok, "an old_string that is not in the file was applied");
7292 assert!(said.contains("line 3"),
7293 "the refusal does not say WHERE it nearly matched, so nothing can be converged \
7294 on: {:?}", said);
7295 assert!(said.contains('\u{201c}'),
7296 "the refusal does not hand back the file's own text, which is the only thing that \
7297 tells the caller which character it got wrong: {:?}", said);
7298
7299 // And a string that is nowhere near says so, rather than pointing at a line at random.
7300 let (ok, said) = res!(filed(&files, &ws, FileOp::Edit {
7301 path: fmt!("{}", target.display()),
7302 old: fmt!("nothing like this is in the file"),
7303 new: fmt!("x"),
7304 }).await);
7305 assert!(!ok);
7306 assert!(said.contains("not a near miss"),
7307 "a string that is nowhere near is dressed up as one: {:?}", said);
7308 Ok(())
7309 }
7310
7311 /// A directory read as a file is answered as a directory, not as an errno.
7312 ///
7313 /// Measured on the first live run of the door, 2026-08-25: a daimon's opening call was
7314 /// `file_read` of `repo/www/i18n`, and the machine arm answered `Is a directory (os error
7315 /// 21)` where the browser-storage arm has named `file_list` since the day before. One
7316 /// wasted call, and the same wasted call the other door had already paid for.
7317 #[cfg(unix)]
7318 #[tokio::test]
7319 async fn a_directory_read_as_a_file_is_told_to_use_file_list() -> Outcome<()> {
7320 let base = res!(fixture("file-door-isdir"));
7321 let ws = base.join("ws");
7322 let files = res!(file_door());
7323 let (ok, said) = res!(filed(&files, &ws, FileOp::Read {
7324 path: fmt!("{}", ws.display()),
7325 offset: 1,
7326 limit: 0,
7327 }).await);
7328 assert!(!ok, "a directory was read as a file: {:?}", said);
7329 assert!(said.contains("file_list"),
7330 "the refusal does not name the tool that answers this, so it costs a call: {:?}",
7331 said);
7332 assert!(!said.contains("os error"),
7333 "the refusal hands back an errno, which is the browser arm's old fault at the \
7334 other door: {:?}", said);
7335 Ok(())
7336 }
7337
7338 /// A file the fence does not reach is refused BY THE KERNEL, not by a check.
7339 ///
7340 /// The assertion is deliberately about the sentence as well as the verdict: a refusal a
7341 /// model reads as "the file is protected" sends it to `chmod`, and the one thing this
7342 /// door must never do is look like a permission bit.
7343 #[cfg(unix)]
7344 #[tokio::test]
7345 async fn a_file_outside_the_fence_cannot_be_read_or_written() -> Outcome<()> {
7346 let base = res!(fixture("file-door-outside"));
7347 let ws = base.join("ws");
7348 let outside = base.join("outside/other.txt");
7349 let files = res!(file_door());
7350
7351 let (ok, said) = res!(filed(&files, &ws, FileOp::Read {
7352 path: fmt!("{}", outside.display()),
7353 offset: 1,
7354 limit: 0,
7355 }).await);
7356 assert!(!ok, "a file tool read outside its fence: {:?}", said);
7357 assert!(said.contains("fence"),
7358 "the refusal did not name the fence, so it reads as a permission bit: {:?}", said);
7359
7360 let (ok, said) = res!(filed(&files, &ws, FileOp::Write {
7361 path: fmt!("{}", outside.display()),
7362 content: fmt!("clobbered"),
7363 }).await);
7364 assert!(!ok, "a file tool wrote outside its fence: {:?}", said);
7365 let still = res!(std::fs::read_to_string(&outside).map_err(|e| err!(e,
7366 "the file outside the fence could not be read back"; Test, IO)));
7367 assert_eq!(still, "other", "a file tool changed a file outside its fence");
7368 Ok(())
7369 }
7370
7371 /// An `old_string` that is not unique is refused WITH ITS COUNT, and nothing changes.
7372 ///
7373 /// The count is the point. `dev/BLOCKERS.md` B2 names the absence of a partial-apply
7374 /// signal as one of three missing things, and a run of six edits with no way to tell
7375 /// which landed is what 71 calls were spent on.
7376 #[cfg(unix)]
7377 #[tokio::test]
7378 async fn an_edit_that_is_not_unique_says_how_many_it_found_and_changes_nothing()
7379 -> Outcome<()>
7380 {
7381 let base = res!(fixture("file-door-count"));
7382 let ws = base.join("ws");
7383 let files = res!(file_door());
7384 let target = ws.join("twice.txt");
7385 res!(std::fs::write(&target, "x\nx\n"));
7386
7387 let (ok, said) = res!(filed(&files, &ws, FileOp::Edit {
7388 path: fmt!("{}", target.display()),
7389 old: fmt!("x"),
7390 new: fmt!("y"),
7391 }).await);
7392 assert!(!ok, "an ambiguous edit was applied");
7393 assert!(said.contains('2'), "the refusal did not say how many it found: {:?}", said);
7394 assert_eq!(res!(std::fs::read_to_string(&target).map_err(|e| err!(e, "read back";
7395 Test, IO))), "x\nx\n", "an ambiguous edit changed the file anyway");
7396
7397 let (ok, said) = res!(filed(&files, &ws, FileOp::Edit {
7398 path: fmt!("{}", target.display()),
7399 old: fmt!("zzz"),
7400 new: fmt!("y"),
7401 }).await);
7402 assert!(!ok, "an edit whose old_string is absent was applied");
7403 assert!(said.contains("not found"), "the refusal did not say it was absent: {:?}", said);
7404 Ok(())
7405 }
7406
7407 /// A payload survives the trip it is going to be sent on.
7408 #[test]
7409 fn a_payload_round_trips() -> Outcome<()> {
7410 let p = Payload {
7411 prog: PathBuf::from("/usr/bin/cargo"),
7412 argv: vec![fmt!("cargo"), fmt!("test"), fmt!("--"), fmt!("a b\tc")],
7413 env: vec![(fmt!("HOME"), fmt!("/home/u")), (fmt!("EMPTY"), fmt!(""))],
7414 plan: Plan {
7415 abi: crate::fence::Abi::V8,
7416 listing: Listing::Sealed,
7417 base: SysBase::Minimal,
7418 reach: Reach::Process,
7419 grants: vec![
7420 Grant { path: PathBuf::from("/usr"), level: Level::Ro },
7421 Grant { path: PathBuf::from("/home/u/ws"), level: Level::Rw },
7422 ],
7423 sealed: vec![PathBuf::from("/home/u/ws")],
7424 dropped: vec![PathBuf::from("/home/u/ws/escape")],
7425 net: false,
7426 waiver: None,
7427 },
7428 tty: true, // round-tripped as set, so the byte is proved to travel
7429 act: Act::Exec,
7430 };
7431 let framed = res!(encode_payload(&p));
7432 let back = res!(decode_payload(&framed[4..]));
7433 assert_eq!(p, back);
7434
7435 // A truncated payload is refused rather than half-applied.
7436 assert!(decode_payload(&framed[4..framed.len() - 3]).is_err(),
7437 "a truncated plan decoded");
7438 // And so is one with something extra on the end.
7439 let mut extra = framed[4..].to_vec();
7440 extra.push(0);
7441 assert!(decode_payload(&extra).is_err(), "a plan with trailing bytes decoded");
7442 Ok(())
7443 }
7444
7445 // ── The confirmed defects ───────────────────────────────────────
7446
7447 /// A fence root of `""` grants everything, and must not be accepted.
7448 ///
7449 /// `Path::new("/etc/ssh").starts_with("")` is true, so the empty string is
7450 /// every path's ancestor. The guard that was there counted roots and an
7451 /// empty root counts.
7452 #[tokio::test]
7453 async fn an_empty_fence_root_is_refused() -> Outcome<()> {
7454 // Broken first, at the level the bug lived: the containment test itself.
7455 assert!(Path::new("/etc/ssh").starts_with(""),
7456 "the premise of this test no longer holds");
7457 assert!(!under(Path::new("/etc/ssh"), Path::new("")),
7458 "an empty root is still every path's ancestor");
7459
7460 let req = match exec("empty-root", &["/bin/cat", "/etc/hostname"]) {
7461 Req::Exec { id, argv, env, stdin, timeout_ms, capture, .. } =>
7462 Req::Exec {
7463 id, argv, env, stdin, timeout_ms, capture,
7464 cwd: fmt!("/etc"),
7465 fence: FenceSpec {
7466 rw: vec![fmt!("")], ro: Vec::new(), deny: Vec::new(), net: false,
7467 },
7468 toolkits: Vec::new(),
7469 },
7470 other => other,
7471 };
7472 let rs = res!(run(req).await);
7473 let why = res!(refusal(&rs));
7474 assert!(why.contains("empty path"), "{}", why);
7475 Ok(())
7476 }
7477
7478 /// An absolute program outside the fence is refused before it is spawned.
7479 #[tokio::test]
7480 async fn a_program_outside_the_fence_is_refused() -> Outcome<()> {
7481 let base = res!(fixture("prog-outside"));
7482 let ws = base.join("ws");
7483 // A real, runnable program that the fence does not reach. Kept under
7484 // its own name: this machine's coreutils is a multi-call binary that
7485 // decides what to be from `argv[0]`, so a copy called `evil` would
7486 // refuse to run for a reason that has nothing to do with the fence.
7487 res!(std::fs::create_dir_all(base.join("outside/bin")));
7488 let evil = base.join("outside/bin/echo");
7489 res!(std::fs::copy("/bin/echo", &evil));
7490
7491 // Broken first: unfenced, it runs. Through `run_fresh`, because this
7492 // process wrote that file a moment ago and a sibling test's fork may
7493 // still be carrying a duplicate of the descriptor it was written
7494 // through; see the note there.
7495 let bare = res!(run_fresh(&evil, &["ran"]));
7496 assert!(bare.status.success(), "the control program does not run: {}",
7497 String::from_utf8_lossy(&bare.stderr));
7498
7499 let fence = FenceSpec {
7500 rw: vec![fmt!("{}", ws.display())], ro: Vec::new(), deny: Vec::new(), net: false,
7501 };
7502 for spelling in [
7503 fmt!("{}", evil.display()), // Absolute, outside.
7504 fmt!("../outside/bin/echo"), // Relative, out through the cwd.
7505 ] {
7506 let req = Req::Exec {
7507 id: fmt!("p-{}", spelling.len()),
7508 argv: vec![spelling.clone(), fmt!("ran")],
7509 cwd: fmt!("{}", ws.display()),
7510 env: Vec::new(),
7511 stdin: None,
7512 timeout_ms: 10_000,
7513 capture: Capture::Both,
7514 fence: fence.clone(),
7515 toolkits: Vec::new(),
7516 };
7517 let rs = res!(run(req).await);
7518 let why = res!(refusal(&rs));
7519 assert!(why.contains("outside this command's fence"),
7520 "{} was refused for the wrong reason: {}", spelling, why);
7521 assert!(text_of(&rs, Stream::Out).is_empty(),
7522 "{} produced output, so it ran", spelling);
7523 }
7524 Ok(())
7525 }
7526
7527 /// A caller-supplied `PATH` finds a program; it does not authorise one.
7528 #[tokio::test]
7529 async fn a_caller_supplied_path_cannot_reach_outside_the_fence() -> Outcome<()> {
7530 let base = res!(fixture("prog-path"));
7531 let ws = base.join("ws");
7532 let dir = base.join("outside/bin");
7533 res!(std::fs::create_dir_all(&dir));
7534 res!(std::fs::copy("/bin/echo", dir.join("echo")));
7535
7536 let req = Req::Exec {
7537 id: fmt!("path"),
7538 argv: vec![fmt!("echo"), fmt!("ran")],
7539 cwd: fmt!("{}", ws.display()),
7540 env: vec![(fmt!("PATH"), fmt!("{}", dir.display()))],
7541 stdin: None,
7542 timeout_ms: 10_000,
7543 capture: Capture::Both,
7544 fence: FenceSpec {
7545 rw: vec![fmt!("{}", ws.display())], ro: Vec::new(), deny: Vec::new(), net: false,
7546 },
7547 toolkits: Vec::new(),
7548 };
7549 let rs = res!(run(req).await);
7550 let why = res!(refusal(&rs));
7551 assert!(why.contains("outside this command's fence"), "{}", why);
7552 Ok(())
7553 }
7554
7555 /// `LD_PRELOAD` and its family are refused, by name.
7556 ///
7557 /// `README.md` gives this as the reason the environment is not the model's;
7558 /// until now the loop applied whatever it was given.
7559 #[tokio::test]
7560 async fn the_loader_environment_is_refused() -> Outcome<()> {
7561 for (k, v) in [
7562 ("LD_PRELOAD", "/tmp/evil.so"),
7563 ("LD_AUDIT", "/tmp/evil.so"),
7564 ("LD_LIBRARY_PATH", "/tmp"),
7565 ("GCONV_PATH", "/tmp"),
7566 ("BASH_ENV", "/tmp/evil.sh"),
7567 ] {
7568 let req = match exec("ld", &["/bin/echo", "hi"]) {
7569 Req::Exec { id, argv, cwd, stdin, timeout_ms, capture, fence, .. } =>
7570 Req::Exec {
7571 id, argv, cwd, stdin, timeout_ms, capture, fence,
7572 env: vec![(fmt!("{}", k), fmt!("{}", v))],
7573 toolkits: Vec::new(),
7574 },
7575 other => other,
7576 };
7577 let rs = res!(run(req).await);
7578 let why = res!(refusal(&rs));
7579 assert!(why.contains(k), "{} was refused without being named: {}", k, why);
7580 }
7581
7582 // An ordinary name is still accepted, so this is a screen and not a ban.
7583 let req = match exec("ld-ok", &["/usr/bin/env"]) {
7584 Req::Exec { id, argv, cwd, stdin, timeout_ms, capture, fence, .. } =>
7585 Req::Exec {
7586 id, argv, cwd, stdin, timeout_ms, capture, fence,
7587 env: vec![(fmt!("LDAP_CONF"), fmt!("x"))],
7588 toolkits: Vec::new(),
7589 },
7590 other => other,
7591 };
7592 let rs = res!(run(req).await);
7593 assert!(text_of(&rs, Stream::Out).lines().any(|l| l == "LDAP_CONF=x"),
7594 "an ordinary name was dropped: {:?}", text_of(&rs, Stream::Out));
7595 Ok(())
7596 }
7597
7598 /// Two runs cannot share one identifier.
7599 ///
7600 /// The first is left running on purpose: the collision only exists while
7601 /// both are live, and it is the live one that used to become unkillable.
7602 #[tokio::test]
7603 async fn a_duplicate_id_is_refused() -> Outcome<()> {
7604 let runner = res!(runner());
7605 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
7606
7607 res!(runner.spawn(exec("same", &["/bin/sleep", "30"]), tx.clone()).await);
7608 match rx.recv().await {
7609 Some(Resp::Started { .. }) => {},
7610 other => return Err(err!("Expected Started, got {:?}.", other; Test, Mismatch)),
7611 }
7612
7613 let second = res!(runner.spawn(exec("same", &["/bin/sleep", "30"]), tx).await);
7614 assert_eq!(second, Launch::Refused);
7615 assert_eq!(res!(runner.live_count()), 1,
7616 "the registry took a second entry under one identifier");
7617
7618 // And the first is still reachable, which is the property that was lost.
7619 assert_eq!(res!(runner.signal("same", Sig::Kill).await), Signalled::Sent);
7620 // Drained to the closing message rather than to the first thing that
7621 // ends a run: the refusal for the second exec is already in the queue.
7622 while let Some(r) = rx.recv().await {
7623 if matches!(r, Resp::Ended { .. }) {
7624 break;
7625 }
7626 }
7627 assert_eq!(res!(runner.live_count()), 0);
7628 Ok(())
7629 }
7630
7631 /// A registry entry does not outlive the announcement it was made for.
7632 #[tokio::test]
7633 async fn a_failed_announcement_leaves_no_entry() -> Outcome<()> {
7634 let runner = res!(runner());
7635 for i in 0..5 {
7636 let (tx, rx) = tokio::sync::mpsc::channel::<Resp>(1);
7637 drop(rx); // The page has gone away.
7638 let out = runner.spawn(exec(&fmt!("gone-{}", i), &["/bin/sleep", "30"]), tx).await;
7639 assert!(out.is_err(), "an unannounceable run reported success");
7640 }
7641 assert_eq!(res!(runner.live_count()), 0,
7642 "unannounced runs left entries no signal can clear");
7643 Ok(())
7644 }
7645
7646 /// A command that will not stop talking is cut off, and told about.
7647 ///
7648 /// `yes` delivered 3.4 GB in three seconds when nothing bounded the total.
7649 /// Memory was never the problem; the journal and the page's own buffers
7650 /// were.
7651 #[tokio::test]
7652 async fn output_is_capped_in_total() -> Outcome<()> {
7653 let req = match exec("flood", &["/usr/bin/yes"]) {
7654 Req::Exec { id, argv, cwd, env, stdin, capture, fence, .. } =>
7655 Req::Exec { id, argv, cwd, env, stdin, timeout_ms: 4_000, capture, fence, toolkits: Vec::new() },
7656 other => other,
7657 };
7658 let rs = res!(run(req).await);
7659
7660 let sent: usize = rs.iter().map(|r| match r {
7661 Resp::Chunk { data, .. } => data.len(),
7662 _ => 0,
7663 }).sum();
7664 let (_, timed_out, _, out_bytes) = match ended(&rs) {
7665 Some(e) => e,
7666 None => return Err(err!("No Ended was sent."; Test, Missing)),
7667 };
7668 assert!(timed_out, "the flood stopped by itself, so nothing was capped");
7669 // Bounded, with one chunk's worth of overshoot allowed: the budget is
7670 // checked per chunk, not per byte.
7671 assert!(sent as u64 <= OUTPUT_TOTAL_MAX + (2 * CHUNK_MAX) as u64,
7672 "{} bytes were forwarded against a cap of {}", sent, OUTPUT_TOTAL_MAX);
7673 // The true total is still reported, so nothing is hidden.
7674 assert!(out_bytes > sent as u64,
7675 "the byte count did not outrun what was forwarded, so nothing was \
7676 truncated and this test proved nothing");
7677 assert!(rs.iter().any(|r| matches!(r,
7678 Resp::Chunk { data, .. } if data.contains("stopped forwarding"))),
7679 "output was truncated without saying so");
7680 Ok(())
7681 }
7682
7683 // ── Somewhere to write ──────────────────────────────────────────
7684
7685 /// Sets a directory's permissions, for a control that needs an unwritable one.
7686 ///
7687 /// # Arguments
7688 /// * `p` - The directory.
7689 /// * `mode` - The mode to set.
7690 #[cfg(unix)]
7691 fn set_mode(p: &Path, mode: u32) -> Outcome<()> {
7692 use std::os::unix::fs::PermissionsExt;
7693 let md = res!(std::fs::metadata(p));
7694 let mut perm = md.permissions();
7695 perm.set_mode(mode);
7696 res!(std::fs::set_permissions(p, perm));
7697 Ok(())
7698 }
7699
7700 /// The scratch directories left behind by one run identifier.
7701 ///
7702 /// Matched on the identifier rather than by taking a difference of the
7703 /// listing, so that tests running beside each other cannot see one
7704 /// another's.
7705 ///
7706 /// # Arguments
7707 /// * `id` - The identifier the run was given.
7708 fn scratches(id: &str) -> Outcome<Vec<PathBuf>> {
7709 let base = res!(scratch_base());
7710 let rd = match std::fs::read_dir(&base) {
7711 Ok(rd) => rd,
7712 Err(_) => return Ok(Vec::new()), // Nothing has been made yet.
7713 };
7714 let want = fmt!("{}-", id);
7715 let mut out = Vec::new();
7716 for ent in rd.flatten() {
7717 if ent.file_name().to_string_lossy().starts_with(&want) {
7718 out.push(ent.path());
7719 }
7720 }
7721 Ok(out)
7722 }
7723
7724 /// Clears whatever an earlier run of the same test left behind.
7725 ///
7726 /// A test that asserts nothing survives a run fails for ever once something
7727 /// has -- and something has, every time these checks are proved against
7728 /// deliberately broken code. Clearing first keeps each assertion about the
7729 /// run it was written for.
7730 ///
7731 /// # Arguments
7732 /// * `id` - The identifier the run will be given.
7733 fn clear_scratches(id: &str) -> Outcome<()> {
7734 for p in res!(scratches(id)) {
7735 let _ = std::fs::remove_dir_all(&p);
7736 }
7737 Ok(())
7738 }
7739
7740 /// A command really can write temporary files, and not into `/tmp`.
7741 ///
7742 /// `mktemp` is the whole test in one program: it creates a file where
7743 /// `TMPDIR` points and prints where it put it, so a run that ends zero with a
7744 /// path under the scratch base has demonstrated the writing, the pointing
7745 /// and the placing at once.
7746 ///
7747 /// The first half is the broken state, and it is the one the bug report
7748 /// described: with `/tmp` outside every fence and nothing in its place, a
7749 /// command asking for a temporary file there is refused by the kernel
7750 /// part-way through its work. Without that half, the second could pass on a
7751 /// machine where `/tmp` happened to be writable and prove nothing.
7752 #[tokio::test]
7753 async fn a_command_can_write_to_its_own_tmpdir() -> Outcome<()> {
7754 res!(clear_scratches("tmp-works"));
7755
7756 // Broken first: /tmp is outside the fence, which is why this exists.
7757 let rs = res!(run(exec("tmp-broken", &["/usr/bin/mktemp", "-p", "/tmp"])).await);
7758 let (exit, ..) = match ended(&rs) {
7759 Some(e) => e,
7760 None => return Err(err!("No Ended was sent."; Test, Missing)),
7761 };
7762 assert_ne!(exit, 0,
7763 "a fenced command wrote into /tmp, so the fence is not what it says");
7764
7765 // And now with nothing said about where: TMPDIR is the hand's answer.
7766 let rs = res!(run(exec("tmp-works", &["/usr/bin/mktemp"])).await);
7767 let (exit, ..) = match ended(&rs) {
7768 Some(e) => e,
7769 None => return Err(err!("No Ended was sent."; Test, Missing)),
7770 };
7771 assert_eq!(exit, 0,
7772 "a command could not write a temporary file: {}", text_of(&rs, Stream::Err));
7773
7774 let said = text_of(&rs, Stream::Out);
7775 let made = Path::new(said.trim());
7776 let base = res!(scratch_base());
7777 assert!(made.starts_with(&base),
7778 "the temporary file went to {} rather than under {}",
7779 made.display(), base.display());
7780 assert!(!made.starts_with("/tmp"), "the temporary file went to /tmp");
7781 // And it went with the run: the directory it was in is not there now.
7782 assert!(!made.exists(), "{} outlived the run", made.display());
7783 assert!(res!(scratches("tmp-works")).is_empty(),
7784 "a scratch directory outlived its run");
7785 Ok(())
7786 }
7787
7788 /// The scratch goes away on every way a run can end.
7789 ///
7790 /// The two long runs are here because "gone afterwards" is a claim that
7791 /// passes trivially against code that never made one: each is watched while
7792 /// it is alive, so the directory is proved to exist before it is proved to
7793 /// be gone.
7794 #[tokio::test]
7795 async fn the_scratch_is_gone_however_the_run_ends() -> Outcome<()> {
7796 // Ended well, and ended badly.
7797 for (id, argv) in [
7798 ("scr-success", vec!["/bin/echo", "done"]),
7799 ("scr-failure", vec!["/bin/false"]),
7800 ] {
7801 res!(clear_scratches(id));
7802 let rs = res!(run(exec(id, &argv)).await);
7803 assert!(ended(&rs).is_some(), "{} never closed", id);
7804 assert!(res!(scratches(id)).is_empty(),
7805 "the scratch for {} outlived it", id);
7806 }
7807
7808 // Timed out.
7809 res!(clear_scratches("scr-timeout"));
7810 let req = match exec("scr-timeout", &["/bin/sleep", "30"]) {
7811 Req::Exec { id, argv, cwd, env, stdin, capture, fence, .. } =>
7812 Req::Exec { id, argv, cwd, env, stdin, timeout_ms: 1_500, capture, fence, toolkits: Vec::new() },
7813 other => other,
7814 };
7815 let waiter = res!(runner());
7816 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
7817 res!(waiter.spawn(req, tx).await);
7818 match rx.recv().await {
7819 Some(Resp::Started { .. }) => {},
7820 other => return Err(err!("Expected Started, got {:?}.", other; Test, Mismatch)),
7821 }
7822 assert_eq!(res!(scratches("scr-timeout")).len(), 1,
7823 "a running command had no scratch directory, so nothing was removed later");
7824 let rs = collect(&mut rx).await;
7825 let (_, timed_out, ..) = match ended(&rs) {
7826 Some(e) => e,
7827 None => return Err(err!("No Ended was sent."; Test, Missing)),
7828 };
7829 assert!(timed_out, "the run did not time out, so this proved nothing");
7830 // Nothing was recorded as left standing. A group that has been killed is
7831 // empty, and a reaped member of it still answers in `/proc` for as long
7832 // as it takes to be collected -- so a probe that counted one would
7833 // announce a run as having left processes behind when it had not, and
7834 // would hold its scratch open for them. See `counts_as_member`.
7835 assert_eq!(res!(waiter.standing_count()), 0,
7836 "a timed-out run was recorded as having left its process group standing");
7837 assert!(res!(scratches("scr-timeout")).is_empty(),
7838 "the scratch survived a timeout");
7839
7840 // Killed.
7841 res!(clear_scratches("scr-killed"));
7842 let killer = res!(runner());
7843 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
7844 res!(killer.spawn(exec("scr-killed", &["/bin/sleep", "30"]), tx).await);
7845 match rx.recv().await {
7846 Some(Resp::Started { .. }) => {},
7847 other => return Err(err!("Expected Started, got {:?}.", other; Test, Mismatch)),
7848 }
7849 assert_eq!(res!(scratches("scr-killed")).len(), 1,
7850 "a running command had no scratch directory, so nothing was removed later");
7851 assert_eq!(res!(killer.signal("scr-killed", Sig::Kill).await), Signalled::Sent);
7852 let rs = collect(&mut rx).await;
7853 let (_, _, killed, _) = match ended(&rs) {
7854 Some(e) => e,
7855 None => return Err(err!("No Ended was sent."; Test, Missing)),
7856 };
7857 assert!(killed, "the run was not killed, so this proved nothing");
7858 assert_eq!(res!(killer.standing_count()), 0,
7859 "a killed run was recorded as having left its process group standing");
7860 assert!(res!(scratches("scr-killed")).is_empty(),
7861 "the scratch survived a kill");
7862 Ok(())
7863 }
7864
7865 /// Two commands running at once are given two different directories.
7866 #[tokio::test]
7867 async fn two_runs_get_different_scratches() -> Outcome<()> {
7868 let runner = res!(runner());
7869 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(1024);
7870 res!(runner.spawn(exec("pair-a", &["/usr/bin/mktemp"]), tx.clone()).await);
7871 res!(runner.spawn(exec("pair-b", &["/usr/bin/mktemp"]), tx).await);
7872
7873 let mut made: HashMap<String, String> = HashMap::new();
7874 let mut done = 0;
7875 while let Some(r) = rx.recv().await {
7876 match r {
7877 Resp::Chunk { id, stream: Stream::Out, data, .. } =>
7878 made.entry(id).or_default().push_str(&data),
7879 Resp::Ended { .. } => { done += 1; if done == 2 { break; } },
7880 Resp::Refused { reason, .. } => return Err(err!(
7881 "A run was refused: {}", reason; Test, Mismatch)),
7882 _ => (),
7883 }
7884 }
7885
7886 let path = |id: &str| -> Outcome<PathBuf> {
7887 match made.get(id) {
7888 Some(s) => {
7889 let t = match s.strip_prefix(HARNESS_NOISE) {
7890 Some(rest) => rest.trim(),
7891 None => s.trim(),
7892 };
7893 Ok(PathBuf::from(t))
7894 },
7895 None => Err(err!("{} said nothing.", id; Test, Missing)),
7896 }
7897 };
7898 let a = res!(path("pair-a"));
7899 let b = res!(path("pair-b"));
7900 let (pa, pb) = match (a.parent(), b.parent()) {
7901 (Some(x), Some(y)) => (x.to_path_buf(), y.to_path_buf()),
7902 _ => return Err(err!(
7903 "A temporary file was made at the root of the filesystem."; Test, Path)),
7904 };
7905 assert_ne!(pa, pb, "two concurrent runs shared one scratch directory");
7906 assert_eq!(pa.parent(), pb.parent(),
7907 "the two scratches are not siblings, so this compared the wrong thing");
7908 Ok(())
7909 }
7910
7911 /// One run cannot read another's scratch, even knowing exactly where it is.
7912 ///
7913 /// The decoy stands in for a Diamond's run that is still going: it is made
7914 /// in the same base, by the same rules, and the fenced command is given its
7915 /// full path. Nothing has to be guessed, so this tests the fence rather
7916 /// than the naming.
7917 #[tokio::test]
7918 async fn one_run_cannot_reach_another_scratch() -> Outcome<()> {
7919 let base = res!(scratch_base());
7920 res!(std::fs::create_dir_all(&base));
7921 let decoy = base.join(scratch_name("decoy"));
7922 res!(std::fs::create_dir(&decoy));
7923 res!(std::fs::write(decoy.join("secret"), "another run's work"));
7924
7925 // Broken first: unfenced, the file reads perfectly well.
7926 let bare = res!(std::process::Command::new("/bin/cat")
7927 .arg(decoy.join("secret")).output());
7928 assert!(bare.status.success(), "the control run could not read the decoy");
7929 assert_eq!("another run's work", String::from_utf8_lossy(&bare.stdout));
7930
7931 let rs = res!(run(exec("peeper",
7932 &["/bin/cat", &fmt!("{}", decoy.join("secret").display())])).await);
7933 let (exit, ..) = match ended(&rs) {
7934 Some(e) => e,
7935 None => return Err(err!("No Ended was sent."; Test, Missing)),
7936 };
7937 let _ = std::fs::remove_dir_all(&decoy);
7938
7939 assert_ne!(exit, 0, "a command read another run's temporary files");
7940 assert_eq!(text_of(&rs, Stream::Out), "",
7941 "another run's temporary files came back anyway");
7942 Ok(())
7943 }
7944
7945 /// A caller cannot say where a command's temporary files go.
7946 #[tokio::test]
7947 async fn a_caller_supplied_tmpdir_is_refused() -> Outcome<()> {
7948 res!(clear_scratches("tmp-caller"));
7949 for name in ["TMPDIR", "TMP", "TEMP", "tmpdir"] {
7950 let req = match exec("tmp-caller", &["/usr/bin/env"]) {
7951 Req::Exec { id, argv, cwd, stdin, timeout_ms, capture, fence, .. } =>
7952 Req::Exec {
7953 id, argv, cwd, stdin, timeout_ms, capture, fence,
7954 env: vec![(fmt!("{}", name), fmt!("{}", root()))],
7955 toolkits: Vec::new(),
7956 },
7957 other => other,
7958 };
7959 let rs = res!(run(req).await);
7960 let why = res!(refusal(&rs));
7961 assert!(why.contains(name),
7962 "{} was refused without being named: {}", name, why);
7963 assert!(res!(scratches("tmp-caller")).is_empty(),
7964 "a refused command left a scratch directory behind");
7965 }
7966 Ok(())
7967 }
7968
7969 /// The scratch cannot be put where a fence over it would reach the journal.
7970 ///
7971 /// Proved on the broken placement first: a base holding the journal is
7972 /// exactly what nobody else checks, since `main` checks the granted folder
7973 /// and `Journal::check_fence` checks the caller's spec, and this root is
7974 /// added after both.
7975 #[test]
7976 fn the_scratch_is_never_where_the_journal_lives() -> Outcome<()> {
7977 let bad = Path::new("/home/u/.local/share/daimond/hand");
7978 let journal = bad.join("journal");
7979 match clear_of_journal(bad, &journal) {
7980 Ok(()) => return Err(err!(
7981 "A scratch base holding the journal was accepted."; Test, Security)),
7982 Err(e) => {
7983 let said = e.msgs().join(" ");
7984 assert!(said.contains("journal"), "the refusal does not name it: {}", said);
7985 },
7986 }
7987 // The base itself, being the journal's directory, is refused too.
7988 assert!(clear_of_journal(&journal, &journal).is_err(),
7989 "a scratch base that IS the journal was accepted");
7990 // And the arrangement the hand actually uses is clear of it.
7991 res!(clear_of_journal(&bad.join("scratch"), &journal));
7992 res!(clear_of_journal(
7993 &res!(scratch_base()),
7994 &res!(crate::journal::default_dir())));
7995 Ok(())
7996 }
7997
7998 /// The proof this whole arrangement exists for: a real `cargo test`, fenced,
7999 /// with the toolchain read-only and nothing said about where to write.
8000 ///
8001 /// This is the case that was measured failing. Driving the real host over a
8002 /// pipe, `cargo test` with the toolchain granted died with `error: couldn't
8003 /// create a temp dir: Permission denied (os error 13) at path
8004 /// "/tmp/rustcOHkDBV"` -- `rustc` writes its intermediate output to a
8005 /// temporary directory, `/tmp` is outside every fence the hand builds, and
8006 /// the compile got most of the way through before finding that out.
8007 ///
8008 /// Nothing in the request below mentions a temporary directory. If this
8009 /// test passes, the hand supplied one; if it is ever made not to, this fails
8010 /// with the same message the bug report carried.
8011 ///
8012 /// Skipped loudly where there is no toolchain to grant, since a machine
8013 /// without one cannot answer the question either way.
8014 #[tokio::test]
8015 async fn a_real_cargo_test_completes_behind_the_fence() -> Outcome<()> {
8016 res!(clear_scratches("cargo-proof"));
8017 let home = match std::env::var("HOME") {
8018 Ok(h) if !h.is_empty() => PathBuf::from(h),
8019 _ => {
8020 println!("[a_real_cargo_test_completes_behind_the_fence] SKIPPED: no HOME.");
8021 return Ok(());
8022 },
8023 };
8024 let cargo_home = home.join(".cargo");
8025 let rustup_home = home.join(".rustup");
8026 let cargo = cargo_home.join("bin/cargo");
8027 if !cargo.exists() || !rustup_home.exists() {
8028 println!(
8029 "[a_real_cargo_test_completes_behind_the_fence] SKIPPED: no \
8030 toolchain at {} to grant.", cargo.display());
8031 return Ok(());
8032 }
8033
8034 // A crate with no dependencies, so nothing is fetched and the only
8035 // reason to write anywhere is the compiler's own working files.
8036 let base = res!(fixture("cargo-proof"));
8037 let ws = base.join("ws");
8038 let proj = ws.join("proj");
8039 res!(std::fs::create_dir_all(proj.join("src")));
8040 res!(std::fs::create_dir_all(ws.join("home")));
8041 res!(std::fs::write(proj.join("Cargo.toml"), concat!(
8042 "[package]\n",
8043 "name = \"fenced\"\n",
8044 "version = \"0.0.0\"\n",
8045 "edition = \"2021\"\n",
8046 "\n",
8047 "[workspace]\n",
8048 "\n",
8049 "[lib]\n",
8050 "path = \"src/lib.rs\"\n")));
8051 res!(std::fs::write(proj.join("src/lib.rs"), concat!(
8052 "//! A crate that exists to be compiled inside a fence.\n",
8053 "\n",
8054 "/// Two and two.\n",
8055 "pub fn four() -> u32 { 2 + 2 }\n",
8056 "\n",
8057 "#[cfg(test)]\n",
8058 "mod tests {\n",
8059 " #[test]\n",
8060 " fn it_adds() { assert_eq!(super::four(), 4); }\n",
8061 "}\n")));
8062
8063 // Broken first, and this is the exact failure that was reported. The
8064 // same project, the same toolchain, no fence at all -- only a TMPDIR
8065 // the compiler cannot write to, which is what a fence with no scratch
8066 // in it amounts to. Without this half, a build that never needed a
8067 // temporary file would make the fenced run below prove nothing.
8068 let notmp = base.join("outside/notmp");
8069 res!(std::fs::create_dir_all(&notmp));
8070 res!(set_mode(&notmp, 0o500));
8071 let bare = res!(std::process::Command::new(&cargo)
8072 .args(["test", "--offline"])
8073 .current_dir(&proj)
8074 .env("TMPDIR", &notmp)
8075 .env("CARGO_TARGET_DIR", proj.join("target-control"))
8076 .output());
8077 res!(set_mode(&notmp, 0o700)); // So the fixture can be cleared next time.
8078 let control = String::from_utf8_lossy(&bare.stderr).to_string();
8079 assert!(!bare.status.success(),
8080 "the control build succeeded with nowhere to write, so this machine \
8081 cannot demonstrate the failure the scratch exists to fix");
8082 assert!(control.contains("couldn't create a temp dir"),
8083 "the control build failed for some other reason:\n{}", control);
8084 let _ = std::fs::remove_dir_all(proj.join("target-control"));
8085
8086 let req = Req::Exec {
8087 id: fmt!("cargo-proof"),
8088 argv: vec![fmt!("cargo"), fmt!("test"), fmt!("--offline")],
8089 cwd: fmt!("{}", proj.display()),
8090 env: vec![
8091 (fmt!("PATH"), fmt!("{}/bin:/usr/bin:/bin", cargo_home.display())),
8092 (fmt!("HOME"), fmt!("{}", ws.join("home").display())),
8093 (fmt!("CARGO_HOME"), fmt!("{}", cargo_home.display())),
8094 (fmt!("RUSTUP_HOME"), fmt!("{}", rustup_home.display())),
8095 // Inside the workspace, because the target directory is output
8096 // rather than scratch and the caller is entitled to say where
8097 // output goes.
8098 (fmt!("CARGO_TARGET_DIR"), fmt!("{}", proj.join("target").display())),
8099 // Nothing about TMPDIR, TMP or TEMP. That is the test.
8100 ],
8101 stdin: None,
8102 timeout_ms: 300_000,
8103 capture: Capture::Both,
8104 fence: FenceSpec {
8105 rw: vec![fmt!("{}", ws.display())],
8106 ro: vec![
8107 fmt!("{}", cargo_home.display()),
8108 fmt!("{}", rustup_home.display()),
8109 ],
8110 deny: Vec::new(),
8111 net: false,
8112 },
8113 toolkits: Vec::new(),
8114 };
8115
8116 let rs = res!(run(req).await);
8117 let (exit, timed_out, ..) = match ended(&rs) {
8118 Some(e) => e,
8119 None => return Err(err!(
8120 "No Ended was sent: {:?}", rs; Test, Missing)),
8121 };
8122 let said = fmt!("{}{}", text_of(&rs, Stream::Out), text_of(&rs, Stream::Err));
8123 assert!(!timed_out, "the build did not finish in time:\n{}", said);
8124 assert!(!said.contains("couldn't create a temp dir"),
8125 "the command still had nowhere to write:\n{}", said);
8126 assert_eq!(exit, 0, "a fenced cargo test did not pass:\n{}", said);
8127 assert!(said.contains("test tests::it_adds ... ok"),
8128 "the test inside the fenced project did not run:\n{}", said);
8129 assert!(res!(scratches("cargo-proof")).is_empty(),
8130 "a build's temporary files outlived it");
8131 Ok(())
8132 }
8133
8134 /// A script that reads `$HOME` runs, and the command is told only the two
8135 /// names the hand fills in.
8136 ///
8137 /// The measured failure is the whole reason this exists: a daimon ran
8138 /// `bash dev/world.sh 3 --up` and it died on its first line with
8139 /// `HOME: unbound variable`, because the environment was the caller's pairs
8140 /// and the caller sends none unless a toolkit was granted. Nearly every
8141 /// script under `dev/` in the app reads `$HOME`, so nearly every one of them
8142 /// died the same way.
8143 ///
8144 /// Both directions are checked. A `set -u` script that reads `$HOME` and
8145 /// `$PATH` has to run and print real values; and the environment has to hold
8146 /// no more than the caller's pairs, the three scratch names and these two,
8147 /// because a default nobody argued for is a variable a command can be
8148 /// steered by.
8149 #[tokio::test]
8150 async fn a_command_is_given_a_home_and_a_path() -> Outcome<()> {
8151 let base = res!(fixture("env-defaults"));
8152 let ws = base.join("ws");
8153 let sh = ws.join("reads-home.sh");
8154 res!(std::fs::write(&sh, concat!(
8155 "#!/bin/bash\n",
8156 "set -u\n",
8157 "echo \"HOME=$HOME\"\n",
8158 "echo \"PATH=$PATH\"\n",
8159 "env | sort\n")));
8160
8161 let rs = res!(run(exec_at("env-defaults",
8162 &["/bin/bash", &fmt!("{}", sh.display())], &ws)).await);
8163 let said = text_of(&rs, Stream::Out);
8164 let (exit, ..) = match ended(&rs) {
8165 Some(e) => e,
8166 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
8167 };
8168 assert_eq!(exit, 0,
8169 "a script that reads $HOME under `set -u` did not run: {}{}",
8170 said, text_of(&rs, Stream::Err));
8171
8172 let home = match home_dir() {
8173 Some(h) => h,
8174 None => return Err(err!(
8175 "These tests need HOME to say what the hand would pass on."; Test, Missing)),
8176 };
8177 assert!(said.contains(&fmt!("HOME={}", home)),
8178 "the command was given some other home: {}", said);
8179 assert!(said.contains(&fmt!("PATH={}", PATH_FALLBACK)),
8180 "the command was given some other path: {}", said);
8181
8182 // And nothing else. Named individually, because each of these is a
8183 // separate decision recorded in the section above `ENV_DEFAULTED` and a
8184 // later edit that adds one should have to change this line.
8185 for refused in ["USER=", "LOGNAME=", "LANG=", "LC_ALL=", "SHELL=", "TERM="] {
8186 assert!(!said.lines().any(|l| l.starts_with(refused)),
8187 "the hand passed {} to a command: {}", refused, said);
8188 }
8189
8190 // The caller's own pair wins, because a default is a floor and not a
8191 // correction. The app sets HOME itself for a git grant.
8192 let req = match exec_at("env-mine", &["/usr/bin/env"], &ws) {
8193 Req::Exec { id, argv, cwd, stdin, timeout_ms, capture, fence, .. } =>
8194 Req::Exec {
8195 id, argv, cwd, stdin, timeout_ms, capture, fence,
8196 env: vec![(fmt!("HOME"), fmt!("{}", ws.display()))],
8197 toolkits: Vec::new(),
8198 },
8199 other => other,
8200 };
8201 let rs = res!(run(req).await);
8202 let said = text_of(&rs, Stream::Out);
8203 assert!(said.contains(&fmt!("HOME={}", ws.display())),
8204 "the hand overrode a HOME the caller set: {}", said);
8205 assert_eq!(said.lines().filter(|l| l.starts_with("HOME=")).count(), 1,
8206 "HOME was set twice, so which one a program reads is luck: {}", said);
8207 Ok(())
8208 }
8209
8210 /// A toolchain folder this machine does not have is skipped; a workspace
8211 /// root that is missing still refuses.
8212 ///
8213 /// `~/.config/git` is absent on the machine this was written on, and
8214 /// `Toolkit::Git` grants it read access -- so ticking the Git toolkit
8215 /// refused EVERY command the Diamond ran, with a sentence about a path the
8216 /// user had never named. Both halves are here because only the pair is the
8217 /// rule: skipping a grant nobody asked for by name tightens the fence, and
8218 /// skipping a root the USER marked would leave a fence that silently did not
8219 /// cover what they marked.
8220 #[test]
8221 fn an_absent_toolchain_folder_is_skipped_and_a_missing_workspace_is_not() -> Outcome<()> {
8222 let home = match home_dir() {
8223 Some(h) => PathBuf::from(h),
8224 None => return Err(err!("This test needs HOME."; Test, Missing)),
8225 };
8226 let base = res!(fixture("kit-absent"));
8227 let ws = base.join("ws");
8228 let kits = vec![fmt!("git"), fmt!("node"), fmt!("python"), fmt!("rust")];
8229
8230 // A toolkit path this machine does not have, named exactly as the app
8231 // spells it. Chosen from the table rather than invented, so a machine
8232 // that HAS them all makes this test say so instead of passing hollowly.
8233 let absent = TOOLKIT_ROOTS.iter()
8234 .map(|k| home.join(k.tail))
8235 .find(|p| !p.exists());
8236 let absent = match absent {
8237 Some(p) => p,
8238 None => {
8239 println!("[an_absent_toolchain_folder_is_skipped_and_a_missing_workspace_is_not] \
8240 SKIPPED: every toolchain folder in TOOLKIT_ROOTS exists here.");
8241 return Ok(());
8242 },
8243 };
8244
8245 let mut spec = FenceSpec {
8246 rw: vec![fmt!("{}", ws.display())],
8247 ro: vec![fmt!("{}", absent.display())],
8248 deny: Vec::new(),
8249 net: false,
8250 };
8251 // The clamp accepts it -- it is a root the grant implies -- and the fence
8252 // would then refuse the command for a path that is simply not there.
8253 assert_eq!(None, vet_roots(&ws, &spec, &kits, Door::Command),
8254 "the clamp refused a toolchain root, so this test is measuring the wrong thing");
8255 assert!(detected_fence().plan(&spec, &Unfenced::Refuse).is_err(),
8256 "a fence naming {} resolved, so this machine cannot show the failure",
8257 absent.display());
8258
8259 let gone = drop_absent_kit_roots(&mut spec, &kits);
8260 assert_eq!(gone, vec![fmt!("{}", absent.display())],
8261 "the absent toolchain root was not the thing dropped");
8262 assert!(detected_fence().plan(&spec, &Unfenced::Refuse).is_ok(),
8263 "the fence still will not resolve after the absent root was dropped");
8264
8265 // The other half. A workspace root that is missing is a fence that would
8266 // not cover what the user marked, and it must keep refusing.
8267 let ghost = base.join("ws/never-made");
8268 let mut marked = FenceSpec {
8269 rw: vec![fmt!("{}", ghost.display())],
8270 ro: Vec::new(),
8271 deny: Vec::new(),
8272 net: false,
8273 };
8274 let gone = drop_absent_kit_roots(&mut marked, &kits);
8275 assert!(gone.is_empty(), "a workspace root was dropped: {:?}", gone);
8276 assert_eq!(marked.rw, vec![fmt!("{}", ghost.display())]);
8277 assert!(detected_fence().plan(&marked, &Unfenced::Refuse).is_err(),
8278 "a marked folder that is not there was accepted");
8279
8280 // And a toolchain folder that IS there is left alone.
8281 let present = TOOLKIT_ROOTS.iter()
8282 .map(|k| home.join(k.tail))
8283 .find(|p| p.exists());
8284 if let Some(p) = present {
8285 let mut have = FenceSpec {
8286 rw: vec![fmt!("{}", ws.display())],
8287 ro: vec![fmt!("{}", p.display())],
8288 deny: Vec::new(),
8289 net: false,
8290 };
8291 let gone = drop_absent_kit_roots(&mut have, &kits);
8292 assert!(gone.is_empty(), "a toolchain folder that exists was dropped: {:?}", gone);
8293 }
8294
8295 // A request naming no toolkit drops nothing at all, whatever is missing:
8296 // the eligibility comes from the toolkit and not from the path.
8297 let mut none = FenceSpec {
8298 rw: Vec::new(),
8299 ro: vec![fmt!("{}", absent.display())],
8300 deny: Vec::new(),
8301 net: false,
8302 };
8303 assert!(drop_absent_kit_roots(&mut none, &[]).is_empty(),
8304 "a root was dropped for a toolkit the request never named");
8305 Ok(())
8306 }
8307
8308 /// A run that leaves a server behind is listed, reachable and stoppable --
8309 /// and nothing else on the machine can reach it.
8310 ///
8311 /// The measured incident, in one test. A daimon brought a dev server and a
8312 /// mock provider up through `run`, the command that started them exited, and
8313 /// then nothing could stop them: a later command's `kill` answered
8314 /// "Operation not permitted" because Landlock scopes signals to the domain
8315 /// that sent them, `/proc` is outside every fence so the pid could not be
8316 /// found, and the teardown script swallowed the failed kill, reported success
8317 /// and deleted its own pid files. Two ports were held with no route to them.
8318 ///
8319 /// Four things are proved here and the third is the one that makes the other
8320 /// three worth having:
8321 ///
8322 /// * the background process really is alive and really is in the run's group,
8323 /// measured from `/proc` by this test rather than by the code under test;
8324 /// * the hand lists it, as `standing`, under the identifier the run was given;
8325 /// * a SECOND fenced command cannot signal it -- which is the fault, still
8326 /// present, and the reason the hand has to be the one that can;
8327 /// * the hand stops it, and afterwards the process is gone, the listing no
8328 /// longer holds it, and the run's scratch directory has been cleared.
8329 #[tokio::test]
8330 async fn a_run_that_leaves_a_group_standing_is_listed_and_can_be_stopped() -> Outcome<()> {
8331 res!(clear_scratches("world-up"));
8332 let base = res!(fixture("standing"));
8333 let ws = base.join("ws");
8334 let pidf = ws.join("child.pid");
8335 let sh = ws.join("leaves-one.sh");
8336 // The shape `dev/world.sh --up` has: start something, write down where it
8337 // went, and return. Nothing here holds a port; the group is the point.
8338 res!(std::fs::write(&sh, fmt!(concat!(
8339 "#!/bin/bash\n",
8340 "set -u\n",
8341 "/bin/sleep 600 &\n",
8342 "echo $! > {}\n",
8343 "echo up\n"), pidf.display())));
8344
8345 let runner = res!(runner());
8346 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
8347 let started = res!(runner.spawn(
8348 exec_at("world-up", &["/bin/bash", &fmt!("{}", sh.display())], &ws), tx).await);
8349 let pgid = match started {
8350 Launch::Started(p) => p,
8351 Launch::Refused => return Err(err!(
8352 "The run was refused: {:?}", collect(&mut rx).await; Test, Mismatch)),
8353 };
8354 let rs = collect(&mut rx).await;
8355 let (exit, ..) = match ended(&rs) {
8356 Some(e) => e,
8357 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
8358 };
8359 assert_eq!(exit, 0, "the script did not run: {}", text_of(&rs, Stream::Err));
8360
8361 // Measured from /proc by this test, so that the listing below is checked
8362 // against the machine and not against the same reading of it.
8363 let child: u32 = match std::fs::read_to_string(&pidf) {
8364 Ok(t) => match t.trim().parse() {
8365 Ok(n) => n,
8366 Err(e) => return Err(err!(e, "The script wrote {:?} as a pid.", t; Test, Invalid)),
8367 },
8368 Err(e) => return Err(err!(e,
8369 "The script did not write down what it started."; Test, Missing)),
8370 };
8371 assert_eq!(Some(pgid), proc_pgrp(child),
8372 "the background process is not in the run's process group, so this test is not \
8373 measuring what it says it is");
8374
8375 // The hand said so on the way out, in a sentence naming the one way in.
8376 let note = rs.iter().find_map(|r| match r {
8377 Resp::Error { id: Some(i), message } if i == "world-up" => Some(message.clone()),
8378 _ => None,
8379 });
8380 let note = match note {
8381 Some(n) => n,
8382 None => return Err(err!(
8383 "The run ended holding a process group and said nothing: {:?}", rs;
8384 Test, Missing)),
8385 };
8386 assert!(note.contains("world-up"), "the note does not name the run: {}", note);
8387 assert!(note.contains(&fmt!("{}", pgid)), "the note does not name the group: {}", note);
8388
8389 // And it is in the listing, as standing, under that identifier.
8390 assert_eq!(res!(runner.live_count()), 0);
8391 assert_eq!(res!(runner.standing_count()), 1);
8392 let (runs, more) = res!(runner.runs().await);
8393 assert_eq!(more, 0);
8394 assert_eq!(runs.len(), 1, "{:?}", runs);
8395 assert_eq!(runs[0].id, "world-up");
8396 assert_eq!(runs[0].pid, pgid);
8397 assert_eq!(runs[0].state, RunState::Standing);
8398 assert!(runs[0].what.contains("leaves-one.sh"),
8399 "the listing does not say what was run: {:?}", runs[0]);
8400
8401 // THE FAULT, still there and now shown rather than described: a second
8402 // command cannot signal the first one's leftovers. This is why the hand
8403 // has to be the one that can.
8404 let rs = res!(run(exec_at("try-kill",
8405 &["/bin/kill", "-s", "TERM", "--", &fmt!("-{}", pgid)], &ws)).await);
8406 let (exit, ..) = match ended(&rs) {
8407 Some(e) => e,
8408 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
8409 };
8410 let said = fmt!("{}{}", text_of(&rs, Stream::Out), text_of(&rs, Stream::Err));
8411 assert_ne!(exit, 0,
8412 "a fenced command signalled another run's process group, so the leak this test is \
8413 about no longer needs the hand to fix it: {}", said);
8414 assert!(std::fs::metadata(fmt!("/proc/{}", child)).is_ok(),
8415 "the second command killed it after all");
8416
8417 // The hand can, and afterwards the machine agrees.
8418 let scratch = res!(scratches("world-up"));
8419 assert_eq!(scratch.len(), 1, "the run's scratch was cleared while its group stood");
8420 assert_eq!(res!(runner.signal("world-up", Sig::Kill).await), Signalled::Finished,
8421 "the hand could not stop a group it started");
8422 assert!(std::fs::metadata(fmt!("/proc/{}", child)).is_err(),
8423 "the background process is still there after the hand stopped its group");
8424 assert_eq!(res!(runner.standing_count()), 0, "the stopped run is still in the registry");
8425 let (runs, _) = res!(runner.runs().await);
8426 assert!(runs.is_empty(), "the listing still holds a run that is gone: {:?}", runs);
8427 assert!(res!(scratches("world-up")).is_empty(),
8428 "the run's temporary directory outlived the group it was held for");
8429
8430 // And asking again is not an error.
8431 assert_eq!(res!(runner.signal("world-up", Sig::Kill).await), Signalled::Finished);
8432 Ok(())
8433 }
8434
8435 /// An identifier holding a standing group cannot be given to a second
8436 /// command.
8437 ///
8438 /// It is the only door there is: a group a run left behind is reachable by
8439 /// that name and by nothing else at all, so letting a second run take the
8440 /// name would shut the first beyond reach exactly as `REVIEW.md` §3.6
8441 /// described for two live runs.
8442 #[tokio::test]
8443 async fn an_identifier_holding_a_standing_group_is_not_reissued() -> Outcome<()> {
8444 let base = res!(fixture("standing-id"));
8445 let ws = base.join("ws");
8446 let sh = ws.join("leaves-one.sh");
8447 res!(std::fs::write(&sh, concat!(
8448 "#!/bin/bash\n",
8449 "set -u\n",
8450 "/bin/sleep 600 &\n")));
8451
8452 let runner = res!(runner());
8453 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
8454 res!(runner.spawn(
8455 exec_at("taken", &["/bin/bash", &fmt!("{}", sh.display())], &ws), tx).await);
8456 let _ = collect(&mut rx).await;
8457 assert_eq!(res!(runner.standing_count()), 1, "nothing was left standing to test with");
8458
8459 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
8460 res!(runner.spawn(exec_at("taken", &["/bin/echo", "hi"], &ws), tx).await);
8461 let rs = collect(&mut rx).await;
8462 let reason = res!(refusal(&rs));
8463 assert!(reason.contains("taken"), "{}", reason);
8464 assert!(reason.contains("still running"), "{}", reason);
8465 assert!(reason.contains("different id"),
8466 "the refusal leaves the caller no way forward: {}", reason);
8467
8468 assert_eq!(res!(runner.signal("taken", Sig::Kill).await), Signalled::Finished);
8469 Ok(())
8470 }
8471
8472 /// Nothing this hand started outlives the conversation, standing groups
8473 /// included.
8474 ///
8475 /// A server a run left behind is reachable through this hand and through
8476 /// nothing else, so a hand that exited without stopping it would leave it
8477 /// holding its port until somebody found it from outside the app -- which is
8478 /// the incident, arriving by a different door.
8479 #[tokio::test]
8480 async fn a_standing_group_does_not_outlive_the_hand() -> Outcome<()> {
8481 let base = res!(fixture("standing-bye"));
8482 let ws = base.join("ws");
8483 let pidf = ws.join("child.pid");
8484 let sh = ws.join("leaves-one.sh");
8485 res!(std::fs::write(&sh, fmt!(concat!(
8486 "#!/bin/bash\n",
8487 "set -u\n",
8488 "/bin/sleep 600 &\n",
8489 "echo $! > {}\n"), pidf.display())));
8490
8491 let runner = res!(runner());
8492 let (tx, mut rx) = tokio::sync::mpsc::channel::<Resp>(256);
8493 res!(runner.spawn(
8494 exec_at("bye-world", &["/bin/bash", &fmt!("{}", sh.display())], &ws), tx).await);
8495 let _ = collect(&mut rx).await;
8496 let child: u32 = match std::fs::read_to_string(&pidf) {
8497 Ok(t) => match t.trim().parse() {
8498 Ok(n) => n,
8499 Err(_) => return Err(err!("The script wrote {:?} as a pid.", t; Test, Invalid)),
8500 },
8501 Err(e) => return Err(err!(e, "The script started nothing."; Test, Missing)),
8502 };
8503 assert!(std::fs::metadata(fmt!("/proc/{}", child)).is_ok());
8504
8505 assert_eq!(res!(runner.stop_all().await), 1, "the goodbye did not reach a standing group");
8506 assert!(std::fs::metadata(fmt!("/proc/{}", child)).is_err(),
8507 "a process the hand started outlived the hand's own goodbye");
8508 assert_eq!(res!(runner.standing_count()), 0);
8509 Ok(())
8510 }
8511
8512 /// A `/proc` line is read for the right field, and a zombie is not counted.
8513 ///
8514 /// The field offsets are the trap: the second field is the executable's name
8515 /// in brackets and a file name may hold brackets and spaces, so counting from
8516 /// the front reads the wrong number and reads it plausibly. The zombie is
8517 /// the other one: an exit status waiting to be collected is not a process
8518 /// holding a port, and counting one would make every killed run look as
8519 /// though it had left something behind.
8520 #[test]
8521 fn a_zombie_is_not_a_process_group_still_standing() {
8522 // A real line, from a `sleep` in group 4242.
8523 let live = "4243 (sleep) S 4242 4242 4242 0 -1 1077936128 96 0 0 0 0 0";
8524 assert!(counts_as_member(live, 4242));
8525 assert!(!counts_as_member(live, 4241), "the wrong group was matched");
8526
8527 // The same process, reaped and not yet collected.
8528 let dead = "4243 (sleep) Z 4242 4242 4242 0 -1 1077936128 96 0 0 0 0 0";
8529 assert!(!counts_as_member(dead, 4242),
8530 "a zombie was counted as a process still holding its group open");
8531
8532 // A name that looks like the rest of the line. Counting from the front
8533 // reads 7 as the group here, which is a plausible number and wrong.
8534 let awkward = "4243 (a b) c 5 6) R 4242 4242 4242 0 -1 0 1 0 0 0 0 0";
8535 assert!(counts_as_member(awkward, 4242),
8536 "the fields were counted from the wrong side of the name");
8537 assert!(!counts_as_member(awkward, 7));
8538
8539 // Nothing usable is not a member, in either direction.
8540 assert!(!counts_as_member("", 4242));
8541 assert!(!counts_as_member("4243 (sleep", 4242));
8542 assert!(!counts_as_member("4243 (sleep) S", 4242));
8543 }
8544
8545 /// A signal that did not take is never reported as a stop.
8546 ///
8547 /// The classifier and not the plumbing, because this one judgement is the
8548 /// defect: `dev/world.sh --down` swallowed a failed kill, said "stopped" and
8549 /// deleted its pid files, and there was no arm anywhere in the hand that
8550 /// could have contradicted it. Every combination is named, including the two
8551 /// that are easy to get backwards -- a `kill` that failed while the group
8552 /// died anyway is a stop, and a `kill` that succeeded while the group stands
8553 /// is not.
8554 #[test]
8555 fn a_signal_that_did_not_take_is_never_reported_as_a_stop() {
8556 let why = || Some(fmt!("/bin/kill exited 1 (kill: (-4242) - Operation not permitted)"));
8557
8558 // The group is gone. The probe outranks the exit status, because BusyBox
8559 // exits 1 on the POSIX spelling and empties the group anyway.
8560 assert_eq!(signalled("r", 4242, None, Some(false)), Signalled::Finished);
8561 assert_eq!(signalled("r", 4242, why(), Some(false)), Signalled::Finished);
8562
8563 // The group is standing and the signal was refused. THE ONE THAT MATTERS.
8564 match signalled("r", 4242, why(), Some(true)) {
8565 Signalled::Failed(s) => {
8566 assert!(s.contains("'r'"), "{}", s);
8567 assert!(s.contains("4242"), "the sentence does not name the group: {}", s);
8568 assert!(s.contains("still running"), "{}", s);
8569 assert!(s.contains("Operation not permitted"),
8570 "the sentence drops what the machine said: {}", s);
8571 },
8572 other => panic!("a refused signal on a standing group answered {:?}", other),
8573 }
8574
8575 // The group is standing and the signal was accepted: the signal went, and
8576 // that is all this says. A TERM is a request.
8577 assert_eq!(signalled("r", 4242, None, Some(true)), Signalled::Sent);
8578
8579 // The machine would not answer. Not a failure, and not a stop either.
8580 assert_eq!(signalled("r", 4242, None, None), Signalled::Sent);
8581 match signalled("r", 4242, why(), None) {
8582 Signalled::Failed(_) => (),
8583 other => panic!("a refused signal nobody could check answered {:?}", other),
8584 }
8585 }
8586
8587 /// A fenced command cannot create a symbolic link, and the kernel is what /// A fenced command cannot create a symbolic link, and the kernel is what
8588 /// refuses it.
8589 ///
8590 /// The link is the leg a daimon supplies to a leak whose other leg is
8591 /// somewhere else entirely. Ore absorbs the CONTENT of a link that leaves
8592 /// the working copy, under the link's own path, into a signed history with
8593 /// no forget; a global `post-commit` hook runs `ore mark` from outside the
8594 /// fence on the owner's key, so `ln -s ../outside/other.txt leak.txt` inside
8595 /// the workspace is the whole of the attack. Nothing about it is Ore's:
8596 /// every archiver, uploader and packager that follows a link is the same
8597 /// shape, which is why the capability is withheld here rather than a target
8598 /// check being written in one of them.
8599 ///
8600 /// Checking the target instead was considered and is weaker twice over: it
8601 /// races a repoint between the check and the read, and it cannot see a
8602 /// `symlink(2)` a compiler makes rather than an `ln` a model runs.
8603 /// Withholding `LANDLOCK_ACCESS_FS_MAKE_SYM` has neither weakness, because
8604 /// there is no call to make.
8605 ///
8606 /// Both halves are here. The control runs the same `ln` on the same paths
8607 /// with no fence, so a machine where `ln` is missing or the fixture is wrong
8608 /// says so instead of passing; the fenced run then has to fail, and the link
8609 /// has to be absent afterwards.
8610 #[tokio::test]
8611 async fn a_fenced_command_cannot_make_a_symlink() -> Outcome<()> {
8612 let base = res!(fixture("symlink-refused"));
8613 let ws = base.join("ws");
8614
8615 // Unfenced first. Without this, a fenced `ln` that failed because the
8616 // program is not there would read as the fence doing its job.
8617 let control = ws.join("control.txt");
8618 let out = res!(std::process::Command::new("/bin/ln")
8619 .args(["-s", "../outside/other.txt"])
8620 .arg(&control)
8621 .output());
8622 assert!(out.status.success(),
8623 "the control link was not made, so this machine cannot show the \
8624 difference: {}", String::from_utf8_lossy(&out.stderr));
8625 assert!(res!(std::fs::symlink_metadata(&control)).file_type().is_symlink(),
8626 "the control wrote something that is not a link");
8627 res!(std::fs::remove_file(&control));
8628
8629 // And now the same call, inside a fence that grants the workspace for
8630 // writing. Everything about it is permitted except the one syscall.
8631 let leak = ws.join("leak.txt");
8632 let req = Req::Exec {
8633 id: fmt!("symlink-refused"),
8634 argv: vec![fmt!("/bin/ln"), fmt!("-s"), fmt!("../outside/other.txt"),
8635 fmt!("{}", leak.display())],
8636 cwd: fmt!("{}", ws.display()),
8637 env: Vec::new(),
8638 stdin: None,
8639 timeout_ms: 30_000,
8640 capture: Capture::Both,
8641 fence: FenceSpec {
8642 rw: vec![fmt!("{}", ws.display())],
8643 ro: Vec::new(),
8644 deny: Vec::new(),
8645 net: false,
8646 },
8647 toolkits: Vec::new(),
8648 };
8649 let rs = res!(run(req).await);
8650 let said = fmt!("{}{}", text_of(&rs, Stream::Out), text_of(&rs, Stream::Err));
8651 let (exit, ..) = match ended(&rs) {
8652 Some(e) => e,
8653 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
8654 };
8655 assert_ne!(exit, 0,
8656 "a fenced command created a symbolic link: MAKE_SYM is granted, so \
8657 `ln -s ../outside/other.txt` succeeded inside the fence. {}", said);
8658 assert!(!leak.exists() && std::fs::symlink_metadata(&leak).is_err(),
8659 "the link is on disk at {} although the command reported failure",
8660 leak.display());
8661 // Named, so that a refusal for some other reason -- a missing program, a
8662 // cwd outside the fence -- cannot pass for this one.
8663 assert!(said.contains("denied") || said.contains("not permitted"),
8664 "the command failed for some reason other than the fence:\n{}", said);
8665
8666 // A file the fence DOES permit is still written, so what was withheld is
8667 // one capability and not the workspace.
8668 let rs = res!(run(exec_at(
8669 "symlink-ordinary",
8670 &["/usr/bin/touch", &fmt!("{}", ws.join("ordinary.txt").display())],
8671 &ws)).await);
8672 let (exit, ..) = match ended(&rs) {
8673 Some(e) => e,
8674 None => return Err(err!("No Ended was sent: {:?}", rs; Test, Missing)),
8675 };
8676 assert_eq!(exit, 0, "withholding MAKE_SYM took ordinary writing with it: {}{}",
8677 text_of(&rs, Stream::Out), text_of(&rs, Stream::Err));
8678 Ok(())
8679 }
8680
8681 /// A name is readable at the front and unguessable at the back.
8682 #[test]
8683 fn a_scratch_name_is_readable_and_unguessable() {
8684 let a = scratch_name("run-cargo");
8685 let b = scratch_name("run-cargo");
8686 assert!(a.starts_with("run-cargo-"), "the run is not recognisable: {}", a);
8687 assert_ne!(a, b, "two names for one identifier were the same");
8688 assert_eq!(a.len(), "run-cargo".len() + 1 + 32);
8689
8690 // Nothing a caller writes reaches the filesystem as anything but a name.
8691 let hostile = scratch_name("../../etc/ssh");
8692 assert!(!hostile.contains('/'), "{}", hostile);
8693 assert!(!hostile.contains('.'), "{}", hostile);
8694 assert_eq!(Path::new(&hostile).components().count(), 1);
8695
8696 // An unbounded identifier does not make an unbounded name.
8697 let long = scratch_name(&"x".repeat(4_000));
8698 assert_eq!(long.len(), SCRATCH_SLUG_MAX + 1 + 32);
8699
8700 // And one made entirely of characters that cannot appear still names
8701 // something.
8702 let empty = scratch_name("");
8703 assert!(empty.starts_with("run-"), "{}", empty);
8704 }
8705
8706 // ── Plain unit checks ───────────────────────────────────────────
8707
8708 #[test]
8709 fn test_containment_is_by_component_not_by_prefix() {
8710 assert!(under(Path::new("/work/a/b"), Path::new("/work")));
8711 assert!(under(Path::new("/work"), Path::new("/work")));
8712 assert!(!under(Path::new("/workshop"), Path::new("/work")));
8713 assert!(!under(Path::new("/elsewhere"), Path::new("/work")));
8714 // The empty root, which every path starts with and which is therefore
8715 // no root at all.
8716 assert!(!under(Path::new("/etc/ssh"), Path::new("")));
8717 assert!(!under(Path::new("/work/a"), Path::new("relative")));
8718 }
8719
8720 #[test]
8721 fn a_degraded_group_signal_is_not_discarded() {
8722 let mut slot = None;
8723 note_signalling(&mut slot, Ok(Signalling::Sent));
8724 assert!(slot.is_none());
8725
8726 note_signalling(&mut slot, Ok(Signalling::Degraded(fmt!("busybox said no"))));
8727 match &slot {
8728 Some(s) => assert!(s.contains("busybox")),
8729 None => panic!("a degraded group signal was thrown away"),
8730 }
8731
8732 // The first explanation stands; a later success does not erase it.
8733 note_signalling(&mut slot, Ok(Signalling::Sent));
8734 assert!(slot.is_some());
8735 }
8736
8737 /// The two cache folders a build in this repository actually writes are
8738 /// granted by name, and `~/.cache` itself never is.
8739 ///
8740 /// Both are here because the pair is the rule. Without the rows the clamp
8741 /// refuses the whole command when the app sends the grant, so a fenced build
8742 /// is worse off than an unfenced one -- and the cheap repair, granting
8743 /// `~/.cache`, would hand a command the pip cache, the go build cache and
8744 /// whatever else lives there, none of which any toolkit lent it.
8745 #[test]
8746 fn the_named_cache_roots_are_granted_and_the_cache_itself_is_not() -> Outcome<()> {
8747 let base = res!(fixture("cache-roots"));
8748 let ws = base.join("ws");
8749 let home = match home_dir() {
8750 Some(h) => PathBuf::from(h),
8751 None => return Err(err!("This test needs HOME."; Test, Missing)),
8752 };
8753 let rw = |p: &Path| -> FenceSpec {
8754 FenceSpec {
8755 rw: vec![fmt!("{}", ws.display()), fmt!("{}", p.display())],
8756 ro: Vec::new(),
8757 deny: Vec::new(),
8758 net: false,
8759 }
8760 };
8761 let kits = |names: &[&str]| -> Vec<String> {
8762 names.iter().map(|n| fmt!("{}", n)).collect()
8763 };
8764
8765 let targets = home.join(".cache/cargo-targets");
8766 let worlds = home.join(".cache/daimond");
8767 assert_eq!(None, vet_roots(&ws, &rw(&targets), &kits(&["rust"]), Door::Command),
8768 "the Rust toolkit cannot write the target directory this repository builds into");
8769 assert_eq!(None, vet_roots(&ws, &rw(&worlds), &kits(&["node"]), Door::Command),
8770 "the Node toolkit cannot write a world's own scratch root");
8771
8772 // Each belongs to ONE toolkit, and a grant of the other does not reach it.
8773 assert!(vet_roots(&ws, &rw(&targets), &kits(&["node"]), Door::Command).is_some(),
8774 "a target directory was granted to a request that named only Node");
8775 assert!(vet_roots(&ws, &rw(&worlds), &kits(&["rust"]), Door::Command).is_some(),
8776 "a world's scratch root was granted to a request that named only Rust");
8777
8778 // And the folder above them is never granted, however many toolkits are
8779 // in play. `~/.cache` holds the pip and go caches as well.
8780 assert!(vet_roots(&ws, &rw(&home.join(".cache")),
8781 &kits(&["rust", "node", "python", "go", "git"]), Door::Command).is_some(),
8782 "the whole of ~/.cache was granted");
8783 Ok(())
8784 }
8785
8786 /// A fence may name only roots this hand's grant could imply.
8787 ///
8788 /// `REVIEW.md` §1.5. Both halves matter equally and the second is the one
8789 /// that decides whether this ships: a clamp that refuses `/etc` and also
8790 /// refuses `~/.cargo` is a clamp that stops `cargo` working, and a security
8791 /// The Remote posture is the key and the wrapper, and neither half alone.
8792 ///
8793 /// This is where the whole of B12's answer sits for the Remote grant: the permission is not
8794 /// stored anywhere, it is READ off the two files `install.sh --remote` writes. So there is
8795 /// no fourth place a permission lives, nothing to migrate, and no setting that could go on
8796 /// saying yes after the key is deleted.
8797 ///
8798 /// Both halves are required because neither half connects to anything. A wrapper with no
8799 /// key behind it is an `ssh` on `PATH` that fails; a key with no wrapper is a key nothing
8800 /// would ever pass to OpenSSH, which takes its home from the passwd entry and not from
8801 /// `HOME`. Announcing "ready" for either would put a toolchain in a terminal's fence that
8802 /// cannot work.
8803 #[test]
8804 fn the_remote_posture_is_the_key_and_the_wrapper() -> Outcome<()> {
8805 let home = res!(fixture("remote_posture"));
8806 let base = home.join(".config/oxedyne/daimond-hand");
8807 res!(std::fs::create_dir_all(base.join("bin")));
8808 res!(std::fs::create_dir_all(base.join("ssh")));
8809
8810 // A machine where the installer never ran.
8811 assert!(!remote_ready_at(&home),
8812 "a home with no Daimond ssh in it was read as set up");
8813
8814 // The wrapper alone: an `ssh` on PATH with nothing behind it.
8815 res!(std::fs::write(base.join("bin/ssh"), "#!/bin/sh\n"));
8816 assert!(!remote_ready_at(&home),
8817 "a wrapper with no key behind it was read as set up");
8818
8819 // The key alone: nothing on PATH would ever hand it to OpenSSH.
8820 res!(std::fs::remove_file(base.join("bin/ssh")));
8821 res!(std::fs::write(base.join("ssh/id_daimond"), "k"));
8822 assert!(!remote_ready_at(&home),
8823 "a key with no wrapper in front of it was read as set up");
8824
8825 // Both, which is what the installer leaves behind.
8826 res!(std::fs::write(base.join("bin/ssh"), "#!/bin/sh\n"));
8827 assert!(remote_ready_at(&home),
8828 "the installer ran and the hand still says the machine is not set up");
8829
8830 // A directory is not a wrapper. `is_file` and not `exists`, because a fence naming a
8831 // folder where a program should be is a terminal that opens on a refusal.
8832 res!(std::fs::remove_file(base.join("bin/ssh")));
8833 res!(std::fs::create_dir_all(base.join("bin/ssh")));
8834 assert!(!remote_ready_at(&home),
8835 "a DIRECTORY called ssh was read as the wrapper");
8836 Ok(())
8837 }
8838
8839 /// The user's own shell files reach a terminal and never a command.
8840 ///
8841 /// Lane U's third refusal was `bash: ~/.bashrc: Permission denied`, and the repair is
8842 /// three named files lent read-only. The half that has to hold is the other one: a
8843 /// `.bashrc` runs code, so a command the model chose reading it would be the model
8844 /// running the user's own aliases with the model's arguments. Both doors are asked here,
8845 /// because a grant that is correct at one and silent at the other is the whole point.
8846 #[test]
8847 fn the_users_shell_files_reach_a_terminal_and_no_command() -> Outcome<()> {
8848 let home = match home_dir() {
8849 Some(h) => PathBuf::from(h),
8850 None => return Ok(()), // Nowhere to resolve them against.
8851 };
8852 let there: Vec<&str> = USER_DOTFILES.iter()
8853 .filter(|t| home.join(t).is_file())
8854 .copied()
8855 .collect();
8856 let bare = || FenceSpec { rw: Vec::new(), ro: Vec::new(), deny: Vec::new(), net: false };
8857
8858 for shut in [Door::Command, Door::File] {
8859 let mut f = bare();
8860 let lent = grant_user_dotfiles(&mut f, shut);
8861 assert!(lent.is_empty(), "a {:?} was lent {:?}", shut, lent);
8862 assert!(f.ro.is_empty(), "a {:?}'s fence grew {:?}", shut, f.ro);
8863 }
8864
8865 let mut f = bare();
8866 let lent = grant_user_dotfiles(&mut f, Door::Terminal);
8867 assert_eq!(there.len(), lent.len(),
8868 "the terminal was lent {:?} where {:?} are on this machine", lent, there);
8869 for t in there.iter() {
8870 let want = fmt!("{}", home.join(t).display());
8871 assert!(lent.contains(&want), "{} was not lent to the terminal", want);
8872 assert!(f.ro.contains(&want), "{} did not reach the fence", want);
8873 }
8874 // READ-ONLY, which is the difference between lending a file and lending the shell
8875 // that could rewrite it.
8876 assert!(f.rw.is_empty(), "the terminal was given WRITE on {:?}", f.rw);
8877 // And never the home directory itself, which is what "named one at a time" means.
8878 let h = fmt!("{}", home.display());
8879 assert!(!f.ro.contains(&h), "the whole home directory was lent");
8880 Ok(())
8881 }
8882
8883 /// A denial already in the fence is not widened from here.
8884 #[test]
8885 fn a_denied_shell_file_stays_denied() -> Outcome<()> {
8886 let home = match home_dir() {
8887 Some(h) => PathBuf::from(h),
8888 None => return Ok(()),
8889 };
8890 let mut f = FenceSpec {
8891 rw: Vec::new(),
8892 ro: Vec::new(),
8893 deny: vec![fmt!("{}", home.display())],
8894 net: false,
8895 };
8896 let lent = grant_user_dotfiles(&mut f, Door::Terminal);
8897 assert!(lent.is_empty(),
8898 "a deny somebody put on the home directory was widened from here: {:?}", lent);
8899 Ok(())
8900 }
8901
8902 /// check that breaks the build is a security check somebody turns off.
8903 #[test]
8904 fn a_fence_may_only_name_roots_the_grant_implies() -> Outcome<()> {
8905 let base = res!(fixture("vet-roots"));
8906 let ws = base.join("ws");
8907 let home = match std::env::var("HOME") {
8908 Ok(h) => PathBuf::from(h),
8909 Err(_) => return Ok(()), // Nothing to resolve a toolchain against.
8910 };
8911 let spec = |rw: Vec<String>, ro: Vec<String>| -> FenceSpec {
8912 FenceSpec { rw, ro, deny: Vec::new(), net: false }
8913 };
8914 let one = |p: &Path| -> Vec<String> { vec![fmt!("{}", p.display())] };
8915 let kits = |names: &[&str]| -> Vec<String> {
8916 names.iter().map(|n| fmt!("{}", n)).collect()
8917 };
8918 let all = kits(&["rust", "node", "python", "go", "git", "remote"]);
8919
8920 // The workspace itself, and anything under it, with no toolkit in play at all.
8921 assert_eq!(None, vet_roots(&ws, &spec(one(&ws), Vec::new()), &[], Door::Command));
8922 assert_eq!(None, vet_roots(&ws, &spec(one(&ws.join("sub")), Vec::new()), &[], Door::Command));
8923
8924 // Every toolchain a granted toolkit can name, at the level that toolkit lends it --
8925 // and at the DOOR it lends it to. A row marked `term` is the Remote toolchain: an ssh
8926 // key, lent to a terminal the user opened, and refused to a command however the
8927 // request spells its grant, because the shell at the far end of an ssh is fenced by
8928 // nothing on this machine.
8929 for k in TOOLKIT_ROOTS {
8930 let p = home.join(k.tail);
8931 let named = kits(&[k.kit]);
8932 let door = match k.term { true => Door::Terminal, false => Door::Command };
8933 if k.term {
8934 for shut in [Door::Command, Door::File] {
8935 let said = vet_roots(&ws, &spec(one(&ws), one(&p)), &named, shut);
8936 match said {
8937 Some(s) => assert!(s.contains("terminal the user opened by hand"),
8938 "the refusal must say WHY the door decides it: {}", s),
8939 None => return Err(err!(
8940 "{} reached a {:?}, and an ssh key must not: a command that could \
8941 run ssh is a command with a shell on another machine that nothing \
8942 here fences.", k.tail, shut; Bug)),
8943 }
8944 }
8945 }
8946 assert_eq!(None, vet_roots(&ws, &spec(one(&ws), one(&p)), &named, door),
8947 "the {} toolchain root was refused as readable", k.tail);
8948 // And a path inside one, which is how the app actually names them:
8949 // `~/.cargo/registry/cache`, not `~/.cargo`.
8950 assert_eq!(None, vet_roots(&ws, &spec(one(&ws), one(&p.join("inner"))), &named, door),
8951 "a path inside the {} toolchain was refused", k.tail);
8952 let writing = vet_roots(&ws, &spec(one(&p), Vec::new()), &named, door);
8953 if k.write {
8954 assert_eq!(None, writing,
8955 "the {} cache must be writable or the build it exists for cannot run", k.tail);
8956 } else {
8957 // The 0c reproduction, in the form that mattered: `~/.local/bin` is first on
8958 // PATH, and a shim written there runs as the user on the next shell command.
8959 assert!(writing.is_some(),
8960 "rw on {} was accepted, and it is lent for reading", k.tail);
8961 match writing {
8962 Some(s) => assert!(s.contains("WRITE"),
8963 "the refusal must say it is the LEVEL that is wrong: {}", s),
8964 None => (),
8965 }
8966 }
8967 }
8968
8969 // The conditionality, which is the other half of 0c: a toolchain folder is out of reach
8970 // for a request that named no toolkit, and for one that named a different toolkit.
8971 for k in TOOLKIT_ROOTS {
8972 let p = home.join(k.tail);
8973 let door = match k.term { true => Door::Terminal, false => Door::Command };
8974 assert!(vet_roots(&ws, &spec(one(&ws), one(&p)), &[], door).is_some(),
8975 "{} was reachable with no toolkit granted", k.tail);
8976 assert!(vet_roots(&ws, &spec(one(&p), Vec::new()), &[], door).is_some(),
8977 "{} was WRITABLE with no toolkit granted", k.tail);
8978 let others: Vec<String> = ["rust", "node", "python", "go", "git", "remote"].iter()
8979 .filter(|n| **n != k.kit).map(|n| fmt!("{}", n)).collect();
8980 assert!(vet_roots(&ws, &spec(one(&ws), one(&p)), &others, door).is_some(),
8981 "{} was reachable to a request that granted only {:?}", k.tail, others);
8982 }
8983
8984 // A name this build does not know grants nothing rather than refusing everything.
8985 assert_eq!(None, vet_roots(&ws, &spec(one(&ws), Vec::new()), &kits(&["zig"]), Door::Command));
8986 assert!(vet_roots(&ws, &spec(one(&ws), one(&home.join(".cargo/bin"))), &kits(&["zig"]), Door::Command)
8987 .is_some(), "an unknown toolkit name granted a toolchain");
8988
8989 // The hand's own scratch, which the hand appends to every fence itself.
8990 if let Ok(s) = scratch_base() {
8991 assert_eq!(None, vet_roots(&ws, &spec(one(&s.join("run-1")), Vec::new()), &[], Door::Command));
8992 }
8993
8994 // And everything else, even with every toolkit granted. `/etc` is the measured one:
8995 // before this existed, `rw:["/etc"]` with `cwd:"/etc"` ran `ls /etc/ssh` and returned it.
8996 for bad in [
8997 PathBuf::from("/etc"),
8998 PathBuf::from("/"),
8999 PathBuf::from("/tmp"),
9000 PathBuf::from("/usr"),
9001 home.clone(),
9002 home.join(".ssh"),
9003 home.join(".config"),
9004 home.join(".cache"), // the folder itself, although two tails under it are granted
9005 home.join(".cargo"), // the folder itself: 2.2 GB, and the crates.io token
9006 base.join("outside"),
9007 ] {
9008 let said = vet_roots(&ws, &spec(one(&bad), Vec::new()), &all, Door::Command);
9009 assert!(said.is_some(), "rw:[{}] was accepted", bad.display());
9010 let said = vet_roots(&ws, &spec(one(&ws), one(&bad)), &all, Door::Command);
9011 assert!(said.is_some(), "ro:[{}] was accepted", bad.display());
9012 // The refusal names the path and where to fix it, or it is not a
9013 // refusal somebody can act on.
9014 match said {
9015 Some(s) => {
9016 assert!(s.contains(&fmt!("{}", bad.display())), "{}", s);
9017 assert!(s.contains("TOOLKIT_ROOTS"), "{}", s);
9018 },
9019 None => (),
9020 }
9021 }
9022
9023 // A deny is never clamped: it only ever takes access away, so a caller
9024 // naming one outside the grant has narrowed its own fence.
9025 assert_eq!(None, vet_roots(&ws, &FenceSpec {
9026 rw: one(&ws),
9027 ro: Vec::new(),
9028 deny: vec![fmt!("/etc"), fmt!("{}", home.join(".ssh").display())],
9029 net: false,
9030 }, &[], Door::Command));
9031 Ok(())
9032 }
9033
9034 /// The whole fence the app composes for a granted toolkit survives the clamp.
9035 ///
9036 /// The table here mirrors `Toolkit::grants` in the app's `src/tools.rs`, and two copies can
9037 /// drift. This is the test that notices: it names the paths the app actually sends for the
9038 /// Rust toolkit -- the ones `Kit::resolve` puts in `ro` and `rw` -- and asserts the clamp
9039 /// takes all of them. A drift shows up here as a refusal of a real build rather than as a
9040 /// user turning the fence off.
9041 #[test]
9042 fn the_fence_the_app_composes_for_a_toolkit_passes_the_clamp() -> Outcome<()> {
9043 let base = res!(fixture("vet-kit"));
9044 let ws = base.join("ws");
9045 let home = match std::env::var("HOME") {
9046 Ok(h) => PathBuf::from(h),
9047 Err(_) => return Ok(()),
9048 };
9049 let at = |t: &str| -> String { fmt!("{}", home.join(t).display()) };
9050 // Exactly what `Kit::resolve` composes for `rust`, plus the workspace.
9051 let spec = FenceSpec {
9052 rw: vec![
9053 fmt!("{}", ws.display()),
9054 at(".cargo/registry"),
9055 at(".cargo/git"),
9056 at(".cargo/.package-cache"),
9057 ],
9058 ro: vec![at(".cargo/bin"), at(".rustup")],
9059 // The app denies these; a deny is not clamped, and the hand must not hand back what
9060 // the app carefully withheld by treating the deny as a grant.
9061 deny: vec![at(".cargo/credentials.toml"), at(".cargo/credentials"), at(".netrc")],
9062 net: true,
9063 };
9064 assert_eq!(None, vet_roots(&ws, &spec, &[fmt!("rust")], Door::Command),
9065 "the fence the app composes for the Rust toolkit was refused by the hand's clamp");
9066 // And the same fence with the grant absent is refused outright.
9067 assert!(vet_roots(&ws, &spec, &[], Door::Command).is_some(),
9068 "the Rust toolchain was reachable to a request that granted no toolkit");
9069
9070 // And the same for git, which is the toolkit whose absence from this table is quiet rather
9071 // than loud: a fenced git that cannot read `~/.gitconfig` runs with no `core.hooksPath`,
9072 // and an unreadable hooks directory looks exactly like an empty one. Nothing here is
9073 // writable -- a configuration a command could rewrite decides what runs on the user's next
9074 // commit -- and every credential the app denies is denied and not granted back.
9075 let spec = FenceSpec {
9076 rw: vec![fmt!("{}", ws.display())],
9077 ro: vec![at(".gitconfig"), at(".config/git")],
9078 deny: vec![at(".git-credentials"), at(".config/git/credentials"), at(".ssh"),
9079 at(".netrc")],
9080 net: true,
9081 };
9082 assert_eq!(None, vet_roots(&ws, &spec, &[fmt!("git")], Door::Command),
9083 "the fence the app composes for the Git toolkit was refused by the hand's clamp");
9084 assert!(vet_roots(&ws, &spec, &[], Door::Command).is_some(),
9085 "the user's git configuration was reachable with no toolkit granted");
9086 // Read-only in the table means read-only at the clamp.
9087 assert!(vet_roots(&ws, &FenceSpec {
9088 rw: vec![fmt!("{}", ws.display()), at(".gitconfig")],
9089 ro: Vec::new(),
9090 deny: Vec::new(),
9091 net: false,
9092 }, &[fmt!("git")], Door::Command).is_some(), "the git configuration was accepted as writable");
9093 Ok(())
9094 }
9095
9096 // ── A push that could destroy work at the far end ────────────────────
9097 //
9098 // Each of these is written as the thing going wrong: a forced push getting through because it
9099 // was spelled with a cluster, or a refspec, or a configuration option -- and an ordinary push
9100 // being refused, which is the failure that gets a guard switched off.
9101
9102 #[test]
9103 fn a_forced_push_is_refused_however_it_is_spelled() {
9104 let argv = |v: &[&str]| -> Vec<String> { v.iter().map(|s| fmt!("{}", s)).collect() };
9105 for cmd in [
9106 vec!["git", "push", "--force"],
9107 vec!["git", "push", "--force-with-lease"],
9108 vec!["git", "push", "--force-with-lease=main"],
9109 vec!["git", "push", "--force-if-includes"],
9110 vec!["git", "push", "--delete", "origin", "main"],
9111 vec!["git", "push", "--mirror"],
9112 vec!["git", "push", "--prune", "origin"],
9113 vec!["git", "push", "--no-verify"],
9114 vec!["git", "push", "--receive-pack=/tmp/x"],
9115 vec!["git", "push", "--exec=/tmp/x"],
9116 vec!["git", "push", "-f"],
9117 vec!["git", "push", "-uf", "origin", "main"], // the cluster
9118 vec!["git", "push", "-qfu", "origin", "main"],
9119 vec!["git", "push", "-d", "origin", "main"],
9120 vec!["git", "push", "origin", "+main:main"], // the refspec spelling of --force
9121 vec!["git", "push", "origin", ":main"], // and of --delete
9122 vec!["git", "push", "origin", "--", "+main"], // still a refspec after `--`
9123 vec!["/usr/bin/git", "push", "--force"], // an absolute program
9124 vec!["git", "-C", "/somewhere", "push", "--force"], // the subcommand is not second
9125 vec!["git", "--no-pager", "push", "-f"],
9126 // Configuration, which is where a forced refspec can be written instead.
9127 vec!["git", "-c", "remote.origin.push=+main:main", "push", "origin"],
9128 vec!["git", "-cremote.origin.push=+main:main", "push", "origin"],
9129 vec!["git", "--config-env=remote.origin.push=X", "push", "origin"],
9130 vec!["git", "--exec-path=/tmp", "push", "origin"],
9131 ] {
9132 match screen_git_push(&argv(&cmd)) {
9133 Some(s) => {
9134 assert!(s.starts_with("Refused: "),
9135 "{:?} was refused in words nobody can act on: {}", cmd, s);
9136 assert!(s.contains("machine hand"),
9137 "{:?} was refused without saying which guard spoke: {}", cmd, s);
9138 },
9139 None => panic!("{:?} was allowed through the hand", cmd),
9140 }
9141 }
9142 }
9143
9144 #[test]
9145 fn an_ordinary_push_is_not_refused() {
9146 let argv = |v: &[&str]| -> Vec<String> { v.iter().map(|s| fmt!("{}", s)).collect() };
9147 for cmd in [
9148 vec!["git", "push"],
9149 vec!["git", "push", "origin", "main"],
9150 vec!["git", "push", "-u", "origin", "main"],
9151 vec!["git", "push", "--set-upstream", "origin", "main"],
9152 vec!["git", "push", "--dry-run"],
9153 vec!["git", "push", "-n"], // `-n` on a push is --dry-run
9154 vec!["git", "push", "--tags"],
9155 vec!["git", "push", "--no-force-with-lease"], // turns forcing OFF
9156 vec!["git", "push", "-o", "ci.skip", "origin", "main"],
9157 // The remote is not this guard's business: the app refuses anything but `origin`
9158 // because ITS credential is scoped to one host, and that reason does not travel here.
9159 vec!["git", "push", "upstream", "main"],
9160 vec!["git", "push", "https://example.com/r.git", "main"],
9161 vec!["git", "push", "--repo=https://example.com/r.git"],
9162 // Not a push at all.
9163 vec!["git", "commit", "-m", "x"],
9164 vec!["git", "log", "--force"],
9165 vec!["git", "--version"],
9166 vec!["git"],
9167 vec!["cargo", "push", "--force"],
9168 vec!["gitk", "push", "--force"],
9169 // `-c` is only refused on a push, because it is the push that this guard is about.
9170 vec!["git", "-c", "user.name=x", "commit", "-m", "y"],
9171 ] {
9172 assert_eq!(None, screen_git_push(&argv(&cmd)),
9173 "{:?} is an ordinary command and was refused", cmd);
9174 }
9175 assert_eq!(None, screen_git_push(&[]));
9176 }
9177
9178 /// The guard is CALLED, and not merely written.
9179 ///
9180 /// `REVIEW.md` §1.2 is why this test exists: `seccomp.rs` implemented its answer, had passing
9181 /// unit tests for both halves, and was called from nowhere -- so a passing unit test on the
9182 /// pure decision was precisely the evidence that failed. This drives the real `spawn`.
9183 #[tokio::test]
9184 async fn a_forced_push_is_refused_by_the_real_spawn_and_not_only_by_the_function() -> Outcome<()> {
9185 let rs = res!(run(exec("gp", &["/usr/bin/git", "push", "--force", "origin", "main"])).await);
9186 match rs.first() {
9187 Some(Resp::Refused { reason, .. }) => {
9188 assert!(reason.contains("fast-forward"),
9189 "the refusal is not the push guard's: {}", reason);
9190 assert!(reason.contains("machine hand"), "{}", reason);
9191 },
9192 other => return Err(err!(
9193 "A forced push reached the launcher; the hand said {:?}.", other; Test, Mismatch)),
9194 }
9195 // And an ordinary git command is not refused by it. `--version` needs no repository, so
9196 // what this measures is the guard and not the state of the checkout.
9197 let rs = res!(run(exec("gv", &["/usr/bin/git", "--version"])).await);
9198 assert!(!matches!(rs.first(), Some(Resp::Refused { .. })),
9199 "an ordinary git command was refused: {:?}", rs.first());
9200 Ok(())
9201 }
9202
9203 #[test]
9204 fn the_refusal_says_it_is_a_rule_rather_than_a_fault() {
9205 let argv: Vec<String> = ["git", "push", "-uf", "origin", "main"].iter()
9206 .map(|s| fmt!("{}", s)).collect();
9207 let s = match screen_git_push(&argv) {
9208 Some(s) => s,
9209 None => panic!("a forced push was allowed"),
9210 };
9211 // The LETTER and not the cluster, or a model told `-uf` was refused tries `-u -f`.
9212 assert!(s.contains("'-f'"), "{}", s);
9213 assert!(s.contains("-uf"), "{}", s);
9214 assert!(s.contains("do not try another spelling"), "{}", s);
9215 assert!(s.contains("let the user push it themselves"),
9216 "the refusal leaves the model no way forward: {}", s);
9217 }
9218
9219 #[test]
9220 fn test_timeout_is_clamped() {
9221 assert_eq!(clamp_timeout(0), DEFAULT_TIMEOUT_MS);
9222 assert_eq!(clamp_timeout(500), 500);
9223 assert_eq!(clamp_timeout(u64::MAX), TIMEOUT_MAX_MS);
9224 }
9225
9226 #[test]
9227 fn test_chunks_split_on_character_boundaries() {
9228 let s = "é".repeat(CHUNK_MAX); // Two bytes each, so twice over the limit.
9229 let parts = split_chunks(&s);
9230 assert!(parts.len() > 1);
9231 for p in &parts {
9232 assert!(p.len() <= CHUNK_MAX);
9233 }
9234 assert_eq!(parts.concat(), s);
9235 }
9236}