Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/hand/src/main.rs

173 KiB, 1 run

created by r2519314175:921, 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//! The native messaging host's entry point, and the loop that serves a browser.
2//!
3//! Chrome launches this binary, speaks length-prefixed JSON over stdin and
4//! stdout, and kills it when the extension lets go. **Standard output is the
5//! wire**, so nothing here may print to it once a browser is on the other end:
6//! a stray `println!` is a corrupted frame and a connection Chrome drops
7//! without explanation. Diagnostics go to standard error. `--report` is the
8//! mode for a person at a terminal rather than a browser at a pipe, and it is
9//! the only one that prints.
10//!
11//! # The shape of the loop
12//!
13//! Three parts, deliberately not one:
14//!
15//! * **The reader** is an operating system thread of its own, blocking on
16//! stdin. It turns bytes into [`Inbound`] and never touches the writer.
17//! * **The dispatcher** is this task. It answers the handshake, writes the
18//! record, gates a command on the fence and hands the run to [`Runner`]. It
19//! never waits on output.
20//! * **The writer** is a task holding stdout. It journals a response and then
21//! frames it.
22//!
23//! That division is the answer to `REVIEW.md` §3.7. A loop that awaited
24//! [`Runner::spawn`] would stop reading while a noisy command filled the
25//! response channel, and the `Signal` that would have stopped the noise would
26//! never arrive. Here the reader cannot be blocked by the writer, and the
27//! dispatcher's own line to the writer is separate from the bulk one, so a
28//! stalled page delays output and nothing else.
29//!
30//! The reader also answers §3.3. An inbound frame that declares more than the
31//! ceiling has its body **consumed** before the refusal is reported, so the
32//! next request is read from a frame boundary rather than from the middle of
33//! somebody else's payload. Beyond [`RESYNC_MAX`] the connection is ended
34//! instead, because a length that large is not a message that went wrong.
35
36use daimond_hand::{
37 codec::{
38 chunk_fit,
39 resp_fits,
40 Fault,
41 Frame,
42 INBOUND_MAX,
43 LEN_PREFIX,
44 },
45 exec::{
46 launch_main,
47 Door,
48 Launcher,
49 Runner,
50 Signalled,
51 LAUNCH_ARG,
52 },
53 fence::{
54 Fence,
55 Plan,
56 Unfenced,
57 },
58 journal::{
59 self,
60 Cfg as JournalCfg,
61 Event,
62 Journal,
63 },
64 seccomp::{
65 Seccomp,
66 Spec as SysSpec,
67 },
68 verify,
69 wire::{
70 proto_ok,
71 proto_refusal,
72 Req,
73 Resp,
74 },
75};
76
77use oxedyne_fe2o3_core::prelude::*;
78use oxedyne_fe2o3_core::rand::Rand;
79
80use std::{
81 fs,
82 io::Read,
83 path::{
84 Path,
85 PathBuf,
86 },
87 sync::{
88 atomic::{
89 AtomicBool,
90 Ordering,
91 },
92 Arc,
93 Mutex,
94 },
95 time::Duration,
96};
97
98use tokio::{
99 io::{
100 AsyncWrite,
101 AsyncWriteExt,
102 },
103 sync::mpsc::{
104 channel,
105 error::TrySendError,
106 Receiver,
107 Sender,
108 },
109};
110
111// ┌───────────────────────────────────────────────────────────────┐
112// │ Limits and constants │
113// └───────────────────────────────────────────────────────────────┘
114
115/// The framing this binary speaks.
116///
117/// Fixed rather than configurable: a native messaging host is launched by a
118/// browser onto a pipe, and the WebSocket arm of [`Frame`] belongs to the Cloud
119/// tier, which supplies its own transport and its own loop.
120const FRAMING: Frame = Frame::NativeMessaging;
121
122/// The largest inbound frame whose body will be read and thrown away to regain
123/// frame synchronisation.
124///
125/// `ext/hand.js` forwards anything under 60 MB, so a message that is merely too
126/// big is a case that will happen and one the page should be told the ceiling
127/// for. A prefix beyond this is not a message that went wrong -- it is a
128/// sender asking the hand to read four gigabytes -- and the connection is ended
129/// instead.
130const RESYNC_MAX: usize = 64 * 1024 * 1024;
131
132/// Bytes discarded in one read while resynchronising.
133const SKIP_CHUNK: usize = 64 * 1024;
134
135/// How many requests may be waiting on the dispatcher.
136///
137/// Bounded, so a page that floods the pipe cannot make the hand allocate
138/// without limit; the reader thread simply blocks, which is backpressure
139/// arriving where it belongs.
140const REQ_QUEUE: usize = 64;
141
142/// How many responses from running commands may be waiting on the writer.
143const BULK_QUEUE: usize = 256;
144
145/// How many of the hand's own responses may be waiting on the writer.
146///
147/// Separate from the bulk queue so that a refusal or a handshake never queues
148/// behind a megabyte of somebody's build output.
149const CTL_QUEUE: usize = 256;
150
151/// How long the writer is given to drain after the conversation has ended.
152const DRAIN_MS: u64 = 3_000;
153
154/// Bytes held back from a chunk for the marker that says what was dropped.
155const MARKER_RESERVE: usize = 128;
156
157/// The name of the file, beside the journal, that names the granted root.
158///
159/// In `lib.rs` because [`journal`] needs it too, to tell its own directory from
160/// somebody else's.
161use daimond_hand::ROOT_FILE;
162use daimond_hand::TERMINAL_ROOT_FILE;
163
164/// The variable that names the granted root, which takes precedence over the file.
165const ROOT_VAR: &str = "DAIMOND_HAND_ROOT";
166
167/// The terminal ceiling's variable, for the same reason [`ROOT_VAR`] exists.
168const TERMINAL_ROOT_VAR: &str = "DAIMOND_HAND_TERMINAL_ROOT";
169
170/// Daimond's own directory inside a workspace.
171///
172/// The app's `DAIMOND_DIR`, spelled here because the two halves have no shared
173/// code and the name is part of the layout rather than of either program. A
174/// fence always denies it (`fence_spec` puts it in `deny`), which is what makes
175/// it the right place for the identity file below: a fenced command cannot read
176/// what it would need to forge one.
177const APP_DIR: &str = ".daimond";
178
179/// The file inside it that says which folder this is.
180const WS_ID_FILE: &str = "workspace.id";
181
182/// The prefix the workspace identity travels under, inside `caps`.
183const WS_CAP: &str = "ws:";
184
185/// What the identity says when the hand could not establish one.
186///
187/// A token is 32 hexadecimal characters and can never be this word, so the page
188/// tells the two apart without a second field.
189const WS_UNPROVEN: &str = "unproven";
190
191// ┌───────────────────────────────────────────────────────────────┐
192// │ What the loop is set up with │
193// └───────────────────────────────────────────────────────────────┘
194
195/// Everything the loop needs that a test may want to vary.
196#[derive(Clone, Debug)]
197struct Serve {
198 /// Where the record is written.
199 journal: JournalCfg,
200 /// What this machine can enforce.
201 fence: Fence,
202 /// The absolute folder this hand may work in.
203 root: PathBuf,
204 /// The folders this machine will let a TERMINAL be fenced to, widest last.
205 ///
206 /// Always at least the granted root. See [`terminal_ceilings`]: the page chooses
207 /// between them and cannot name a third, which is the Toolkit rule applied to the
208 /// root itself.
209 term_ceilings: Vec<PathBuf>,
210 /// Whether `terminal-root.txt` pinned that list, which is a decision the user already made.
211 term_pinned: bool,
212 /// What is re-executed to apply the fence before a command exists.
213 ///
214 /// [`Launcher::SelfExe`] everywhere but in a test, where `/proc/self/exe`
215 /// is libtest and libtest's `main` does not dispatch [`LAUNCH_ARG`].
216 launcher: Launcher,
217}
218
219/// How the conversation finished.
220///
221/// An enum rather than a bare `Ok(())`, because the closing line of the journal
222/// should say which of these happened and a test should be able to assert on it.
223#[derive(Clone, Debug, Eq, PartialEq)]
224enum Ending {
225 /// The page said `bye`.
226 Goodbye,
227 /// The stream ended, which is how a browser usually says goodbye.
228 Closed,
229 /// The hand ended it, and this is why.
230 Stopped(String),
231}
232
233impl Ending {
234
235 /// The phrase the journal's closing line carries.
236 fn why(&self) -> String {
237 match self {
238 Self::Goodbye => fmt!("the page said goodbye"),
239 Self::Closed => fmt!("the page closed the pipe"),
240 Self::Stopped(s) => s.clone(),
241 }
242 }
243}
244
245/// One thing the reader thread hands the dispatcher.
246///
247/// A frame that did not become a message is [`Inbound::Bad`] rather than an
248/// error, because the framing is still intact and the next request is still
249/// worth serving; only [`Inbound::Gone`] ends the conversation.
250#[derive(Clone, Debug)]
251enum Inbound {
252 /// A message the page sent.
253 Msg(Req),
254 /// A frame that did not become a message, and the sentence saying why.
255 Bad {
256 /// Which named fault the codec raised, where it named one.
257 fault: Option<Fault>,
258 /// What was wrong, in a sentence the page can act on.
259 detail: String,
260 },
261 /// The stream ended, and this is how.
262 Gone {
263 /// Whether it ended between frames, which is how a browser says goodbye.
264 clean: bool,
265 /// Why, in a phrase.
266 reason: String,
267 },
268}
269
270// ┌───────────────────────────────────────────────────────────────┐
271// │ Modes for a person at a terminal │
272// └───────────────────────────────────────────────────────────────┘
273
274/// What a person gets when they run the binary themselves.
275///
276/// The installer tells them to, and it is the honest answer to "is this thing
277/// actually protecting me": the capabilities are what the fence can enforce on
278/// THIS kernel, and the holes are what it cannot. A tool that printed only the
279/// first list would be worse than one that printed nothing, because it would be
280/// believed.
281fn report() -> Outcome<()> {
282 let os = res!(daimond_hand::checked_os());
283 println!("{} {} on {}", daimond_hand::HOST_NAME, daimond_hand::version(), os);
284 println!("protocol {}", daimond_hand::PROTO);
285 println!();
286
287 // Which folder, and whether the browser could tell it is that folder. Both
288 // are configuration a person can get wrong, and neither is visible anywhere
289 // else, so a report that omitted them would be answering the easy half.
290 println!("Which folder it may work in:");
291 match journal::default_dir().and_then(|d| granted_root(&d)) {
292 Ok(root) => {
293 println!(" {}", root.display());
294 match workspace_id(&root) {
295 Identity::Known(t) => println!(
296 " identity {}, from {}", t,
297 root.join(APP_DIR).join(WS_ID_FILE).display()),
298 Identity::Unproven(why) => println!(
299 " - no identity, so the browser cannot check that this is \
300 the folder you opened: {}", why),
301 }
302 },
303 Err(e) => println!(" - none: {}", e.msgs().join(" ")),
304 }
305 println!();
306
307 // WHAT RUNS OUTSIDE THE FENCE, said to a person before the fence is described --
308 // because a list of what the compartment enforces, printed above a verb that steps
309 // around it, would be a report that told the truth twice and the whole truth never.
310 println!("Verifiers it will run, OUTSIDE the fence:");
311 match journal::default_dir().and_then(|d| granted_root(&d)) {
312 Ok(root) => match verify::catalogue(&root) {
313 Ok(v) if v.is_empty() => println!(
314 " - none: there is no dev/verify_*.mjs in that folder, so the \
315 verify verb refuses on this machine."),
316 Ok(v) => {
317 println!(" {} in {}", v.len(), root.join(verify::DEV_DIR).display());
318 println!(
319 " Each is run by NAME, looked up in that directory, with an argument \
320 vector this program builds; a page cannot name a path, a program or an \
321 argument. They run unfenced deliberately -- a fenced command cannot \
322 reach the display server, so a verifier that drives a browser cannot \
323 run at all -- and each run is journalled with fence:none.");
324 },
325 Err(e) => println!(" - none: {}", e.msgs().join(" ")),
326 },
327 Err(_) => println!(" - none, since no folder is granted."),
328 }
329 println!();
330
331 println!("What this machine can enforce:");
332 for c in enforcing() {
333 println!(" {}", c);
334 }
335 println!();
336 println!("What it cannot:");
337 for h in remaining() {
338 println!(" - {}", h);
339 }
340 Ok(())
341}
342
343/// Everything in force on a command, from both layers, in one list.
344///
345/// The compartment is two mechanisms and the user is owed one answer. Landlock
346/// says what a command may open; [`daimond_hand::seccomp`] says what it may
347/// *call*, which is where `chmod` on a denied file and the session bus live. A
348/// caps list from one of them describes half a compartment.
349fn enforcing() -> Vec<String> {
350 let mut out = Fence::detect().caps();
351 out.extend(Seccomp::detect().caps());
352 out
353}
354
355/// Everything still reachable, from both layers, in one list.
356///
357/// **This is the sentence the consent window is drawn from**, so it has to be
358/// the composed truth rather than either half. `Fence::holes` is told what the
359/// filter closes, so the two entries seccomp answers -- the metadata calls and
360/// the session bus -- do not appear twice and do not appear at all once they are
361/// shut; and what the filter itself cannot do is added from its own two lists,
362/// which are about the mechanism and about the spec that was chosen.
363fn remaining() -> Vec<String> {
364 let sys = Seccomp::detect();
365 let spec = SysSpec::for_command();
366 let have = matches!(sys, Seccomp::Linux { .. });
367 let mut out = Fence::detect().holes(if have { Some(&spec) } else { None });
368 out.extend(sys.holes());
369 if let Ok(f) = sys.plan(&spec) {
370 out.extend(f.holes());
371 }
372 out
373}
374
375/// Whether an argument means "a browser launched this".
376///
377/// Chrome passes the calling extension's origin as the first argument and
378/// nothing else; on Windows it adds a parent window handle. Firefox passes the
379/// manifest path and the extension id. None of them is a flag, so an argument
380/// that looks like one of these is not an error -- it is the browser
381/// introducing itself.
382///
383/// # Arguments
384/// * `arg` - The first argument.
385fn is_browser_arg(arg: &str) -> bool {
386 arg.starts_with("chrome-extension://")
387 || arg.starts_with("moz-extension://")
388 || arg.starts_with("--parent-window=")
389}
390
391// ┌───────────────────────────────────────────────────────────────┐
392// │ The granted root │
393// └───────────────────────────────────────────────────────────────┘
394
395/// The absolute folder this hand may work in.
396///
397/// The page cannot know it. The File System Access API hands the page a
398/// *handle* and never a path, so the only end of the conversation that can name
399/// the folder in the machine's own terms is this one, and `src/tools.rs`
400/// refuses to run anything without it.
401///
402/// [`ROOT_VAR`] first, then a single line in [`ROOT_FILE`] beside the journal.
403/// There is deliberately no third answer: a hand that guessed a root would be
404/// guessing what a command may touch.
405///
406/// # Arguments
407/// * `dir` - The journal directory, which is where the file lives.
408///
409/// # Returns
410/// The canonical directory, or an error naming both places it looked.
411fn granted_root(dir: &Path) -> Outcome<PathBuf> {
412 let raw = match std::env::var(ROOT_VAR) {
413 Ok(v) if !v.trim().is_empty() => v.trim().to_string(),
414 _ => match root_from_file(dir) {
415 Some(s) => s,
416 None => return Err(err!(
417 "This hand has not been told which folder it may work in, so it \
418 will not serve a page. Set {} to an absolute path, or write that \
419 path as the first line of '{}'. Nothing was guessed, because a \
420 guessed root is a guess about what a command may touch.",
421 ROOT_VAR, dir.join(ROOT_FILE).display();
422 Missing, Configuration, Path)),
423 },
424 };
425 if raw.is_empty() {
426 return Err(err!(
427 "The granted root is the empty string, which names every path there \
428 is rather than none. Set {} to an absolute path.", ROOT_VAR;
429 Invalid, Configuration, Path));
430 }
431 let p = PathBuf::from(&raw);
432 if !p.is_absolute() {
433 return Err(err!(
434 "The granted root '{}' is not an absolute path. The hand joins the \
435 page's workspace-relative names onto it, and a relative root would \
436 resolve against whatever directory the browser happened to be \
437 started in.", raw;
438 Invalid, Configuration, Path));
439 }
440 let real = res!(fs::canonicalize(&p).map_err(|e| err!(e,
441 "The granted root '{}' cannot be resolved, so the hand cannot say what \
442 a command would be allowed to touch. It must exist before it can be \
443 granted.", raw;
444 Invalid, Configuration, Path)));
445 if !real.is_dir() {
446 return Err(err!(
447 "The granted root '{}' is not a directory.", real.display();
448 Invalid, Configuration, Path));
449 }
450 Ok(real)
451}
452
453// ┌───────────────────────────────────────────────────────────────┐
454// │ Whether the granted root is the page's workspace │
455// └───────────────────────────────────────────────────────────────┘
456
457/// What the hand can say about *which* folder it was granted.
458///
459/// # The problem this exists for
460///
461/// `REVIEW.md` §1.14. The two ends name the same folder in two ways that
462/// cannot be compared: the page holds a File System Access *handle*, which has
463/// no path and cannot be turned into one, and the hand holds a path it was
464/// configured with. `Tool::run` then joins the page's workspace-relative names
465/// -- `src/main.rs`, `Cargo.toml` -- onto the hand's root and fences the result.
466///
467/// Nothing checked that these were the same folder, and every failure is silent
468/// and plausible. A `root.txt` left behind from a different project, a page
469/// whose workspace lives only in OPFS and has no folder at all, a user who
470/// granted the browser one directory and the hand another: in each of them the
471/// model reads one tree, the command runs in a second, and the output is
472/// perfectly reasonable answers to a question nobody asked. Fencing works
473/// exactly as designed the whole time, because the fence was told to protect the
474/// wrong folder.
475///
476/// # What is done about it
477///
478/// The hand puts a random token in `<root>/.daimond/workspace.id` and publishes
479/// it in `caps`. The page can read that file through the handle it already has,
480/// and one comparison then settles the question: the same token means the two
481/// names denote one directory, and anything else means they do not. The token
482/// is written once and kept, so it is an identity for the folder rather than for
483/// the run, and a page that remembers it notices the folder changing underneath
484/// it as well.
485///
486/// It sits inside `.daimond` deliberately. A fence denies that directory, so a
487/// command cannot read the token, and a command that has been talked into
488/// helping cannot answer a challenge about a folder it is not in.
489///
490/// The hand cannot make the comparison itself -- it has one of the two names --
491/// so this is evidence rather than enforcement, and the refusal belongs where
492/// both names meet.
493#[derive(Clone, Debug, Eq, PartialEq)]
494enum Identity {
495 /// A token the page can compare against the folder it holds.
496 Known(String),
497 /// No token could be established, and this is why.
498 Unproven(String),
499}
500
501impl Identity {
502
503 /// The `caps` entry this becomes.
504 fn cap(&self) -> String {
505 match self {
506 Self::Known(t) => fmt!("{}{}", WS_CAP, t),
507 Self::Unproven(_) => fmt!("{}{}", WS_CAP, WS_UNPROVEN),
508 }
509 }
510}
511
512/// How many hexadecimal characters an identity token has.
513const WS_ID_LEN: usize = 32;
514
515/// The token naming the granted folder, written where the page can read it.
516///
517/// An existing token is kept: the file is the folder's name to the page, and a
518/// hand that minted a new one on every launch would tell the page its workspace
519/// had been replaced every time the browser restarted.
520///
521/// # Arguments
522/// * `root` - The granted folder.
523fn workspace_id(root: &Path) -> Identity {
524 let dir = root.join(APP_DIR);
525 let path = dir.join(WS_ID_FILE);
526 if let Ok(txt) = fs::read_to_string(&path) {
527 if let Some(tok) = id_from_file(&txt) {
528 return Identity::Known(tok);
529 }
530 }
531 if let Err(e) = fs::create_dir_all(&dir) {
532 return Identity::Unproven(fmt!(
533 "'{}' could not be created ({})", dir.display(), e));
534 }
535 let tok = mint_id();
536 let txt = fmt!(
537 "# Daimond wrote this so that the browser and the machine hand can tell \
538 whether\n# they are talking about the same folder. It is not a secret \
539 and not a key.\n# Deleting it costs nothing: the next hand to start \
540 writes a new one, and the\n# page will ask you to confirm the folder \
541 again.\n{}\n", tok);
542 if let Err(e) = fs::write(&path, txt) {
543 return Identity::Unproven(fmt!(
544 "'{}' could not be written ({})", path.display(), e));
545 }
546 tighten(&path);
547 Identity::Known(tok)
548}
549
550/// The token a written identity file carries, where it carries a valid one.
551///
552/// The first line that is neither blank nor a `#` comment, exactly as
553/// [`root_from_file`] reads its own, so a person opening either file finds the
554/// same convention.
555///
556/// # Arguments
557/// * `txt` - The file's contents.
558fn id_from_file(txt: &str) -> Option<String> {
559 for line in txt.lines() {
560 let t = line.trim();
561 if t.is_empty() || t.starts_with('#') {
562 continue;
563 }
564 // A token that is not the shape a token has is not a token. Rewriting is
565 // the right answer: whatever is in there, it did not come from here.
566 if t.len() == WS_ID_LEN
567 && t.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
568 {
569 return Some(t.to_string());
570 }
571 return None;
572 }
573 None
574}
575
576/// A new identity token.
577fn mint_id() -> String {
578 fmt!("{:016x}{:016x}", Rand::rand_u64(), Rand::rand_u64())
579}
580
581/// Makes the identity file the user's own, where the platform has such a notion.
582///
583/// Best effort: a file that could not be tightened is still a usable identity,
584/// and the token is not a secret.
585///
586/// # Arguments
587/// * `path` - The file.
588fn tighten(path: &Path) {
589 #[cfg(unix)]
590 {
591 use std::os::unix::fs::PermissionsExt;
592 let mut perm = match fs::metadata(path) {
593 Ok(md) => md.permissions(),
594 Err(_) => return,
595 };
596 perm.set_mode(0o600);
597 let _ = fs::set_permissions(path, perm);
598 }
599 #[cfg(not(unix))]
600 {
601 let _ = path;
602 }
603}
604
605/// The root named by the file beside the journal, where there is one.
606///
607/// The first line that is neither blank nor a `#` comment, so the file can
608/// explain itself to whoever opens it next.
609///
610/// # Arguments
611/// * `dir` - The journal directory.
612fn root_from_file(dir: &Path) -> Option<String> {
613 named_in_file(dir, ROOT_FILE)
614}
615
616/// The terminal ceiling, where the installer wrote one.
617///
618/// Resolved the same way and to the same rules as [`granted_root`], and it is a
619/// CEILING: what a terminal may never reach past, not where one opens. A ceiling that
620/// is not an absolute directory is treated as absent rather than refused -- the hand
621/// still has a granted root to work from, and refusing to start over a file the user
622/// may not know exists would take the machine away over an optional setting.
623///
624/// # Arguments
625/// * `dir` - The journal directory, which is where the file lives.
626fn terminal_ceilings(dir: &Path, root: &Path) -> Vec<PathBuf> {
627 // Pinned: an installer that named a ceiling has made the decision, and the browser is offered
628 // that and nothing else.
629 if let Some(p) = terminal_ceiling(dir) {
630 return vec![p];
631 }
632 // Otherwise the two folders this machine can honestly offer: what it was granted, and the
633 // account it runs as. Both come from the machine, which is the property that matters -- the
634 // page CHOOSES between them and cannot invent a third.
635 let mut out = vec![root.to_path_buf()];
636 if let Ok(h) = std::env::var("HOME") {
637 let p = PathBuf::from(&h);
638 if p.is_absolute() {
639 if let Ok(c) = fs::canonicalize(&p) {
640 if c.is_dir() && c != root {
641 out.push(c);
642 }
643 }
644 }
645 }
646 out
647}
648
649/// The ceiling the installer pinned, where it pinned one.
650fn terminal_ceiling(dir: &Path) -> Option<PathBuf> {
651 let raw = match std::env::var(TERMINAL_ROOT_VAR) {
652 Ok(v) if !v.trim().is_empty() => v.trim().to_string(),
653 _ => match named_in_file(dir, TERMINAL_ROOT_FILE) {
654 Some(s) => s,
655 None => return None,
656 },
657 };
658 let p = PathBuf::from(&raw);
659 if !p.is_absolute() {
660 eprintln!("daimond-hand: the terminal ceiling '{}' is not an absolute path, so it was \
661 ignored and a terminal gets the granted root.", raw);
662 return None;
663 }
664 match fs::canonicalize(&p) {
665 Ok(c) if c.is_dir() => Some(c),
666 _ => {
667 eprintln!("daimond-hand: the terminal ceiling '{}' is not a directory on this \
668 machine, so it was ignored and a terminal gets the granted root.", raw);
669 None
670 },
671 }
672}
673
674/// The first line of `name` beside the journal that is neither blank nor a comment.
675fn named_in_file(dir: &Path, name: &str) -> Option<String> {
676 let txt = match fs::read_to_string(dir.join(name)) {
677 Ok(t) => t,
678 Err(_) => return None,
679 };
680 for line in txt.lines() {
681 let t = line.trim();
682 if t.is_empty() || t.starts_with('#') {
683 continue;
684 }
685 return Some(t.to_string());
686 }
687 None
688}
689
690// ┌───────────────────────────────────────────────────────────────┐
691// │ Reading │
692// └───────────────────────────────────────────────────────────────┘
693
694/// How much of a buffer arrived.
695///
696/// `Read::read_exact` cannot tell a clean end from a short one, and the
697/// difference decides whether the page has gone or something is wrong.
698enum Filled {
699 /// The whole buffer.
700 All,
701 /// The stream ended after this many bytes.
702 Ended(usize),
703 /// The stream could not be read, and this is what it said.
704 Failed(String),
705}
706
707/// Fills a buffer, distinguishing a clean end from a short one.
708///
709/// # Arguments
710/// * `r` - The stream.
711/// * `buf` - The buffer to fill.
712fn fill<R: Read>(r: &mut R, buf: &mut [u8]) -> Filled {
713 let mut n = 0;
714 while n < buf.len() {
715 match r.read(&mut buf[n..]) {
716 Ok(0) => return Filled::Ended(n),
717 Ok(k) => n += k,
718 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
719 Err(e) => return Filled::Failed(fmt!("{}", e)),
720 }
721 }
722 Filled::All
723}
724
725/// Discards a declared body so that the next read starts at a frame boundary.
726///
727/// This is the whole of the answer to `REVIEW.md` §3.3. Refusing an oversized
728/// frame without consuming it leaves its payload in the pipe, where the next
729/// four bytes of somebody's JSON are read as a length prefix and every request
730/// after it is nonsense.
731///
732/// # Arguments
733/// * `r` - The stream.
734/// * `n` - How many bytes the prefix declared.
735///
736/// # Returns
737/// Nothing where the body was consumed, or a phrase where the stream ended part
738/// way through it.
739fn skip<R: Read>(r: &mut R, n: usize) -> Option<String> {
740 let mut left = n;
741 let mut pail = vec![0u8; SKIP_CHUNK.min(n.max(1))];
742 while left > 0 {
743 let want = left.min(pail.len());
744 match fill(r, &mut pail[..want]) {
745 Filled::All => left -= want,
746 Filled::Ended(got) => return Some(fmt!(
747 "the stream ended {} bytes into a {}-byte frame that was being \
748 discarded", n - left + got, n)),
749 Filled::Failed(e) => return Some(fmt!(
750 "the stream could not be read while discarding an oversized \
751 frame: {}", e)),
752 }
753 }
754 None
755}
756
757/// Reads frames until the stream ends, handing each to the dispatcher.
758///
759/// Runs on a thread of its own so that nothing the writer does can stop it.
760/// The four-byte prefix is read here rather than through [`Frame::read_payload`]
761/// for one reason: resynchronising after an oversized frame needs the declared
762/// length, and the codec refuses such a frame before it can be asked for it.
763/// Everything after that read -- the ceiling, the UTF-8, the JSON, the shape --
764/// is the codec's, and the whole frame is handed back to
765/// [`Frame::read_req`] rather than taken apart here.
766///
767/// # Arguments
768/// * `r` - The stream, which this thread owns.
769/// * `tx` - Where each message goes.
770fn read_frames<R: Read>(mut r: R, tx: Sender<Inbound>) {
771 loop {
772 let mut pre = [0u8; LEN_PREFIX];
773 match fill(&mut r, &mut pre) {
774 Filled::All => (),
775 Filled::Ended(0) => {
776 let _ = tx.blocking_send(Inbound::Gone {
777 clean: true,
778 reason: fmt!("the page closed the pipe"),
779 });
780 return;
781 },
782 Filled::Ended(got) => {
783 let _ = tx.blocking_send(Inbound::Gone {
784 clean: false,
785 reason: fmt!(
786 "the stream ended {} bytes into a {}-byte length prefix",
787 got, LEN_PREFIX),
788 });
789 return;
790 },
791 Filled::Failed(e) => {
792 let _ = tx.blocking_send(Inbound::Gone {
793 clean: false,
794 reason: fmt!("the stream could not be read: {}", e),
795 });
796 return;
797 },
798 }
799
800 let n = u32::from_ne_bytes(pre) as usize;
801 if n > INBOUND_MAX {
802 if n > RESYNC_MAX {
803 let _ = tx.blocking_send(Inbound::Gone {
804 clean: false,
805 reason: fmt!(
806 "a frame declared {} bytes, and the hand will not read \
807 past {} to find the next one", n, RESYNC_MAX),
808 });
809 return;
810 }
811 // Consume it, then say so: the next frame must start where the next
812 // frame starts.
813 if let Some(why) = skip(&mut r, n) {
814 let _ = tx.blocking_send(Inbound::Gone { clean: false, reason: why });
815 return;
816 }
817 let sent = tx.blocking_send(Inbound::Bad {
818 fault: Some(Fault::LengthTooBig),
819 detail: fmt!(
820 "A message of {} bytes arrived and this hand reads at most \
821 {}. It was discarded whole and the connection carries on \
822 from the next message; send the same thing in smaller \
823 pieces.", n, INBOUND_MAX),
824 });
825 if sent.is_err() {
826 return;
827 }
828 continue;
829 }
830
831 let mut body = vec![0u8; n];
832 match fill(&mut r, &mut body) {
833 Filled::All => (),
834 Filled::Ended(got) => {
835 let _ = tx.blocking_send(Inbound::Gone {
836 clean: false,
837 reason: fmt!(
838 "the stream ended {} bytes into a {}-byte message", got, n),
839 });
840 return;
841 },
842 Filled::Failed(e) => {
843 let _ = tx.blocking_send(Inbound::Gone {
844 clean: false,
845 reason: fmt!("the stream could not be read: {}", e),
846 });
847 return;
848 },
849 }
850
851 // One whole frame, handed back to the codec rather than taken apart here.
852 let mut whole = Vec::with_capacity(LEN_PREFIX + n);
853 whole.extend_from_slice(&pre);
854 whole.extend_from_slice(&body);
855 let mut cur: &[u8] = &whole;
856 let msg = match FRAMING.read_req(&mut cur) {
857 Ok(Some(req)) => Inbound::Msg(req),
858 Ok(None) => Inbound::Bad {
859 fault: None,
860 detail: fmt!("A frame of {} bytes carried no message at all.", n),
861 },
862 Err(e) => Inbound::Bad {
863 fault: Fault::of(&e),
864 detail: e.msgs().join("; "),
865 },
866 };
867 if tx.blocking_send(msg).is_err() {
868 return;
869 }
870 }
871}
872
873// ┌───────────────────────────────────────────────────────────────┐
874// │ Writing │
875// └───────────────────────────────────────────────────────────────┘
876
877/// The run a response concerns, where it concerns one.
878///
879/// # Arguments
880/// * `resp` - The response.
881fn id_of(resp: &Resp) -> Option<String> {
882 match resp {
883 Resp::Started { id, .. } => Some(id.clone()),
884 Resp::Chunk { id, .. } => Some(id.clone()),
885 Resp::Ended { id, .. } => Some(id.clone()),
886 Resp::Refused { id, .. } => Some(id.clone()),
887 Resp::Error { id, .. } => id.clone(),
888 Resp::Opened { id, .. } => Some(id.clone()),
889 Resp::Output { id, .. } => Some(id.clone()),
890 Resp::Closed { id, .. } => Some(id.clone()),
891 Resp::Filed { id, .. } => Some(id.clone()),
892 // A listing is about every run at once, so it is about no single one.
893 Resp::Runs { .. } => None,
894 // A folder listing is about no run at all.
895 Resp::Dirs { .. } => None,
896 Resp::Granted { .. } => None,
897 // Sent before the greeting, when there is no run to name and no conversation
898 // to name one in.
899 Resp::Fault { .. } => None,
900 Resp::Hello { .. } => None,
901 }
902}
903
904/// The largest character boundary of `text` at or below `n`.
905///
906/// # Arguments
907/// * `text` - The text.
908/// * `n` - The byte offset wanted.
909fn snap(text: &str, n: usize) -> usize {
910 let mut m = n.min(text.len());
911 while m > 0 && !text.is_char_boundary(m) {
912 m -= 1;
913 }
914 m
915}
916
917/// The marker that stands in for what would not fit.
918///
919/// Plain ASCII with nothing JSON escapes, so its cost on the wire is its length
920/// and the arithmetic above it does not have to guess.
921///
922/// # Arguments
923/// * `dropped` - How many bytes were left out.
924fn marker(dropped: usize) -> String {
925 fmt!(" [{} bytes were dropped here: one frame cannot carry them]", dropped)
926}
927
928/// The largest prefix of `text` for which the response built from it fits.
929///
930/// Bisection over the encoder rather than arithmetic over it: JSON escaping
931/// inflates by up to six times for a control byte, so a subtraction from
932/// [`daimond_hand::wire::FRAME_MAX`] would be a guess that is wrong exactly
933/// when it matters.
934///
935/// # Arguments
936/// * `text` - The text to cut.
937/// * `build` - How to make the response from a candidate prefix.
938fn largest_fit<F>(text: &str, build: F) -> Outcome<usize>
939where
940 F: Fn(&str) -> Resp,
941{
942 if res!(resp_fits(FRAMING, &build(text))) {
943 return Ok(text.len());
944 }
945 if !res!(resp_fits(FRAMING, &build(""))) {
946 // The envelope alone is too large, so no cut helps.
947 return Ok(usize::MAX);
948 }
949 let mut lo = 0usize;
950 let mut hi = text.len();
951 while hi - lo > 1 {
952 let m = snap(text, lo + (hi - lo) / 2);
953 if m <= lo {
954 break;
955 }
956 if res!(resp_fits(FRAMING, &build(&text[..m]))) {
957 lo = m;
958 } else {
959 hi = m;
960 }
961 }
962 Ok(lo)
963}
964
965/// The same response with its one long field cut down to fit a frame.
966///
967/// `REVIEW.md` §3.1: an oversized chunk was dropped and the run's output
968/// vanished. Silence is the one answer that is not honest, so what fits is
969/// sent, the marker says how much did not, and the caller sends a
970/// [`Resp::Error`] as well so that the loss is a fact on the wire and in the
971/// journal rather than an inference from a short transcript.
972///
973/// # Arguments
974/// * `resp` - The response that would not fit.
975///
976/// # Returns
977/// The smaller response and the sentence naming what was cut, or nothing where
978/// the envelope alone is too large for a frame and no cut would help.
979fn cut(resp: &Resp) -> Outcome<Option<(Resp, String)>> {
980 match resp {
981 Resp::Chunk { id, stream, seq, data } => {
982 // The measured fit, from the codec's own helper.
983 let room = match chunk_fit(FRAMING, id, *stream, *seq, data) {
984 Ok(r) => r,
985 Err(_) => return Ok(None),
986 };
987 let keep = snap(data, room.saturating_sub(MARKER_RESERVE));
988 let lost = data.len() - keep;
989 let mut txt = data[..keep].to_string();
990 txt.push_str(&marker(lost));
991 let mut out = Resp::Chunk {
992 id: id.clone(),
993 stream: *stream,
994 seq: *seq,
995 data: txt,
996 };
997 if !res!(resp_fits(FRAMING, &out)) {
998 // The reserve was not enough, which means the envelope is very
999 // large; the marker alone is still worth sending.
1000 out = Resp::Chunk {
1001 id: id.clone(),
1002 stream: *stream,
1003 seq: *seq,
1004 data: marker(data.len()),
1005 };
1006 if !res!(resp_fits(FRAMING, &out)) {
1007 return Ok(None);
1008 }
1009 }
1010 Ok(Some((out, fmt!(
1011 "{} bytes of output from run '{}' did not fit in one message and \
1012 were dropped. The transcript is short by that much.",
1013 data.len() - keep, id))))
1014 },
1015 Resp::Refused { id, reason } => {
1016 let keep = res!(largest_fit(reason, |t| Resp::Refused {
1017 id: id.clone(),
1018 reason: t.to_string(),
1019 }));
1020 if keep == usize::MAX {
1021 return Ok(None);
1022 }
1023 let keep = snap(reason, keep.saturating_sub(MARKER_RESERVE));
1024 Ok(Some((
1025 Resp::Refused {
1026 id: id.clone(),
1027 reason: fmt!("{}{}", &reason[..keep], marker(reason.len() - keep)),
1028 },
1029 fmt!("A refusal for run '{}' was too long for one message and was cut.", id),
1030 )))
1031 },
1032 Resp::Error { id, message } => {
1033 let keep = res!(largest_fit(message, |t| Resp::Error {
1034 id: id.clone(),
1035 message: t.to_string(),
1036 }));
1037 if keep == usize::MAX {
1038 return Ok(None);
1039 }
1040 let keep = snap(message, keep.saturating_sub(MARKER_RESERVE));
1041 Ok(Some((
1042 Resp::Error {
1043 id: id.clone(),
1044 message: fmt!("{}{}", &message[..keep], marker(message.len() - keep)),
1045 },
1046 fmt!("A message was too long for one frame and was cut."),
1047 )))
1048 },
1049 // Terminal output is cut like a chunk, but only ever on a whole base64 quantum:
1050 // base64 decodes four characters to three bytes, so a cut anywhere else hands the
1051 // page a fragment it cannot decode -- and unlike a truncated line of text, a
1052 // half-decoded escape sequence does not merely look wrong, it steers the terminal.
1053 Resp::Output { id, seq, data } => {
1054 // `output_frames` is what a PRODUCER should use; this is the last-resort trim for
1055 // a response already built, so it measures the same way the Error arm does.
1056 let room = res!(largest_fit(data, |t| Resp::Output {
1057 id: id.clone(),
1058 seq: *seq,
1059 data: t.to_string(),
1060 }));
1061 if room == usize::MAX {
1062 return Ok(None);
1063 }
1064 let keep = (room / 4) * 4;
1065 if keep == 0 {
1066 return Ok(None);
1067 }
1068 let lost = data.len() - keep;
1069 Ok(Some((
1070 Resp::Output { id: id.clone(), seq: *seq, data: data[..keep].to_string() },
1071 fmt!("{} characters of terminal output for '{}' would not fit and were \
1072 dropped.", lost, id),
1073 )))
1074 },
1075 // The rest are bounded by their own fields and cannot be cut without
1076 // changing what they say.
1077 // A listing is bounded at the source: `wire::RUNS_MAX` entries of
1078 // `wire::RUN_WHAT_MAX` bytes cannot approach a frame, and what did not
1079 // fit is COUNTED in `more` rather than cut here. Trimming it would drop
1080 // a run silently, which is the one thing a listing must never do.
1081 Resp::Runs { .. } => Ok(None),
1082 // A folder listing has nothing that can be cut in half and still mean anything: half a
1083 // list of directory names reads as a folder with fewer folders in it. The bound keeps
1084 // it small -- one directory's immediate children, names only.
1085 Resp::Dirs { .. } => Ok(None),
1086 // One path and one sentence; there is nothing in it to cut.
1087 Resp::Granted { .. } => Ok(None),
1088 // A file's text, cut like a refusal's sentence: what fits is sent and the marker says
1089 // how much did not. The launcher caps it at `wire::FILE_TEXT_MAX` already, so reaching
1090 // here means an enormous path or a very long refusal, not an ordinary read.
1091 Resp::Filed { id, ok, text } => {
1092 let keep = res!(largest_fit(text, |t| Resp::Filed {
1093 id: id.clone(),
1094 ok: *ok,
1095 text: t.to_string(),
1096 }));
1097 if keep == usize::MAX {
1098 return Ok(None);
1099 }
1100 let keep = snap(text, keep.saturating_sub(MARKER_RESERVE));
1101 Ok(Some((
1102 Resp::Filed {
1103 id: id.clone(),
1104 ok: *ok,
1105 text: fmt!("{}{}", &text[..keep], marker(text.len() - keep)),
1106 },
1107 fmt!("The answer to file request '{}' was too long for one message and was \
1108 cut.", id),
1109 )))
1110 },
1111 Resp::Hello { .. } | Resp::Started { .. } | Resp::Ended { .. }
1112 | Resp::Opened { .. } | Resp::Closed { .. } => Ok(None),
1113 // Composed by this binary from its own refusal, so it is a sentence and not a
1114 // transcript. Nothing here is long enough to need cutting, and a cut one would be
1115 // the reason a hand will not start, truncated.
1116 Resp::Fault { .. } => Ok(None),
1117 }
1118}
1119
1120/// The bytes one response occupies, cut down where it would not fit.
1121///
1122/// # Arguments
1123/// * `resp` - The response.
1124///
1125/// # Returns
1126/// The frame, and the sentence naming what was cut where anything was.
1127fn encode(resp: &Resp) -> Outcome<(Vec<u8>, Option<String>)> {
1128 let mut buf = Vec::new();
1129 match FRAMING.write_resp(&mut buf, resp) {
1130 Ok(()) => return Ok((buf, None)),
1131 Err(e) => match Fault::of(&e) {
1132 // Nothing was written: the codec refuses before it writes, so there
1133 // is no half a frame in the pipe to worry about.
1134 Some(Fault::FrameTooBig) => (),
1135 _ => return Err(e),
1136 },
1137 }
1138 match res!(cut(resp)) {
1139 Some((smaller, note)) => {
1140 buf.clear();
1141 res!(FRAMING.write_resp(&mut buf, &smaller));
1142 Ok((buf, Some(note)))
1143 },
1144 None => Err(Fault::FrameTooBig.raise(&fmt!(
1145 "A {} response does not fit in one frame even with its text \
1146 removed, so nothing about it could be sent.", kind_of(resp)))),
1147 }
1148}
1149
1150/// The wire's word for which response this is, for a diagnostic.
1151///
1152/// # Arguments
1153/// * `resp` - The response.
1154fn kind_of(resp: &Resp) -> &'static str {
1155 match resp {
1156 Resp::Hello { .. } => "hello",
1157 Resp::Started { .. } => "started",
1158 Resp::Chunk { .. } => "chunk",
1159 Resp::Ended { .. } => "ended",
1160 Resp::Refused { .. } => "refused",
1161 Resp::Error { .. } => "error",
1162 Resp::Opened { .. } => "opened",
1163 Resp::Output { .. } => "output",
1164 Resp::Closed { .. } => "closed",
1165 Resp::Runs { .. } => "runs",
1166 Resp::Dirs { .. } => "dirs",
1167 Resp::Granted { .. } => "granted",
1168 Resp::Filed { .. } => "filed",
1169 Resp::Fault { .. } => "fault",
1170 }
1171}
1172
1173/// Writes responses until both lines close.
1174///
1175/// The hand's own line is preferred over the bulk one, so a refusal is not
1176/// queued behind a build's output; within one run every response comes down the
1177/// bulk line and so keeps its order.
1178///
1179/// Each response is written to the journal **before** it is written to the
1180/// wire, so the record cannot be missing something the page was told.
1181///
1182/// # Arguments
1183/// * `w` - Where the frames go.
1184/// * `ctl` - The hand's own responses.
1185/// * `bulk` - Everything the runs say.
1186/// * `jr` - The record.
1187/// * `sound` - Cleared where the record could not be written.
1188/// * `alive` - Cleared where the page stopped listening.
1189async fn write_loop<W>(
1190 mut w: W,
1191 mut ctl: Receiver<Resp>,
1192 mut bulk: Receiver<Resp>,
1193 jr: Arc<Mutex<Journal>>,
1194 sound: Arc<AtomicBool>,
1195 alive: Arc<AtomicBool>,
1196)
1197 -> Outcome<()>
1198where
1199 W: AsyncWrite + Unpin + Send + 'static,
1200{
1201 let mut ctl_open = true;
1202 let mut bulk_open = true;
1203 while ctl_open || bulk_open {
1204 let got = tokio::select! {
1205 biased;
1206 r = ctl.recv(), if ctl_open => match r {
1207 Some(r) => Some(r),
1208 None => { ctl_open = false; None },
1209 },
1210 r = bulk.recv(), if bulk_open => match r {
1211 Some(r) => Some(r),
1212 None => { bulk_open = false; None },
1213 },
1214 };
1215 let resp = match got {
1216 Some(r) => r,
1217 None => continue,
1218 };
1219 if !res!(deliver(&mut w, &resp, &jr, &sound).await) {
1220 alive.store(false, Ordering::SeqCst);
1221 break;
1222 }
1223 }
1224 let _ = w.flush().await;
1225 Ok(())
1226}
1227
1228/// Journals one response and writes it, with whatever note the cut produced.
1229///
1230/// # Arguments
1231/// * `w` - Where the frame goes.
1232/// * `resp` - The response.
1233/// * `jr` - The record.
1234/// * `sound` - Cleared where the record could not be written.
1235///
1236/// # Returns
1237/// Whether the page is still listening.
1238async fn deliver<W>(
1239 w: &mut W,
1240 resp: &Resp,
1241 jr: &Arc<Mutex<Journal>>,
1242 sound: &Arc<AtomicBool>,
1243)
1244 -> Outcome<bool>
1245where
1246 W: AsyncWrite + Unpin,
1247{
1248 // The record first. A `Started` nobody wrote down is a command that ran
1249 // without a record, which is the one thing the journal exists to prevent.
1250 if let Some(ev) = Event::from_resp(resp) {
1251 let done = {
1252 let mut g = lock_mutex!(jr);
1253 g.append(&ev)
1254 };
1255 if let Err(e) = done {
1256 sound.store(false, Ordering::SeqCst);
1257 eprintln!(
1258 "daimond-hand: the journal could not be written, so no further \
1259 command will be run: {}", e);
1260 }
1261 }
1262 let (bytes, note) = match encode(resp) {
1263 Ok(v) => v,
1264 Err(e) => {
1265 // Nothing was written, so the stream is still in step; the loss is
1266 // reported rather than hidden.
1267 eprintln!("daimond-hand: a response could not be sent: {}", e);
1268 return Ok(true);
1269 },
1270 };
1271 match w.write_all(&bytes).await {
1272 Ok(()) => (),
1273 Err(e) => {
1274 eprintln!("daimond-hand: the page stopped listening: {}", e);
1275 return Ok(false);
1276 },
1277 }
1278 match w.flush().await {
1279 Ok(()) => (),
1280 Err(e) => {
1281 eprintln!("daimond-hand: the page stopped listening: {}", e);
1282 return Ok(false);
1283 },
1284 }
1285 match note {
1286 // The loss is now a message of its own, so the page and the journal
1287 // both hold it as a fact rather than as a short transcript.
1288 Some(n) => {
1289 let told = Resp::Error { id: id_of(resp), message: n };
1290 let inner = {
1291 if let Some(ev) = Event::from_resp(&told) {
1292 let mut g = lock_mutex!(jr);
1293 let _ = g.append(&ev);
1294 }
1295 encode(&told)
1296 };
1297 match inner {
1298 Ok((b, _)) => match w.write_all(&b).await {
1299 Ok(()) => {
1300 let _ = w.flush().await;
1301 Ok(true)
1302 },
1303 Err(_) => Ok(false),
1304 },
1305 Err(e) => {
1306 eprintln!("daimond-hand: a truncation could not be reported: {}", e);
1307 Ok(true)
1308 },
1309 }
1310 },
1311 None => Ok(true),
1312 }
1313}
1314
1315// ┌───────────────────────────────────────────────────────────────┐
1316// │ The dispatcher │
1317// └───────────────────────────────────────────────────────────────┘
1318
1319/// Everything the dispatcher answers a message with.
1320///
1321/// Held together in one place so that the handlers take one argument rather
1322/// than nine, and so that the two lines to the writer cannot be confused with
1323/// each other.
1324struct Desk {
1325 /// What this machine can enforce with Landlock.
1326 fence: Fence,
1327 /// What it can refuse at the system-call layer.
1328 ///
1329 /// Asked once, because [`Seccomp::detect`] answers by installing a throwaway
1330 /// filter on a thread of its own and that is not a thing to do per command.
1331 sys: Seccomp,
1332 /// The folder this hand may work in.
1333 root: PathBuf,
1334 /// The folders a terminal may be fenced to, widest last. Never empty.
1335 term_ceilings: Vec<PathBuf>,
1336 /// Whether the installer PINNED that list to one folder, rather than offering a choice.
1337 term_pinned: bool,
1338 /// What the page can check that folder's identity against.
1339 ws: Identity,
1340 /// The operating system, in the wire's own vocabulary.
1341 os: &'static str,
1342 /// Live runs.
1343 runner: Runner,
1344 /// Live terminal sessions.
1345 ptys: daimond_hand::pty::PtySessions,
1346 files: daimond_hand::exec::Files, // the file door, which has nothing live to hold
1347 /// The record.
1348 jr: Arc<Mutex<Journal>>,
1349 /// Whether the record is still being written.
1350 sound: Arc<AtomicBool>,
1351 /// Whether the page is still listening.
1352 alive: Arc<AtomicBool>,
1353 /// The hand's own line to the writer.
1354 ctl: Sender<Resp>,
1355 /// The line every run's output takes.
1356 bulk: Sender<Resp>,
1357}
1358
1359impl Desk {
1360
1361 /// Writes one event, and remembers a failure.
1362 ///
1363 /// # Arguments
1364 /// * `ev` - What happened.
1365 fn record(&self, ev: &Event) -> Outcome<()> {
1366 let done = {
1367 let mut g = lock_mutex!(self.jr);
1368 g.append(ev)
1369 };
1370 match done {
1371 Ok(_) => Ok(()),
1372 Err(e) => {
1373 self.sound.store(false, Ordering::SeqCst);
1374 Err(e)
1375 },
1376 }
1377 }
1378
1379 /// Sends one of the hand's own responses.
1380 ///
1381 /// Never awaits. The dispatcher must be able to answer a `Signal` while a
1382 /// command floods the pipe, and a queue that has taken 256 unread
1383 /// acknowledgements is a page that is not reading rather than a page that
1384 /// is behind.
1385 ///
1386 /// # Arguments
1387 /// * `resp` - The response.
1388 ///
1389 /// # Returns
1390 /// Whether the conversation can carry on.
1391 fn say(&self, resp: Resp) -> bool {
1392 match self.ctl.try_send(resp) {
1393 Ok(()) => true,
1394 Err(TrySendError::Full(_)) => {
1395 eprintln!(
1396 "daimond-hand: {} of the hand's own messages are unread, so \
1397 the page is not listening; the connection was ended.",
1398 CTL_QUEUE);
1399 self.alive.store(false, Ordering::SeqCst);
1400 false
1401 },
1402 Err(TrySendError::Closed(_)) => {
1403 self.alive.store(false, Ordering::SeqCst);
1404 false
1405 },
1406 }
1407 }
1408
1409 /// Answers the opening exchange.
1410 ///
1411 /// The `caps` list is the real one, from [`Fence::caps`], so the page can
1412 /// say which guarantee this machine offers rather than repeating a claim.
1413 ///
1414 /// **The granted root travels in `caps` as a `root:` entry.** The page
1415 /// cannot know the folder -- the File System Access API gives it a handle
1416 /// and never a path -- and `wire::Resp::Hello` has no field for it, so this
1417 /// is where it goes until the wire grows one.
1418 ///
1419 /// **The folder's identity travels beside it as a `ws:` entry**, and the two
1420 /// answer different questions: `root:` says *where* the hand will work, and
1421 /// `ws:` is what lets the page find out whether that is the folder it is
1422 /// looking at. See [`Identity`] for why a path alone settles nothing.
1423 ///
1424 /// # Arguments
1425 /// * `req` - The [`Req::Hello`].
1426 ///
1427 /// # Returns
1428 /// An ending where the conversation cannot continue.
1429 fn hello(&self, req: &Req) -> Outcome<Option<Ending>> {
1430 let proto = match req {
1431 Req::Hello { proto, .. } => *proto,
1432 _ => return Ok(None),
1433 };
1434 // Written down before it is answered.
1435 if let Some(ev) = Event::from_req(req, &[]) {
1436 if let Err(e) = self.record(&ev) {
1437 eprintln!("daimond-hand: the handshake could not be journalled: {}", e);
1438 }
1439 }
1440 if !proto_ok(proto) {
1441 self.say(Resp::Refused {
1442 id: fmt!("hello"),
1443 reason: proto_refusal(proto),
1444 });
1445 return Ok(Some(Ending::Stopped(fmt!(
1446 "the page speaks protocol {} and this hand speaks {}",
1447 proto, daimond_hand::PROTO))));
1448 }
1449 // Both layers, because the window's wording is chosen from this list and a
1450 // compartment made of two mechanisms cannot be described by one of them.
1451 let mut caps = self.fence.caps();
1452 // A TERMINAL IS PLANNED AGAINST ITS OWN CARVE DECISION, so one `carve:` cap standing for
1453 // both doors would be a capability that is true of a command and false of a terminal --
1454 // and a cap the page cannot rely on is worse than one it does not have. See
1455 // `exec::detected_terminal_fence`.
1456 for c in daimond_hand::exec::detected_terminal_fence().caps() {
1457 if let Some(rest) = c.strip_prefix("carve:") {
1458 caps.push(fmt!("terminal-carve:{}", rest));
1459 }
1460 }
1461 caps.extend(self.sys.caps());
1462 caps.push(fmt!("root:{}", self.root.display()));
1463 // The CEILING, not where a terminal opens. Said only where it differs from the
1464 // granted root, so a page reading no `terminal-root:` gets the behaviour every
1465 // build before this one had rather than a second name for the same folder.
1466 // OFFERS and a PIN are different statements and the page acts on them differently. An
1467 // offer is a folder this machine is willing to fence a terminal to, and the user chooses
1468 // among them; a pin is a choice the user already made at a shell, and the page follows it
1469 // rather than offering anything. Only the machine writes either.
1470 // That a folder BROWSER is available, and where it will start. A page that cannot see
1471 // this cap falls back to the two-item choice, which is what every build before this one
1472 // had.
1473 caps.push(fmt!("browse:dirs"));
1474 for c in &self.term_ceilings {
1475 if c != &self.root {
1476 caps.push(fmt!("terminal-ceiling:{}", c.display()));
1477 }
1478 }
1479 if self.term_pinned {
1480 if let Some(c) = self.term_ceilings.last() {
1481 caps.push(fmt!("terminal-root:{}", c.display()));
1482 }
1483 }
1484 caps.push(self.ws.cap());
1485 // Whether this folder holds verifiers at all. A page that knows the answer can say
1486 // "not on this computer" once, instead of letting a model find it out one refusal at
1487 // a time -- which is what `fence:` beside it is for.
1488 caps.push(verify::cap(&self.root));
1489 // That this hand can be ASKED what it is still running, and told to stop
1490 // one of them. A page that cannot see the capability cannot know whether
1491 // silence means "nothing is running" or "this hand is older than the
1492 // question", and those are opposite answers.
1493 caps.push(fmt!("runs:list-and-stop"));
1494 // Where the user's home is, so the page can place a toolkit.
1495 //
1496 // A compiler does not live in the workspace: cargo is under ~/.cargo, node under
1497 // ~/.nvm. The page cannot know that path -- the File System Access API hands it a
1498 // handle and never a path -- and guessing `/home/<something>` would be inventing a
1499 // fence root, which is the one thing it must never do. So the hand says, and a hand
1500 // that cannot say leaves the toolkit ungranted rather than approximate.
1501 //
1502 // It rides in `caps` beside `root:` because `wire.rs` has no field for it and the
1503 // wire is fixed. Both belong in `Resp::Hello` properly one day.
1504 //
1505 // Read through `exec::home_dir`, which is also what a command's defaulted
1506 // `HOME` comes from. Two answers to "where is home" -- one told to the
1507 // page and a different one given to the command -- would be a bug nobody
1508 // would think to look for.
1509 match daimond_hand::exec::home_dir() {
1510 Some(h) => caps.push(fmt!("home:{}", h)),
1511 None => {},
1512 }
1513 // WHICH COMPUTER THIS IS. Rides beside `root:` and `home:` for the same reason and
1514 // with the same apology about the wire.
1515 //
1516 // On 2026-08-20 a daimon was asked to build and deploy, found no `cargo` and no
1517 // `.git`, and reported that the Rust toolchain was not installed and the repository
1518 // did not exist. Both were true where it was standing and false where the user was:
1519 // the browser was open on the author's SECOND machine, whose `~/usr` is a Syncthing
1520 // copy, and `.stignore` there holds `target` and `.*` -- so the repository and every
1521 // build artefact are absent by design and always will be.
1522 //
1523 // Nothing the daimon could reach could have told it that. The briefing named the
1524 // operating system and the fence and never the HOST, so "this machine" meant a
1525 // machine it could not name, and the user -- reading it beside a terminal on the
1526 // other box -- read it as a claim about the one in front of him. He spent a round
1527 // telling it that cargo was at a path where, on that computer, it genuinely was not.
1528 //
1529 // A name costs nine bytes and turns "the toolchain is not installed" into "the
1530 // toolchain is not installed on gilgamesh", which is a sentence somebody can act on.
1531 match hostname() {
1532 Some(h) => caps.push(fmt!("host:{}", h)),
1533 None => {},
1534 }
1535 // WHICH SHELL THE USER USES, so a terminal opens on the one they know.
1536 //
1537 // The page cannot read an environment; it defaulted to `/bin/sh`, which is the one
1538 // program POSIX promises is there and on this machine is `dash` -- no prompt worth
1539 // the name, no history, no completion, and none of the user's own aliases. Rides in
1540 // `caps` beside `home:` and `host:`, with the same apology about the wire.
1541 //
1542 // From `SHELL`, which is what the login session set and therefore what every other
1543 // terminal on this machine opens. Absent or relative, nothing is said and the page
1544 // keeps its own default -- a guessed shell is a terminal that opens on a refusal.
1545 match std::env::var("SHELL") {
1546 Ok(sh) if sh.starts_with('/') && !sh.contains('\0') => caps.push(fmt!("shell:{}", sh)),
1547 _ => {},
1548 }
1549 // WHETHER DAIMOND'S OWN SSH IS SET UP HERE, which is the whole of the Remote
1550 // toolchain's permission.
1551 //
1552 // It used to be a grant the user gave a Diamond, one Diamond at a time, and the owner's
1553 // objection to that is the entry `dev/BLOCKERS.md` calls B12: a terminal is not tied to
1554 // a Diamond, so neither is what a terminal may reach. The posture is the USER'S and it
1555 // is per COMPUTER -- `install.sh --remote` on this machine, or nothing.
1556 //
1557 // Saying it here rather than storing it in the app means there is no fourth place a
1558 // permission lives: the answer is read from the key and the wrapper themselves, so it
1559 // cannot drift from them, cannot be left on after the key is deleted, and cannot be
1560 // turned on by anything but the user running the installer. See
1561 // `exec::remote_ready`, and `Machine::remote` at the other end.
1562 if daimond_hand::exec::remote_ready() {
1563 caps.push(fmt!("remote:ready"));
1564 }
1565 if !self.say(Resp::Hello {
1566 proto: daimond_hand::PROTO,
1567 host: fmt!("{}", daimond_hand::HOST_NAME),
1568 version: fmt!("{}", daimond_hand::version()),
1569 os: fmt!("{}", self.os),
1570 caps,
1571 }) {
1572 return Ok(Some(Ending::Stopped(fmt!("the page stopped listening"))));
1573 }
1574 Ok(None)
1575 }
1576
1577 /// Refuses a command, in a whole sentence.
1578 ///
1579 /// The refusal is journalled by the writer on its way out, so a refusal the
1580 /// page saw is a refusal the record holds.
1581 ///
1582 /// # Arguments
1583 /// * `id` - The run.
1584 /// * `reason` - The whole sentence.
1585 fn refuse(&self, id: &str, reason: String) {
1586 self.say(Resp::Refused { id: fmt!("{}", id), reason });
1587 }
1588
1589 /// Which mechanisms this machine brings to a command.
1590 ///
1591 /// Residual, and worth naming: this is what the machine can enforce and
1592 /// what the plan asked for, not proof that the kernel bound *this* child.
1593 /// That proof arrives with the launcher of `README.md` gate 4, and this
1594 /// list should be replaced by what it reports.
1595 ///
1596 /// # Arguments
1597 /// * `plan` - The plan the fence made.
1598 fn mechs(&self, plan: &Plan) -> Vec<String> {
1599 let mut v = self.fence.caps();
1600 v.extend(self.sys.caps());
1601 v.push(match plan.net {
1602 true => fmt!("net:open"),
1603 false => fmt!("net:none"),
1604 });
1605 v
1606 }
1607
1608 /// Starts a command, or says why not.
1609 ///
1610 /// The order is the whole point, and it is: the record must be writable,
1611 /// the journal must be out of the command's reach, the fence must be in
1612 /// force, the command must be written down, and only then does anything
1613 /// run.
1614 ///
1615 /// # Arguments
1616 /// * `req` - The [`Req::Exec`].
1617 async fn exec(&self, req: Req) -> Outcome<()> {
1618 // A terminal is gated exactly as a command is -- the journal, the fence guard, release
1619 // gate 1 -- because it IS a command, differing only in how the conversation is shaped.
1620 // Written once so the two cannot drift: a second copy of this would eventually be the
1621 // copy that forgot to check something.
1622 let (id, mut spec, kits, argv, cwd, door) = match &req {
1623 Req::Exec { id, fence, toolkits, argv, cwd, .. } =>
1624 (id.clone(), fence.clone(), toolkits.clone(), argv.clone(), cwd.clone(),
1625 Door::Command),
1626 Req::Open { id, fence, toolkits, argv, cwd, .. } =>
1627 (id.clone(), fence.clone(), toolkits.clone(), argv.clone(), cwd.clone(),
1628 Door::Terminal),
1629 // A file op has no `argv`, and that is the whole of what it is for. An empty one is
1630 // handed to the gates below on purpose rather than a synthetic line: `git_hooks_
1631 // refusal` and `vet_roots` both read it, and a made-up command line is a thing
1632 // written to be read as real.
1633 Req::File { id, fence, toolkits, cwd, .. } =>
1634 (id.clone(), fence.clone(), toolkits.clone(), Vec::new(), cwd.clone(),
1635 Door::File),
1636 _ => return Ok(()),
1637 };
1638
1639 // A command whose record cannot be written is a command that does not run.
1640 if !self.sound.load(Ordering::SeqCst) {
1641 self.refuse(&id, fmt!(
1642 "Refused: the hand's journal cannot be written, and a command \
1643 that cannot be written down is not run. Every run is recorded \
1644 so that it can be checked afterwards, and a run with no record \
1645 would break that promise silently. Look at the hand's standard \
1646 error for what the file system said."));
1647 return Ok(());
1648 }
1649
1650 // A fence naming somewhere this hand was never granted is not this hand's
1651 // fence. `REVIEW.md` §1.5: the spec is computed in the page, the page is
1652 // not the app, and the hand was honouring whatever arrived -- `rw:["/etc"]`
1653 // with `cwd:"/etc"` ran and returned a listing of /etc/ssh. Checked before
1654 // the journal test, because "you may not fence a command around /etc" is a
1655 // better sentence than "your fence reaches my journal", and before anything
1656 // is written down, because a refused command is still recorded as refused.
1657 // A TERMINAL IS VETTED AGAINST ITS CEILING, where the installer named one. The
1658 // ceiling lives on the machine and is written by `install.sh`, so this is still
1659 // the hand checking an arriving fence against a grant the page could not have
1660 // chosen -- which is the whole property `vet_roots` exists to keep. A command
1661 // and a file operation are vetted against the granted root exactly as before.
1662 // The WIDEST folder this machine offered. The page composes the fence and may make it
1663 // narrower -- that is what the user's choice in the UI does -- but it cannot reach past
1664 // what the machine put on the list.
1665 let widest = self.term_ceilings.last().map(|p| p.as_path());
1666 let against = daimond_hand::exec::vet_against(&self.root, widest, door);
1667 if let Some(s) = daimond_hand::exec::vet_roots(against, &spec, &kits, door) {
1668 self.refuse(&id, s);
1669 return Ok(());
1670 }
1671
1672 // A toolchain folder this machine does not have is dropped rather than
1673 // refused. The app expands one ticked toolkit into several paths, and a
1674 // machine that keeps git's configuration in `~/.gitconfig` alone has no
1675 // `~/.config/git` -- so before this line, ticking Git refused EVERY
1676 // command the Diamond ran, naming a path the user had never heard of.
1677 // Only a toolkit's own paths are eligible: a workspace root that cannot
1678 // be resolved still refuses, because that one is a fence that would
1679 // silently not cover what the user marked.
1680 // Not said to the page: a path that is not there grants nothing, and a
1681 // note on every command naming a folder the user never asked for is
1682 // noise. The list comes back so that a caller who wants it has it.
1683 let _dropped = daimond_hand::exec::drop_absent_kit_roots(&mut spec, &kits);
1684
1685 // The directory git runs `pre-commit` from, which on this machine holds the
1686 // credential scanner. Read here rather than in the page, because the page cannot
1687 // see `core.hooksPath` and the model must not be the one who chooses it. Read-only,
1688 // which carries execute, which is what a hook needs. Nothing is added where the user
1689 // granted no Git toolchain: without it the fenced git cannot read the configuration
1690 // that names the directory either, so the grant would buy nothing -- and the
1691 // refusal below is what covers that case instead.
1692 let _hooks = daimond_hand::exec::grant_git_hooks(&mut spec, &kits, &[]);
1693
1694 // The user's own shell configuration, lent to a TERMINAL and to nothing else.
1695 // Read here rather than in the page for the reason the hooks directory is: the page
1696 // cannot see which of the three files this machine has, and `fence::canonical`
1697 // refuses a root it cannot resolve -- so a page naming `~/.inputrc` on a machine
1698 // without one would refuse the whole terminal. `grant_user_dotfiles` adds nothing at
1699 // the other two doors, which are the two a daimon reaches.
1700 let _dots = daimond_hand::exec::grant_user_dotfiles(&mut spec, door);
1701
1702 // A fence that reaches the journal is a fence over the record of what
1703 // the fence was used for. And denied outright as well, in case a root
1704 // is widened later or reached through a link.
1705 let guarded = {
1706 let g = lock_mutex!(self.jr);
1707 if let Err(e) = g.check_fence(&spec) {
1708 self.refuse(&id, e.msgs().join(" "));
1709 return Ok(());
1710 }
1711 g.fence_guard(&spec)
1712 };
1713
1714 // Release gate 1: the fence is in force, or the command is refused.
1715 // Never run it and mention it afterwards.
1716 let plan = match self.fence.plan(&guarded, &Unfenced::Refuse) {
1717 Ok(p) => p,
1718 Err(e) => {
1719 self.refuse(&id, e.msgs().join(" "));
1720 return Ok(());
1721 },
1722 };
1723 if !plan.is_fenced() {
1724 self.refuse(&id, self.fence.refusal("This command"));
1725 return Ok(());
1726 }
1727
1728 // Release gate 1's companion: a commit runs the user's hooks or it does not run.
1729 // Asked of the PLAN and not of the spec, because the plan is what the kernel will
1730 // enforce -- and asked after it, because a command refused for its fence should say
1731 // so about the fence. See `exec::git_hooks_refusal` for what each sentence means and
1732 // for the one spelling this cannot see.
1733 if let Some(s) = daimond_hand::exec::git_hooks_refusal(&plan, &argv, &cwd, &[]) {
1734 self.refuse(&id, s);
1735 return Ok(());
1736 }
1737
1738 // Written down before it runs, so the record cannot be missing a
1739 // command that started.
1740 let req = with_fence(req, guarded);
1741 let mechs = self.mechs(&plan);
1742 match Event::from_req(&req, &mechs) {
1743 Some(ev) => {
1744 if let Err(e) = self.record(&ev) {
1745 eprintln!("daimond-hand: a command was not journalled: {}", e);
1746 self.refuse(&id, fmt!(
1747 "Refused: this command could not be written to the \
1748 hand's journal, so it was not run. A command with no \
1749 record cannot be checked afterwards, and running it \
1750 anyway would make the journal a record of only the \
1751 commands that happened to be recordable."));
1752 return Ok(());
1753 }
1754 },
1755 None => (),
1756 }
1757
1758 // Handed to a task of its own. The dispatcher must not wait on the
1759 // response channel: `REVIEW.md` §3.7 is exactly the loop that does.
1760 let runner = self.runner.clone();
1761 let ptys = self.ptys.clone();
1762 let bulk = self.bulk.clone();
1763 let ctl = self.ctl.clone();
1764 let files = self.files.clone();
1765 tokio::spawn(async move {
1766 let out = match door {
1767 Door::Terminal => ptys.open(req, bulk).await.map(|_| ()),
1768 Door::Command => runner.spawn(req, bulk).await.map(|_| ()),
1769 Door::File => files.apply(req, bulk).await,
1770 };
1771 if let Err(e) = out {
1772 let _ = ctl.send(Resp::Error {
1773 id: Some(id),
1774 message: fmt!("{}", e),
1775 }).await;
1776 }
1777 });
1778 Ok(())
1779 }
1780
1781 /// Runs a named verifier, clean and under each break it declares.
1782 ///
1783 /// The gates are the same three a command meets, in the same order and for
1784 /// the same reasons -- the record must be writable, the request must be
1785 /// written down, and only then does anything run -- and then two of its own:
1786 ///
1787 /// * **The name has to resolve to a file that is really there.**
1788 /// [`verify::resolve`] reads the directory and matches; what goes on to
1789 /// the command line is the directory entry, not the caller's string.
1790 /// * **The break has to be one the verifier declares**, parsed out of that
1791 /// file's own source by [`verify::declared_breaks`].
1792 ///
1793 /// The fence is NOT one of them, and that is the deliberate difference from
1794 /// [`Desk::exec`]. A fenced command cannot reach the display server's
1795 /// socket or listen on a port, so a verifier that drives a browser cannot
1796 /// run under one at all -- and the whole reason this verb exists is that
1797 /// browser evidence was the half of a release a daimon could not produce.
1798 /// What is fenced here is the INPUT: a name looked up in a directory and a
1799 /// break looked up in a file, with no route from a model's text to a
1800 /// program, an argument or a path.
1801 ///
1802 /// # Arguments
1803 /// * `req` - The [`Req::Verify`].
1804 async fn verify(&self, req: Req) -> Outcome<()> {
1805 let (id, name, want, budget_ms) = match &req {
1806 Req::Verify { id, name, breaks, timeout_ms } =>
1807 (id.clone(), name.clone(), breaks.clone(), *timeout_ms),
1808 _ => return Ok(()),
1809 };
1810
1811 // A run whose record cannot be written is a run that does not happen. The same
1812 // sentence as `exec`'s, because it is the same promise.
1813 if !self.sound.load(Ordering::SeqCst) {
1814 self.refuse(&id, fmt!(
1815 "Refused: the hand's journal cannot be written, and a run that cannot be \
1816 written down is not made. Look at the hand's standard error for what the file \
1817 system said."));
1818 return Ok(());
1819 }
1820
1821 let script = match verify::resolve(&self.root, &name) {
1822 Ok(s) => s,
1823 Err(s) => { self.refuse(&id, s); return Ok(()); },
1824 };
1825 let breaks = match verify::chosen(&script, &want) {
1826 Ok(b) => b,
1827 Err(s) => { self.refuse(&id, s); return Ok(()); },
1828 };
1829 let node = match verify::on_path("node") {
1830 Some(n) => n,
1831 None => {
1832 self.refuse(&id, fmt!(
1833 "Refused: there is no 'node' on this hand's PATH, and every verifier in \
1834 dev/ is a Node script. Nothing was run. Tell the user; the file tools and \
1835 'run' do not need it."));
1836 return Ok(());
1837 },
1838 };
1839
1840 // A budget of nothing is a budget the caller forgot rather than one they meant.
1841 let budget = match budget_ms {
1842 0 => verify::BUDGET_DEFAULT_MS,
1843 n => n.min(verify::BUDGET_MAX_MS),
1844 };
1845
1846 let job = verify::Job {
1847 id: id.clone(),
1848 root: self.root.clone(),
1849 script,
1850 breaks,
1851 node,
1852 budget: Duration::from_millis(budget),
1853 ledger: verify::Ledger::new(Arc::clone(&self.jr), Arc::clone(&self.sound)),
1854 };
1855
1856 // A task of its own, as a command is: the dispatcher must stay able to answer while a
1857 // sequence of browser verifiers runs for twenty minutes.
1858 let bulk = self.bulk.clone();
1859 let ctl = self.ctl.clone();
1860 tokio::spawn(async move {
1861 if let Err(e) = verify::conduct(job, bulk).await {
1862 let _ = ctl.send(Resp::Error {
1863 id: Some(id),
1864 message: fmt!("{}", e),
1865 }).await;
1866 }
1867 });
1868 Ok(())
1869 }
1870
1871 /// Passes a signal to a run.
1872 ///
1873 /// The record is written first, as everywhere else, but a failure to write
1874 /// it does **not** stop the signal. Refusing to start a command that
1875 /// cannot be recorded is a safe failure; refusing to stop one is not, and
1876 /// would leave a process running that somebody asked to have killed.
1877 ///
1878 /// # Arguments
1879 /// * `req` - The [`Req::Signal`].
1880 async fn signal(&self, req: &Req) -> Outcome<()> {
1881 let (id, sig) = match req {
1882 Req::Signal { id, sig } => (id.clone(), *sig),
1883 _ => return Ok(()),
1884 };
1885 if let Some(ev) = Event::from_req(req, &[]) {
1886 if let Err(e) = self.record(&ev) {
1887 eprintln!("daimond-hand: a signal was not journalled: {}", e);
1888 }
1889 }
1890 // Handed to a task of its own, like an exec and for the same reason:
1891 // `REVIEW.md` §3.7 is the loop that stopped reading while it waited.
1892 // Signalling a LIVE run is instant -- one send down a channel the
1893 // supervisor owns -- but signalling the group a finished run left
1894 // standing starts a `kill` and then waits to see whether the group
1895 // emptied, and the dispatcher must not be the thing waiting.
1896 let runner = self.runner.clone();
1897 let ctl = self.ctl.clone();
1898 tokio::spawn(async move {
1899 let told = match runner.signal(&id, sig).await {
1900 Ok(Signalled::Sent) | Ok(Signalled::Finished) => return,
1901 // The half that was missing. A signal that did not take used to
1902 // be indistinguishable from one that had nothing left to reach,
1903 // so a page was told its command had stopped when it had not.
1904 Ok(Signalled::Failed(why)) => why,
1905 Err(e) => fmt!("{}", e),
1906 };
1907 let _ = ctl.send(Resp::Error { id: Some(id), message: told }).await;
1908 });
1909 Ok(())
1910 }
1911
1912 /// Answers what this hand is still running.
1913 ///
1914 /// Not journalled: a question is not an act, and every run it can name was
1915 /// written down when it started. See `Event::from_req`.
1916 /// Write the folder this hand may work in, as the installer writes it.
1917 ///
1918 /// **This does not make the grant; it records the one the user made.** They walked the
1919 /// machine's own folders through [`Self::dirs`], which is bounded, and chose one. What is
1920 /// replaced is a step that otherwise happens at a shell before a person knows what the app
1921 /// does with a folder -- and, on discovering they chose wrongly, by editing `root.txt` by
1922 /// hand.
1923 ///
1924 /// **Three refusals, and each is a fence rule rather than a preference.** `/` is the
1925 /// machine and not a workspace. A path that is not a directory cannot bound anything. And
1926 /// a folder CONTAINING this hand's journal would let a fenced command rewrite the record of
1927 /// itself, which is the rule [`journal::check_fence_at`] applies on every command -- caught
1928 /// here, where the sentence can name the fix, rather than on every later run.
1929 ///
1930 /// **It takes effect when the hand next starts**, and the answer says so. A running hand
1931 /// reads its root once; re-reading it here would move the fence under commands already
1932 /// running.
1933 async fn grant(&self, path: &str) -> Outcome<()> {
1934 let say = |m: String| async move { let _ = self.ctl.send(Resp::Error { id: None, message: m }).await; };
1935 let asked = PathBuf::from(path);
1936 if !asked.is_absolute() {
1937 say(fmt!("'{}' is not an absolute path, and a fence written against a relative one \
1938 fences whatever the hand happens to be standing in.", path)).await;
1939 return Ok(());
1940 }
1941 let here = match std::fs::canonicalize(&asked) {
1942 Ok(p) if p.is_dir() => p,
1943 _ => {
1944 say(fmt!("'{}' is not a folder on this computer, so there is nothing for it to \
1945 bound.", path)).await;
1946 return Ok(());
1947 },
1948 };
1949 if here == Path::new("/") {
1950 say(fmt!("'/' is the machine, not a workspace. Everything any command could reach \
1951 would be everything there is.")).await;
1952 return Ok(());
1953 }
1954 let jdir = {
1955 let g = lock_mutex!(self.jr);
1956 g.dir().to_path_buf()
1957 };
1958 if journal::check_fence_at(&jdir, &daimond_hand::wire::FenceSpec {
1959 rw: vec![fmt!("{}", here.display())], ro: Vec::new(), deny: Vec::new(), net: false,
1960 }).is_err() {
1961 say(fmt!(
1962 "'{}' contains this hand's own record, at '{}', so a command fenced to it could \
1963 rewrite the record of what it did. Move the record outside the folder first: set \
1964 DAIMOND_HAND_JOURNAL_DIR to somewhere the folder does not contain.",
1965 here.display(), jdir.display())).await;
1966 return Ok(());
1967 }
1968 let file = jdir.join(daimond_hand::ROOT_FILE);
1969 let txt = fmt!("# The one folder Daimond's machine hand may work in.\n{}\n", here.display());
1970 if let Err(e) = std::fs::write(&file, txt) {
1971 say(fmt!("'{}' could not be written ({}), so the folder was not changed.",
1972 file.display(), e)).await;
1973 return Ok(());
1974 }
1975 let _ = std::fs::set_permissions(&file, std::os::unix::fs::PermissionsExt::from_mode(0o600));
1976 // Written down: it changes what every LATER command may touch, and a record without it
1977 // leaves a reader unable to say which fence an earlier line was written under.
1978 if let Some(ev) = Event::from_req(&Req::Grant { path: fmt!("{}", here.display()) }, &[]) {
1979 if let Err(e) = self.record(&ev) {
1980 eprintln!("daimond-hand: the grant was not journalled: {}", e);
1981 }
1982 }
1983 let _ = self.ctl.send(Resp::Granted {
1984 path: fmt!("{}", here.display()),
1985 note: fmt!("The folder is written down. This hand is still working in '{}' until it \
1986 is restarted, which happens when the page is reloaded.", self.root.display()),
1987 }).await;
1988 Ok(())
1989 }
1990
1991 /// The directories inside `path`, so a person can choose a folder and get its real path.
1992 ///
1993 /// **Bounded by what this hand would fence a terminal to**, which is the granted root and
1994 /// the account it runs as. Anywhere else is refused, so this is a folder chooser and not a
1995 /// way to enumerate the machine. Names of DIRECTORIES only: no files, no contents, no
1996 /// sizes, and dotted directories last rather than hidden, because a person looking for
1997 /// `.config` should be able to find it.
1998 ///
1999 /// An empty `path` asks where to start, and the answer is the same bound.
2000 async fn dirs(&self, path: &str) -> Outcome<()> {
2001 let roots: Vec<PathBuf> = self.term_ceilings.clone();
2002 if path.trim().is_empty() {
2003 let said = Resp::Dirs {
2004 path: fmt!(""),
2005 up: fmt!(""),
2006 dirs: Vec::new(),
2007 roots: roots.iter().map(|p| fmt!("{}", p.display())).collect(),
2008 };
2009 let _ = self.ctl.send(said).await;
2010 return Ok(());
2011 }
2012 let asked = PathBuf::from(path);
2013 let here = match std::fs::canonicalize(&asked) {
2014 Ok(p) if p.is_dir() => p,
2015 _ => {
2016 let _ = self.ctl.send(Resp::Error {
2017 id: None,
2018 message: fmt!("'{}' is not a folder on this computer.", path),
2019 }).await;
2020 return Ok(());
2021 },
2022 };
2023 // The bound, checked on the CANONICAL path: a symlink out of the grant is the whole
2024 // reason this is not a string comparison on what arrived.
2025 if !roots.iter().any(|r| here.starts_with(r)) {
2026 let _ = self.ctl.send(Resp::Error {
2027 id: None,
2028 message: fmt!(
2029 "'{}' is outside the folders this computer will offer a terminal, so it is \
2030 not browsable from here. Those are: {}.",
2031 here.display(),
2032 roots.iter().map(|p| fmt!("'{}'", p.display()))
2033 .collect::<Vec<_>>().join(", ")),
2034 }).await;
2035 return Ok(());
2036 }
2037 let mut dirs: Vec<String> = Vec::new();
2038 if let Ok(rd) = std::fs::read_dir(&here) {
2039 for e in rd.flatten() {
2040 // `file_type` rather than `metadata`: a symlink to a directory is followed by
2041 // the latter, and a listing that walked into one would leave the bound by
2042 // showing a name that is somewhere else entirely.
2043 match e.file_type() {
2044 Ok(t) if t.is_dir() => {},
2045 _ => continue,
2046 }
2047 if let Some(n) = e.file_name().to_str() {
2048 dirs.push(fmt!("{}", n));
2049 }
2050 }
2051 }
2052 dirs.sort_by(|a, b| {
2053 let da = a.starts_with('.');
2054 let db = b.starts_with('.');
2055 da.cmp(&db).then_with(|| a.to_lowercase().cmp(&b.to_lowercase()))
2056 });
2057 // Up, but never past the bound: the parent of a root is not this hand's to show.
2058 let up = here.parent()
2059 .filter(|p| roots.iter().any(|r| p.starts_with(r)))
2060 .map(|p| fmt!("{}", p.display()))
2061 .unwrap_or_default();
2062 let said = Resp::Dirs {
2063 path: fmt!("{}", here.display()),
2064 up,
2065 dirs,
2066 roots: roots.iter().map(|p| fmt!("{}", p.display())).collect(),
2067 };
2068 let _ = self.ctl.send(said).await;
2069 Ok(())
2070 }
2071
2072 async fn runs(&self) -> Outcome<()> {
2073 // Also off the loop. The listing walks `/proc` once per standing group,
2074 // which is quick and is still not the dispatcher's work to do.
2075 let runner = self.runner.clone();
2076 let ctl = self.ctl.clone();
2077 tokio::spawn(async move {
2078 let said = match runner.runs().await {
2079 Ok((runs, more)) => Resp::Runs { runs, more },
2080 Err(e) => Resp::Error {
2081 id: None,
2082 message: fmt!(
2083 "The hand could not say what it is still running. {}", e.msgs().join(" ")),
2084 },
2085 };
2086 let _ = ctl.send(said).await;
2087 });
2088 Ok(())
2089 }
2090
2091 /// Takes messages until the conversation ends.
2092 ///
2093 /// # Arguments
2094 /// * `rx` - Where the reader puts what it read.
2095 async fn run(&self, rx: &mut Receiver<Inbound>) -> Outcome<Ending> {
2096 loop {
2097 if !self.alive.load(Ordering::SeqCst) {
2098 return Ok(Ending::Stopped(fmt!("the page stopped listening")));
2099 }
2100 let inb = match rx.recv().await {
2101 Some(i) => i,
2102 None => return Ok(Ending::Closed),
2103 };
2104 match inb {
2105 Inbound::Msg(req) => match req {
2106 Req::Hello { .. } => {
2107 if let Some(end) = res!(self.hello(&req)) {
2108 return Ok(end);
2109 }
2110 },
2111 Req::Exec { .. } => res!(self.exec(req).await),
2112 Req::Open { .. } => res!(self.exec(req).await),
2113 // The same gates, the same order, the same function. A file op is a write
2114 // to this machine and is journalled, fence-guarded and gate-1 refused
2115 // exactly as a command is; only what happens at the far end differs.
2116 Req::File { .. } => res!(self.exec(req).await),
2117 // Keystrokes and resizes are answered synchronously and are never
2118 // journalled -- see `Event::from_req`, which refuses to write down the
2119 // message a password is typed into.
2120 Req::Input { ref id, ref data } => {
2121 if let Err(e) = self.ptys.input(id, data) {
2122 eprintln!("daimond-hand: input was not delivered: {}", e);
2123 }
2124 },
2125 Req::Resize { ref id, size } => {
2126 if let Err(e) = self.ptys.resize(id, size) {
2127 eprintln!("daimond-hand: resize was not delivered: {}", e);
2128 }
2129 },
2130 Req::Verify { .. } => res!(self.verify(req).await),
2131 Req::Signal { .. } => res!(self.signal(&req).await),
2132 Req::Runs => res!(self.runs().await),
2133 Req::Dirs { path } => res!(self.dirs(&path).await),
2134 Req::Grant { path } => res!(self.grant(&path).await),
2135 Req::Bye => {
2136 // Written down before anything is stopped.
2137 if let Some(ev) = Event::from_req(&req, &[]) {
2138 if let Err(e) = self.record(&ev) {
2139 eprintln!(
2140 "daimond-hand: the goodbye was not journalled: {}", e);
2141 }
2142 }
2143 return Ok(Ending::Goodbye);
2144 },
2145 },
2146 Inbound::Bad { fault, detail } => {
2147 // The framing is still in step, so the next request is
2148 // still worth serving; the page is told what was wrong.
2149 let what = match fault {
2150 Some(f) => fmt!("{}: {}", f.name(), detail),
2151 None => detail,
2152 };
2153 if !self.say(Resp::Error { id: None, message: what }) {
2154 return Ok(Ending::Stopped(fmt!("the page stopped listening")));
2155 }
2156 },
2157 Inbound::Gone { clean, reason } => {
2158 return Ok(match clean {
2159 true => Ending::Closed,
2160 false => Ending::Stopped(reason),
2161 });
2162 },
2163 }
2164 }
2165 }
2166}
2167
2168/// The same request with a hardened fence in place of the one that arrived.
2169///
2170/// **Both kinds of request, and that is the whole of the care here.** This used to rewrite a
2171/// `Req::Exec` and let a `Req::Open` fall through the `other => other` arm unchanged, so a
2172/// terminal ran under the fence that ARRIVED while a command ran under the one the journal guard
2173/// had hardened -- the journal's own directory denied to one and not to the other. Two paragraphs
2174/// above, [`Desk::exec`] says a terminal is gated exactly as a command is, "written once so the
2175/// two cannot drift"; the drift was inside the function that sentence is about.
2176///
2177/// The variants that fall through genuinely have no fence: a signal, a keystroke, a resize, a
2178/// goodbye.
2179///
2180/// # Arguments
2181/// * `req` - The request.
2182/// * `spec` - The fence to carry instead.
2183fn with_fence(req: Req, spec: daimond_hand::wire::FenceSpec) -> Req {
2184 match req {
2185 Req::Exec { id, argv, cwd, env, stdin, timeout_ms, capture, toolkits, .. } => Req::Exec {
2186 id,
2187 argv,
2188 cwd,
2189 env,
2190 stdin,
2191 timeout_ms,
2192 capture,
2193 fence: spec,
2194 toolkits,
2195 },
2196 Req::Open { id, argv, cwd, env, size, toolkits, .. } => Req::Open {
2197 id,
2198 argv,
2199 cwd,
2200 env,
2201 size,
2202 fence: spec,
2203 toolkits,
2204 },
2205 // The third door, and it is here for the reason the paragraph above records: this
2206 // function once knew about `Exec` alone, and a `Req::Open` fell through `other => other`
2207 // carrying the fence that ARRIVED rather than the one the journal guard hardened. A file
2208 // op writes to disc; leaving it out would be the same defect in the same place.
2209 Req::File { id, op, cwd, toolkits, .. } => Req::File {
2210 id,
2211 op,
2212 cwd,
2213 fence: spec,
2214 toolkits,
2215 },
2216 other => other,
2217 }
2218}
2219
2220// ┌───────────────────────────────────────────────────────────────┐
2221// │ Serving │
2222// └───────────────────────────────────────────────────────────────┘
2223
2224/// Serves one conversation, from the first frame to the last.
2225///
2226/// Generic over both streams so that a test can drive the whole loop over
2227/// memory rather than over a browser, and so that the Cloud tier can hand it a
2228/// socket without this file learning what a socket is.
2229///
2230/// # Arguments
2231/// * `input` - Where frames come from. Owned by a thread of its own.
2232/// * `output` - Where frames go.
2233/// * `cfg` - The journal, the fence and the granted root.
2234///
2235/// # Returns
2236/// How the conversation finished, or an error where it could not be started at
2237/// all -- which, for the journal, is the point: no record, no service.
2238async fn serve<R, W>(input: R, output: W, cfg: Serve) -> Outcome<Ending>
2239where
2240 R: Read + Send + 'static,
2241 W: AsyncWrite + Unpin + Send + 'static,
2242{
2243 let os = res!(daimond_hand::checked_os());
2244
2245 // A kernel with no fence refuses every command one at a time, journalled
2246 // and answered -- which is right, and is also a page that fails command
2247 // after command for a reason nothing has stated. Said once, here.
2248 if let Fence::None { why } = &cfg.fence {
2249 eprintln!(
2250 "daimond-hand: this kernel offers no fence, so every command will be \
2251 refused rather than run unconfined: {}", why);
2252 }
2253
2254 // No journal, no service. This is the gate `README.md` states and
2255 // `REVIEW.md` §2.8 found nowhere in code.
2256 let jr = res!(Journal::open(cfg.journal.clone()));
2257 let dir = jr.dir().to_path_buf();
2258
2259 // A grant that contains the record would be refused on every command; say
2260 // so once, now, where a person can read it.
2261 if journal::check_fence_at(&dir, &daimond_hand::wire::FenceSpec {
2262 rw: vec![fmt!("{}", cfg.root.display())],
2263 ro: Vec::new(),
2264 deny: Vec::new(),
2265 net: true,
2266 }).is_err() {
2267 eprintln!(
2268 "daimond-hand: the granted folder '{}' contains the journal at \
2269 '{}', so every command will be refused. Set \
2270 DAIMOND_HAND_JOURNAL_DIR to a directory outside the grant.",
2271 cfg.root.display(), dir.display());
2272 }
2273
2274 let jr = Arc::new(Mutex::new(jr));
2275 let sound = Arc::new(AtomicBool::new(true));
2276 let alive = Arc::new(AtomicBool::new(true));
2277
2278 let (req_tx, mut req_rx) = channel::<Inbound>(REQ_QUEUE);
2279 let (ctl_tx, ctl_rx) = channel::<Resp>(CTL_QUEUE);
2280 let (bulk_tx, bulk_rx) = channel::<Resp>(BULK_QUEUE);
2281
2282 // A thread, not a task: it blocks on a pipe, and a task that blocks is a
2283 // worker that is not working.
2284 let reader = res!(std::thread::Builder::new()
2285 .name(fmt!("hand-reader"))
2286 .spawn(move || read_frames(input, req_tx))
2287 .map_err(|e| err!(e, "The hand could not start its reader thread."; IO, Init)));
2288
2289 let writer = tokio::spawn(write_loop(
2290 output,
2291 ctl_rx,
2292 bulk_rx,
2293 Arc::clone(&jr),
2294 Arc::clone(&sound),
2295 Arc::clone(&alive),
2296 ));
2297
2298 // Established once, before a page is served: the root cannot change while
2299 // the process lives, so neither can the answer.
2300 let ws = workspace_id(&cfg.root);
2301 if let Identity::Unproven(why) = &ws {
2302 eprintln!(
2303 "daimond-hand: the granted folder '{}' could not be given an \
2304 identity, so the page cannot check that it is the folder you opened \
2305 in the browser: {}. Commands may be refused until it can.",
2306 cfg.root.display(), why);
2307 }
2308
2309 let desk = Desk {
2310 fence: cfg.fence.clone(),
2311 sys: Seccomp::detect(),
2312 root: cfg.root.clone(),
2313 term_ceilings: cfg.term_ceilings.clone(),
2314 term_pinned: cfg.term_pinned,
2315 ws,
2316 os,
2317 runner: Runner::with_launcher(cfg.launcher.clone()),
2318 ptys: daimond_hand::pty::PtySessions::with_launcher(cfg.launcher.clone()),
2319 files: daimond_hand::exec::Files::with_launcher(cfg.launcher.clone()),
2320 jr: Arc::clone(&jr),
2321 sound: Arc::clone(&sound),
2322 alive: Arc::clone(&alive),
2323 ctl: ctl_tx.clone(),
2324 bulk: bulk_tx.clone(),
2325 };
2326
2327 // A failure in the loop itself -- a poisoned lock, an event with no
2328 // canonical form -- ends the conversation rather than escaping it, so that
2329 // the shutdown below still runs and nothing is left behind.
2330 let ending = match desk.run(&mut req_rx).await {
2331 Ok(e) => e,
2332 Err(e) => Ending::Stopped(fmt!("the loop stopped: {}", e.msgs().join("; "))),
2333 };
2334
2335 // Nothing outlives the conversation -- and a terminal is a thing that outlives it, which is
2336 // why there are two lines here and not one. A run is stopped; a SESSION was left to be cleaned
2337 // up as a side effect of the master file descriptor closing when this process exited, which
2338 // reaches a shell that dies on SIGHUP and misses one that ignores it or has re-parented. It
2339 // also meant the hand waited for the program inside the terminal before it could exit at all:
2340 // measured at five minutes for a `sleep 300` the page had already walked away from.
2341 //
2342 // `PtySessions::close_all` sweeps every process group in each session, not merely the leader's
2343 // -- a terminal is what makes job control work, so `sleep 60 &` is in a group of its own (see
2344 // `pty::sweep`).
2345 if let Err(e) = desk.runner.stop_all().await {
2346 eprintln!("daimond-hand: not every run could be stopped: {}", e);
2347 }
2348 match desk.ptys.close_all() {
2349 Ok(0) => (),
2350 Ok(n) => eprintln!("daimond-hand: closed {} terminal session(s) on the way out.", n),
2351 Err(e) => eprintln!("daimond-hand: not every terminal could be closed: {}", e),
2352 }
2353 if ending != Ending::Goodbye {
2354 let ev = Event::Closed { reason: ending.why() };
2355 if let Err(e) = desk.record(&ev) {
2356 eprintln!("daimond-hand: the closing line was not journalled: {}", e);
2357 }
2358 }
2359
2360 // Let the writer finish what is already queued, then stop waiting for it:
2361 // a page that has gone away will never drain, and the hand must still exit.
2362 drop(desk);
2363 drop(ctl_tx);
2364 drop(bulk_tx);
2365 match tokio::time::timeout(Duration::from_millis(DRAIN_MS), writer).await {
2366 Ok(Ok(Ok(()))) => (),
2367 Ok(Ok(Err(e))) => eprintln!("daimond-hand: the writer stopped: {}", e),
2368 Ok(Err(e)) => eprintln!("daimond-hand: the writer failed: {}", e),
2369 Err(_) => eprintln!(
2370 "daimond-hand: the page did not read the last of the output within \
2371 {} ms, so the hand stopped waiting for it.", DRAIN_MS),
2372 }
2373 // The reader is blocked on a pipe nobody will write to again; the request
2374 // channel is closed, so it ends the moment the pipe does.
2375 drop(req_rx);
2376 drop(reader);
2377
2378 Ok(ending)
2379}
2380
2381// ┌───────────────────────────────────────────────────────────────┐
2382// │ Saying why, when the journal cannot │
2383// └───────────────────────────────────────────────────────────────┘
2384//
2385// The hand says everything in the journal, and the failure this section exists
2386// for is the failure to open the journal. A hand that reported it only there
2387// would be mute exactly when it had most to say -- which is what happened: a
2388// snap Chromium started the hand, the hand could not open a journal behind a
2389// hidden directory, and it exited with Chrome reporting nothing but "Native
2390// host has exited". Diagnosis took an hour.
2391//
2392// Standard error is the one channel that survives, because Chrome copies a
2393// native messaging host's standard error into its own log. So every refusal on
2394// the startup path goes through [`refuse`], in words, before the process ends.
2395
2396/// Written and removed to find out whether the journal directory can be written.
2397const PROBE_FILE: &str = ".daimond-hand-write-probe";
2398
2399/// Whether the hand can get at the directory the record lives in.
2400enum Reach {
2401 /// It can be read and written, or it is merely absent and can be made.
2402 Ok,
2403 /// It, or the nearest ancestor that exists, would not open.
2404 Closed {
2405 /// The path that would not open.
2406 at: PathBuf,
2407 /// What the operating system said.
2408 why: String,
2409 },
2410 /// It is there and cannot be written.
2411 Frozen {
2412 /// The directory.
2413 at: PathBuf,
2414 /// What the operating system said.
2415 why: String,
2416 },
2417}
2418
2419/// Asks whether the journal directory can be reached, without opening a journal.
2420///
2421/// Walks up until something exists, because an absent directory is not a fault
2422/// -- the journal makes its own -- and a directory whose *parent* cannot be
2423/// opened is.
2424///
2425/// # Arguments
2426/// * `dir` - Where the record is to live.
2427fn reach(dir: &Path) -> Reach {
2428 let mut at = Some(dir);
2429 while let Some(cur) = at {
2430 match fs::read_dir(cur) {
2431 Ok(_) => return writable(cur),
2432 Err(e) if e.kind() == std::io::ErrorKind::NotFound => at = cur.parent(),
2433 Err(e) => return Reach::Closed {
2434 at: cur.to_path_buf(),
2435 why: fmt!("{}", e),
2436 },
2437 }
2438 }
2439 Reach::Ok
2440}
2441
2442/// Whether a directory can be written, found out by writing in it and tidying up.
2443///
2444/// Only ever called after something has already failed, so the probe file costs
2445/// nothing on the path that works.
2446///
2447/// # Arguments
2448/// * `dir` - The directory.
2449fn writable(dir: &Path) -> Reach {
2450 let p = dir.join(PROBE_FILE);
2451 match fs::OpenOptions::new().create(true).write(true).truncate(true).open(&p) {
2452 Ok(_) => {
2453 let _ = fs::remove_file(&p);
2454 Reach::Ok
2455 },
2456 Err(e) => Reach::Frozen {
2457 at: dir.to_path_buf(),
2458 why: fmt!("{}", e),
2459 },
2460 }
2461}
2462
2463/// The first hidden component of a path inside `$HOME`, where there is one.
2464///
2465/// This is the whole difference between a path a confined browser's child can
2466/// open and one it cannot: snap's `home` interface and flatpak's `--filesystem`
2467/// grant the non-hidden files in a home directory and nothing else.
2468///
2469/// # Arguments
2470/// * `p` - The path.
2471fn hidden_in_home(p: &Path) -> Option<String> {
2472 let home = match std::env::var("HOME") {
2473 Ok(h) if !h.is_empty() => PathBuf::from(h),
2474 _ => return None,
2475 };
2476 hidden_under(p, &home)
2477}
2478
2479/// The first hidden component of `p` below `home`, where there is one.
2480///
2481/// Separate from [`hidden_in_home`] so a test can ask the question without
2482/// setting `HOME` out from under every other test in the binary.
2483///
2484/// # Arguments
2485/// * `p` - The path.
2486/// * `home` - The home directory it may be under.
2487/// This computer's name, or `None` where it will not say.
2488///
2489/// `/etc/hostname` first because it is a file the fence can be given and `gethostname` is a
2490/// syscall the seccomp filter would have to allow; `HOSTNAME` after it, which a shell exports
2491/// and a bare service does not. An empty or absurd answer is treated as no answer -- a briefing
2492/// that names the machine wrongly is worse than one that does not name it.
2493fn hostname() -> Option<String> {
2494 let from_file = std::fs::read_to_string("/etc/hostname").ok()
2495 .map(|s| s.trim().to_string());
2496 let h = match from_file {
2497 Some(s) if !s.is_empty() => s,
2498 _ => std::env::var("HOSTNAME").unwrap_or_default().trim().to_string(),
2499 };
2500 if h.is_empty() || h.len() > 64 || h.contains(char::is_whitespace) {
2501 return None;
2502 }
2503 Some(h)
2504}
2505
2506fn hidden_under(p: &Path, home: &Path) -> Option<String> {
2507 let rest = match p.strip_prefix(home) {
2508 Ok(r) => r,
2509 Err(_) => return None,
2510 };
2511 for c in rest.components() {
2512 let s = c.as_os_str().to_string_lossy();
2513 if s.len() > 1 && s.starts_with('.') {
2514 return Some(s.to_string());
2515 }
2516 }
2517 None
2518}
2519
2520/// Says on standard error why the hand will not serve, before the process ends.
2521///
2522/// Each line stands alone, because it will be read out of context in a browser
2523/// log by somebody already confused: what was attempted, what stopped it, what
2524/// to do.
2525///
2526/// # Arguments
2527/// * `e` - The refusal.
2528fn refuse(e: &Error<ErrTag>) {
2529 // The cause before the symptom. Where the journal directory will not open,
2530 // every other refusal is downstream of it -- and "write your folder into
2531 // root.txt" is actively misleading advice about a file in a directory
2532 // nothing can read.
2533 if let Some(n) = journal_note() {
2534 eprintln!("daimond-hand: {}", n);
2535 }
2536 eprintln!("daimond-hand: {}", e.plain());
2537 eprintln!(
2538 "daimond-hand: 'hand/install/install.sh --check' lists what has to be \
2539 true, and the fix for each thing that is not.");
2540}
2541
2542/// The sentence about the record's own directory, where there is one to say.
2543///
2544/// # Returns
2545/// What is wrong with the journal directory and what to do about it, or `None`
2546/// where it can be reached and the refusal is about something else.
2547fn journal_note() -> Option<String> {
2548 let dir = match journal::default_dir() {
2549 Ok(d) => d,
2550 Err(_) => return None, // The refusal itself is that there is no such path.
2551 };
2552 let (at, why, verb) = match reach(&dir) {
2553 Reach::Ok => return None,
2554 Reach::Closed { at, why } => (at, why, "could not be opened"),
2555 Reach::Frozen { at, why } => (at, why, "cannot be written"),
2556 };
2557 Some(match hidden_in_home(&at) {
2558 // The hour. A confined browser hands the hand its confinement, and the
2559 // record is behind a hidden directory, so the hand cannot write the one
2560 // thing it would have used to explain itself.
2561 Some(h) => fmt!(
2562 "the journal at '{}' {}: {}. '{}' is hidden, and a snap or flatpak \
2563 browser lets the programs it starts see only the files in $HOME \
2564 that are not -- so the hand exits before it can say so. Fix: a \
2565 Chromium-family browser installed from a .deb. \
2566 DAIMOND_HAND_JOURNAL_DIR will not help, because the browser gives \
2567 this program its own environment rather than yours.",
2568 at.display(), verb, why, h),
2569 None => fmt!(
2570 "the journal at '{}' {}: {}. Nothing is served without a record of \
2571 it.", at.display(), verb, why),
2572 })
2573}
2574
2575/// Reads where the journal goes, which folder is granted, and what can be enforced.
2576fn configure() -> Outcome<Serve> {
2577 let dir = res!(journal::default_dir());
2578 let root = res!(granted_root(&dir));
2579 // Optional, and read AFTER the granted root: a hand with no ceiling is every hand
2580 // built before 2026-08-26, and it serves exactly as it did.
2581 let term_pinned = terminal_ceiling(&dir).is_some();
2582 let term_ceilings = terminal_ceilings(&dir, &root);
2583 Ok(Serve {
2584 journal: JournalCfg::at(dir),
2585 fence: Fence::detect(),
2586 root,
2587 term_ceilings,
2588 term_pinned,
2589 launcher: Launcher::SelfExe,
2590 })
2591}
2592
2593/// Serves a browser on stdin and stdout.
2594fn host() -> Outcome<()> {
2595 let cfg = res!(configure());
2596 let rt = res!(tokio::runtime::Builder::new_multi_thread()
2597 .enable_all()
2598 .build()
2599 .map_err(|e| err!(e, "The hand could not start its runtime."; IO, Init)));
2600 let end = res!(rt.block_on(serve(std::io::stdin(), tokio::io::stdout(), cfg)));
2601 eprintln!("daimond-hand: {}.", end.why());
2602 Ok(())
2603}
2604
2605/// Whichever of the four things this binary is being asked to be.
2606///
2607/// The launcher arm comes first and never returns; everything else is either a
2608/// person at a terminal or a browser at a pipe.
2609///
2610/// Nothing is returned to the runtime. `fn main() -> Outcome<()>` ends a
2611/// refusal with `Error: UpstreamErr{"src/main.rs:1764"}` and two more frames of
2612/// the same, which is a stack trace where a sentence was wanted; [`refuse`]
2613/// writes the sentence instead.
2614fn main() {
2615 let args: Vec<String> = std::env::args().skip(1).collect();
2616 // Whether a browser is on the other end of standard output, which decides where a
2617 // refusal can be READ. A person at a terminal reads standard error; a browser
2618 // discards it, so for a browser the refusal has to go down the pipe as a frame.
2619 let piped = match args.first().map(|s| s.as_str()) {
2620 Some(a) if a == LAUNCH_ARG => false,
2621 Some("--report") | Some("-r") | Some("--version") | Some("-V") => false,
2622 Some(a) => is_browser_arg(a),
2623 None => true,
2624 };
2625 let done = match args.first().map(|s| s.as_str()) {
2626 // The launcher, and the first thing this binary looks at.
2627 //
2628 // `exec.rs` re-executes the hand to apply a fence and then become the
2629 // command: Landlock restricts the thread that calls it, so the fence
2630 // has to be applied in a process that has started no runtime and opened
2631 // nothing. This arm must therefore stay first, and it never returns.
2632 Some(a) if a == LAUNCH_ARG => launch_main(),
2633 Some("--report") | Some("-r") => report(),
2634 Some("--version") | Some("-V") => {
2635 println!("{} {}", daimond_hand::HOST_NAME, daimond_hand::version());
2636 Ok(())
2637 },
2638 // A browser passes the calling extension's origin and nothing else, so
2639 // an argument of that shape means the pipe is already there.
2640 Some(a) if is_browser_arg(a) => host(),
2641 Some(other) => Err(err!(
2642 "Unknown argument '{}'. This is a native messaging host: a browser \
2643 launches it and speaks to it over a pipe. Run it with --report to \
2644 see what the fence can enforce on this machine.", other;
2645 Invalid, Input)),
2646 // No arguments means a pipe as well, which is how a test drives it.
2647 None => host(),
2648 };
2649 if let Err(e) = done {
2650 // Down the pipe FIRST, because that is the end with a reader who can act on it.
2651 // A native messaging host's standard error is discarded by the browser, so every
2652 // refusal here -- no granted root, a journal that will not open, a second hand
2653 // already holding the record -- reached the page as the browser's own "Native host
2654 // has exited" and nothing else. The page then guessed, in a paragraph, at which of
2655 // two causes it was; the hand knew all along.
2656 if piped {
2657 say_fault(&fault_line(&e));
2658 }
2659 refuse(&e);
2660 std::process::exit(1);
2661 }
2662}
2663
2664/// The one sentence a browser is told when the hand will not start.
2665///
2666/// [`refuse`] writes three lines, which is right for a person at a terminal and is the
2667/// right ORDER too -- the cause before the symptom, because a journal directory that
2668/// cannot be reached makes every other refusal downstream of it. A frame carries one
2669/// sentence, so the same ordering picks which one it is.
2670///
2671/// # Arguments
2672/// * `e` - What stopped the hand.
2673fn fault_line(e: &Error<ErrTag>) -> String {
2674 match journal_note() {
2675 Some(n) => n,
2676 None => e.plain(),
2677 }
2678}
2679
2680/// The framed bytes of one [`Resp::Fault`], or nothing where it will not encode.
2681///
2682/// Separate from [`say_fault`] so the frame can be read back in a test: the whole
2683/// point of it is that a browser can read it, and a test that only proved the
2684/// function ran would prove nothing about that.
2685///
2686/// # Arguments
2687/// * `reason` - The sentence, as [`fault_line`] composed it.
2688fn fault_frame(reason: &str) -> Option<Vec<u8>> {
2689 let mut buf = Vec::new();
2690 match FRAMING.write_resp(&mut buf, &Resp::Fault { reason: fmt!("{}", reason) }) {
2691 Ok(()) => Some(buf),
2692 Err(_) => None,
2693 }
2694}
2695
2696/// Writes one [`Resp::Fault`] to the pipe, best effort.
2697///
2698/// Best effort by construction: the hand is on its way out either way, and a failure
2699/// here has no reader left to tell.
2700///
2701/// # Arguments
2702/// * `reason` - The sentence, as [`fault_line`] composed it.
2703fn say_fault(reason: &str) {
2704 use std::io::Write;
2705 if let Some(buf) = fault_frame(reason) {
2706 let mut out = std::io::stdout();
2707 let _ = out.write_all(&buf);
2708 let _ = out.flush();
2709 }
2710}
2711
2712// ┌───────────────────────────────────────────────────────────────┐
2713// │ Tests │
2714// └───────────────────────────────────────────────────────────────┘
2715
2716#[cfg(test)]
2717mod tests {
2718 use super::*;
2719
2720 use daimond_hand::wire::{
2721 Capture,
2722 FenceSpec,
2723 Sig,
2724 Stream,
2725 };
2726
2727 use std::{
2728 collections::VecDeque,
2729 io::Cursor,
2730 sync::mpsc as std_mpsc,
2731 };
2732
2733 use tokio::io::AsyncReadExt;
2734
2735 // ── Becoming the launcher ───────────────────────────────────────
2736 //
2737 // A command is fenced by re-executing this binary, and in a test binary
2738 // `/proc/self/exe` is libtest, whose `main` does not dispatch `LAUNCH_ARG`.
2739 // So the launcher is invoked as "run exactly the test named below", and
2740 // that test calls `launch_main`. The same device `exec.rs` uses, for the
2741 // same reason: a test that skipped the launcher would not be testing the
2742 // path that ships.
2743
2744 /// The environment name that turns a copy of the test binary into a launcher.
2745 const LAUNCH_CHILD: &str = "DAIMOND_HAND_MAIN_TEST_LAUNCHER";
2746
2747 /// What libtest writes to standard output before it reaches a test.
2748 ///
2749 /// Removed by name rather than tolerated, so that a change in the harness
2750 /// fails a test instead of quietly moving a number. Whether the second
2751 /// line reaches the pipe at all depends on when libtest flushes, which the
2752 /// `exec` does not wait for, so both are removed and neither is required.
2753 const HARNESS_NOISE: &str = "\nrunning 1 test\n";
2754
2755 /// The line libtest writes as it enters the launcher entry point.
2756 const HARNESS_LINE: &str = "test tests::launcher_child_entry ... ";
2757
2758 /// A hand that will not start says so down the pipe, where a browser can read it.
2759 ///
2760 /// The owner opened a Terminal on 2026-08-26 and was told the hand "disconnected without
2761 /// finishing", that it had either crashed or sent a message over the browser's 1 MB frame
2762 /// limit, and to "tell the user to check the hand's journal" -- to a reader who WAS the
2763 /// user, about a record that held nothing, because the hand had exited before opening it.
2764 /// It had a whole sentence for him and wrote it to a standard error the browser discards.
2765 ///
2766 /// So the sentence goes down the pipe as a frame first. Read back through the codec here
2767 /// rather than merely counted, because "a browser can read it" is the entire claim.
2768 #[test]
2769 fn a_hand_that_will_not_start_says_why_on_the_pipe() -> Outcome<()> {
2770 let said = "Daimond is already open in another browser window on this computer.";
2771 let buf = match fault_frame(said) {
2772 Some(b) => b,
2773 None => return Err(err!("a refusal of {} bytes did not fit a frame", said.len(); Bug)),
2774 };
2775 let mut cur = Cursor::new(buf);
2776 match res!(FRAMING.read_resp(&mut cur)) {
2777 Some(Resp::Fault { reason }) => assert_eq!(said, reason,
2778 "the sentence did not survive the frame"),
2779 other => return Err(err!(
2780 "the pipe carried {:?} where the hand's own refusal was wanted", other; Bug)),
2781 }
2782 Ok(())
2783 }
2784
2785 /// Only a browser is sent one, because only a browser cannot read standard error.
2786 ///
2787 /// The launcher is the arm that matters: it re-executes this binary to become a fenced
2788 /// command, and its standard output is the COMMAND'S. A frame written there would arrive
2789 /// in the middle of a program's output as unexplained bytes.
2790 #[test]
2791 fn the_pipe_is_told_and_a_person_at_a_terminal_is_not() {
2792 for (arg, piped) in [
2793 (Some(LAUNCH_ARG), false),
2794 (Some("--report"), false),
2795 (Some("-r"), false),
2796 (Some("--version"), false),
2797 (Some("-V"), false),
2798 (Some("chrome-extension://abc/"), true),
2799 (Some("moz-extension://abc/"), true),
2800 (Some("--parent-window=1"), true),
2801 (Some("nonsense"), false),
2802 (None, true),
2803 ] {
2804 let got = match arg {
2805 Some(a) if a == LAUNCH_ARG => false,
2806 Some("--report") | Some("-r") | Some("--version") | Some("-V") => false,
2807 Some(a) => is_browser_arg(a),
2808 None => true,
2809 };
2810 assert_eq!(piped, got, "argument {:?} was read as piped={}", arg, got);
2811 }
2812 }
2813
2814 /// The hardened fence reaches a terminal, not only a command.
2815 ///
2816 /// `Desk::exec` says a terminal is gated exactly as a command is, "written once so the two
2817 /// cannot drift" -- and then [`with_fence`] carried the guarded spec into a `Req::Exec` and
2818 /// dropped it for a `Req::Open`, two paragraphs below the comment. So the journal's own
2819 /// directory was denied to a command and not to a terminal, inside the one function whose
2820 /// comment asserts the two cannot differ.
2821 #[test]
2822 fn a_terminal_carries_the_hardened_fence_a_command_does() {
2823 let arrived = FenceSpec {
2824 rw: vec![fmt!("/home/u/ws")],
2825 ro: Vec::new(),
2826 deny: vec![fmt!("/home/u/ws/.daimond")],
2827 net: false,
2828 };
2829 // What `Journal::fence_guard` produces: the same fence with the record put out of reach.
2830 let guarded = FenceSpec {
2831 deny: vec![fmt!("/home/u/ws/.daimond"), fmt!("/home/u/journal")],
2832 ..arrived.clone()
2833 };
2834 let open = Req::Open {
2835 id: fmt!("t1"),
2836 argv: vec![fmt!("bash")],
2837 cwd: fmt!("/home/u/ws"),
2838 env: Vec::new(),
2839 size: daimond_hand::wire::PtySize { cols: 80, rows: 24 },
2840 fence: arrived.clone(),
2841 toolkits: Vec::new(),
2842 };
2843 match with_fence(open, guarded.clone()) {
2844 Req::Open { fence, .. } => assert_eq!(guarded, fence,
2845 "a terminal was opened with the fence that ARRIVED, so the journal directory the \
2846 guard added is missing from it"),
2847 other => panic!("with_fence changed the request into {:?}", other),
2848 }
2849 // The control: the command path, which has always carried it.
2850 let exec = Req::Exec {
2851 id: fmt!("r1"),
2852 argv: vec![fmt!("/bin/true")],
2853 cwd: fmt!("/home/u/ws"),
2854 env: Vec::new(),
2855 stdin: None,
2856 timeout_ms: 1000,
2857 capture: Capture::Both,
2858 fence: arrived,
2859 toolkits: Vec::new(),
2860 };
2861 match with_fence(exec, guarded.clone()) {
2862 Req::Exec { fence, .. } => assert_eq!(guarded, fence),
2863 other => panic!("with_fence changed the request into {:?}", other),
2864 }
2865 }
2866
2867 /// A terminal does not outlive the conversation.
2868 ///
2869 /// `PtySessions::close_all` says it ends every session "for `Req::Bye`", and nothing called
2870 /// it: `Req::Bye` journalled the goodbye and returned, and the shutdown block below the loop
2871 /// stopped every RUN under the comment "Nothing outlives the conversation" and never a
2872 /// terminal.
2873 ///
2874 /// The visible cost was not an orphan but a hang: the session task holds a response sender, so
2875 /// the hand could not finish its own shutdown until the program inside the terminal ended by
2876 /// itself. Measured at five minutes for a `sleep 300` the page had already walked away from.
2877 /// The orphan is the other half -- a shell is otherwise cleaned up only as a side effect of
2878 /// the master file descriptor closing, which reaches one that dies on `SIGHUP` and misses one
2879 /// that ignores it or has re-parented.
2880 ///
2881 /// Both halves are asserted, and the second on the KERNEL: the child's process id comes back
2882 /// in `Resp::Opened`, and afterwards that process must be gone. A registry count would go to
2883 /// zero the moment the map was emptied, whether or not anything died.
2884 #[tokio::test]
2885 async fn a_terminal_does_not_outlive_the_conversation() -> Outcome<()> {
2886 if !can_fence() {
2887 return Ok(()); // A machine that cannot fence refuses the open, so there is no session.
2888 }
2889 let (cfg, _jdir) = res!(setup("pty-bye", Fence::detect()));
2890 // `sleep` rather than a shell: it neither reads its input nor dies of a closed terminal,
2891 // so if it is gone afterwards something signalled it.
2892 let open = Req::Open {
2893 id: fmt!("t1"),
2894 argv: vec![fmt!("/bin/sleep"), fmt!("300")],
2895 cwd: fmt!("{}", cfg.root.display()),
2896 env: Vec::new(),
2897 size: daimond_hand::wire::PtySize { cols: 80, rows: 24 },
2898 fence: FenceSpec {
2899 rw: vec![fmt!("{}", cfg.root.display())],
2900 ro: Vec::new(),
2901 deny: Vec::new(),
2902 net: true,
2903 },
2904 toolkits: Vec::new(),
2905 };
2906
2907 // A live feed rather than the fixed `Cursor` the other tests use, and the difference is
2908 // load-bearing: a terminal is registered by a task `Desk::exec` spawns, so a `Bye` sent in
2909 // the same breath as the `Open` can be processed before the session exists -- and a test
2910 // written that way would prove only that `close_all` was called on an empty registry.
2911 let (src, tx) = feed();
2912 let (w, mut r) = tokio::io::duplex(1 << 20);
2913 let task = tokio::spawn(serve(src, w, cfg));
2914
2915 res!(tx.send(res!(framed(&hello()))).map_err(|e| err!(e, "send hello"; Test, IO)));
2916 res!(tx.send(res!(framed(&open))).map_err(|e| err!(e, "send open"; Test, IO)));
2917
2918 // Wait for the session to actually exist before saying goodbye, or this proves nothing.
2919 let mut seen = Vec::new();
2920 let mut pid = 0u32;
2921 for _ in 0..200 {
2922 let mut buf = [0u8; 4096];
2923 match tokio::time::timeout(Duration::from_millis(100), r.read(&mut buf)).await {
2924 Ok(Ok(0)) => break,
2925 Ok(Ok(n)) => seen.extend_from_slice(&buf[..n]),
2926 Ok(Err(_)) => break,
2927 Err(_) => (), // Nothing yet; look at what has arrived so far.
2928 }
2929 if let Ok(rs) = responses(&seen) {
2930 if let Some(p) = rs.iter().find_map(|x| match x {
2931 Resp::Opened { pid, .. } => Some(*pid),
2932 _ => None,
2933 }) {
2934 pid = p;
2935 break;
2936 }
2937 // Refused rather than opened: no session, and nothing to outlive anything.
2938 if rs.iter().any(|x| matches!(x, Resp::Refused { .. })) {
2939 return Ok(());
2940 }
2941 }
2942 }
2943 if pid == 0 {
2944 return Ok(()); // No terminal was opened on this machine.
2945 }
2946 assert!(std::path::Path::new(&fmt!("/proc/{}", pid)).exists(),
2947 "the terminal's process {} was not running even before the goodbye", pid);
2948
2949 res!(tx.send(res!(framed(&Req::Bye))).map_err(|e| err!(e, "send bye"; Test, IO)));
2950 drop(tx);
2951
2952 // Bounded, and the bound is half the assertion: without `close_all` the goodbye does not
2953 // end the terminal, the session task goes on holding a response sender, and the hand's own
2954 // shutdown waits for the program inside it.
2955 let mut rest = Vec::new();
2956 let waited = tokio::time::timeout(
2957 Duration::from_secs(30), r.read_to_end(&mut rest)).await;
2958 assert!(waited.is_ok(),
2959 "the conversation did not end within 30 s of the page saying goodbye: the terminal \
2960 was never closed, so the hand waited for the program inside it");
2961 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
2962 assert_eq!(Ending::Goodbye, end);
2963
2964 // The kernel is the oracle. A short wait, because signalling and reaping are not
2965 // instantaneous and the alternative is a flake in whichever direction the machine is slow.
2966 let mut alive = true;
2967 for _ in 0..100 {
2968 if !std::path::Path::new(&fmt!("/proc/{}", pid)).exists() {
2969 alive = false;
2970 break;
2971 }
2972 tokio::time::sleep(Duration::from_millis(50)).await;
2973 }
2974 assert!(!alive,
2975 "the terminal's process {} was still running after the page said goodbye and the \
2976 hand's conversation ended", pid);
2977 Ok(())
2978 }
2979
2980 /// The launcher entry point, reached only in a re-executed test binary.
2981 #[test]
2982 fn launcher_child_entry() {
2983 if std::env::var(LAUNCH_CHILD).is_err() {
2984 return;
2985 }
2986 launch_main()
2987 }
2988
2989 /// A launcher that re-enters this test binary at [`launcher_child_entry`].
2990 fn test_launcher() -> Outcome<Launcher> {
2991 let exe = res!(std::env::current_exe().map_err(|e| err!(e,
2992 "The loop's tests need to know their own binary."; Test, IO)));
2993 Ok(Launcher::Explicit {
2994 prog: exe,
2995 args: vec![
2996 fmt!("tests::launcher_child_entry"),
2997 fmt!("--exact"),
2998 fmt!("--nocapture"),
2999 fmt!("--test-threads=1"),
3000 ],
3001 env: vec![(fmt!("{}", LAUNCH_CHILD), fmt!("1"))],
3002 })
3003 }
3004
3005 /// A directory to work in, under the build's own target tree.
3006 ///
3007 /// Never `/tmp`: it is a tmpfs here, and a test that fills it takes the
3008 /// machine's memory with it.
3009 ///
3010 /// # Arguments
3011 /// * `name` - A name unique to the test.
3012 fn scratch(name: &str) -> Outcome<PathBuf> {
3013 let base = match std::env::var("CARGO_TARGET_DIR") {
3014 Ok(v) if !v.is_empty() => PathBuf::from(v),
3015 _ => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"),
3016 };
3017 let dir = base.join("main-tests").join(name);
3018 if dir.exists() {
3019 res!(fs::remove_dir_all(&dir));
3020 }
3021 res!(fs::create_dir_all(&dir));
3022 Ok(dir)
3023 }
3024
3025 /// A stream a test can feed and then close, so a reader can be left waiting.
3026 ///
3027 /// Blocking, because that is what a pipe is, and the reader under test is
3028 /// written for one.
3029 struct Feed {
3030 /// Where the next bytes come from.
3031 rx: std_mpsc::Receiver<Vec<u8>>,
3032 /// What has arrived and not yet been read.
3033 buf: VecDeque<u8>,
3034 }
3035
3036 impl Read for Feed {
3037 fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
3038 while self.buf.is_empty() {
3039 match self.rx.recv() {
3040 Ok(v) => self.buf.extend(v),
3041 Err(_) => return Ok(0), // The far end closed.
3042 }
3043 }
3044 let n = out.len().min(self.buf.len());
3045 for (i, b) in self.buf.drain(..n).enumerate() {
3046 out[i] = b;
3047 }
3048 Ok(n)
3049 }
3050 }
3051
3052 /// A feed and the handle that writes to it.
3053 fn feed() -> (Feed, std_mpsc::Sender<Vec<u8>>) {
3054 let (tx, rx) = std_mpsc::channel::<Vec<u8>>();
3055 (Feed { rx, buf: VecDeque::new() }, tx)
3056 }
3057
3058 /// The bytes one request occupies on the wire.
3059 ///
3060 /// # Arguments
3061 /// * `req` - The request.
3062 fn framed(req: &Req) -> Outcome<Vec<u8>> {
3063 let mut b = Vec::new();
3064 res!(FRAMING.write_req(&mut b, req));
3065 Ok(b)
3066 }
3067
3068 /// Every response a byte stream carries.
3069 ///
3070 /// # Arguments
3071 /// * `bytes` - What the hand wrote.
3072 fn responses(bytes: &[u8]) -> Outcome<Vec<Resp>> {
3073 let mut cur = Cursor::new(bytes.to_vec());
3074 let mut out = Vec::new();
3075 loop {
3076 match FRAMING.read_resp(&mut cur) {
3077 Ok(Some(r)) => out.push(r),
3078 Ok(None) => break,
3079 Err(e) => return Err(e),
3080 }
3081 }
3082 Ok(out)
3083 }
3084
3085 /// The configuration a test serves with.
3086 ///
3087 /// # Arguments
3088 /// * `name` - A name unique to the test.
3089 /// * `fence` - What the machine is to be treated as able to enforce.
3090 fn setup(name: &str, fence: Fence) -> Outcome<(Serve, PathBuf)> {
3091 let dir = res!(scratch(name));
3092 let jdir = dir.join("journal");
3093 let root = dir.join("work");
3094 res!(fs::create_dir_all(&root));
3095 Ok((
3096 Serve {
3097 journal: JournalCfg::at(&jdir),
3098 fence,
3099 root: res!(fs::canonicalize(&root)),
3100 term_ceilings: vec![res!(fs::canonicalize(&root))],
3101 term_pinned: false,
3102 launcher: res!(test_launcher()),
3103 },
3104 jdir,
3105 ))
3106 }
3107
3108 /// The kinds of every journal entry, in order.
3109 ///
3110 /// # Arguments
3111 /// * `dir` - The journal directory.
3112 fn kinds(dir: &Path) -> Outcome<Vec<String>> {
3113 let mut out = Vec::new();
3114 for (_, path) in res!(journal::journal_files(dir)) {
3115 let txt = res!(fs::read_to_string(&path), IO, File);
3116 for line in txt.lines() {
3117 if line.trim().is_empty() {
3118 continue;
3119 }
3120 out.push(res!(journal::parse_line(line)).kind);
3121 }
3122 }
3123 Ok(out)
3124 }
3125
3126 /// A hello of the version this build speaks.
3127 fn hello() -> Req {
3128 Req::Hello { proto: daimond_hand::PROTO, client: fmt!("test") }
3129 }
3130
3131 /// An exec inside the granted root.
3132 ///
3133 /// # Arguments
3134 /// * `id` - The run's identifier.
3135 /// * `argv` - The program and its arguments.
3136 /// * `root` - The granted folder, which is also the fence and the cwd.
3137 /// * `ms` - The wall-clock limit.
3138 fn exec(id: &str, argv: &[&str], root: &Path, ms: u64) -> Req {
3139 Req::Exec {
3140 id: fmt!("{}", id),
3141 argv: argv.iter().map(|a| fmt!("{}", a)).collect(),
3142 cwd: fmt!("{}", root.display()),
3143 env: Vec::new(),
3144 stdin: None,
3145 timeout_ms: ms,
3146 capture: Capture::Both,
3147 // Net left open, so that a kernel without Landlock's network rules
3148 // refuses for the reason under test rather than for that one.
3149 fence: FenceSpec {
3150 rw: vec![fmt!("{}", root.display())],
3151 ro: Vec::new(),
3152 deny: Vec::new(),
3153 net: true,
3154 },
3155 toolkits: Vec::new(),
3156 }
3157 }
3158
3159 /// Whether this machine can fence a command at all.
3160 ///
3161 /// The tests that need a real process assert the full stream where it can,
3162 /// and a refusal where it cannot, because both are correct behaviour and
3163 /// which one is correct depends on the kernel underneath.
3164 fn can_fence() -> bool {
3165 !Fence::detect().caps().iter().any(|c| c == "fence:none")
3166 }
3167
3168 /// A fence value for a machine that can enforce nothing.
3169 fn no_fence() -> Fence {
3170 Fence::None { why: fmt!("This is a test with no kernel behind it.") }
3171 }
3172
3173 /// The handshake answers with the protocol, the build, the caps and the root.
3174 /// **A grant is refused where it would swallow the record of what it did.**
3175 ///
3176 /// The one rule that is not a preference: [`journal::check_fence_at`] refuses any fence
3177 /// reaching the journal, so a folder CONTAINING the journal would be refused on every
3178 /// command afterwards. Caught at the grant, where the sentence can name the fix, rather
3179 /// than as a mystery on the next run. `/` and a path that is not a directory are checked
3180 /// beside it because all three answer the same question: can this folder bound anything.
3181 #[tokio::test]
3182 async fn a_grant_that_would_swallow_the_record_is_refused() -> Outcome<()> {
3183 let (cfg, jdir) = res!(setup("grant", Fence::detect()));
3184 let swallows = res!(jdir.parent().ok_or_else(|| err!(
3185 "the journal has no parent"; Test, Missing))).to_path_buf();
3186 let mut input = res!(framed(&hello()));
3187 input.extend_from_slice(&res!(framed(&Req::Grant {
3188 path: fmt!("{}", swallows.display()) })));
3189 input.extend_from_slice(&res!(framed(&Req::Grant { path: fmt!("/") })));
3190 input.extend_from_slice(&res!(framed(&Req::Grant {
3191 path: fmt!("{}/not-a-folder-at-all", swallows.display()) })));
3192 input.extend_from_slice(&res!(framed(&Req::Bye)));
3193
3194 let (w, mut r) = tokio::io::duplex(1 << 20);
3195 let task = tokio::spawn(serve(Cursor::new(input), w, cfg.clone()));
3196 let mut bytes = Vec::new();
3197 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3198 let _ = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3199 let rs = res!(responses(&bytes));
3200
3201 assert!(!rs.iter().any(|x| matches!(x, Resp::Granted { .. })),
3202 "one of three refusable grants was written: {:?}", rs);
3203 let said: Vec<String> = rs.iter().filter_map(|x| match x {
3204 Resp::Error { message, .. } => Some(message.clone()),
3205 _ => None,
3206 }).collect();
3207 assert_eq!(3, said.len(), "expected three refusals, got {:?}", said);
3208 assert!(said.iter().any(|m| m.contains("record")),
3209 "the swallowed-record refusal does not name the record: {:?}", said);
3210 assert!(said.iter().any(|m| m.contains("the machine")),
3211 "'/' was not refused as the machine: {:?}", said);
3212 let after = std::fs::read_to_string(jdir.join(daimond_hand::ROOT_FILE)).unwrap_or_default();
3213 assert!(!after.contains(&fmt!("{}", swallows.display())),
3214 "a refused grant reached root.txt: {:?}", after);
3215 Ok(())
3216 }
3217
3218 /// **A folder browser is BOUNDED, and the bound survives `..`.**
3219 ///
3220 /// This exists because the browser's own `showDirectoryPicker` cannot serve a fence: it
3221 /// answers with a handle carrying a name and no path. So the hand offers the chooser, and
3222 /// the moment it does it is a thing that lists directories on somebody's machine -- which
3223 /// is only safe while the bound is the bound. Checked on the CANONICAL path, so a walk up
3224 /// through `..` leaves by the same door it came in.
3225 #[tokio::test]
3226 async fn a_folder_walk_is_bounded_and_says_where_it_will_start() -> Outcome<()> {
3227 let (cfg, _jdir) = res!(setup("dirs", Fence::detect()));
3228 let outside = fmt!("{}/..", cfg.root.display());
3229 let mut input = res!(framed(&hello()));
3230 input.extend_from_slice(&res!(framed(&Req::Dirs { path: fmt!("") })));
3231 input.extend_from_slice(&res!(framed(&Req::Dirs { path: outside.clone() })));
3232 input.extend_from_slice(&res!(framed(&Req::Bye)));
3233
3234 let (w, mut r) = tokio::io::duplex(1 << 20);
3235 let task = tokio::spawn(serve(Cursor::new(input), w, cfg.clone()));
3236 let mut bytes = Vec::new();
3237 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3238 let _ = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3239 let rs = res!(responses(&bytes));
3240
3241 // An empty ask says where it will start, and that is the granted root and no more.
3242 let start = rs.iter().find_map(|r| match r {
3243 Resp::Dirs { path, roots, .. } if path.is_empty() => Some(roots.clone()),
3244 _ => None,
3245 });
3246 let roots = res!(start.ok_or_else(|| err!(
3247 "the hand did not say where a folder walk starts: {:?}", rs; Test, Missing)));
3248 assert!(roots.iter().any(|r| r == &fmt!("{}", cfg.root.display())),
3249 "the granted root is not among the places a walk may start: {:?}", roots);
3250
3251 // And a walk out through `..` is refused, naming what it would allow.
3252 let refused = rs.iter().any(|r| matches!(r,
3253 Resp::Error { message, .. } if message.contains("outside the folders")));
3254 assert!(refused, "a walk up out of the grant was not refused: {:?}", rs);
3255 // Belt and braces: it must not have ANSWERED with the parent's listing.
3256 assert!(!rs.iter().any(|r| matches!(r, Resp::Dirs { path, .. }
3257 if !path.is_empty() && !path.starts_with(&fmt!("{}", cfg.root.display())))),
3258 "a listing outside the grant came back: {:?}", rs);
3259 Ok(())
3260 }
3261
3262 #[tokio::test]
3263 async fn handshake_answers_with_caps_and_the_granted_root() -> Outcome<()> {
3264 let (cfg, jdir) = res!(setup("handshake", Fence::detect()));
3265 let mut input = res!(framed(&hello()));
3266 input.extend_from_slice(&res!(framed(&Req::Bye)));
3267
3268 let (w, mut r) = tokio::io::duplex(1 << 20);
3269 let task = tokio::spawn(serve(Cursor::new(input), w, cfg.clone()));
3270 let mut bytes = Vec::new();
3271 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3272 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3273
3274 assert_eq!(Ending::Goodbye, end);
3275 let rs = res!(responses(&bytes));
3276 match rs.first() {
3277 Some(Resp::Hello { proto, host, version, os, caps }) => {
3278 assert_eq!(daimond_hand::PROTO, *proto);
3279 assert_eq!(daimond_hand::HOST_NAME, host);
3280 assert_eq!(daimond_hand::version(), version);
3281 assert_eq!(daimond_hand::os(), os);
3282 // The real list, not a hard-coded one.
3283 for c in Fence::detect().caps() {
3284 assert!(caps.contains(&c), "{:?} is missing {}", caps, c);
3285 }
3286 let want = fmt!("root:{}", cfg.root.display());
3287 assert!(caps.contains(&want), "{:?} is missing {}", caps, want);
3288 },
3289 other => return Err(err!("Expected a hello, got {:?}.", other; Test, Invalid)),
3290 }
3291 // The handshake and the goodbye are both in the record.
3292 let ks = res!(kinds(&jdir));
3293 assert_eq!(vec![fmt!("opened"), fmt!("closed")], ks);
3294 Ok(())
3295 }
3296
3297 /// What one `caps` entry carries, where the list has it.
3298 ///
3299 /// # Arguments
3300 /// * `caps` - The list from a hello.
3301 /// * `prefix` - The entry wanted.
3302 fn cap_value(caps: &[String], prefix: &str) -> Option<String> {
3303 caps.iter()
3304 .find(|c| c.starts_with(prefix))
3305 .map(|c| c[prefix.len()..].to_string())
3306 }
3307
3308 /// The `caps` of one whole conversation that says hello and goodbye.
3309 ///
3310 /// # Arguments
3311 /// * `cfg` - What to serve with.
3312 async fn caps_of(cfg: Serve) -> Outcome<Vec<String>> {
3313 let mut input = res!(framed(&hello()));
3314 input.extend_from_slice(&res!(framed(&Req::Bye)));
3315 let (w, mut r) = tokio::io::duplex(1 << 20);
3316 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3317 let mut bytes = Vec::new();
3318 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3319 res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3320 for resp in res!(responses(&bytes)) {
3321 if let Resp::Hello { caps, .. } = resp {
3322 return Ok(caps);
3323 }
3324 }
3325 Err(err!("The hand never said hello."; Test, Missing))
3326 }
3327
3328 /// The page is given something it can check the folder's identity against.
3329 ///
3330 /// `REVIEW.md` §1.14. Five properties, and the last two are the ones that
3331 /// make the first three worth anything:
3332 ///
3333 /// 1. A token reaches the page, and it is the token in the file the page can
3334 /// read through its own handle.
3335 /// 2. It survives a restart, so it names the folder rather than the run.
3336 /// 3. A different folder gets a different one, so the comparison can fail.
3337 /// 4. Rubbish in the file is replaced rather than published, so a token the
3338 /// page is given always came from here.
3339 /// 5. Where no token can be established the hand says `unproven` rather than
3340 /// inventing one or saying nothing, because a page cannot tell silence
3341 /// from an older hand.
3342 #[tokio::test]
3343 async fn the_page_is_told_which_folder_this_is() -> Outcome<()> {
3344 let (cfg, _) = res!(setup("workspace-id", Fence::detect()));
3345 let idf = cfg.root.join(APP_DIR).join(WS_ID_FILE);
3346
3347 // 1. The token on the wire is the token in the folder.
3348 let caps = res!(caps_of(cfg.clone()).await);
3349 let tok = match cap_value(&caps, WS_CAP) {
3350 Some(t) => t,
3351 None => return Err(err!(
3352 "No {:?} entry in {:?}.", WS_CAP, caps; Test, Missing)),
3353 };
3354 assert_eq!(WS_ID_LEN, tok.len(), "the token is not a token: {:?}", tok);
3355 assert_ne!(WS_UNPROVEN, tok);
3356 let txt = res!(fs::read_to_string(&idf), IO, File);
3357 assert_eq!(Some(tok.clone()), id_from_file(&txt));
3358 // It explains itself to whoever opens it.
3359 assert!(txt.starts_with('#'), "the identity file says nothing: {:?}", txt);
3360
3361 // 2. A second launch is the same folder.
3362 assert_eq!(Some(tok.clone()), cap_value(&res!(caps_of(cfg.clone()).await), WS_CAP));
3363
3364 // 3. A different folder is a different folder.
3365 let (other, _) = res!(setup("workspace-id-other", Fence::detect()));
3366 let tok2 = cap_value(&res!(caps_of(other).await), WS_CAP);
3367 assert_ne!(Some(tok.clone()), tok2, "two folders share one identity");
3368
3369 // 4. Something that is not a token is not published as one.
3370 res!(fs::write(&idf, "# planted\nnot-a-token\n"), IO, File);
3371 let tok3 = match cap_value(&res!(caps_of(cfg.clone()).await), WS_CAP) {
3372 Some(t) => t,
3373 None => return Err(err!("No identity after a plant."; Test, Missing)),
3374 };
3375 assert_eq!(WS_ID_LEN, tok3.len(), "a planted line reached the page: {:?}", tok3);
3376 assert_ne!(tok, tok3, "the planted file was left in place");
3377
3378 // 5. A folder that cannot hold one is said to be unproven.
3379 #[cfg(unix)]
3380 {
3381 use std::os::unix::fs::PermissionsExt;
3382 res!(fs::remove_dir_all(cfg.root.join(APP_DIR)), IO, File);
3383 res!(fs::set_permissions(&cfg.root, fs::Permissions::from_mode(0o500)), IO, File);
3384 let said = cap_value(&res!(caps_of(cfg.clone()).await), WS_CAP);
3385 res!(fs::set_permissions(&cfg.root, fs::Permissions::from_mode(0o700)), IO, File);
3386 assert_eq!(Some(fmt!("{}", WS_UNPROVEN)), said,
3387 "a folder with no identity was given one anyway");
3388 }
3389 Ok(())
3390 }
3391
3392 /// Only a line shaped like a token is read as one.
3393 #[test]
3394 fn an_identity_file_is_read_strictly() {
3395 let good = mint_id();
3396 assert_eq!(WS_ID_LEN, good.len());
3397 assert_eq!(Some(good.clone()), id_from_file(&fmt!("# why\n\n{}\n", good)));
3398 assert_ne!(mint_id(), good, "two tokens were the same");
3399
3400 for bad in [
3401 "", // Nothing at all.
3402 "# only a comment\n", // Nothing but a comment.
3403 "\n\n", // Nothing but blank lines.
3404 "0123456789abcdef", // Too short.
3405 "0123456789abcdef0123456789abcdef0", // Too long.
3406 "0123456789ABCDEF0123456789abcdef", // Not the spelling written.
3407 "0123456789abcdef0123456789abcdeg", // Not hexadecimal.
3408 "../../etc/passwd", // Not a token at all.
3409 ] {
3410 assert_eq!(None, id_from_file(bad), "{:?} was read as a token", bad);
3411 }
3412 }
3413
3414 /// A page speaking another protocol is refused, and the conversation ends.
3415 #[tokio::test]
3416 async fn a_protocol_mismatch_is_refused() -> Outcome<()> {
3417 let (cfg, jdir) = res!(setup("mismatch", Fence::detect()));
3418 let input = res!(framed(&Req::Hello { proto: 999, client: fmt!("test") }));
3419
3420 let (w, mut r) = tokio::io::duplex(1 << 16);
3421 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3422 let mut bytes = Vec::new();
3423 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3424 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3425
3426 match end {
3427 Ending::Stopped(_) => (),
3428 other => return Err(err!("Expected a stop, got {:?}.", other; Test, Invalid)),
3429 }
3430 let rs = res!(responses(&bytes));
3431 match rs.first() {
3432 Some(Resp::Refused { reason, .. }) => {
3433 assert!(reason.contains("999"), "{}", reason);
3434 assert!(reason.contains(&fmt!("{}", daimond_hand::PROTO)), "{}", reason);
3435 },
3436 other => return Err(err!("Expected a refusal, got {:?}.", other; Test, Invalid)),
3437 }
3438 // The refusal was written down before it was sent.
3439 assert!(res!(kinds(&jdir)).contains(&fmt!("refused")));
3440 Ok(())
3441 }
3442
3443 // ── Saying why, when the journal cannot ─────────────────────────
3444 //
3445 // The hour of 2026-08-02: a snap Chromium started the hand, the hand could
3446 // not open a journal behind `~/.local`, and it exited with Chrome reporting
3447 // "Native host has exited" and the hand reporting nothing -- because the
3448 // thing it could not do was the thing it would have used to say so.
3449
3450 /// The hidden component that a confined browser cannot reach is named.
3451 #[test]
3452 fn a_hidden_directory_under_home_is_named() {
3453 let home = Path::new("/home/u");
3454 assert_eq!(
3455 hidden_under(Path::new("/home/u/.local/share/daimond/hand/journal"), home),
3456 Some(fmt!(".local")),
3457 "the component a snap cannot open is the one to name");
3458 // A journal somewhere the browser CAN reach says nothing about snap,
3459 // because saying it would send the next hour the wrong way.
3460 assert_eq!(hidden_under(Path::new("/home/u/daimond/journal"), home), None);
3461 // Outside the home directory the confinement does not apply.
3462 assert_eq!(hidden_under(Path::new("/srv/daimond/journal"), home), None);
3463 // The home directory's own name may start with a dot without that
3464 // meaning anything about what is inside it.
3465 assert_eq!(hidden_under(Path::new("/home/.u/work"), Path::new("/home/.u")), None);
3466 }
3467
3468 /// A journal directory that will not open is reported, and named.
3469 #[cfg(unix)]
3470 #[test]
3471 fn an_unopenable_journal_directory_is_reported() -> Outcome<()> {
3472 use std::os::unix::fs::PermissionsExt;
3473 let dir = res!(scratch("reach-closed"));
3474 let jdir = dir.join("journal");
3475 res!(fs::create_dir(&jdir));
3476 res!(fs::set_permissions(&jdir, fs::Permissions::from_mode(0o000)), IO, File);
3477 let got = reach(&jdir);
3478 // Restored before any assertion, so a failure does not leave a
3479 // directory the next `cargo clean` cannot remove.
3480 res!(fs::set_permissions(&jdir, fs::Permissions::from_mode(0o700)), IO, File);
3481 match got {
3482 Reach::Closed { at, .. } => assert_eq!(at, jdir),
3483 _ => return Err(err!(
3484 "A journal directory at mode 000 must be reported as closed, \
3485 which is the snap failure in a form a test can make."; Test, Bug)),
3486 }
3487 Ok(())
3488 }
3489
3490 /// A journal directory that will not take a file is reported, and named.
3491 #[cfg(unix)]
3492 #[test]
3493 fn an_unwritable_journal_directory_is_reported() -> Outcome<()> {
3494 use std::os::unix::fs::PermissionsExt;
3495 let dir = res!(scratch("reach-frozen"));
3496 let jdir = dir.join("journal");
3497 res!(fs::create_dir(&jdir));
3498 res!(fs::set_permissions(&jdir, fs::Permissions::from_mode(0o500)), IO, File);
3499 let got = reach(&jdir);
3500 res!(fs::set_permissions(&jdir, fs::Permissions::from_mode(0o700)), IO, File);
3501 match got {
3502 Reach::Frozen { at, .. } => assert_eq!(at, jdir),
3503 _ => return Err(err!(
3504 "A readable directory that takes no file must be reported as \
3505 frozen: the hand cannot write the record and will exit."; Test, Bug)),
3506 }
3507 Ok(())
3508 }
3509
3510 /// A directory that is merely absent is not a fault, and the probe leaves nothing.
3511 #[test]
3512 fn an_absent_journal_directory_is_not_a_fault() -> Outcome<()> {
3513 let dir = res!(scratch("reach-ok"));
3514 // Absent, with a reachable ancestor: the journal makes its own.
3515 match reach(&dir.join("not").join("there").join("yet")) {
3516 Reach::Ok => {},
3517 _ => return Err(err!(
3518 "An absent journal directory under a writable ancestor must not \
3519 be reported as unreachable, or every first run says so."; Test, Bug)),
3520 }
3521 match reach(&dir) {
3522 Reach::Ok => {},
3523 _ => return Err(err!("A writable directory must be reachable."; Test, Bug)),
3524 }
3525 let left: Vec<_> = res!(fs::read_dir(&dir), IO, File)
3526 .filter_map(|e| e.ok())
3527 .map(|e| e.file_name().to_string_lossy().to_string())
3528 .collect();
3529 assert!(left.is_empty(),
3530 "the write probe must tidy up after itself, found {:?}", left);
3531 Ok(())
3532 }
3533
3534 /// A machine with no fence refuses every command, and never runs one.
3535 #[tokio::test]
3536 async fn an_unfenceable_machine_refuses_rather_than_running() -> Outcome<()> {
3537 let (cfg, jdir) = res!(setup("unfenced", no_fence()));
3538 let mut input = res!(framed(&hello()));
3539 input.extend_from_slice(&res!(framed(&exec("r1", &["/bin/echo", "hi"], &cfg.root, 5_000))));
3540 input.extend_from_slice(&res!(framed(&Req::Bye)));
3541
3542 let (w, mut r) = tokio::io::duplex(1 << 20);
3543 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3544 let mut bytes = Vec::new();
3545 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3546 res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3547
3548 let rs = res!(responses(&bytes));
3549 let refused = rs.iter().any(|r| match r {
3550 Resp::Refused { id, reason } => id == "r1" && reason.contains("not run"),
3551 _ => false,
3552 });
3553 assert!(refused, "{:?}", rs);
3554 // Nothing started, which is the difference between refusing and
3555 // mentioning it afterwards.
3556 assert!(!rs.iter().any(|r| matches!(r, Resp::Started { .. })), "{:?}", rs);
3557 let ks = res!(kinds(&jdir));
3558 assert!(ks.contains(&fmt!("refused")), "{:?}", ks);
3559 assert!(!ks.contains(&fmt!("exec")), "{:?}", ks);
3560 assert!(!ks.contains(&fmt!("started")), "{:?}", ks);
3561 Ok(())
3562 }
3563
3564 /// A command streams its output and ends, and the record leads the run.
3565 #[tokio::test]
3566 async fn an_exec_streams_and_ends() -> Outcome<()> {
3567 let (cfg, jdir) = res!(setup("exec", Fence::detect()));
3568 let mut input = res!(framed(&hello()));
3569 input.extend_from_slice(&res!(framed(&exec("r1", &["/bin/echo", "hello"], &cfg.root, 10_000))));
3570 input.extend_from_slice(&res!(framed(&Req::Bye)));
3571
3572 let (w, mut r) = tokio::io::duplex(1 << 20);
3573 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3574 let mut bytes = Vec::new();
3575 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3576 res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3577
3578 let rs = res!(responses(&bytes));
3579 if !can_fence() {
3580 assert!(rs.iter().any(|r| matches!(r, Resp::Refused { .. })), "{:?}", rs);
3581 return Ok(());
3582 }
3583 assert!(rs.iter().any(|r| matches!(r, Resp::Started { .. })), "{:?}", rs);
3584 let said = rs.iter().fold(String::new(), |mut acc, r| {
3585 if let Resp::Chunk { data, .. } = r {
3586 acc.push_str(data);
3587 }
3588 acc
3589 });
3590 // The launcher here is the test binary, which announces itself before
3591 // it reaches the entry point; removed by name, so a change in the
3592 // harness fails this rather than moving quietly.
3593 assert_eq!("hello\n", said.replace(HARNESS_NOISE, "").replace(HARNESS_LINE, ""));
3594 match rs.iter().find(|r| matches!(r, Resp::Ended { .. })) {
3595 Some(Resp::Ended { exit, .. }) => assert_eq!(0, *exit),
3596 other => return Err(err!("Expected an ending, got {:?}.", other; Test, Missing)),
3597 }
3598 // The order in the record is the order the rule states: written down,
3599 // then started, then ended.
3600 let ks = res!(kinds(&jdir));
3601 let at = |k: &str| ks.iter().position(|x| x == k);
3602 match (at("exec"), at("started"), at("ended")) {
3603 (Some(a), Some(b), Some(c)) => assert!(a < b && b < c, "{:?}", ks),
3604 _ => return Err(err!("The record is missing a line: {:?}.", ks; Test, Missing)),
3605 }
3606 Ok(())
3607 }
3608
3609 /// A signal reaches a run, and the run says it was killed.
3610 #[tokio::test]
3611 async fn a_signal_reaches_a_run() -> Outcome<()> {
3612 if !can_fence() {
3613 return Ok(());
3614 }
3615 let (cfg, jdir) = res!(setup("signal", Fence::detect()));
3616 let (input, tap) = feed();
3617 let (w, mut r) = tokio::io::duplex(1 << 20);
3618 let task = tokio::spawn(serve(input, w, cfg.clone()));
3619
3620 res!(tap.send(res!(framed(&hello()))).map_err(|e| err!(e, "send"; IO)));
3621 res!(tap.send(res!(framed(&exec("r1", &["/bin/sleep", "30"], &cfg.root, 60_000))))
3622 .map_err(|e| err!(e, "send"; IO)));
3623
3624 // Wait for the run to be announced before signalling it.
3625 let mut bytes = Vec::new();
3626 let mut buf = [0u8; 4096];
3627 while !responses(&bytes).map(|v| v.iter().any(|r| matches!(r, Resp::Started { .. })))
3628 .unwrap_or(false)
3629 {
3630 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3631 if n == 0 {
3632 break;
3633 }
3634 bytes.extend_from_slice(&buf[..n]);
3635 }
3636 res!(tap.send(res!(framed(&Req::Signal { id: fmt!("r1"), sig: Sig::Kill })))
3637 .map_err(|e| err!(e, "send"; IO)));
3638 res!(tap.send(res!(framed(&Req::Bye))).map_err(|e| err!(e, "send"; IO)));
3639 drop(tap);
3640
3641 loop {
3642 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3643 if n == 0 {
3644 break;
3645 }
3646 bytes.extend_from_slice(&buf[..n]);
3647 }
3648 res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3649
3650 let rs = res!(responses(&bytes));
3651 match rs.iter().find(|r| matches!(r, Resp::Ended { .. })) {
3652 Some(Resp::Ended { killed, .. }) => assert!(*killed, "{:?}", rs),
3653 other => return Err(err!("Expected an ending, got {:?}.", other; Test, Missing)),
3654 }
3655 assert!(res!(kinds(&jdir)).contains(&fmt!("signalled")));
3656 Ok(())
3657 }
3658
3659 /// An oversized frame is discarded whole, and the next request is served.
3660 ///
3661 /// `REVIEW.md` §3.3: without this the body stays in the pipe and every
3662 /// later request is read from the middle of it.
3663 #[tokio::test]
3664 async fn an_oversized_frame_does_not_poison_the_stream() -> Outcome<()> {
3665 let (cfg, _) = res!(setup("oversize", Fence::detect()));
3666 let n = (INBOUND_MAX + 4_096) as u32;
3667 let mut input = n.to_ne_bytes().to_vec();
3668 input.extend(std::iter::repeat(b'x').take(n as usize));
3669 // The valid request that must still be served.
3670 input.extend_from_slice(&res!(framed(&hello())));
3671 input.extend_from_slice(&res!(framed(&Req::Bye)));
3672
3673 let (w, mut r) = tokio::io::duplex(1 << 21);
3674 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3675 let mut bytes = Vec::new();
3676 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3677 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3678
3679 assert_eq!(Ending::Goodbye, end);
3680 let rs = res!(responses(&bytes));
3681 match rs.first() {
3682 Some(Resp::Error { id, message }) => {
3683 assert!(id.is_none());
3684 // The page is told the real ceiling, which §3.3 says it never was.
3685 assert!(message.contains(&fmt!("{}", INBOUND_MAX)), "{}", message);
3686 },
3687 other => return Err(err!("Expected an error, got {:?}.", other; Test, Invalid)),
3688 }
3689 match rs.get(1) {
3690 Some(Resp::Hello { .. }) => (),
3691 other => return Err(err!(
3692 "The request after the oversized frame was not served: {:?}.",
3693 other; Test, Invalid)),
3694 }
3695 Ok(())
3696 }
3697
3698 /// A frame beyond any plausible message ends the connection cleanly.
3699 #[tokio::test]
3700 async fn an_absurd_length_ends_the_connection() -> Outcome<()> {
3701 let (cfg, _) = res!(setup("absurd", Fence::detect()));
3702 let mut input = u32::MAX.to_ne_bytes().to_vec();
3703 input.extend_from_slice(b"{}");
3704
3705 let (w, mut r) = tokio::io::duplex(1 << 16);
3706 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3707 let mut bytes = Vec::new();
3708 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3709 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3710
3711 match end {
3712 Ending::Stopped(why) => assert!(why.contains("declared"), "{}", why),
3713 other => return Err(err!("Expected a stop, got {:?}.", other; Test, Invalid)),
3714 }
3715 Ok(())
3716 }
3717
3718 /// Garbage in a well-formed frame is answered, and the stream survives it.
3719 #[tokio::test]
3720 async fn garbage_is_answered_and_the_stream_survives() -> Outcome<()> {
3721 let (cfg, jdir) = res!(setup("garbage", Fence::detect()));
3722 let junk: &[u8] = b"not json at all";
3723 let mut input = (junk.len() as u32).to_ne_bytes().to_vec();
3724 input.extend_from_slice(junk);
3725 // A frame of legal JSON that is not a message, and one with a tag from
3726 // a build that does not exist.
3727 for body in ["[1, 2]", "{\"t\": \"detonate\"}"] {
3728 input.extend_from_slice(&(body.len() as u32).to_ne_bytes());
3729 input.extend_from_slice(body.as_bytes());
3730 }
3731 input.extend_from_slice(&res!(framed(&hello())));
3732 input.extend_from_slice(&res!(framed(&Req::Bye)));
3733
3734 let (w, mut r) = tokio::io::duplex(1 << 18);
3735 let task = tokio::spawn(serve(Cursor::new(input), w, cfg));
3736 let mut bytes = Vec::new();
3737 res!(r.read_to_end(&mut bytes).await.map_err(|e| err!(e, "read"; IO)));
3738 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3739
3740 assert_eq!(Ending::Goodbye, end);
3741 let rs = res!(responses(&bytes));
3742 let errs = rs.iter().filter(|r| matches!(r, Resp::Error { .. })).count();
3743 assert_eq!(3, errs, "{:?}", rs);
3744 // Each names the fault, so a reader is not left matching on prose.
3745 match rs.first() {
3746 Some(Resp::Error { message, .. }) => assert!(message.starts_with("codec."), "{}", message),
3747 other => return Err(err!("Expected an error, got {:?}.", other; Test, Invalid)),
3748 }
3749 assert!(rs.iter().any(|r| matches!(r, Resp::Hello { .. })), "{:?}", rs);
3750 // Every failure is in the record.
3751 let ks = res!(kinds(&jdir));
3752 assert_eq!(3, ks.iter().filter(|k| *k == "failed").count(), "{:?}", ks);
3753 Ok(())
3754 }
3755
3756 /// A stream that ends mid-run stops the run and closes the record.
3757 #[tokio::test]
3758 async fn stdin_closing_mid_run_ends_cleanly() -> Outcome<()> {
3759 if !can_fence() {
3760 return Ok(());
3761 }
3762 let (cfg, jdir) = res!(setup("closed", Fence::detect()));
3763 let (input, tap) = feed();
3764 let (w, mut r) = tokio::io::duplex(1 << 20);
3765 let task = tokio::spawn(serve(input, w, cfg.clone()));
3766
3767 res!(tap.send(res!(framed(&hello()))).map_err(|e| err!(e, "send"; IO)));
3768 res!(tap.send(res!(framed(&exec("r1", &["/bin/sleep", "30"], &cfg.root, 60_000))))
3769 .map_err(|e| err!(e, "send"; IO)));
3770
3771 let mut bytes = Vec::new();
3772 let mut buf = [0u8; 4096];
3773 while !responses(&bytes).map(|v| v.iter().any(|r| matches!(r, Resp::Started { .. })))
3774 .unwrap_or(false)
3775 {
3776 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3777 if n == 0 {
3778 break;
3779 }
3780 bytes.extend_from_slice(&buf[..n]);
3781 }
3782 // The page goes away without saying goodbye.
3783 drop(tap);
3784 loop {
3785 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3786 if n == 0 {
3787 break;
3788 }
3789 bytes.extend_from_slice(&buf[..n]);
3790 }
3791 let end = res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3792
3793 assert_eq!(Ending::Closed, end);
3794 let ks = res!(kinds(&jdir));
3795 assert!(ks.contains(&fmt!("closed")), "{:?}", ks);
3796 // The run was stopped rather than orphaned.
3797 let rs = res!(responses(&bytes));
3798 match rs.iter().find(|r| matches!(r, Resp::Ended { .. })) {
3799 Some(Resp::Ended { killed, .. }) => assert!(*killed, "{:?}", rs),
3800 // The ending may not have reached the wire before it closed; the
3801 // record is the thing that must be complete.
3802 _ => assert!(ks.contains(&fmt!("ended")) || ks.contains(&fmt!("closed")), "{:?}", ks),
3803 }
3804 Ok(())
3805 }
3806
3807 /// A page that stops reading does not stop the hand from reading.
3808 ///
3809 /// `REVIEW.md` §3.7 exactly: a noisy run fills the one response channel, a
3810 /// second command is asked for, and the `Signal` that would stop the noise
3811 /// arrives behind it. A loop that awaited [`Runner::spawn`] would still be
3812 /// inside the second command -- its `Started` cannot be sent -- and would
3813 /// never take the signal at all. The proof is the journal, which the
3814 /// dispatcher writes as it goes: the `signalled` line appears while the
3815 /// writer is still stalled on a consumer that has read nothing.
3816 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3817 async fn output_does_not_block_the_next_request() -> Outcome<()> {
3818 if !can_fence() {
3819 return Ok(());
3820 }
3821 let (cfg, jdir) = res!(setup("headofline", Fence::detect()));
3822 let (input, tap) = feed();
3823 // A tiny window, so the writer stalls almost at once.
3824 let (w, mut r) = tokio::io::duplex(256);
3825 let task = tokio::spawn(serve(input, w, cfg.clone()));
3826
3827 res!(tap.send(res!(framed(&hello()))).map_err(|e| err!(e, "send"; IO)));
3828 res!(tap.send(res!(framed(&exec("flood", &["/bin/yes"], &cfg.root, 20_000))))
3829 .map_err(|e| err!(e, "send"; IO)));
3830 // Let the flood fill every queue there is.
3831 tokio::time::sleep(Duration::from_millis(400)).await;
3832 // A second command, whose announcement cannot be sent while the queue
3833 // is full, and then the signal that must be taken anyway.
3834 res!(tap.send(res!(framed(&exec("second", &["/bin/echo", "hi"], &cfg.root, 10_000))))
3835 .map_err(|e| err!(e, "send"; IO)));
3836 res!(tap.send(res!(framed(&Req::Signal { id: fmt!("flood"), sig: Sig::Kill })))
3837 .map_err(|e| err!(e, "send"; IO)));
3838
3839 // Nothing has been read from the pipe, and the signal must still have
3840 // been taken. Give it a moment to be written down, but not long.
3841 let mut seen = false;
3842 for _ in 0..40 {
3843 tokio::time::sleep(Duration::from_millis(50)).await;
3844 if res!(kinds(&jdir)).contains(&fmt!("signalled")) {
3845 seen = true;
3846 break;
3847 }
3848 }
3849 assert!(seen, "the signal never reached the loop while output was flowing");
3850
3851 // Now drain, so the run can finish and the hand can exit.
3852 res!(tap.send(res!(framed(&Req::Bye))).map_err(|e| err!(e, "send"; IO)));
3853 drop(tap);
3854 let mut buf = [0u8; 65_536];
3855 loop {
3856 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3857 if n == 0 {
3858 break;
3859 }
3860 }
3861 res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3862 Ok(())
3863 }
3864
3865 /// A command that cannot be written down is not run.
3866 ///
3867 /// The failure is made to happen rather than argued for: the journal is
3868 /// given a size limit of one byte, so the next entry has to open a new
3869 /// file, and the directory is taken away from it after the handshake. The
3870 /// append then genuinely fails, and the only correct answer is a refusal.
3871 #[cfg(unix)]
3872 #[tokio::test]
3873 async fn a_command_that_cannot_be_journalled_is_not_run() -> Outcome<()> {
3874 use std::os::unix::fs::PermissionsExt;
3875
3876 if !can_fence() {
3877 return Ok(());
3878 }
3879 let (mut cfg, jdir) = res!(setup("nowrite", Fence::detect()));
3880 // One byte, so the entry after the handshake must roll into a new file.
3881 cfg.journal.max_bytes = 1;
3882
3883 let (input, tap) = feed();
3884 let (w, mut r) = tokio::io::duplex(1 << 20);
3885 let task = tokio::spawn(serve(input, w, cfg.clone()));
3886
3887 res!(tap.send(res!(framed(&hello()))).map_err(|e| err!(e, "send"; IO)));
3888 let mut bytes = Vec::new();
3889 let mut buf = [0u8; 4096];
3890 while !responses(&bytes).map(|v| !v.is_empty()).unwrap_or(false) {
3891 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3892 if n == 0 {
3893 break;
3894 }
3895 bytes.extend_from_slice(&buf[..n]);
3896 }
3897 // The journal is open; now nothing more can be created beside it.
3898 res!(fs::set_permissions(&jdir, fs::Permissions::from_mode(0o500)), IO, File);
3899
3900 res!(tap.send(res!(framed(&exec("r1", &["/bin/echo", "hi"], &cfg.root, 10_000))))
3901 .map_err(|e| err!(e, "send"; IO)));
3902 res!(tap.send(res!(framed(&Req::Bye))).map_err(|e| err!(e, "send"; IO)));
3903 drop(tap);
3904 loop {
3905 let n = res!(r.read(&mut buf).await.map_err(|e| err!(e, "read"; IO)));
3906 if n == 0 {
3907 break;
3908 }
3909 bytes.extend_from_slice(&buf[..n]);
3910 }
3911 res!(res!(task.await.map_err(|e| err!(e, "join"; IO))));
3912 // Left as it was found, so the next run can clear it away.
3913 res!(fs::set_permissions(&jdir, fs::Permissions::from_mode(0o700)), IO, File);
3914
3915 let rs = res!(responses(&bytes));
3916 assert!(!rs.iter().any(|r| matches!(r, Resp::Started { .. })), "{:?}", rs);
3917 let refused = rs.iter().any(|r| match r {
3918 Resp::Refused { id, reason } => id == "r1" && reason.contains("journal"),
3919 _ => false,
3920 });
3921 assert!(refused, "{:?}", rs);
3922 Ok(())
3923 }
3924
3925 /// With no journal there is no service, whatever else is in order.
3926 #[tokio::test]
3927 async fn no_journal_means_no_service() -> Outcome<()> {
3928 let dir = res!(scratch("nojournal"));
3929 let root = dir.join("work");
3930 res!(fs::create_dir_all(&root));
3931 // A file where the journal directory should be, so it cannot be made.
3932 let jdir = dir.join("journal");
3933 res!(fs::write(&jdir, b"in the way"), IO, File);
3934
3935 let cfg = Serve {
3936 journal: JournalCfg::at(&jdir),
3937 fence: Fence::detect(),
3938 root: res!(fs::canonicalize(&root)),
3939 term_ceilings: vec![res!(fs::canonicalize(&root))],
3940 term_pinned: false,
3941 launcher: res!(test_launcher()),
3942 };
3943 let (w, _r) = tokio::io::duplex(1 << 16);
3944 let input = res!(framed(&hello()));
3945 match serve(Cursor::new(input), w, cfg).await {
3946 Ok(e) => Err(err!("Expected a refusal to start, got {:?}.", e; Test, Invalid)),
3947 Err(_) => Ok(()),
3948 }
3949 }
3950
3951 /// An oversized response is cut down and the loss is reported, never dropped.
3952 ///
3953 /// `REVIEW.md` §3.1: the shipping path could emit a frame Chrome refuses,
3954 /// and the run's output vanished with it.
3955 #[test]
3956 fn an_oversized_chunk_is_cut_and_the_loss_is_reported() -> Outcome<()> {
3957 let big = Resp::Chunk {
3958 id: fmt!("r1"),
3959 stream: Stream::Out,
3960 seq: 7,
3961 // Control bytes cost six each in JSON, so this is far over the cap
3962 // as a frame while being well under it as bytes.
3963 data: "\u{1}".repeat(400_000),
3964 };
3965 let (bytes, note) = res!(encode(&big));
3966 assert!(bytes.len() <= daimond_hand::wire::FRAME_MAX);
3967 match note {
3968 Some(n) => assert!(n.contains("dropped"), "{}", n),
3969 None => return Err(err!("The cut was not reported."; Test, Missing)),
3970 }
3971 // What did arrive is a chunk, in sequence, saying what it lost.
3972 let mut cur = Cursor::new(bytes);
3973 match res!(FRAMING.read_resp(&mut cur)) {
3974 Some(Resp::Chunk { id, seq, data, .. }) => {
3975 assert_eq!("r1", id);
3976 assert_eq!(7, seq);
3977 assert!(data.ends_with("cannot carry them]"), "{}", &data[data.len() - 60..]);
3978 },
3979 other => return Err(err!("Expected a chunk, got {:?}.", other; Test, Invalid)),
3980 }
3981 Ok(())
3982 }
3983
3984 /// A refusal too long for a frame is cut rather than lost.
3985 #[test]
3986 fn an_oversized_refusal_is_cut() -> Outcome<()> {
3987 let long = Resp::Refused {
3988 id: fmt!("r1"),
3989 reason: "\u{1}".repeat(300_000),
3990 };
3991 let (bytes, note) = res!(encode(&long));
3992 assert!(bytes.len() <= daimond_hand::wire::FRAME_MAX);
3993 assert!(note.is_some());
3994 let mut cur = Cursor::new(bytes);
3995 match res!(FRAMING.read_resp(&mut cur)) {
3996 Some(Resp::Refused { id, reason }) => {
3997 assert_eq!("r1", id);
3998 assert!(reason.ends_with("cannot carry them]"), "{}", reason);
3999 },
4000 other => return Err(err!("Expected a refusal, got {:?}.", other; Test, Invalid)),
4001 }
4002 Ok(())
4003 }
4004
4005 /// An ordinary response is not touched by the cut.
4006 #[test]
4007 fn an_ordinary_response_is_framed_unchanged() -> Outcome<()> {
4008 let r = Resp::Started { id: fmt!("r1"), pid: 42 };
4009 let (bytes, note) = res!(encode(&r));
4010 assert!(note.is_none());
4011 let mut cur = Cursor::new(bytes);
4012 assert_eq!(Some(r), res!(FRAMING.read_resp(&mut cur)));
4013 Ok(())
4014 }
4015
4016 /// The granted root is read from the variable, and refused when it is not a folder.
4017 #[test]
4018 fn the_granted_root_is_configured_or_refused() -> Outcome<()> {
4019 let dir = res!(scratch("root"));
4020 // Nothing set anywhere: a refusal that names both places.
4021 std::env::remove_var(ROOT_VAR);
4022 match granted_root(&dir) {
4023 Ok(p) => return Err(err!("Expected a refusal, got {:?}.", p; Test, Invalid)),
4024 Err(e) => {
4025 let m = e.msgs().join(" ");
4026 assert!(m.contains(ROOT_VAR), "{}", m);
4027 assert!(m.contains(ROOT_FILE), "{}", m);
4028 },
4029 }
4030 // The file beside the journal, comments and all.
4031 let work = dir.join("work");
4032 res!(fs::create_dir_all(&work));
4033 res!(fs::write(dir.join(ROOT_FILE),
4034 fmt!("# the folder this hand may work in\n\n{}\n", work.display())), IO, File);
4035 assert_eq!(res!(fs::canonicalize(&work)), res!(granted_root(&dir)));
4036
4037 // A relative root is refused rather than resolved against nothing.
4038 res!(fs::write(dir.join(ROOT_FILE), b"work"), IO, File);
4039 assert!(granted_root(&dir).is_err());
4040 // So is one that does not exist.
4041 res!(fs::write(dir.join(ROOT_FILE), b"/nowhere/at/all/really"), IO, File);
4042 assert!(granted_root(&dir).is_err());
4043 // And a file is not a folder.
4044 let f = dir.join("a-file");
4045 res!(fs::write(&f, b"x"), IO, File);
4046 res!(fs::write(dir.join(ROOT_FILE), fmt!("{}", f.display())), IO, File);
4047 assert!(granted_root(&dir).is_err());
4048 Ok(())
4049 }
4050
4051 /// The browser's own argument is not mistaken for a flag.
4052 #[test]
4053 fn a_browser_argument_is_recognised() -> Outcome<()> {
4054 assert!(is_browser_arg("chrome-extension://abcdefghijklmnop/"));
4055 assert!(is_browser_arg("moz-extension://abcdef/"));
4056 assert!(is_browser_arg("--parent-window=12345"));
4057 assert!(!is_browser_arg("--report"));
4058 assert!(!is_browser_arg("/etc/passwd"));
4059 Ok(())
4060 }
4061}