Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/hand/src/journal.rs

215 KiB, 1 run

created by r2519314175:917, 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//! What was run, what it returned, and what it was stopped from doing.
2//!
3//! Daimond's claim is that it can be **checked rather than trusted**: the client
4//! is public, it rebuilds byte for byte, and every release is sealed in a public
5//! append-only log whose entries chain onto one another. The hand extends that
6//! claim onto the user's machine, and this module is how. A refusal is recorded
7//! as carefully as a success, because *what an agent was stopped from doing* is
8//! the half of the record nobody else offers.
9//!
10//! # The shape of the record
11//!
12//! One JSON object per line, appended and never rewritten, in the same idiom as
13//! `verify/transparency.jsonl` so that a reader who has learned to check one has
14//! learned to check both:
15//!
16//! ```text
17//! {"seq":N,"ts":MS,"kind":"exec","body":{…},"prev":"<64 hex>","entry":"<64 hex>"}
18//! ```
19//!
20//! `entry` is the SHA-256 of **everything on the line before `,"entry":"`** --
21//! that is, of the line's own text including the `prev` it points at. So the
22//! check is arithmetic anyone can do with `sha256sum` and `sed`, in any
23//! language, with no JSON parser:
24//!
25//! ```text
26//! head -1 hand-00000000.jsonl | sed 's/,"entry":"[0-9a-f]*"}$//' \
27//! | tr -d '\n' | sha256sum
28//! ```
29//!
30//! The transparency log hashes a pipe-joined preimage of its fields; this hashes
31//! the emitted text instead, because a hand entry's body varies by kind and a
32//! per-kind preimage rule is one more thing to get wrong. Hashing the text
33//! covers every field of every kind by construction. The `body` is itself
34//! RFC 8785 canonical JSON, so a second implementation that rebuilds the body
35//! from parsed values arrives at the identical bytes.
36//!
37//! `ts` is UTC milliseconds since the Unix epoch, as an integer, rather than the
38//! ISO string the transparency log carries. It is unambiguous, needs no
39//! calendar code in a binary whose reproducible rebuild is part of the product's
40//! claim, and the rotation boundary falls out of it by integer division. The
41//! record stores the fact; the page renders it.
42//!
43//! # Where the journal lives, and why it is not in the Diamond
44//!
45//! **The authoritative journal lives in the hand's own directory, outside every
46//! fence the hand ever applies.** An earlier design note put it in the Diamond's
47//! own directory. That is wrong, and the reason is worth stating plainly: a
48//! daimon's fence grants it read *and write* over its Diamond directory, so a
49//! journal kept there is a journal the journalled process can edit. Hash
50//! chaining does not rescue it. A chain is evidence against an editor who
51//! cannot recompute the hashes; anyone holding the whole file can recompute all
52//! of them and produce a shorter, perfectly self-consistent history. The entry
53//! a misbehaving process would most want gone -- its own refusal -- would be the
54//! one sitting in the only directory it was handed a pen for.
55//!
56//! So: the record goes under the hand's data directory (see [`default_dir`]),
57//! [`Journal::check_fence`] refuses outright if that path falls inside any
58//! `FenceSpec.rw` root, and [`Journal::fence_guard`] adds the journal root to
59//! every fence's `deny` list so a command cannot so much as read it. A copy may
60//! be surfaced to the page for display; the copy is not the record.
61//!
62//! What this does **not** claim: the hand runs as the user, so the user's own
63//! account can still rewrite the file. Nothing local can prevent that. What the
64//! chain gives is that any such rewrite is *detectable* by anyone who has seen an
65//! earlier `entry` hash -- so publishing the head, exactly as the transparency
66//! log publishes its head, is what turns detectability into evidence. That step
67//! is deliberately left to the caller.
68//!
69//! # What a chain cannot do for itself, and what does it instead
70//!
71//! A hash chain answers *"has any entry been changed?"*. It cannot answer
72//! *"is anything missing from the end?"*, because a shorter history is a
73//! perfectly good chain. An earlier version of this module said that only the
74//! live file's tail was unprotected; that was wrong, and the review of
75//! 2026-08-02 proved it by deleting the last three files of a twenty-nine file
76//! journal, getting `Intact`, and letting the hand resume and *stay* intact.
77//! Blanking the final file did the same. **Any suffix of history erased
78//! silently** -- precisely the property a tamper-evident log exists to deny.
79//!
80//! [`Mark`] closes it: one line, outside the rotated files, naming how far the
81//! record has got. It is written after every flush, so it is a lower bound --
82//! a history shorter than the mark has lost something and [`verify_dir`] says
83//! so; a history longer than the mark is a crash between an entry reaching the
84//! disk and the mark following it, which is ordinary. Where a loss is found,
85//! [`Journal::open`] writes an [`Event::Gap`] recording what the mark vouched
86//! for against what is there, and every later verification reports the history
87//! as broken from that entry on. Removing the gap entry breaks the chain of
88//! everything after it, so the loss cannot be quietly undone.
89//!
90//! The residual is stated rather than glossed: an attacker who can write the
91//! journal directory can rewrite the mark as well as the files, and a
92//! consistent pair of forgeries verifies. What the mark buys is that
93//! truncation is no longer *free* -- it costs two coordinated forgeries instead
94//! of one `truncate` -- and that the honest failures, a crash and an
95//! interrupted write, are told apart from the dishonest one. Publishing the
96//! head remains the only thing that makes any of it evidence rather than
97//! detection.
98//!
99//! # One writer
100//!
101//! [`Journal::open`] takes an exclusive lock on the directory and holds it for
102//! as long as the journal lives. Chrome will launch more than one host, and
103//! the design expects one per tab; two writers resuming from the same head and
104//! interleaving their appends broke the chain permanently in the review. The
105//! lock is `flock` through `std::fs::File::try_lock`, which is safe Rust,
106//! needs no dependency, and is released by the operating system when the
107//! process goes away.
108//!
109//! # Secrets
110//!
111//! A journal is written to be shown to someone. Everything in it must therefore
112//! be safe to show, and the policy follows from that one sentence:
113//!
114//! * **Environment values are never recorded -- keys only.** `Req::Exec.env` is
115//! an explicit allow-list, which is precisely where a caller lends a command a
116//! credential. The accountability question is *"was this command handed a
117//! credential, and which one"*, and the key answers it; the value answers
118//! nothing further. Redacting values by pattern was considered and rejected:
119//! a pattern list over user-chosen names cannot be complete (`FOO` may hold a
120//! token), so it fails **open** on the names nobody thought of -- which is the
121//! exact shape of the leak this project has already suffered once.
122//! * **Standard input is recorded only by length.** Same reasoning; a heredoc is
123//! as good a place to pass a key as an environment variable is.
124//! * **Output is recorded only by byte count.** Never the bytes.
125//! * **`argv` is recorded verbatim**, because a journal that redacts what was run
126//! does not record what was run. [`redact_argv`] removes the credential
127//! shapes first and counts what it removed, so the record says that something
128//! was taken out rather than quietly altering it. That pass fails *open* by
129//! design and is a courtesy, not a guarantee: an argument vector is visible in
130//! `ps` to every process the user owns, so it was never a safe place for a
131//! secret. The journal cannot make it one and does not pretend to.
132//! * **Every free-text field goes through [`redact_text`], at one chokepoint.**
133//! The review of 2026-08-02 got a single secret into the file eight times
134//! through fields nobody had thought of as free text: a refusal's reason -- a
135//! refusal that quotes the offending command is the natural wording, and what
136//! the app's own refusals do -- an error's message, the working directory, the
137//! run's own identifier, an environment *key*, a fence path, a mechanism name
138//! and the client's build string. A redaction applied to the fields somebody
139//! listed is a redaction applied to the wrong list, so it is applied in
140//! [`Event::body`], which is the one place every field of every kind passes
141//! through, and every body carries a `redactions` count so that a removal is
142//! never invisible.
143//!
144//! # Durability
145//!
146//! Every entry is written to the operating system the moment it is made
147//! ([`Durability::Os`], the default). It is not `fsync`ed. The reasoning:
148//!
149//! * A write into the page cache is a memcpy. It does not stall the command
150//! loop, so the "buffer it" instinct buys nothing here -- the hand journals
151//! about three lines per command, not three thousand, because output chunks
152//! are never journalled.
153//! * The realistic threat to the record is the hand being killed or crashing,
154//! and `Os` survives that intact. A power cut can still lose the tail; that is
155//! what [`Durability::Sync`] is for, and it costs a disk round trip per entry.
156//! * [`Durability::Batched`] exists for bulk replay and is documented as lossy.
157//! An unflushed journal loses the last thing that happened, which is exactly
158//! the thing you most want to read afterwards.
159//!
160//! The ordering rule that makes the above sound: **journal before acting.**
161//! Append the `exec` entry before spawning and the `refused` entry before
162//! sending the refusal, so the record can never be missing something that
163//! happened.
164//!
165//! # Rotation
166//!
167//! Files are `hand-00000000.jsonl`, `hand-00000001.jsonl`, … and roll over on
168//! size or on a change of UTC day -- including a day that turned while the hand
169//! was not running, which is why a resumed file takes its day from its last
170//! entry rather than from the clock. `seq` is global and `prev` carries across
171//! the boundary, so the whole history verifies as one chain ([`verify_dir`]).
172//! The first entry of each rolled file is a `rotated` entry naming the file it
173//! continues -- which also means that lopping whole lines off the end of a
174//! *rotated* file is caught, since the next file's `seq` no longer follows on.
175//!
176//! The index is bounded by the eight digits the name format carries: rotation
177//! stops rather than writing `hand-100000000.jsonl`, a name no verifier reads.
178//! Anything else in the directory named like a journal file, including a
179//! directory or a symbolic link with such a name, is moved aside and recorded
180//! as an [`Event::Stray`] before a byte is appended -- it is never deleted, and
181//! it never steers the writer.
182
183use crate::wire::{
184 Capture,
185 FenceSpec,
186 FileOp,
187 Req,
188 Resp,
189 Sig,
190};
191
192use oxedyne_fe2o3_core::{
193 prelude::*,
194 byte::B32,
195 string::ToHexString,
196};
197use oxedyne_fe2o3_hash::sha256;
198use oxedyne_fe2o3_jdat::prelude::*;
199
200use std::{
201 fs,
202 fs::{
203 File,
204 OpenOptions,
205 },
206 io::Write,
207 path::{
208 Component,
209 Path,
210 PathBuf,
211 },
212 sync::{
213 Arc,
214 atomic::{
215 AtomicI64,
216 Ordering,
217 },
218 },
219 time::{
220 SystemTime,
221 UNIX_EPOCH,
222 },
223};
224
225// ┌───────────────────────────────────────────────────────────────┐
226// │ The line format, stated once │
227// └───────────────────────────────────────────────────────────────┘
228
229/// The predecessor of the first entry: a chain has to start pointing at nothing.
230///
231/// Sixty four zeros, the same sentinel `verify/lib.mjs` uses for `GENESIS_PREV`.
232pub const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
233
234/// The characters separating an entry's hashed text from the hash itself.
235const ENTRY_TAG: &str = ",\"entry\":\"";
236
237/// The characters introducing the predecessor's hash, last field before it.
238const PREV_TAG: &str = ",\"prev\":\"";
239
240/// The characters every entry opens with, which also carry its sequence.
241const SEQ_TAG: &str = "{\"seq\":";
242
243/// The characters introducing the timestamp.
244const TS_TAG: &str = ",\"ts\":";
245
246/// The characters introducing the event kind.
247const KIND_TAG: &str = ",\"kind\":\"";
248
249/// The characters introducing the body.
250const BODY_TAG: &str = ",\"body\":";
251
252/// A SHA-256 digest's length in lower case hexadecimal.
253const HEX_LEN: usize = 64;
254
255/// How many bytes of a line follow the hashed text: `,"entry":"<64 hex>"}`.
256const ENTRY_SUFFIX: usize = ENTRY_TAG.len() + HEX_LEN + 2;
257
258/// How many bytes of the hashed text are the predecessor: `,"prev":"<64 hex>"`.
259const PREV_SUFFIX: usize = PREV_TAG.len() + HEX_LEN + 1;
260
261/// What a journal file is called, before its index.
262const FILE_STEM: &str = "hand-";
263
264/// What a journal file is called, after its index.
265const FILE_EXT: &str = ".jsonl";
266
267/// How many digits a journal file's index is padded to, so the names sort.
268const FILE_DIGITS: usize = 8;
269
270/// The largest index the name format can carry.
271///
272/// The review found `maybe_rotate` incrementing past a planted
273/// `hand-99999999.jsonl` into `hand-100000000.jsonl`, a nine-digit name that
274/// [`journal_files`] never returns and no verifier ever reads. The rotation is
275/// bounded here so that a name outside the format is a refusal rather than a
276/// silent hole.
277const MAX_IDX: u32 = 99_999_999;
278
279/// Where the high-water mark lives: outside the rotated files, by design.
280///
281/// A chain proves that a history has not been *edited*. It cannot, on its own,
282/// prove that a history has not been *shortened*, because a shorter chain is
283/// still a chain. The mark is the smallest thing that closes that: one line
284/// naming how far the record had got, kept where lopping the tail off the files
285/// does not touch it.
286const MARK_FILE: &str = "head.json";
287
288/// Where the mark is written before it is renamed into place.
289const MARK_TMP: &str = "head.json.new";
290
291/// The lock every writer must hold, so that two hands cannot interleave.
292const LOCK_FILE: &str = "lock";
293
294/// What a file named like a journal file but written by something else becomes.
295///
296/// Moved rather than deleted: the journal does not destroy what it did not
297/// write, and a plant is evidence about whoever planted it.
298const STRAY_STEM: &str = "foreign-";
299
300/// The characters introducing the mark's own hash, last field on its line.
301const MARK_TAG: &str = ",\"mark\":\"";
302
303/// How many times the directory's lock is asked for before it is a refusal.
304const LOCK_TRIES: usize = 25;
305
306/// How long to wait between asking for the lock, in milliseconds.
307const LOCK_WAIT_MS: u64 = 20;
308
309/// Milliseconds in a UTC day, which is the rotation boundary.
310///
311/// UTC has no daylight saving, so the day a timestamp falls in is integer
312/// division and needs no calendar.
313const DAY_MS: i64 = 86_400_000;
314
315/// The default size at which a file rolls over: generous enough that a working
316/// day lands in one file, small enough that the file stays readable.
317pub const DEFAULT_MAX_BYTES: u64 = 8 * 1024 * 1024;
318
319/// The largest integer canonical JSON will carry, `2^53 - 1`.
320///
321/// The encoder refuses anything beyond it, on the grounds that a JavaScript
322/// reader could not hold it exactly. A byte count that large is not physical, so
323/// clamping is the right failure: a journal must not decline to record an event
324/// because a counter was absurd.
325const JS_SAFE: u64 = 9_007_199_254_740_991;
326
327/// What replaces a value the journal declined to write down.
328pub const REDACTED: &str = "<redacted>";
329
330// ┌───────────────────────────────────────────────────────────────┐
331// │ Time │
332// └───────────────────────────────────────────────────────────────┘
333
334/// Where an entry's timestamp comes from.
335///
336/// An enum rather than a trait object: there are exactly two sources, the system
337/// and a test's own hand, and a boxed closure would buy nothing but an
338/// allocation and an indirection.
339#[derive(Clone, Debug)]
340pub enum Clock {
341 /// The machine's own clock.
342 System,
343 /// A clock the caller moves, for tests and for replay.
344 Fixed(Arc<AtomicI64>),
345}
346
347impl Default for Clock {
348 fn default() -> Self {
349 Self::System
350 }
351}
352
353impl Clock {
354
355 /// Creates a clock stopped at a given instant.
356 ///
357 /// # Arguments
358 /// * `ms` - UTC milliseconds since the Unix epoch.
359 pub fn fixed(ms: i64) -> Self {
360 Self::Fixed(Arc::new(AtomicI64::new(ms)))
361 }
362
363 /// Reads the clock, in UTC milliseconds since the Unix epoch.
364 ///
365 /// # Returns
366 /// The instant, or an error where the system clock reads before the epoch.
367 pub fn now_ms(&self) -> Outcome<i64> {
368 match self {
369 Self::System => match SystemTime::now().duration_since(UNIX_EPOCH) {
370 Ok(d) => Ok(d.as_millis() as i64),
371 Err(_) => Err(err!(
372 "The system clock reads before the Unix epoch, so the hand \
373 cannot stamp an entry with a time anyone could order.";
374 System, Invalid)),
375 },
376 Self::Fixed(a) => Ok(a.load(Ordering::SeqCst)),
377 }
378 }
379
380 /// Moves a fixed clock forward.
381 ///
382 /// # Arguments
383 /// * `ms` - How far to advance, in milliseconds.
384 ///
385 /// # Returns
386 /// An error where the clock is the system's, which nobody may move.
387 pub fn advance(&self, ms: i64) -> Outcome<()> {
388 match self {
389 Self::Fixed(a) => {
390 a.fetch_add(ms, Ordering::SeqCst);
391 Ok(())
392 },
393 Self::System => Err(err!(
394 "The system clock cannot be advanced by the journal.";
395 Invalid, Input)),
396 }
397 }
398}
399
400/// The UTC day a timestamp falls in, counted from the epoch.
401///
402/// # Arguments
403/// * `ts` - UTC milliseconds since the Unix epoch.
404fn day_of(ts: i64) -> i64 {
405 ts.div_euclid(DAY_MS)
406}
407
408// ┌───────────────────────────────────────────────────────────────┐
409// │ Events │
410// └───────────────────────────────────────────────────────────────┘
411
412/// One thing worth writing down.
413///
414/// The variants track the wire's own vocabulary, so turning a message into a
415/// record is a `match` and not a judgement call. [`Event::Refused`] is the
416/// load-bearing one: a record of what the hand declined is what separates this
417/// from a shell history.
418#[derive(Clone, Debug, Eq, PartialEq)]
419pub enum Event {
420 /// A page introduced itself.
421 Opened {
422 /// The protocol version it speaks.
423 proto: u32,
424 /// Which build of the app is asking.
425 client: String,
426 },
427 /// A command was about to be run.
428 ///
429 /// Written **before** the spawn, so the record cannot be missing a command
430 /// that started.
431 Exec {
432 /// The caller's identifier for the run.
433 id: String,
434 /// The program and its arguments, credential shapes removed.
435 argv: Vec<String>,
436 /// How many arguments [`redact_argv`] took something out of.
437 redactions: u32,
438 /// The working directory.
439 cwd: String,
440 /// The environment's keys. Never its values -- see the module doc.
441 env_keys: Vec<String>,
442 /// How many bytes of standard input were supplied. Never the bytes.
443 stdin_bytes: u64,
444 /// The hard wall-clock limit that was set.
445 timeout_ms: u64,
446 /// Which streams the caller asked for.
447 capture: Capture,
448 /// What the command was allowed to touch.
449 fence: FenceSpec,
450 /// Which fence mechanisms were actually in force.
451 ///
452 /// Free-form names supplied by the fence layer, because *what was asked
453 /// for* and *what the kernel actually enforced* are different facts and
454 /// only the second one is a guarantee.
455 mechs: Vec<String>,
456 },
457 /// A command started, and is now a real process.
458 Started {
459 /// The caller's identifier.
460 id: String,
461 /// The child's process id.
462 pid: u32,
463 },
464 /// A signal was delivered to a running command.
465 Signalled {
466 /// The caller's identifier.
467 id: String,
468 /// Which signal.
469 sig: Sig,
470 },
471 /// A command finished, one way or another.
472 Ended {
473 /// The caller's identifier.
474 id: String,
475 /// The exit status, or -1 where there was none.
476 exit: i32,
477 /// Whether the hard timeout killed it.
478 timed_out: bool,
479 /// Whether a signal killed it.
480 killed: bool,
481 /// How many bytes of standard output were produced.
482 out_bytes: u64,
483 /// How many bytes of standard error were produced.
484 err_bytes: u64,
485 },
486 /// The hand declined, and this is why.
487 Refused {
488 /// The caller's identifier.
489 id: String,
490 /// The whole sentence the model was given.
491 reason: String,
492 },
493 /// Something went wrong that is nobody's fault in particular.
494 Failed {
495 /// The run it concerns, where there is one.
496 id: Option<String>,
497 /// What happened.
498 message: String,
499 },
500 /// A new file continues the chain from an older one.
501 Rotated {
502 /// The file this one continues.
503 from_file: String,
504 /// The last sequence number that file carried.
505 from_seq: u64,
506 /// The last entry hash that file carried.
507 from_entry: String,
508 /// Trailing bytes found after its last complete entry, from a torn write.
509 torn_bytes: u64,
510 /// The line at which that file stopped verifying, where it did.
511 broken_at: Option<u64>,
512 },
513 /// History that the high-water mark vouched for is no longer there.
514 ///
515 /// The one entry a journal cannot write about itself honestly unless it
516 /// keeps a mark outside the rotated files -- see [`Mark`]. A `gap` is
517 /// indelible in the only sense that matters locally: [`verify_dir`] reports
518 /// the whole history as broken from here on, and removing the entry breaks
519 /// the chain of everything after it.
520 Gap {
521 /// The sequence number the mark said the next entry would carry.
522 expect_seq: u64,
523 /// The entry hash the mark said the record had reached.
524 expect_entry: String,
525 /// The sequence number actually found.
526 found_seq: u64,
527 /// The entry hash actually found.
528 found_entry: String,
529 /// What was noticed, in a sentence.
530 note: String,
531 },
532 /// Something named like a journal file, which the journal did not write.
533 ///
534 /// Recorded rather than ignored, because the review of 2026-08-02 showed a
535 /// single planted name silently sending every later entry to a file no
536 /// verifier reads.
537 Stray {
538 /// What it was called.
539 name: String,
540 /// What it was: `file`, `directory` or `other`.
541 shape: String,
542 /// Where it was moved to, so nothing is destroyed.
543 moved_to: String,
544 },
545 /// The page went away.
546 Closed {
547 /// Why, in a phrase.
548 reason: String,
549 },
550 /// The folder this hand may work in was changed.
551 ///
552 /// Written because it changes what every LATER command may touch, and a record that did
553 /// not carry it would leave a reader unable to say which fence any earlier line was
554 /// written under.
555 Granted {
556 /// The folder now named in `root.txt`.
557 path: String,
558 },
559}
560
561impl Event {
562
563 /// The event's kind, as the line spells it.
564 ///
565 /// Drawn from a closed vocabulary of lower case ASCII, so the field never
566 /// needs escaping and a reader can match on it without a parser.
567 pub fn kind(&self) -> &'static str {
568 match self {
569 Self::Opened {..} => "opened",
570 Self::Exec {..} => "exec",
571 Self::Started {..} => "started",
572 Self::Signalled {..} => "signalled",
573 Self::Ended {..} => "ended",
574 Self::Refused {..} => "refused",
575 Self::Failed {..} => "failed",
576 Self::Rotated {..} => "rotated",
577 Self::Gap {..} => "gap",
578 Self::Stray {..} => "stray",
579 Self::Closed {..} => "closed",
580 Self::Granted {..} => "granted",
581 }
582 }
583
584 /// The event as RFC 8785 canonical JSON, which is what the line carries.
585 ///
586 /// **This is where redaction happens**, and it happens here rather than at
587 /// each of the places an event is built because the review of 2026-08-02
588 /// found the same credential reaching the file eight times through fields
589 /// nobody had listed as free text. One chokepoint that every field passes
590 /// through is a rule; a list of the fields somebody remembered is a hope.
591 ///
592 /// Every body carries `redactions`, so that a value having been removed is
593 /// never invisible -- including on the kinds where a redaction is unlikely,
594 /// because a caller-chosen `id` is free text like any other.
595 ///
596 /// # Returns
597 /// The canonical text, or an error where a value has no canonical form.
598 pub fn body(&self) -> Outcome<String> {
599 let mut s = Scrub::new();
600 let d = match self {
601 Self::Opened { proto, client } => {
602 let client = s.one(client);
603 mapdat!{
604 "proto" => Dat::U32(*proto),
605 "client" => client,
606 "redactions" => Dat::U32(s.cut),
607 }
608 },
609 Self::Exec {
610 id,
611 argv,
612 redactions,
613 cwd,
614 env_keys,
615 stdin_bytes,
616 timeout_ms,
617 capture,
618 fence,
619 mechs,
620 } => {
621 let id = s.one(id);
622 let argv = s.argv(argv);
623 let cwd = s.one(cwd);
624 let env_keys = s.many(env_keys);
625 let fence = fence_dat(&mut s, fence);
626 let mechs = s.many(mechs);
627 mapdat!{
628 "id" => id,
629 "argv" => argv,
630 // What `from_req` already took out, plus what this pass did.
631 // The pass is idempotent, so a vector redacted twice is not
632 // counted twice.
633 "redactions" => Dat::U32(redactions.saturating_add(s.cut)),
634 "cwd" => cwd,
635 "env_keys" => env_keys,
636 "stdin_bytes" => Dat::U64(clamp(*stdin_bytes)),
637 "timeout_ms" => Dat::U64(clamp(*timeout_ms)),
638 "capture" => Dat::Str(capture_name(*capture).to_string()),
639 "fence" => fence,
640 "mechs" => mechs,
641 }
642 },
643 Self::Started { id, pid } => {
644 let id = s.one(id);
645 mapdat!{
646 "id" => id,
647 "pid" => Dat::U32(*pid),
648 "redactions" => Dat::U32(s.cut),
649 }
650 },
651 Self::Signalled { id, sig } => {
652 let id = s.one(id);
653 mapdat!{
654 "id" => id,
655 "sig" => Dat::Str(sig_name(*sig).to_string()),
656 "redactions" => Dat::U32(s.cut),
657 }
658 },
659 Self::Ended { id, exit, timed_out, killed, out_bytes, err_bytes } => {
660 let id = s.one(id);
661 mapdat!{
662 "id" => id,
663 "exit" => Dat::I32(*exit),
664 "timed_out" => Dat::Bool(*timed_out),
665 "killed" => Dat::Bool(*killed),
666 "out_bytes" => Dat::U64(clamp(*out_bytes)),
667 "err_bytes" => Dat::U64(clamp(*err_bytes)),
668 "redactions" => Dat::U32(s.cut),
669 }
670 },
671 Self::Refused { id, reason } => {
672 let id = s.one(id);
673 let reason = s.one(reason);
674 mapdat!{
675 "id" => id,
676 "reason" => reason,
677 "redactions" => Dat::U32(s.cut),
678 }
679 },
680 Self::Failed { id, message } => {
681 let id = s.opt(id);
682 let message = s.one(message);
683 mapdat!{
684 "id" => id,
685 "message" => message,
686 "redactions" => Dat::U32(s.cut),
687 }
688 },
689 Self::Rotated { from_file, from_seq, from_entry, torn_bytes, broken_at } => {
690 let from_file = s.one(from_file);
691 let from_entry = s.one(from_entry);
692 mapdat!{
693 "from_file" => from_file,
694 "from_seq" => Dat::U64(clamp(*from_seq)),
695 "from_entry" => from_entry,
696 "torn_bytes" => Dat::U64(clamp(*torn_bytes)),
697 "broken_at" => match broken_at {
698 Some(n) => Dat::U64(clamp(*n)),
699 None => Dat::Empty,
700 },
701 "redactions" => Dat::U32(s.cut),
702 }
703 },
704 Self::Gap { expect_seq, expect_entry, found_seq, found_entry, note } => {
705 let note = s.one(note);
706 let expect = s.one(expect_entry);
707 let found = s.one(found_entry);
708 mapdat!{
709 "expect_seq" => Dat::U64(clamp(*expect_seq)),
710 "expect_entry" => expect,
711 "found_seq" => Dat::U64(clamp(*found_seq)),
712 "found_entry" => found,
713 "note" => note,
714 "redactions" => Dat::U32(s.cut),
715 }
716 },
717 Self::Stray { name, shape, moved_to } => {
718 let name = s.one(name);
719 let moved = s.one(moved_to);
720 mapdat!{
721 "name" => name,
722 "shape" => Dat::Str(shape.clone()),
723 "moved_to" => moved,
724 "redactions" => Dat::U32(s.cut),
725 }
726 },
727 Self::Closed { reason } => {
728 let reason = s.one(reason);
729 mapdat!{
730 "reason" => reason,
731 "redactions" => Dat::U32(s.cut),
732 }
733 },
734 Self::Granted { path } => {
735 let path = s.one(path);
736 mapdat!{
737 "path" => path,
738 "redactions" => Dat::U32(s.cut),
739 }
740 },
741 };
742 Ok(res!(d.json_canonical()))
743 }
744
745 /// The event a request deserves, where it deserves one.
746 ///
747 /// # Arguments
748 /// * `req` - The message the page sent.
749 /// * `mechs` - The fence mechanisms actually in force for this run.
750 ///
751 /// # Returns
752 /// `None` for a message not worth a line of its own.
753 pub fn from_req(req: &Req, mechs: &[String]) -> Option<Self> {
754 match req {
755 Req::Hello { proto, client } => Some(Self::Opened {
756 proto: *proto,
757 client: client.clone(),
758 }),
759 // A terminal session is recorded exactly as a command is: what was run, where, and
760 // under what fence. It is the same act.
761 // The toolkit names are not recorded beside the fence because the fence already
762 // carries what they resolved to -- the absolute toolchain paths are in `rw` and `ro`,
763 // which is the answer to "what could this command reach" in the form that decides it.
764 Req::Open { id, argv, cwd, env, size: _, fence, toolkits: _ } => {
765 let (safe, cut) = redact_argv(argv);
766 Some(Self::Exec {
767 id: id.clone(),
768 argv: safe,
769 cwd: cwd.clone(),
770 env_keys: env_keys(env),
771 stdin_bytes: 0,
772 timeout_ms: 0,
773 // A terminal merges the two streams by construction, so Both is the only
774 // honest answer; the size is not a capture and is not recorded as one.
775 capture: Capture::Both,
776 fence: fence.clone(),
777 mechs: mechs.to_vec(),
778 redactions: cut,
779 })
780 },
781 // **Keystrokes are NEVER written down.** A pty exists so that a program can ask a
782 // question, and the questions are `sudo` wanting a password, `ssh` wanting a
783 // passphrase, `gpg` wanting the key. Recording input would put the one secret the
784 // user typed by hand into a file whose whole purpose is to be kept and read. The
785 // count is not recorded either: how many characters a password has is not nothing.
786 Req::Input { .. } => None,
787 // Nothing happened that anyone need answer for.
788 Req::Resize { .. } => None,
789 Req::Exec { id, argv, cwd, env, stdin, timeout_ms, capture, fence, toolkits: _ } => {
790 let (safe, cut) = redact_argv(argv);
791 Some(Self::Exec {
792 id: id.clone(),
793 argv: safe,
794 redactions: cut,
795 cwd: cwd.clone(),
796 env_keys: env_keys(env),
797 stdin_bytes: match stdin {
798 Some(s) => s.len() as u64,
799 None => 0,
800 },
801 timeout_ms: *timeout_ms,
802 capture: *capture,
803 fence: fence.clone(),
804 mechs: mechs.to_vec(),
805 })
806 },
807 // A FILE OP IS AN ACT and is written down as one, in the same `Exec` shape a
808 // command gets -- because from the record's point of view it is the same thing: a
809 // fence was applied and a file on this machine changed inside it. What is NOT
810 // written down is the text. A `write` carries the whole new content and an `edit`
811 // carries both sides of the replacement, and either may hold whatever the file
812 // holds; the journal keeps the size, which is the half that can be checked against
813 // what is on disk, and never the bytes.
814 Req::File { id, op, cwd, fence, toolkits: _ } => {
815 let (safe, cut) = redact_argv(&[
816 fmt!("file"),
817 fmt!("{}", op.word()),
818 fmt!("{}", op.path()),
819 ]);
820 Some(Self::Exec {
821 id: id.clone(),
822 argv: safe,
823 redactions: cut,
824 cwd: cwd.clone(),
825 env_keys: Vec::new(),
826 stdin_bytes: op_bytes(op),
827 timeout_ms: 0,
828 capture: Capture::Out,
829 fence: fence.clone(),
830 mechs: mechs.to_vec(),
831 })
832 },
833 // Asking what is running is a question and not an act, so it is not written down --
834 // and every run it can name was journalled when it started. The SIGNAL that follows
835 // is recorded, which is the half a reader would want.
836 Req::Runs => None,
837 // Nothing was run and nothing was read: a folder browser reports directory NAMES so
838 // a person can choose one. The journal is a record of what this hand DID to the
839 // machine, and listing a directory is not a thing done to it.
840 Req::Dirs { .. } => None,
841 // A grant IS a thing done to the machine -- it changes what every later command
842 // may touch -- so it is written down, and written down as the folder it names.
843 Req::Grant { path } => Some(Self::Granted { path: path.clone() }),
844 // A VERIFY IS NOT ITSELF AN ACT, and writing it down as one would put a verb in the
845 // record where a reader expects a process. Every run it makes is journalled as the
846 // `Exec` it really is -- the node command line, the granted root, and `fence:none` in
847 // the mechanisms, because a verifier runs outside the command fence on purpose. A
848 // verify that is REFUSED is still recorded, by the refusal on its way out.
849 Req::Verify { .. } => None,
850 Req::Signal { id, sig } => Some(Self::Signalled {
851 id: id.clone(),
852 sig: *sig,
853 }),
854 Req::Bye => Some(Self::Closed {
855 reason: "the page said goodbye".to_string(),
856 }),
857 }
858 }
859
860 /// The event a response deserves, where it deserves one.
861 ///
862 /// # Arguments
863 /// * `resp` - The message the hand is about to send.
864 ///
865 /// # Returns
866 /// `None` for output chunks and for the opening handshake. A chunk carries
867 /// the command's output, which the journal never keeps; the handshake says
868 /// nothing the request has not already said.
869 pub fn from_resp(resp: &Resp) -> Option<Self> {
870 match resp {
871 Resp::Opened { id, pid } => Some(Self::Started {
872 id: id.clone(),
873 pid: *pid,
874 }),
875 // As a chunk is: the terminal's own bytes are the command's output, and the journal
876 // never keeps output. Here it matters twice over, because a terminal echoes what was
877 // typed -- so keeping output would keep the password the input arm refuses.
878 Resp::Output { .. } => None,
879 Resp::Closed { id, exit, killed } => Some(Self::Ended {
880 id: id.clone(),
881 exit: *exit,
882 timed_out: false,
883 killed: *killed,
884 out_bytes: 0,
885 err_bytes: 0,
886 }),
887 Resp::Started { id, pid } => Some(Self::Started {
888 id: id.clone(),
889 pid: *pid,
890 }),
891 Resp::Ended { id, exit, timed_out, killed, out_bytes, err_bytes } => Some(Self::Ended {
892 id: id.clone(),
893 exit: *exit,
894 timed_out: *timed_out,
895 killed: *killed,
896 out_bytes: *out_bytes,
897 err_bytes: *err_bytes,
898 }),
899 Resp::Refused { id, reason } => Some(Self::Refused {
900 id: id.clone(),
901 reason: reason.clone(),
902 }),
903 Resp::Error { id, message } => Some(Self::Failed {
904 id: id.clone(),
905 message: message.clone(),
906 }),
907 // The file op ended, recorded exactly as a command's end is: `exit` 0 where it was
908 // carried out and 1 where it was refused, so a reader counting failed acts counts
909 // this one too. The text is measured and not kept, for the reason the request arm
910 // gives.
911 Resp::Filed { id, ok, text } => Some(Self::Ended {
912 id: id.clone(),
913 exit: match ok { true => 0, false => 1 },
914 timed_out: false,
915 killed: false,
916 out_bytes: text.len() as u64,
917 err_bytes: 0,
918 }),
919 // A listing is a measurement of what the journal already records the
920 // starting of. Writing it down again would put a reader's question
921 // in a record of a machine's acts.
922 Resp::Runs {..} => None,
923 Resp::Dirs {..} => None,
924 Resp::Granted {..} => None,
925 // A fault is written before the journal exists -- it is the sentence saying the
926 // hand could not open one -- so there is nowhere to record it and nothing that
927 // could. It reaches the record only in the sense that the next hand to start
928 // finds the same fault still there.
929 Resp::Fault {..} => None,
930 Resp::Hello {..} | Resp::Chunk {..} => None,
931 }
932 }
933}
934
935/// A fence specification as a daticle, with its roots ordered and redacted.
936///
937/// Sorting means two callers who granted the same thing in a different order
938/// produce the same record, so a diff of two entries is about the grant rather
939/// than about the spelling. A path is free text like any other -- a directory
940/// can be named after a token -- so it goes through the scrubber too.
941///
942/// # Arguments
943/// * `s` - The running redaction count for this entry.
944/// * `f` - The specification that was applied.
945fn fence_dat(s: &mut Scrub, f: &FenceSpec) -> Dat {
946 let rw = s.sorted(&f.rw);
947 let ro = s.sorted(&f.ro);
948 let deny = s.sorted(&f.deny);
949 mapdat!{
950 "rw" => rw,
951 "ro" => ro,
952 "deny" => deny,
953 "net" => Dat::Bool(f.net),
954 }
955}
956
957/// A capture selection, as the line spells it.
958///
959/// # Arguments
960/// * `c` - The selection.
961fn capture_name(c: Capture) -> &'static str {
962 match c {
963 Capture::Both => "both",
964 Capture::Out => "out",
965 Capture::Err => "err",
966 Capture::None => "none",
967 }
968}
969
970/// A signal, as the line spells it.
971///
972/// # Arguments
973/// * `s` - The signal.
974fn sig_name(s: Sig) -> &'static str {
975 match s {
976 Sig::Term => "term",
977 Sig::Kill => "kill",
978 Sig::Int => "int",
979 }
980}
981
982/// A count canonical JSON can carry, clamped rather than refused.
983///
984/// # Arguments
985/// * `n` - The count.
986fn clamp(n: u64) -> u64 {
987 if n > JS_SAFE { JS_SAFE } else { n }
988}
989
990// ┌───────────────────────────────────────────────────────────────┐
991// │ Secrets │
992// └───────────────────────────────────────────────────────────────┘
993
994/// The environment's keys, sorted, with the values discarded.
995///
996/// Duplicates are kept: two pairs with the same name is an oddity a reader
997/// should see rather than one the journal quietly tidies away.
998///
999/// # Arguments
1000/// * `env` - The pairs the page supplied.
1001pub fn env_keys(env: &[(String, String)]) -> Vec<String> {
1002 let mut out: Vec<String> = env.iter().map(|(k, _)| k.clone()).collect();
1003 out.sort();
1004 out
1005}
1006
1007/// Name tails that make a value a credential, wherever the name is spelled.
1008///
1009/// Matched as a plain suffix of the normalised name, which is what catches
1010/// `PGPASSWORD` and `AWS_SECRET_ACCESS_KEY` as well as `--password`. The
1011/// review of 2026-08-02 found the previous exact-match list catching none of
1012/// `--PASSWORD`, `--Token`, `--secret-access-key` or `--private-key`: an
1013/// exact list over a case-sensitive comparison is a list of the spellings
1014/// somebody happened to think of.
1015const SECRET_TAILS: &[&str] = &[
1016 "password",
1017 "passwd",
1018 "passphrase",
1019 "secret",
1020 "token",
1021 "credential",
1022 "credentials",
1023 "authorization",
1024 "apikey",
1025 "api-key",
1026 "access-key",
1027 "private-key",
1028 "signing-key",
1029 "session-key",
1030 "session",
1031 "master-key",
1032 "encryption-key",
1033 "cookie",
1034 "bearer",
1035];
1036
1037/// Flags whose value is a `user:password` pair rather than a bare credential.
1038///
1039/// `-u` is deliberately not a secret flag -- `useradd -u 1000` is not a
1040/// credential and over-redaction damages the record -- but `curl -u me:hunter2`
1041/// is, and the colon is what tells them apart. So the value is redacted only
1042/// from the colon onwards, and an argument without one is left alone.
1043const USERINFO_FLAGS: &[&str] = &[
1044 "u",
1045 "user",
1046 "username",
1047 "userinfo",
1048 "login",
1049];
1050
1051/// Name tails that count only where they stand as a whole word.
1052///
1053/// `--pass` is a credential and `--bypass` is not, so these match at the start
1054/// of a name or after a separator and nowhere else.
1055const SECRET_WORDS: &[&str] = &[
1056 "pass",
1057 "auth",
1058 "pwd",
1059 "pat",
1060];
1061
1062/// Published key formats, each with the least trailing length worth believing.
1063///
1064/// The length matters: `sk-` is three characters and appears inside ordinary
1065/// words, so a bare prefix test over a long string is a false positive waiting
1066/// to happen. A real key of that family is long, and requiring the length
1067/// keeps `sk-headless-renderer` out of the redactor while keeping every real
1068/// key in it.
1069const SECRET_PREFIXES: &[(&str, usize)] = &[
1070 ("sk-", 20),
1071 ("sk_live_", 8),
1072 ("sk_test_", 8),
1073 ("rk_live_", 8),
1074 ("pk_live_", 8),
1075 ("ghp_", 8),
1076 ("gho_", 8),
1077 ("ghu_", 8),
1078 ("ghs_", 8),
1079 ("ghr_", 8),
1080 ("github_pat_", 8),
1081 ("xoxb-", 8),
1082 ("xoxp-", 8),
1083 ("xoxa-", 8),
1084 ("xoxs-", 8),
1085 ("AKIA", 8),
1086 ("ASIA", 8),
1087 ("AIza", 8),
1088 ("ya29.", 8),
1089 ("eyJ", 8),
1090];
1091
1092/// Header names whose value is an authorisation and never anything else.
1093const SECRET_HEADERS: &[&str] = &[
1094 "authorization:",
1095 "proxy-authorization:",
1096 "x-api-key:",
1097 "x-auth-token:",
1098 "x-amz-security-token:",
1099 "api-key:",
1100 "cookie:",
1101 "set-cookie:",
1102];
1103
1104/// Authorisation schemes whose next word is the credential.
1105const SCHEMES: &[&str] = &[
1106 "bearer",
1107 "basic",
1108 "token",
1109];
1110
1111/// A name with the spellings that differ but do not matter folded together.
1112///
1113/// # Arguments
1114/// * `s` - The name as it was written.
1115fn norm_name(s: &str) -> String {
1116 s.trim_start_matches('-')
1117 .chars()
1118 .map(|c| if c == '_' { '-' } else { c.to_ascii_lowercase() })
1119 .collect()
1120}
1121
1122/// Whether a name says its value is a credential.
1123///
1124/// # Arguments
1125/// * `name` - The flag or variable name, as it was written.
1126fn looks_secret_name(name: &str) -> bool {
1127 let n = norm_name(name);
1128 if n.is_empty() {
1129 return false;
1130 }
1131 if SECRET_TAILS.iter().any(|t| n.ends_with(t)) {
1132 return true;
1133 }
1134 SECRET_WORDS.iter().any(|t| {
1135 if !n.ends_with(t) {
1136 return false;
1137 }
1138 match n.len().checked_sub(t.len()) {
1139 Some(0) => true,
1140 Some(i) => n.as_bytes()[i - 1] == b'-',
1141 None => false,
1142 }
1143 })
1144}
1145
1146/// Whether a string is shaped like a flag or an environment variable's name.
1147///
1148/// Used to decide whether the text before an `=` is a *name* at all, so that a
1149/// URL carrying a `?key=` is not mistaken for one.
1150///
1151/// # Arguments
1152/// * `s` - The text before the `=`.
1153fn is_name_like(s: &str) -> bool {
1154 let t = s.trim_start_matches('-');
1155 !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
1156}
1157
1158/// The last segment of a name reached through a path, a query or a colon.
1159///
1160/// `//registry.npmjs.org/:_authToken` names a credential and is not shaped like
1161/// a flag; its last segment is.
1162///
1163/// # Arguments
1164/// * `s` - The text before the `=`.
1165fn tail_name(s: &str) -> &str {
1166 match s.rfind(|c| c == '/' || c == ':' || c == '?' || c == '&' || c == '.') {
1167 Some(i) => &s[i + 1..],
1168 None => s,
1169 }
1170}
1171
1172/// Whether a byte may sit immediately before a published key format.
1173///
1174/// A token inside a URL is the commonest way a key lands in an argument vector,
1175/// so the test cannot be `starts_with`; but it cannot be `contains` either, or
1176/// every word with `sk-` in the middle is redacted. The answer is a boundary.
1177///
1178/// # Arguments
1179/// * `b` - The byte before the candidate.
1180fn is_boundary(b: u8) -> bool {
1181 matches!(b,
1182 b'/' | b':' | b'=' | b'?' | b'&' | b'@' | b',' | b';' | b'"' | b'\'' |
1183 b'`' | b'(' | b'[' | b'{' | b'<' | b'-' | b'_' | b' ' | b'\t')
1184}
1185
1186/// Whether a character ends a run of credential text.
1187///
1188/// # Arguments
1189/// * `c` - The character.
1190fn is_run_end(c: char) -> bool {
1191 c.is_whitespace() || matches!(c,
1192 '"' | '\'' | '`' | '&' | '@' | ',' | ';' | '<' | '>' | ')' | ']' | '}' | '/' | ':' | '\\')
1193}
1194
1195/// An ASCII case-insensitive prefix test that never slices by the wrong length.
1196///
1197/// The review found `&a[..h.len()]` indexing the original string by the length
1198/// of its *lowercased* copy. No panic was reachable with the constants of the
1199/// day, but the bug was one added constant away from one. Comparing bytes in
1200/// place removes the second string, and with it the second length.
1201///
1202/// # Arguments
1203/// * `s` - The text.
1204/// * `pre` - The ASCII prefix to look for.
1205fn starts_ci(s: &str, pre: &str) -> bool {
1206 s.len() >= pre.len() && s.as_bytes()[..pre.len()].eq_ignore_ascii_case(pre.as_bytes())
1207}
1208
1209/// Whether a value is a port, a port mapping or a protocol-qualified one.
1210///
1211/// `docker run -p8080:80` and `mysql -phunter2` are the same shape, and only
1212/// one of them is a credential. This is what tells them apart.
1213///
1214/// # Arguments
1215/// * `v` - The text attached to the short flag.
1216fn is_port_like(v: &str) -> bool {
1217 let core = match v.rsplit_once('/') {
1218 Some((head, tail)) if tail == "tcp" || tail == "udp" => head,
1219 _ => v,
1220 };
1221 !core.is_empty() && core.bytes().all(|b| b.is_ascii_digit() || b == b':' || b == b'.')
1222}
1223
1224/// The text before and after the first `=`, where there is one.
1225///
1226/// # Arguments
1227/// * `s` - The candidate.
1228fn split_assign(s: &str) -> Option<(&str, &str)> {
1229 s.split_once('=')
1230}
1231
1232/// One redaction pass, and what it has taken out so far.
1233///
1234/// The state is one bit -- whether the last thing read named a credential that
1235/// the next thing carries -- plus the count. An enum would be two variants of
1236/// one boolean, so a boolean it is.
1237struct Pass {
1238 /// What the next thing read is expected to be.
1239 pending: Pend,
1240 /// How many values have been taken out.
1241 cut: u32,
1242}
1243
1244/// What the thing after a flag is.
1245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1246enum Pend {
1247 /// Nothing in particular.
1248 No,
1249 /// The whole of it is a credential.
1250 Value,
1251 /// It is a `user:password` pair, and only the half after the colon is.
1252 UserInfo,
1253}
1254
1255impl Pass {
1256
1257 /// A pass that has taken nothing out yet.
1258 fn new() -> Self {
1259 Self { pending: Pend::No, cut: 0 }
1260 }
1261
1262 /// A `user:password` pair with the password taken out.
1263 ///
1264 /// # Arguments
1265 /// * `v` - The pair, or something that is not one.
1266 fn userinfo(&mut self, v: &str) -> String {
1267 match v.find(':') {
1268 Some(c) => {
1269 let r = self.replace(&v[c + 1..]);
1270 fmt!("{}{}", &v[..c + 1], r)
1271 },
1272 None => v.to_string(),
1273 }
1274 }
1275
1276 /// The `name=value` pairs of a form-encoded body, redacted by name.
1277 ///
1278 /// `--data 'grant_type=password&client_secret=hunter2'` is one argument
1279 /// carrying two fields, and splitting only at the first `=` reads the whole
1280 /// of it as one.
1281 ///
1282 /// # Arguments
1283 /// * `a` - The candidate.
1284 fn scrub_pairs(&mut self, a: &str) -> String {
1285 if !a.contains('&') {
1286 return a.to_string();
1287 }
1288 let mut out = String::with_capacity(a.len());
1289 let mut first = true;
1290 for pair in a.split('&') {
1291 if !first {
1292 out.push('&');
1293 }
1294 first = false;
1295 match split_assign(pair) {
1296 Some((k, v)) if is_name_like(k) && looks_secret_name(k) => {
1297 let r = self.replace(v);
1298 out.push_str(k);
1299 out.push('=');
1300 out.push_str(&r);
1301 },
1302 _ => out.push_str(pair),
1303 }
1304 }
1305 out
1306 }
1307
1308 /// Replaces a value, counting it unless it has already been replaced.
1309 ///
1310 /// Idempotence is what lets the same pass run twice -- once on the way into
1311 /// an [`Event`] and once on the way out to the line -- without the count
1312 /// telling the reader that two things were removed when one was.
1313 ///
1314 /// # Arguments
1315 /// * `v` - The value to remove.
1316 fn replace(&mut self, v: &str) -> String {
1317 if v == REDACTED {
1318 return v.to_string();
1319 }
1320 self.cut += 1;
1321 REDACTED.to_string()
1322 }
1323
1324 /// The rules that do not depend on what came before: headers, schemes,
1325 /// URLs, joined `name=value` forms, attached short flags and key formats.
1326 ///
1327 /// # Arguments
1328 /// * `a` - One argument, or one word of free text.
1329 fn scrub(&mut self, a: &str) -> String {
1330 // An authorisation header carried as one argument. The name is kept,
1331 // because which header was set is part of what was run.
1332 for h in SECRET_HEADERS.iter() {
1333 if starts_ci(a, h) {
1334 let rest = a[h.len()..].trim();
1335 if rest.is_empty() || rest == REDACTED {
1336 return a.to_string();
1337 }
1338 self.cut += 1;
1339 return fmt!("{} {}", &a[..h.len()], REDACTED);
1340 }
1341 }
1342 // `Bearer <token>`, and its friends, as one argument.
1343 for s in SCHEMES.iter() {
1344 let pre = fmt!("{} ", s);
1345 if starts_ci(a, &pre) {
1346 let rest = a[pre.len()..].trim();
1347 if rest.is_empty() || rest == REDACTED {
1348 return a.to_string();
1349 }
1350 self.cut += 1;
1351 return fmt!("{} {}", &a[..s.len()], REDACTED);
1352 }
1353 }
1354 if let Some(s) = self.short_attached(a) {
1355 return s;
1356 }
1357 // A flag and its value in one argument, as `--passphrase hunter2`.
1358 if a.starts_with('-') {
1359 if let Some((head, val)) = a.split_once(' ') {
1360 if looks_secret_name(head) {
1361 let v = self.replace(val.trim_start());
1362 return fmt!("{} {}", head, v);
1363 }
1364 }
1365 }
1366 if let Some((head, val)) = split_assign(a) {
1367 if is_name_like(head) {
1368 if looks_secret_name(head) {
1369 let v = self.replace(val);
1370 return fmt!("{}={}", head, v);
1371 }
1372 if USERINFO_FLAGS.iter().any(|f| *f == norm_name(head)) {
1373 let v = self.userinfo(val);
1374 return fmt!("{}={}", head, v);
1375 }
1376 // A flag whose value may itself be a header, a URL or a key.
1377 let v = self.scrub(val);
1378 return fmt!("{}={}", head, v);
1379 }
1380 // A name reached through a path or a query, as npm's
1381 // `//registry/:_authToken=…`. The last segment is the name.
1382 if looks_secret_name(tail_name(head)) {
1383 let v = self.replace(val);
1384 return fmt!("{}={}", head, v);
1385 }
1386 }
1387 let a = self.scrub_url(a);
1388 let a = self.scrub_pairs(&a);
1389 self.scrub_prefixes(&a)
1390 }
1391
1392 /// A short flag with its value stuck to it, such as `-phunter2`.
1393 ///
1394 /// # Arguments
1395 /// * `a` - The argument.
1396 ///
1397 /// # Returns
1398 /// The redacted argument, or `None` where this is not that shape.
1399 fn short_attached(&mut self, a: &str) -> Option<String> {
1400 if a.len() <= 2 || a.starts_with("--") || !a.starts_with('-') {
1401 return None;
1402 }
1403 let letter = &a[1..2];
1404 if letter != "p" {
1405 return None;
1406 }
1407 let val = &a[2..];
1408 if is_port_like(val) {
1409 return None;
1410 }
1411 let v = self.replace(val);
1412 Some(fmt!("-p{}", v))
1413 }
1414
1415 /// A URL with its credentials taken out, in the two places URLs carry them.
1416 ///
1417 /// # Arguments
1418 /// * `a` - The candidate.
1419 fn scrub_url(&mut self, a: &str) -> String {
1420 let at = match a.find("://") {
1421 Some(n) => n + 3,
1422 None => return a.to_string(),
1423 };
1424 let rest = &a[at..];
1425 let end = match rest.find(|c| c == '/' || c == '?' || c == '#') {
1426 Some(n) => n,
1427 None => rest.len(),
1428 };
1429 let mut out = a[..at].to_string();
1430 let authority = &rest[..end];
1431 match authority.rfind('@') {
1432 Some(u) => {
1433 let info = &authority[..u];
1434 let host = &authority[u..];
1435 match info.find(':') {
1436 Some(c) => {
1437 let v = self.replace(&info[c + 1..]);
1438 out.push_str(&info[..c + 1]);
1439 out.push_str(&v);
1440 },
1441 None => {
1442 if secret_prefix_at(info, 0).is_some() {
1443 let v = self.replace(info);
1444 out.push_str(&v);
1445 } else {
1446 out.push_str(info);
1447 }
1448 },
1449 }
1450 out.push_str(host);
1451 },
1452 None => out.push_str(authority),
1453 }
1454 // The query string, where a key is as often carried as in the authority.
1455 let tail = &rest[end..];
1456 match tail.find('?') {
1457 Some(q) => {
1458 out.push_str(&tail[..q + 1]);
1459 let (query, frag) = match tail[q + 1..].find('#') {
1460 Some(h) => (&tail[q + 1..q + 1 + h], &tail[q + 1 + h..]),
1461 None => (&tail[q + 1..], ""),
1462 };
1463 let mut first = true;
1464 for pair in query.split('&') {
1465 if !first {
1466 out.push('&');
1467 }
1468 first = false;
1469 match split_assign(pair) {
1470 Some((k, v)) if is_name_like(k) && looks_secret_name(k) => {
1471 let r = self.replace(v);
1472 out.push_str(k);
1473 out.push('=');
1474 out.push_str(&r);
1475 },
1476 _ => out.push_str(pair),
1477 }
1478 }
1479 out.push_str(frag);
1480 },
1481 None => out.push_str(tail),
1482 }
1483 out
1484 }
1485
1486 /// Every run of text that opens with a published key format, taken out.
1487 ///
1488 /// # Arguments
1489 /// * `a` - The candidate.
1490 fn scrub_prefixes(&mut self, a: &str) -> String {
1491 let mut out = String::with_capacity(a.len());
1492 let mut i = 0usize;
1493 let bytes = a.as_bytes();
1494 while i < a.len() {
1495 let boundary = i == 0 || is_boundary(bytes[i - 1]);
1496 if boundary && secret_prefix_at(a, i).is_some() {
1497 let rest = &a[i..];
1498 let j = i + match rest.find(is_run_end) {
1499 Some(n) => n,
1500 None => rest.len(),
1501 };
1502 match a.get(i..j) {
1503 Some(run) => {
1504 let r = self.replace(run);
1505 out.push_str(&r);
1506 i = j;
1507 continue;
1508 },
1509 None => {},
1510 }
1511 }
1512 match a[i..].chars().next() {
1513 Some(c) => {
1514 out.push(c);
1515 i += c.len_utf8();
1516 },
1517 None => break,
1518 }
1519 }
1520 out
1521 }
1522
1523 /// One argument of a vector, with the flag rules that reach across arguments.
1524 ///
1525 /// # Arguments
1526 /// * `a` - The argument.
1527 fn element(&mut self, a: &str) -> String {
1528 match self.pending {
1529 Pend::Value => {
1530 self.pending = Pend::No;
1531 return self.replace(a);
1532 },
1533 Pend::UserInfo => {
1534 self.pending = Pend::No;
1535 return self.userinfo(a);
1536 },
1537 Pend::No => {},
1538 }
1539 if a.starts_with('-') && !a.contains('=') {
1540 if looks_secret_name(a) {
1541 self.pending = Pend::Value;
1542 return a.to_string();
1543 }
1544 if USERINFO_FLAGS.iter().any(|f| *f == norm_name(a)) {
1545 self.pending = Pend::UserInfo;
1546 return a.to_string();
1547 }
1548 }
1549 self.scrub(a)
1550 }
1551
1552 /// One word of free text, with the punctuation around it put back.
1553 ///
1554 /// Free text carries a credential when a refusal quotes the command it
1555 /// refused -- which is the natural wording, and the way the app's own
1556 /// refusals are written. The rules are the argument rules, applied a word
1557 /// at a time, with one difference: a scheme word between a header name and
1558 /// its value is kept and the expectation passed on, so
1559 /// `Authorization: Bearer …` redacts the token rather than the word.
1560 ///
1561 /// # Arguments
1562 /// * `t` - The word, punctuation and all.
1563 fn token(&mut self, t: &str) -> String {
1564 let (lead, core, trail) = split_punct(t);
1565 if core.is_empty() {
1566 return t.to_string();
1567 }
1568 let low = core.to_lowercase();
1569 let done = match self.pending {
1570 Pend::Value if SCHEMES.iter().any(|s| *s == low) => core.to_string(),
1571 Pend::Value => {
1572 self.pending = Pend::No;
1573 self.replace(core)
1574 },
1575 Pend::UserInfo => {
1576 self.pending = Pend::No;
1577 self.userinfo(core)
1578 },
1579 Pend::No => {
1580 if core.ends_with(':') && SECRET_HEADERS.iter().any(|h| starts_ci(core, h)) {
1581 self.pending = Pend::Value;
1582 core.to_string()
1583 // The HTTP schemes, in the spelling a header uses. Lower case
1584 // `basic` is an ordinary English word and is left alone; capital
1585 // `Basic` in the middle of a sentence is a header being quoted.
1586 } else if low == "bearer" || core == "Basic" || core == "Token" {
1587 self.pending = Pend::Value;
1588 core.to_string()
1589 } else if core.starts_with('-') && !core.contains('=')
1590 && looks_secret_name(core) {
1591 self.pending = Pend::Value;
1592 core.to_string()
1593 } else if core.starts_with('-') && !core.contains('=')
1594 && USERINFO_FLAGS.iter().any(|f| *f == norm_name(core)) {
1595 self.pending = Pend::UserInfo;
1596 core.to_string()
1597 } else {
1598 self.scrub(core)
1599 }
1600 },
1601 };
1602 fmt!("{}{}{}", lead, done, trail)
1603 }
1604}
1605
1606/// Where a published key format starts at a given offset, and how long it is.
1607///
1608/// # Arguments
1609/// * `s` - The text.
1610/// * `i` - Where to look.
1611fn secret_prefix_at(s: &str, i: usize) -> Option<usize> {
1612 let rest = match s.get(i..) {
1613 Some(r) => r,
1614 None => return None,
1615 };
1616 let run = match rest.find(is_run_end) {
1617 Some(n) => n,
1618 None => rest.len(),
1619 };
1620 for (p, min) in SECRET_PREFIXES.iter() {
1621 // The published formats are ASCII, and their case is part of the format.
1622 if rest.starts_with(p) && run >= p.len() + min {
1623 return Some(p.len());
1624 }
1625 }
1626 None
1627}
1628
1629/// A word split into the punctuation around it and the word itself.
1630///
1631/// # Arguments
1632/// * `t` - The word as it appeared.
1633fn split_punct(t: &str) -> (&str, &str, &str) {
1634 // `<` and `>` are deliberately absent: `<redacted>` is spelled with them,
1635 // and stripping them would make the pass replace its own replacement.
1636 let lead: &[char] = &['`', '\'', '"', '(', '[', '{'];
1637 let trail: &[char] = &['`', '\'', '"', ')', ']', '}', ',', ';', '!', '?'];
1638 let a = t.trim_start_matches(lead);
1639 let b = a.trim_end_matches(trail);
1640 let l = &t[..t.len() - a.len()];
1641 let r = &a[b.len()..];
1642 (l, b, r)
1643}
1644
1645/// An argument vector with the credential shapes taken out, and a count.
1646///
1647/// A courtesy, not a guarantee, and the distinction is worth stating twice: the
1648/// pass fails **open**, so a secret in an argument no rule recognises is
1649/// recorded. An argument vector is visible in `ps` to every process the user
1650/// owns, so it was never a safe place for a secret; the journal cannot make it
1651/// one. What the journal *can* do is never write down the places a caller is
1652/// entitled to think are private -- the environment and standard input -- and it
1653/// does not.
1654///
1655/// # What is deliberately left alone
1656///
1657/// Ambiguous single-letter flags with a *separate* value: `redis-cli -a secret`
1658/// and `docker login -p secret` are recorded in full, because `ls -a`,
1659/// `mkdir -p dir`, `cp -p file` and `docker run -p 8080:80` are not credentials
1660/// and redacting them would damage far more records than it protected. The
1661/// attached forms are caught, because `-p8080:80` and `-phunter2` can be told
1662/// apart by shape. A flag whose value is a *path* to a credential --
1663/// `--access-token-file=/etc/tok` -- is also left alone: the path is not the
1664/// secret, and the file is not something the journal reads.
1665///
1666/// # Arguments
1667/// * `argv` - The program and its arguments, as the page sent them.
1668///
1669/// # Returns
1670/// The vector to record, and how many values something was taken out of.
1671pub fn redact_argv(argv: &[String]) -> (Vec<String>, u32) {
1672 let mut p = Pass::new();
1673 let mut out = Vec::with_capacity(argv.len());
1674 for a in argv {
1675 out.push(p.element(a));
1676 }
1677 (out, p.cut)
1678}
1679
1680/// Free text with the credential shapes taken out, and a count.
1681///
1682/// Every string the journal writes goes through this, because the review of
1683/// 2026-08-02 got one secret into the file eight times through fields nobody
1684/// had thought of as free text: a refusal's reason, an error's message, the
1685/// working directory, the run's own identifier, an environment *key*, a fence
1686/// path, a mechanism name and the client's build string. A redaction applied
1687/// to the fields somebody remembered is a redaction applied to the wrong list,
1688/// so this is applied at the one place every field passes through:
1689/// [`Event::body`].
1690///
1691/// Whitespace and punctuation are preserved exactly, so a sentence still reads
1692/// as a sentence.
1693///
1694/// # Arguments
1695/// * `s` - The text.
1696///
1697/// # Returns
1698/// The text to record, and how many values were taken out.
1699pub fn redact_text(s: &str) -> (String, u32) {
1700 let mut p = Pass::new();
1701 let mut out = String::with_capacity(s.len());
1702 for part in s.split_inclusive(char::is_whitespace) {
1703 let (tok, ws) = match part.char_indices().next_back() {
1704 Some((i, c)) if c.is_whitespace() => (&part[..i], &part[i..]),
1705 _ => (part, ""),
1706 };
1707 out.push_str(&p.token(tok));
1708 out.push_str(ws);
1709 }
1710 (out, p.cut)
1711}
1712
1713/// Somewhere to put the redacted text while a body is being built, and the
1714/// running count of what has been taken out of it.
1715struct Scrub {
1716 /// How many values have been removed from this entry.
1717 cut: u32,
1718}
1719
1720impl Scrub {
1721
1722 /// A scrubber that has removed nothing yet.
1723 fn new() -> Self {
1724 Self { cut: 0 }
1725 }
1726
1727 /// One string, redacted.
1728 ///
1729 /// # Arguments
1730 /// * `s` - The text.
1731 fn one(&mut self, s: &str) -> Dat {
1732 let (t, c) = redact_text(s);
1733 self.cut = self.cut.saturating_add(c);
1734 Dat::Str(t)
1735 }
1736
1737 /// A list of strings, each redacted.
1738 ///
1739 /// # Arguments
1740 /// * `v` - The strings.
1741 fn many(&mut self, v: &[String]) -> Dat {
1742 let mut out = Vec::with_capacity(v.len());
1743 for s in v {
1744 out.push(self.one(s));
1745 }
1746 Dat::List(out)
1747 }
1748
1749 /// A list of strings sorted first, so the record is about the grant rather
1750 /// than about the order it was written in.
1751 ///
1752 /// # Arguments
1753 /// * `v` - The strings.
1754 fn sorted(&mut self, v: &[String]) -> Dat {
1755 let mut s = v.to_vec();
1756 s.sort();
1757 self.many(&s)
1758 }
1759
1760 /// An argument vector, redacted by the rules that reach across arguments.
1761 ///
1762 /// # Arguments
1763 /// * `v` - The vector.
1764 fn argv(&mut self, v: &[String]) -> Dat {
1765 let (out, c) = redact_argv(v);
1766 self.cut = self.cut.saturating_add(c);
1767 Dat::List(out.into_iter().map(Dat::Str).collect())
1768 }
1769
1770 /// An optional string, redacted where there is one.
1771 ///
1772 /// # Arguments
1773 /// * `o` - The string, or nothing.
1774 fn opt(&mut self, o: &Option<String>) -> Dat {
1775 match o {
1776 Some(s) => self.one(s),
1777 None => Dat::Empty,
1778 }
1779 }
1780}
1781
1782// ┌───────────────────────────────────────────────────────────────┐
1783// │ Where the journal lives │
1784// └───────────────────────────────────────────────────────────────┘
1785
1786/// The hand's own journal directory, outside every fence it will ever apply.
1787///
1788/// `DAIMOND_HAND_JOURNAL_DIR` overrides it, for an operator keeping the record
1789/// on a different volume. Otherwise it is the platform's data directory, which
1790/// belongs to the hand and to no Diamond -- see the module doc for why that
1791/// distinction is the whole point.
1792///
1793/// # Returns
1794/// The directory, or an error naming the variable that would have said where it
1795/// is. There is deliberately no fallback to the working directory: a journal
1796/// written somewhere nobody expected is worse than one that refuses to start.
1797pub fn default_dir() -> Outcome<PathBuf> {
1798 if let Ok(v) = std::env::var("DAIMOND_HAND_JOURNAL_DIR") {
1799 if !v.is_empty() {
1800 return Ok(PathBuf::from(v));
1801 }
1802 }
1803 let tail = Path::new("daimond").join("hand").join("journal");
1804 match crate::os() {
1805 "macos" => match std::env::var("HOME") {
1806 Ok(h) if !h.is_empty() => Ok(Path::new(&h)
1807 .join("Library")
1808 .join("Application Support")
1809 .join(&tail)),
1810 _ => Err(err!(
1811 "HOME is not set, so the hand cannot say where its journal \
1812 belongs. Set DAIMOND_HAND_JOURNAL_DIR.";
1813 Missing, Configuration, Path)),
1814 },
1815 "windows" => match std::env::var("APPDATA") {
1816 Ok(a) if !a.is_empty() => Ok(Path::new(&a).join(&tail)),
1817 _ => Err(err!(
1818 "APPDATA is not set, so the hand cannot say where its journal \
1819 belongs. Set DAIMOND_HAND_JOURNAL_DIR.";
1820 Missing, Configuration, Path)),
1821 },
1822 _ => {
1823 if let Ok(x) = std::env::var("XDG_DATA_HOME") {
1824 if !x.is_empty() {
1825 return Ok(Path::new(&x).join(&tail));
1826 }
1827 }
1828 match std::env::var("HOME") {
1829 Ok(h) if !h.is_empty() => Ok(Path::new(&h)
1830 .join(".local")
1831 .join("share")
1832 .join(&tail)),
1833 _ => Err(err!(
1834 "Neither XDG_DATA_HOME nor HOME is set, so the hand cannot \
1835 say where its journal belongs. Set \
1836 DAIMOND_HAND_JOURNAL_DIR.";
1837 Missing, Configuration, Path)),
1838 }
1839 },
1840 }
1841}
1842
1843/// A path with `.` and `..` resolved textually, and symlinks resolved where the
1844/// path exists.
1845///
1846/// Canonicalisation is attempted first because a symlinked grant pointing at the
1847/// journal would defeat a purely textual comparison. Where the path does not
1848/// exist yet -- a fence root for a directory a command is about to create -- the
1849/// textual form is all there is, and that residual is noted on
1850/// [`Journal::check_fence`].
1851///
1852/// # Arguments
1853/// * `p` - The path to settle.
1854fn settle(p: &Path) -> PathBuf {
1855 if let Ok(c) = fs::canonicalize(p) {
1856 return c;
1857 }
1858 let mut out = PathBuf::new();
1859 for c in p.components() {
1860 match c {
1861 Component::CurDir => {},
1862 Component::ParentDir => { out.pop(); },
1863 other => out.push(other.as_os_str()),
1864 }
1865 }
1866 out
1867}
1868
1869/// Whether `child` is `root` or sits beneath it.
1870///
1871/// Compared by path component, so `/home/u/database` is not beneath
1872/// `/home/u/dat`.
1873///
1874/// # Arguments
1875/// * `child` - The path in question.
1876/// * `root` - The root it might sit under.
1877fn is_inside(child: &Path, root: &Path) -> bool {
1878 let c = settle(child);
1879 let r = settle(root);
1880 c == r || c.starts_with(&r)
1881}
1882
1883// ┌───────────────────────────────────────────────────────────────┐
1884// │ The journal │
1885// └───────────────────────────────────────────────────────────────┘
1886
1887/// When an entry reaches the disk.
1888///
1889/// See the module doc for the reasoning; the short of it is that `Os` survives
1890/// the failure that actually happens, and the rest is opt-in.
1891#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1892pub enum Durability {
1893 /// Written to the operating system as each entry is made, not `fsync`ed.
1894 ///
1895 /// Survives the hand crashing or being killed. A power cut may lose the
1896 /// tail. This is the default.
1897 Os,
1898 /// `fsync`ed after every entry.
1899 ///
1900 /// Survives a power cut, and puts a disk round trip in the command loop.
1901 Sync,
1902 /// Held in memory until `n` entries have accrued or [`Journal::flush`] is
1903 /// called.
1904 ///
1905 /// **Lossy on a crash**, and lossy in the worst way: what is lost is the last
1906 /// thing that happened, which is the thing you most want to read afterwards.
1907 /// For bulk replay only.
1908 Batched(usize),
1909}
1910
1911impl Default for Durability {
1912 fn default() -> Self {
1913 Self::Os
1914 }
1915}
1916
1917/// How a journal is set up.
1918#[derive(Clone, Debug)]
1919pub struct Cfg {
1920 /// Where the files live. Must be outside every fence -- see the module doc.
1921 pub dir: PathBuf,
1922 /// The size at which a file rolls over.
1923 pub max_bytes: u64,
1924 /// When an entry reaches the disk.
1925 pub durability: Durability,
1926 /// Where a timestamp comes from.
1927 pub clock: Clock,
1928}
1929
1930impl Cfg {
1931
1932 /// A configuration writing to a given directory, with the defaults.
1933 ///
1934 /// # Arguments
1935 /// * `dir` - Where the files live.
1936 pub fn at<P: AsRef<Path>>(dir: P) -> Self {
1937 Self {
1938 dir: dir.as_ref().to_path_buf(),
1939 max_bytes: DEFAULT_MAX_BYTES,
1940 durability: Durability::default(),
1941 clock: Clock::default(),
1942 }
1943 }
1944}
1945
1946/// How far a chain has got: the next sequence number and the head hash.
1947#[derive(Clone, Debug, Eq, PartialEq)]
1948pub struct Chain {
1949 /// The sequence number the next entry will carry.
1950 pub seq: u64,
1951 /// The hash the next entry will point at.
1952 pub head: String,
1953}
1954
1955impl Chain {
1956
1957 /// The state a chain starts in, pointing at nothing.
1958 pub fn genesis() -> Self {
1959 Self {
1960 seq: 0,
1961 head: GENESIS.to_string(),
1962 }
1963 }
1964}
1965
1966/// The append-only record.
1967///
1968/// Deliberately neither `Clone` nor shareable: one writer, one file, one chain.
1969/// A second writer would interleave entries and the `prev` links would stop
1970/// meaning what they say.
1971pub struct Journal {
1972 /// How it was set up.
1973 cfg: Cfg,
1974 /// The file being appended to.
1975 file: File,
1976 /// Its path.
1977 path: PathBuf,
1978 /// Its index in the rotation.
1979 idx: u32,
1980 /// How far the chain has got.
1981 chain: Chain,
1982 /// How many bytes the current file holds, including anything buffered.
1983 bytes: u64,
1984 /// The UTC day the current file was opened on.
1985 day: i64,
1986 /// Entries written but not yet handed to the operating system.
1987 buf: String,
1988 /// How many entries `buf` holds.
1989 pending: usize,
1990 /// The exclusive lock on the directory, held for as long as this is.
1991 ///
1992 /// Kept as a field and never read: dropping it closes the descriptor, and
1993 /// closing the descriptor is what releases the lock. One writer, one file,
1994 /// one chain -- the review interleaved two hands on one directory and broke
1995 /// the chain permanently, and Chrome will launch more than one host.
1996 _lock: File,
1997}
1998
1999impl Journal {
2000
2001 /// Opens the journal, continuing whatever chain is already there.
2002 ///
2003 /// Only the newest file is read, which is what resuming needs; checking the
2004 /// whole history is [`verify_dir`]'s job and not something to do on every
2005 /// launch. Where that newest file does not verify to its end -- a torn write
2006 /// from a kill, or an edit -- the damaged file is **left exactly as it is**
2007 /// and a fresh one is started whose first entry records the break.
2008 /// Truncating the evidence to tidy up would be the one unforgivable thing a
2009 /// journal could do.
2010 ///
2011 /// # Arguments
2012 /// * `cfg` - Where the files live and how they are written.
2013 ///
2014 /// # Returns
2015 /// The open journal, or an error where the directory cannot be made or read.
2016 pub fn open(cfg: Cfg) -> Outcome<Self> {
2017 res!(ensure_dir(&cfg.dir));
2018 let now = res!(cfg.clock.now_ms());
2019 // The journal's own furniture is moved aside where it is not a plain
2020 // file, before anything depends on being able to open it. A directory
2021 // named `lock` or `head.json` would otherwise stop the hand for good,
2022 // which is the shape of failure the review found and named.
2023 let mut told = res!(clear_furniture(&cfg.dir, now));
2024 let lock = res!(take_lock(&cfg.dir));
2025
2026 let mark = res!(read_mark(&cfg.dir));
2027
2028 // Anything named like a journal file that the journal did not write is
2029 // moved aside before a single byte is appended, so the writer's idea of
2030 // "the newest file" is never steered by a plant.
2031 let mut surv = res!(survey(&cfg.dir));
2032 let vouched = match &mark {
2033 MarkState::At(m) => Some(m.files),
2034 _ => None,
2035 };
2036 let mut keep = Vec::new();
2037 for (idx, path) in surv.files.drain(..) {
2038 match vouched {
2039 Some(v) if idx > v => surv.stray.push((path, Shape::File)),
2040 _ => keep.push((idx, path)),
2041 }
2042 }
2043 for (path, shape) in surv.stray.iter() {
2044 let to = res!(quarantine(path, now));
2045 told.push(Event::Stray {
2046 name: match path.file_name() {
2047 Some(n) => n.to_string_lossy().to_string(),
2048 None => path.to_string_lossy().to_string(),
2049 },
2050 shape: shape.name().to_string(),
2051 moved_to: to,
2052 });
2053 }
2054
2055 // Where the record had got to, as the files say it.
2056 //
2057 // The newest file with anything in it is the one that says so. A newer
2058 // *empty* one is a file created a moment before the hand died, and
2059 // reading the chain from it would say the history had gone back to
2060 // nothing -- a false alarm on a log whose whole value is that its alarms
2061 // mean something.
2062 let mut at = None;
2063 for (i, (_, p)) in keep.iter().enumerate() {
2064 let len = match fs::metadata(p) {
2065 Ok(m) => m.len(),
2066 Err(_) => 0,
2067 };
2068 if len > 0 {
2069 at = Some(i);
2070 }
2071 }
2072 let (found, scan) = match at.and_then(|i| keep.get(i)) {
2073 None => (Chain::genesis(), None),
2074 Some((_, path)) => {
2075 let s = res!(scan_file(path));
2076 (s.chain.clone(), Some(s))
2077 },
2078 };
2079 let damaged = match &scan {
2080 Some(s) => s.torn_bytes > 0 || s.broken_at.is_some(),
2081 None => false,
2082 };
2083
2084 // And where the mark says it had got to, which the files cannot shorten.
2085 let (start, gap) = match &mark {
2086 MarkState::At(m) => {
2087 if found.seq < m.seq {
2088 (Chain { seq: m.seq, head: m.head.clone() }, Some(fmt!(
2089 "the record reaches entry {} and the mark vouches for {}, \
2090 so {} entries have been removed from the end of the \
2091 history", found.seq, m.seq, m.seq - found.seq)))
2092 } else if found.seq == m.seq && found.head != m.head {
2093 (Chain { seq: m.seq, head: m.head.clone() }, Some(
2094 "the record is the length the mark vouches for and does \
2095 not end in the entry it names, so the history has been \
2096 rewritten".to_string()))
2097 } else {
2098 (found.clone(), None)
2099 }
2100 },
2101 MarkState::Damaged(why) => (found.clone(), Some(fmt!(
2102 "the high-water mark is there and {}, so nothing vouches for how \
2103 far the record had got", why))),
2104 MarkState::Absent => {
2105 if found.seq > 0 {
2106 (found.clone(), Some(
2107 "the high-water mark is missing, so nothing vouches for \
2108 how far the record had got".to_string()))
2109 } else {
2110 (found.clone(), None)
2111 }
2112 },
2113 };
2114
2115 // A damaged file is left exactly as it is and a new one started, and so
2116 // is a file the mark says has lost entries: appending to either would
2117 // put a break in the middle of a file rather than at a boundary a reader
2118 // can see.
2119 let fresh = keep.is_empty() || damaged || gap.is_some();
2120 let (idx, path, bytes, day) = if fresh {
2121 let highest = keep.last().map(|(n, _)| *n);
2122 let vouched = match &mark {
2123 MarkState::At(m) => Some(m.files),
2124 _ => None,
2125 };
2126 let next = match (highest, vouched) {
2127 (None, None) => 0,
2128 (None, Some(v)) => if v == 0 { 0 } else { res!(next_idx(v)) },
2129 (Some(h), None) => res!(next_idx(h)),
2130 (Some(h), Some(v)) => res!(next_idx(h.max(v))),
2131 };
2132 let path = cfg.dir.join(file_name(next));
2133 (next, path, 0u64, day_of(now))
2134 } else {
2135 match keep.last() {
2136 Some((n, p)) => {
2137 // A file resumed on a later day takes its day from its last
2138 // entry, so the rotation the doc describes actually happens
2139 // when the hand was not running as the day turned.
2140 let (bytes, when) = match (&scan, at == Some(keep.len() - 1)) {
2141 // The newest file is the one the chain was read from.
2142 (Some(s), true) => (s.bytes, match s.last_ts {
2143 Some(t) => t,
2144 None => now,
2145 }),
2146 // The newest file is empty, so it is where the next
2147 // entry goes and it carries no day of its own yet.
2148 _ => (0u64, now),
2149 };
2150 (*n, p.clone(), bytes, day_of(when))
2151 },
2152 None => (0, cfg.dir.join(file_name(0)), 0u64, day_of(now)),
2153 }
2154 };
2155
2156 let file = res!(open_append(&path));
2157 let mut j = Self {
2158 cfg,
2159 file,
2160 path,
2161 idx,
2162 chain: start,
2163 bytes,
2164 day,
2165 buf: String::new(),
2166 pending: 0,
2167 _lock: lock,
2168 };
2169
2170 // The mark is written before the first entry, so the window in which a
2171 // fresh directory has files and no mark is as short as it can be.
2172 res!(j.stamp());
2173
2174 for ev in told.iter() {
2175 res!(j.append(ev));
2176 }
2177 if damaged {
2178 if let Some(s) = &scan {
2179 if let Some((n, _)) = at.and_then(|i| keep.get(i)) {
2180 res!(j.append(&Event::Rotated {
2181 from_file: file_name(*n),
2182 from_seq: found.seq.saturating_sub(1),
2183 from_entry: found.head.clone(),
2184 torn_bytes: s.torn_bytes,
2185 broken_at: s.broken_at,
2186 }));
2187 }
2188 }
2189 }
2190 if let Some(note) = gap {
2191 let (eseq, ehead) = match &mark {
2192 MarkState::At(m) => (m.seq, m.head.clone()),
2193 _ => (found.seq, found.head.clone()),
2194 };
2195 res!(j.append(&Event::Gap {
2196 expect_seq: eseq,
2197 expect_entry: ehead,
2198 found_seq: found.seq,
2199 found_entry: found.head.clone(),
2200 note,
2201 }));
2202 }
2203 Ok(j)
2204 }
2205
2206 /// Appends an event and returns its entry hash.
2207 ///
2208 /// # Arguments
2209 /// * `ev` - What happened.
2210 ///
2211 /// # Returns
2212 /// The new head of the chain, which a caller may publish, or an error where
2213 /// the event has no canonical form or the file cannot be written.
2214 pub fn append(&mut self, ev: &Event) -> Outcome<String> {
2215 let ts = res!(self.cfg.clock.now_ms());
2216 res!(self.maybe_rotate(ts));
2217 let hash = res!(self.push(ts, ev));
2218 let due = match self.cfg.durability {
2219 Durability::Os | Durability::Sync => true,
2220 Durability::Batched(n) => self.pending >= n.max(1),
2221 };
2222 if due {
2223 res!(self.flush());
2224 }
2225 Ok(hash)
2226 }
2227
2228 /// Hands everything buffered to the operating system, and to the disk where
2229 /// [`Durability::Sync`] was asked for.
2230 ///
2231 /// # Returns
2232 /// An error where the write fails.
2233 pub fn flush(&mut self) -> Outcome<()> {
2234 if !self.buf.is_empty() {
2235 res!(self.file.write_all(self.buf.as_bytes()), IO, File);
2236 self.buf.clear();
2237 self.pending = 0;
2238 }
2239 res!(self.file.flush(), IO, File);
2240 if let Durability::Sync = self.cfg.durability {
2241 res!(self.file.sync_data(), IO, File);
2242 }
2243 res!(self.reachable());
2244 res!(self.stamp());
2245 Ok(())
2246 }
2247
2248 /// Refuses to call an entry written where nothing can read it back.
2249 ///
2250 /// A descriptor outlives the name it was opened by. After the journal
2251 /// directory was removed mid-run the review saw `append` return `Ok` and the
2252 /// refusal it had just recorded vanish -- the write reached an inode with no
2253 /// path to it. Under "journal before acting" that is not a cosmetic defect:
2254 /// the caller acts on the strength of an entry that does not exist.
2255 ///
2256 /// # Returns
2257 /// An error where the file the journal holds open is no longer the file its
2258 /// own path names.
2259 fn reachable(&self) -> Outcome<()> {
2260 let named = match fs::metadata(&self.path) {
2261 Ok(m) => m,
2262 Err(e) => return Err(err!(e,
2263 "The journal wrote to '{}' and that path no longer names a file, \
2264 so the entry has reached nothing anybody can read back.",
2265 self.path.display();
2266 IO, File, Path)),
2267 };
2268 #[cfg(unix)]
2269 {
2270 use std::os::unix::fs::MetadataExt;
2271 let held = res!(self.file.metadata(), IO, File);
2272 if held.dev() != named.dev() || held.ino() != named.ino() {
2273 return Err(err!(
2274 "The journal is writing to a file that '{}' no longer names, \
2275 so the entry has reached nothing anybody can read back.",
2276 self.path.display();
2277 IO, File, Path, Security));
2278 }
2279 }
2280 #[cfg(not(unix))]
2281 {
2282 if named.len() < self.bytes {
2283 return Err(err!(
2284 "The file at '{}' is shorter than the journal has written to \
2285 it, so the entry has reached nothing anybody can read back.",
2286 self.path.display();
2287 IO, File, Path));
2288 }
2289 }
2290 Ok(())
2291 }
2292
2293 /// Writes the high-water mark, so the record cannot be shortened silently.
2294 ///
2295 /// Written *after* the entry, never before: the mark is a lower bound on
2296 /// what must exist, so a crash between the two leaves the files ahead of the
2297 /// mark, which is recoverable, rather than the mark ahead of the files,
2298 /// which would read as tampering.
2299 fn stamp(&self) -> Outcome<()> {
2300 let sync = matches!(self.cfg.durability, Durability::Sync);
2301 write_mark(&self.cfg.dir, &Mark {
2302 seq: self.chain.seq,
2303 files: self.idx,
2304 head: self.chain.head.clone(),
2305 }, sync)
2306 }
2307
2308 /// Builds one entry and buffers it, advancing the chain.
2309 ///
2310 /// Shared by [`Journal::append`] and the rotation entry, so a rolled file's
2311 /// first line is chained by exactly the same code as every other line.
2312 ///
2313 /// # Arguments
2314 /// * `ts` - The instant to stamp it with.
2315 /// * `ev` - What happened.
2316 fn push(&mut self, ts: i64, ev: &Event) -> Outcome<String> {
2317 let body = res!(ev.body());
2318 let line = res!(build_line(self.chain.seq, ts, ev.kind(), &body, &self.chain.head));
2319 let hash = match entry_hash_of(&line) {
2320 Some(h) => h.to_string(),
2321 None => return Err(err!(
2322 "The entry just built does not end in a hash, which is a fault \
2323 in the journal itself rather than in what it was asked to \
2324 record.";
2325 Bug, Encode)),
2326 };
2327 self.buf.push_str(&line);
2328 self.buf.push('\n');
2329 self.bytes += (line.len() + 1) as u64;
2330 self.pending += 1;
2331 self.chain.seq += 1;
2332 self.chain.head = hash.clone();
2333 Ok(hash)
2334 }
2335
2336 /// Starts a new file where the current one is full or the UTC day has turned.
2337 ///
2338 /// # Arguments
2339 /// * `ts` - The instant the next entry will carry.
2340 fn maybe_rotate(&mut self, ts: i64) -> Outcome<()> {
2341 let full = self.bytes >= self.cfg.max_bytes;
2342 let aged = day_of(ts) != self.day;
2343 if !full && !aged {
2344 return Ok(());
2345 }
2346 // Nothing to roll where the file is still empty; rolling would produce a
2347 // file whose only content is the note saying it was produced.
2348 if self.bytes == 0 {
2349 self.day = day_of(ts);
2350 return Ok(());
2351 }
2352 res!(self.flush());
2353
2354 let ev = Event::Rotated {
2355 from_file: file_name(self.idx),
2356 from_seq: self.chain.seq.saturating_sub(1),
2357 from_entry: self.chain.head.clone(),
2358 torn_bytes: 0,
2359 broken_at: None,
2360 };
2361
2362 self.idx = res!(next_idx(self.idx));
2363 self.path = self.cfg.dir.join(file_name(self.idx));
2364 self.file = res!(open_append(&self.path));
2365 self.bytes = 0;
2366 self.day = day_of(ts);
2367
2368 // The head entry stitches the files together, and is chained like any
2369 // other, so the boundary is not a special case for the verifier.
2370 res!(self.push(ts, &ev));
2371 res!(self.flush());
2372 Ok(())
2373 }
2374
2375 /// Refuses a fence that would let a command reach the journal.
2376 ///
2377 /// This is the defensive check the module doc argues for. A grant of write
2378 /// access over the journal is a grant to rewrite the record of what the grant
2379 /// was used for, and no amount of hashing survives it, so the hand declines
2380 /// to run the command at all rather than run it and record it somewhere the
2381 /// command can edit.
2382 ///
2383 /// Read-only grants are refused too. The journal names every command every
2384 /// Diamond has run; handing one Diamond a reader over that crosses the
2385 /// compartment boundary the fence exists to draw.
2386 ///
2387 /// A root that is not absolute is refused rather than compared, because a
2388 /// comparison that cannot be resolved must not be allowed to pass quietly.
2389 ///
2390 /// Residual: a root that does not exist yet is compared textually, so a
2391 /// symlink created between this check and the command's first write is not
2392 /// caught here. [`Journal::fence_guard`] closes that, by denying the journal
2393 /// root outright at the layer the kernel enforces.
2394 ///
2395 /// # Arguments
2396 /// * `fence` - The specification about to be applied.
2397 ///
2398 /// # Returns
2399 /// An error naming the offending root, or nothing.
2400 pub fn check_fence(&self, fence: &FenceSpec) -> Outcome<()> {
2401 check_fence_at(&self.cfg.dir, fence)
2402 }
2403
2404 /// The same fence with the journal root denied outright.
2405 ///
2406 /// Belt and braces over [`Journal::check_fence`]: that one refuses a grant
2407 /// naming the journal, this one makes sure the kernel would refuse the access
2408 /// even if a root were widened later or reached through a link.
2409 ///
2410 /// # Arguments
2411 /// * `fence` - The specification to harden.
2412 pub fn fence_guard(&self, fence: &FenceSpec) -> FenceSpec {
2413 let mut out = fence.clone();
2414 let dir = self.cfg.dir.to_string_lossy().to_string();
2415 if !out.deny.iter().any(|d| d == &dir) {
2416 out.deny.push(dir);
2417 }
2418 out
2419 }
2420
2421 /// The file currently being appended to.
2422 pub fn path(&self) -> &Path {
2423 &self.path
2424 }
2425
2426 /// The directory the record lives in.
2427 pub fn dir(&self) -> &Path {
2428 &self.cfg.dir
2429 }
2430
2431 /// How far the chain has got: the next sequence number and the head hash.
2432 pub fn chain(&self) -> &Chain {
2433 &self.chain
2434 }
2435}
2436
2437impl Drop for Journal {
2438 /// Hands anything buffered over on the way out, and moves the mark with it.
2439 ///
2440 /// A best effort: a failure here has nowhere to be reported, which is one
2441 /// more reason [`Durability::Os`] rather than [`Durability::Batched`] is the
2442 /// default.
2443 fn drop(&mut self) {
2444 if !self.buf.is_empty() {
2445 let _ = self.file.write_all(self.buf.as_bytes());
2446 let _ = self.file.flush();
2447 self.buf.clear();
2448 self.pending = 0;
2449 }
2450 let _ = self.stamp();
2451 }
2452}
2453
2454/// The next index in the rotation, or a refusal to leave the name format.
2455///
2456/// # Arguments
2457/// * `idx` - The index now in use.
2458fn next_idx(idx: u32) -> Outcome<u32> {
2459 if idx >= MAX_IDX {
2460 return Err(err!(
2461 "The journal has reached '{}', the last name its format allows. The \
2462 next file would be named outside the format, where no verifier would \
2463 ever read it, so the hand stops instead. Archive the directory and \
2464 start a fresh one.", file_name(idx);
2465 LimitReached, File, Path));
2466 }
2467 Ok(idx + 1)
2468}
2469
2470/// Takes the directory's exclusive lock, or says who has it.
2471///
2472/// `flock` through `std::fs::File::try_lock`, which is safe Rust and needs no
2473/// dependency, and which the platform releases when the descriptor closes --
2474/// including when the process dies, which a lock file holding a process id does
2475/// not manage. The lock is per open file description, so two journals in one
2476/// process contend exactly as two processes do.
2477///
2478/// # Arguments
2479/// * `dir` - The journal directory.
2480fn take_lock(dir: &Path) -> Outcome<File> {
2481 let path = dir.join(LOCK_FILE);
2482 let mut o = OpenOptions::new();
2483 o.create(true).write(true).truncate(false);
2484 #[cfg(unix)]
2485 {
2486 use std::os::unix::fs::OpenOptionsExt;
2487 o.mode(0o600);
2488 }
2489 let f = res!(o.open(&path), IO, File, Path);
2490 // A held lock is held until its owner goes away, so a lock that is genuinely
2491 // another hand's is still refused after the last try. What the tries are
2492 // for is the other thing that holds a descriptor briefly: a `fork` anywhere
2493 // in the process duplicates every open descriptor until the matching `exec`
2494 // closes it, and the hand forks for every command it runs. Refusing to
2495 // journal because a sibling command was starting would be a fault invented
2496 // by the fix.
2497 let mut last = None;
2498 for n in 0..LOCK_TRIES {
2499 match f.try_lock() {
2500 Ok(()) => return Ok(f),
2501 Err(std::fs::TryLockError::WouldBlock) => last = None,
2502 Err(std::fs::TryLockError::Error(e)) => last = Some(e),
2503 }
2504 if n + 1 < LOCK_TRIES {
2505 std::thread::sleep(std::time::Duration::from_millis(LOCK_WAIT_MS));
2506 }
2507 }
2508 match last {
2509 Some(e) => Err(err!(e,
2510 "The journal's lock at '{}' cannot be taken, and the hand will not \
2511 write a record it cannot claim sole authorship of.", path.display();
2512 IO, Lock, File)),
2513 None => Err(err!(
2514 "Daimond is already open in another browser window on this computer, and only one \
2515 of them can keep the machine hand's record at a time -- two writers would \
2516 interleave their entries and the record would stop meaning what it says. Close the \
2517 other window and try again. The record is at '{}'.", dir.display();
2518 Conflict, Lock, File)),
2519 }
2520}
2521
2522/// Moves the journal's own furniture aside where it is not a plain file.
2523///
2524/// The lock, the mark and the mark's temporary file are opened by name on every
2525/// launch. A directory or a symbolic link with one of those names makes the
2526/// open fail, and "journal before acting" turns a failed open into a hand that
2527/// can never run anything again -- a denial of service any process running as
2528/// the user could arrange with one `mkdir`.
2529///
2530/// # Arguments
2531/// * `dir` - The journal directory.
2532/// * `now` - The instant, for the name the stray is given.
2533///
2534/// # Returns
2535/// An event for each thing moved, to be recorded once the journal is open.
2536fn clear_furniture(dir: &Path, now: i64) -> Outcome<Vec<Event>> {
2537 let mut out = Vec::new();
2538 for name in [LOCK_FILE, MARK_FILE, MARK_TMP] {
2539 let path = dir.join(name);
2540 let md = match fs::symlink_metadata(&path) {
2541 Ok(m) => m,
2542 Err(_) => continue,
2543 };
2544 if md.file_type().is_file() {
2545 continue;
2546 }
2547 let shape = if md.file_type().is_dir() { Shape::Directory } else { Shape::Other };
2548 let to = res!(quarantine(&path, now));
2549 out.push(Event::Stray {
2550 name: name.to_string(),
2551 shape: shape.name().to_string(),
2552 moved_to: to,
2553 });
2554 }
2555 Ok(out)
2556}
2557
2558/// Moves something named like a journal file out of the way, without losing it.
2559///
2560/// # Arguments
2561/// * `path` - What was found.
2562/// * `ts` - The instant, so two of the same name do not collide.
2563///
2564/// # Returns
2565/// What it is now called.
2566fn quarantine(path: &Path, ts: i64) -> Outcome<String> {
2567 let name = match path.file_name() {
2568 Some(n) => n.to_string_lossy().to_string(),
2569 None => return Err(err!(
2570 "'{}' has no file name to move.", path.display(); Bug, Path)),
2571 };
2572 let dir = match path.parent() {
2573 Some(d) => d.to_path_buf(),
2574 None => return Err(err!(
2575 "'{}' has no directory to move within.", path.display(); Bug, Path)),
2576 };
2577 let mut to = fmt!("{}{}.{}", STRAY_STEM, name, ts);
2578 let mut nth = 0u32;
2579 while dir.join(&to).exists() {
2580 nth += 1;
2581 to = fmt!("{}{}.{}.{}", STRAY_STEM, name, ts, nth);
2582 if nth > 1_000 {
2583 return Err(err!(
2584 "'{}' cannot be moved aside: a thousand names like it are taken.",
2585 path.display(); File, Path));
2586 }
2587 }
2588 res!(fs::rename(path, dir.join(&to)), IO, File, Path);
2589 Ok(to)
2590}
2591
2592/// Refuses a fence that would let a command reach a journal directory.
2593///
2594/// Split out from [`Journal::check_fence`] so the rule can be applied before a
2595/// journal has been opened, and so it can be tested on its own.
2596///
2597/// # Arguments
2598/// * `dir` - Where the journal lives.
2599/// * `fence` - The specification about to be applied.
2600pub fn check_fence_at(dir: &Path, fence: &FenceSpec) -> Outcome<()> {
2601 for (kind, roots) in [("rw", &fence.rw), ("ro", &fence.ro)] {
2602 for r in roots.iter() {
2603 let p = Path::new(r);
2604 if !p.is_absolute() {
2605 return Err(err!(
2606 "The fence names '{}' as a {} root, which is not an absolute \
2607 path. The hand cannot tell whether it covers the journal at \
2608 '{}', and a comparison it cannot make is not one it will \
2609 assume passed.", r, kind, dir.display();
2610 Invalid, Input, Path, Security));
2611 }
2612 if is_inside(dir, p) {
2613 return Err(err!(
2614 "The fence grants '{}' access to '{}', and the journal lives \
2615 at '{}', inside it. A command that can reach its own record \
2616 can delete the entry that says it was refused, so the hand \
2617 will not run under this fence. Move the journal, or narrow \
2618 the grant.", kind, r, dir.display();
2619 Invalid, Input, Path, Security));
2620 }
2621 }
2622 }
2623 Ok(())
2624}
2625
2626/// Restricts a journal directory to its owner, where the platform has the notion.
2627///
2628/// # Arguments
2629/// * `dir` - The directory the journal made for itself.
2630#[cfg(unix)]
2631fn tighten(dir: &Path) -> Outcome<()> {
2632 use std::os::unix::fs::PermissionsExt;
2633 let md = res!(fs::metadata(dir), IO, File, Path);
2634 let mut perm = md.permissions();
2635 perm.set_mode(0o700);
2636 res!(fs::set_permissions(dir, perm), IO, File, Path);
2637 Ok(())
2638}
2639
2640/// Restricts a journal directory to its owner, where the platform has the notion.
2641///
2642/// # Arguments
2643/// * `dir` - The directory the journal made for itself.
2644#[cfg(not(unix))]
2645fn tighten(_dir: &Path) -> Outcome<()> {
2646 Ok(())
2647}
2648
2649/// Whether a directory holds nothing but the journal's own furniture.
2650///
2651/// The test for "may this be tightened to 0700": a directory the journal
2652/// plainly owns. `DAIMOND_HAND_JOURNAL_DIR` is an operator's variable and can
2653/// name anything, and the old code chmodded whatever it named -- point it at a
2654/// home directory and the home directory became 0700.
2655///
2656/// # Arguments
2657/// * `dir` - The directory.
2658fn is_ours(dir: &Path) -> Outcome<bool> {
2659 let rd = res!(fs::read_dir(dir), IO, File, Path);
2660 for ent in rd {
2661 let ent = res!(ent, IO, File);
2662 let name = ent.file_name().to_string_lossy().to_string();
2663 // `root.txt` counts, because the hand puts it here and the installer
2664 // writes it here. Without it, the directory the documented install
2665 // produces is one the hand refuses to tighten and then refuses to use.
2666 let mine = (name.starts_with(FILE_STEM) && name.ends_with(FILE_EXT))
2667 || name.starts_with(STRAY_STEM)
2668 || name == MARK_FILE
2669 || name == MARK_TMP
2670 || name == LOCK_FILE
2671 || name == crate::ROOT_FILE;
2672 if !mine {
2673 return Ok(false);
2674 }
2675 }
2676 Ok(true)
2677}
2678
2679/// Whether a directory is readable by anyone but its owner.
2680///
2681/// # Arguments
2682/// * `dir` - The directory.
2683#[cfg(unix)]
2684fn too_open(dir: &Path) -> Outcome<bool> {
2685 use std::os::unix::fs::PermissionsExt;
2686 let md = res!(fs::metadata(dir), IO, File, Path);
2687 Ok(md.permissions().mode() & 0o077 != 0)
2688}
2689
2690/// Whether a directory is readable by anyone but its owner.
2691///
2692/// # Arguments
2693/// * `dir` - The directory.
2694#[cfg(not(unix))]
2695fn too_open(_dir: &Path) -> Outcome<bool> {
2696 Ok(false)
2697}
2698
2699/// Makes sure the journal has a private directory to write in.
2700///
2701/// Creates it where it is absent and tightens *that*. Where it is already
2702/// there, it is tightened only if it holds nothing but the journal's own files;
2703/// otherwise a directory open to other users is a refusal, naming the variable
2704/// that chose it, rather than a silent re-permissioning of somebody's data.
2705///
2706/// # Arguments
2707/// * `dir` - Where the journal is to live.
2708fn ensure_dir(dir: &Path) -> Outcome<()> {
2709 if dir.is_dir() {
2710 if res!(is_ours(dir)) {
2711 res!(tighten(dir));
2712 return Ok(());
2713 }
2714 if res!(too_open(dir)) {
2715 return Err(err!(
2716 "The journal directory '{}' is readable by other users and \
2717 holds files the journal did not write, so the hand will not \
2718 tighten it -- that would re-permission your files, and leaving \
2719 it puts the record of every command every Diamond ran where \
2720 others can read it. Fix: chmod 700 '{}', or point \
2721 DAIMOND_HAND_JOURNAL_DIR at a directory of its own.",
2722 dir.display(), dir.display();
2723 Invalid, Configuration, Path, Security));
2724 }
2725 return Ok(());
2726 }
2727 if let Some(p) = dir.parent() {
2728 if !p.as_os_str().is_empty() {
2729 res!(fs::create_dir_all(p), IO, File, Path);
2730 }
2731 }
2732 match fs::create_dir(dir) {
2733 Ok(()) => {},
2734 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {},
2735 Err(e) => return Err(err!(e, "Creating '{}'.", dir.display(); IO, File, Path)),
2736 }
2737 res!(tighten(dir));
2738 Ok(())
2739}
2740
2741/// Opens a journal file for appending, creating it readable by nobody else.
2742///
2743/// The mode is set at creation *and* after, because the flag only applies to a
2744/// file this call brings into being and a file left behind by an older build
2745/// would keep whatever the umask gave it. 0664 was what the review found: only
2746/// the directory's own 0700 stood between the record and every other user.
2747///
2748/// # Arguments
2749/// * `path` - The file.
2750fn open_append(path: &Path) -> Outcome<File> {
2751 let mut o = OpenOptions::new();
2752 o.create(true).append(true);
2753 #[cfg(unix)]
2754 {
2755 use std::os::unix::fs::OpenOptionsExt;
2756 o.mode(0o600);
2757 }
2758 let f = res!(o.open(path), IO, File, Path);
2759 res!(make_private(path));
2760 Ok(f)
2761}
2762
2763/// Creates or replaces a file readable by nobody else.
2764///
2765/// # Arguments
2766/// * `path` - The file.
2767fn create_private(path: &Path) -> Outcome<File> {
2768 let mut o = OpenOptions::new();
2769 o.create(true).write(true).truncate(true);
2770 #[cfg(unix)]
2771 {
2772 use std::os::unix::fs::OpenOptionsExt;
2773 o.mode(0o600);
2774 }
2775 let f = res!(o.open(path), IO, File, Path);
2776 res!(make_private(path));
2777 Ok(f)
2778}
2779
2780/// Takes group and world access off a file, where the platform has the notion.
2781///
2782/// # Arguments
2783/// * `path` - The file.
2784#[cfg(unix)]
2785fn make_private(path: &Path) -> Outcome<()> {
2786 use std::os::unix::fs::PermissionsExt;
2787 let md = res!(fs::metadata(path), IO, File, Path);
2788 let mut perm = md.permissions();
2789 if perm.mode() & 0o177 != 0 {
2790 perm.set_mode(0o600);
2791 res!(fs::set_permissions(path, perm), IO, File, Path);
2792 }
2793 Ok(())
2794}
2795
2796/// Takes group and world access off a file, where the platform has the notion.
2797///
2798/// # Arguments
2799/// * `path` - The file.
2800#[cfg(not(unix))]
2801fn make_private(_path: &Path) -> Outcome<()> {
2802 Ok(())
2803}
2804
2805/// The name of the journal file with a given index.
2806///
2807/// # Arguments
2808/// * `idx` - Its place in the rotation.
2809fn file_name(idx: u32) -> String {
2810 fmt!("{}{:0width$}{}", FILE_STEM, idx, FILE_EXT, width = FILE_DIGITS)
2811}
2812
2813/// What a directory holds that is named like a journal file.
2814///
2815/// The two lists are the point: the old `journal_files` returned only the names
2816/// it liked and said nothing about the rest, so a planted name was invisible to
2817/// every reader while still steering the writer. Anything named like a journal
2818/// file and not written by the journal now has somewhere to be reported.
2819#[derive(Clone, Debug, Eq, PartialEq)]
2820pub struct Survey {
2821 /// Well-formed journal files, in index order.
2822 pub files: Vec<(u32, PathBuf)>,
2823 /// Named like a journal file, and not one.
2824 pub stray: Vec<(PathBuf, Shape)>,
2825}
2826
2827/// What a stray turned out to be.
2828#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
2829pub enum Shape {
2830 /// A regular file whose name is outside the format.
2831 File,
2832 /// A directory, which would make every read of it an error.
2833 Directory,
2834 /// A symbolic link or anything else, which could redirect a write.
2835 Other,
2836}
2837
2838impl Shape {
2839
2840 /// The word the record uses.
2841 pub fn name(&self) -> &'static str {
2842 match self {
2843 Self::File => "file",
2844 Self::Directory => "directory",
2845 Self::Other => "other",
2846 }
2847 }
2848}
2849
2850/// Everything in a directory named like a journal file, sorted into the two.
2851///
2852/// A name is a journal file's when it is `hand-`, exactly eight digits,
2853/// `.jsonl`, **and it is a regular file**. The last clause is not pedantry: a
2854/// directory with that name made both `open` and `verify_dir` return an error,
2855/// which under "journal before acting" means the hand can never act again; and
2856/// a symbolic link with that name would send the writer wherever the link
2857/// pointed.
2858///
2859/// # Arguments
2860/// * `dir` - Where the files live.
2861pub fn survey(dir: &Path) -> Outcome<Survey> {
2862 let mut files = Vec::new();
2863 let mut stray = Vec::new();
2864 let rd = res!(fs::read_dir(dir), IO, File, Path);
2865 for ent in rd {
2866 let ent = res!(ent, IO, File);
2867 let name = ent.file_name().to_string_lossy().to_string();
2868 if !name.starts_with(FILE_STEM) || !name.ends_with(FILE_EXT) {
2869 continue;
2870 }
2871 let shape = match ent.file_type() {
2872 Ok(t) if t.is_file() => None,
2873 Ok(t) if t.is_dir() => Some(Shape::Directory),
2874 Ok(_) => Some(Shape::Other),
2875 Err(_) => Some(Shape::Other),
2876 };
2877 if let Some(s) = shape {
2878 stray.push((ent.path(), s));
2879 continue;
2880 }
2881 let mid = &name[FILE_STEM.len()..name.len() - FILE_EXT.len()];
2882 if mid.len() != FILE_DIGITS || !mid.bytes().all(|b| b.is_ascii_digit()) {
2883 stray.push((ent.path(), Shape::File));
2884 continue;
2885 }
2886 match mid.parse::<u32>() {
2887 Ok(n) => files.push((n, ent.path())),
2888 Err(_) => stray.push((ent.path(), Shape::File)),
2889 }
2890 }
2891 files.sort_by_key(|(n, _)| *n);
2892 stray.sort();
2893 Ok(Survey { files, stray })
2894}
2895
2896/// Every well-formed journal file in a directory, ordered by index.
2897///
2898/// # Arguments
2899/// * `dir` - Where the files live.
2900pub fn journal_files(dir: &Path) -> Outcome<Vec<(u32, PathBuf)>> {
2901 Ok(res!(survey(dir)).files)
2902}
2903
2904// ┌───────────────────────────────────────────────────────────────┐
2905// │ The high-water mark │
2906// └───────────────────────────────────────────────────────────────┘
2907
2908/// How far the record had got, kept outside the files it describes.
2909///
2910/// # Why this exists
2911///
2912/// A hash chain answers "has any entry been changed?" and cannot, by
2913/// construction, answer "is anything missing from the end?" -- a shorter
2914/// history is a perfectly good chain. The review of 2026-08-02 deleted the
2915/// last three files of a twenty-nine file journal and got `Intact`, then let
2916/// the hand resume and *stay* intact. Blanking the final file did the same.
2917/// Any suffix of history erased silently, which is exactly the property a
2918/// tamper-evident log exists to deny.
2919///
2920/// The mark is one line naming the sequence number the next entry will carry,
2921/// the hash the record had reached, and the highest file index in the rotation.
2922/// It is written after every flush, so it is a **lower bound**: a history
2923/// shorter than the mark is a history that lost something, and a history longer
2924/// than the mark is a crash between an entry reaching the disk and the mark
2925/// following it.
2926///
2927/// # What it does not claim
2928///
2929/// The hand runs as the user, so the user's own account can rewrite the mark
2930/// too. What the mark buys is that truncation is no longer *free*: it takes
2931/// two consistent forgeries instead of a `truncate`. Publishing the head, as
2932/// `verify/transparency.jsonl` publishes its own, is what turns detectability
2933/// into evidence, and that step remains the caller's.
2934#[derive(Clone, Debug, Eq, PartialEq)]
2935pub struct Mark {
2936 /// The sequence number the next entry will carry.
2937 pub seq: u64,
2938 /// The highest file index the rotation has reached.
2939 pub files: u32,
2940 /// The hash of the last entry written.
2941 pub head: String,
2942}
2943
2944/// What reading the mark found.
2945#[derive(Clone, Debug, Eq, PartialEq)]
2946pub enum MarkState {
2947 /// No mark has been written yet.
2948 Absent,
2949 /// A mark is there and does not read back.
2950 Damaged(String),
2951 /// A mark is there.
2952 At(Mark),
2953}
2954
2955/// Where the mark lives.
2956///
2957/// # Arguments
2958/// * `dir` - The journal directory.
2959pub fn mark_path(dir: &Path) -> PathBuf {
2960 dir.join(MARK_FILE)
2961}
2962
2963/// The mark's line, hash and all.
2964///
2965/// Hashed exactly as an entry is, so the same `sed` and `sha256sum` idiom
2966/// checks it and a reader who has learned one has learned both.
2967///
2968/// # Arguments
2969/// * `m` - What to write down.
2970fn mark_line(m: &Mark) -> Outcome<String> {
2971 if !is_hex64(&m.head) {
2972 return Err(err!(
2973 "The mark's head '{}' is not sixty four hexadecimal characters.", m.head;
2974 Bug, Invalid, Encode));
2975 }
2976 let mut line = String::with_capacity(200);
2977 line.push_str("{\"seq\":");
2978 line.push_str(&fmt!("{}", m.seq));
2979 line.push_str(",\"files\":");
2980 line.push_str(&fmt!("{}", m.files));
2981 line.push_str(",\"head\":\"");
2982 line.push_str(&m.head);
2983 line.push('"');
2984 let hash = hex(sha256::digest(line.as_bytes()));
2985 line.push_str(MARK_TAG);
2986 line.push_str(&hash);
2987 line.push_str("\"}");
2988 Ok(line)
2989}
2990
2991/// Reads the mark, saying what it found rather than guessing.
2992///
2993/// # Arguments
2994/// * `dir` - The journal directory.
2995pub fn read_mark(dir: &Path) -> Outcome<MarkState> {
2996 let path = mark_path(dir);
2997 // A mark that cannot be read at all is damaged, not an error: an error here
2998 // makes `verify_dir` unable to reach a verdict, and under "journal before
2999 // acting" a verifier that cannot answer is a hand that cannot act. Planting
3000 // a *directory* called `head.json` would otherwise do exactly that.
3001 let text = match fs::read(&path) {
3002 Ok(b) => b,
3003 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(MarkState::Absent),
3004 Err(e) => return Ok(MarkState::Damaged(fmt!("it cannot be read: {}", e))),
3005 };
3006 let text = match String::from_utf8(text) {
3007 Ok(t) => t,
3008 Err(_) => return Ok(MarkState::Damaged("the mark is not text".to_string())),
3009 };
3010 let line = text.trim_end_matches('\n');
3011 let suffix = MARK_TAG.len() + HEX_LEN + 2;
3012 if line.len() < suffix || !line.ends_with("\"}") {
3013 return Ok(MarkState::Damaged("the mark does not close with a hash".to_string()));
3014 }
3015 let cut = line.len() - suffix;
3016 match line.get(cut..cut + MARK_TAG.len()) {
3017 Some(t) if t == MARK_TAG => {},
3018 _ => return Ok(MarkState::Damaged(
3019 "the mark does not carry its hash where it must".to_string())),
3020 }
3021 let claimed = match line.get(cut + MARK_TAG.len()..line.len() - 2) {
3022 Some(h) if is_hex64(h) => h,
3023 _ => return Ok(MarkState::Damaged(
3024 "the mark's hash is not sixty four hexadecimal characters".to_string())),
3025 };
3026 let covered = match line.get(..cut) {
3027 Some(c) => c,
3028 None => return Ok(MarkState::Damaged(
3029 "the mark's hashed text does not end on a character boundary".to_string())),
3030 };
3031 if hex(sha256::digest(covered.as_bytes())) != claimed {
3032 return Ok(MarkState::Damaged(
3033 "the mark's hash does not match the text it covers".to_string()));
3034 }
3035 // Read the three fields by position, exactly as an entry is read.
3036 let rest = match covered.strip_prefix("{\"seq\":") {
3037 Some(r) => r,
3038 None => return Ok(MarkState::Damaged("the mark does not open with a seq".to_string())),
3039 };
3040 let (seq, rest) = match take_int(rest, ",\"files\":") {
3041 Some(p) => p,
3042 None => return Ok(MarkState::Damaged("the mark's seq is not an integer".to_string())),
3043 };
3044 let (files, rest) = match take_int(rest, ",\"head\":\"") {
3045 Some(p) => p,
3046 None => return Ok(MarkState::Damaged("the mark's file index is not an integer".to_string())),
3047 };
3048 let head = match rest.strip_suffix('"') {
3049 Some(h) if is_hex64(h) => h.to_string(),
3050 _ => return Ok(MarkState::Damaged("the mark's head is not a hash".to_string())),
3051 };
3052 let seq = match u64::try_from(seq) {
3053 Ok(n) => n,
3054 Err(_) => return Ok(MarkState::Damaged("the mark's seq is negative".to_string())),
3055 };
3056 let files = match u32::try_from(files) {
3057 Ok(n) => n,
3058 Err(_) => return Ok(MarkState::Damaged("the mark's file index is out of range".to_string())),
3059 };
3060 Ok(MarkState::At(Mark { seq, files, head }))
3061}
3062
3063/// Writes the mark, atomically, so a torn write never loses the old one.
3064///
3065/// # Arguments
3066/// * `dir` - The journal directory.
3067/// * `m` - What the record has reached.
3068/// * `sync` - Whether to wait for the disk.
3069fn write_mark(dir: &Path, m: &Mark, sync: bool) -> Outcome<()> {
3070 let line = res!(mark_line(m));
3071 let tmp = dir.join(MARK_TMP);
3072 {
3073 let mut f = res!(create_private(&tmp));
3074 res!(f.write_all(line.as_bytes()), IO, File);
3075 res!(f.write_all(b"\n"), IO, File);
3076 res!(f.flush(), IO, File);
3077 if sync {
3078 res!(f.sync_data(), IO, File);
3079 }
3080 }
3081 res!(fs::rename(&tmp, mark_path(dir)), IO, File, Path);
3082 Ok(())
3083}
3084
3085// ┌───────────────────────────────────────────────────────────────┐
3086// │ Building and reading a line │
3087// └───────────────────────────────────────────────────────────────┘
3088
3089/// Builds one journal line, hash and all.
3090///
3091/// The field order is fixed here and nowhere else, and it matters: `entry` must
3092/// be last so that the text it covers is a prefix of the line, which is what
3093/// makes the check doable with `sed` and `sha256sum`.
3094///
3095/// # Arguments
3096/// * `seq` - The entry's place in the chain.
3097/// * `ts` - UTC milliseconds since the Unix epoch.
3098/// * `kind` - The event kind, lower case ASCII from a closed vocabulary.
3099/// * `body` - The event as canonical JSON.
3100/// * `prev` - The previous entry's hash, or [`GENESIS`].
3101fn build_line(seq: u64, ts: i64, kind: &str, body: &str, prev: &str) -> Outcome<String> {
3102 if kind.is_empty() || !kind.bytes().all(|b| b.is_ascii_lowercase() || b == b'_') {
3103 return Err(err!(
3104 "The event kind '{}' is outside the closed vocabulary the line \
3105 format assumes, so it would need escaping and the reader does not \
3106 unescape it.", kind;
3107 Bug, Invalid, Encode));
3108 }
3109 if !is_hex64(prev) {
3110 return Err(err!(
3111 "The previous entry's hash '{}' is not sixty four hexadecimal \
3112 characters, so the chain would not read back.", prev;
3113 Bug, Invalid, Encode));
3114 }
3115 let mut line = String::with_capacity(body.len() + 220);
3116 line.push_str(SEQ_TAG);
3117 line.push_str(&fmt!("{}", seq));
3118 line.push_str(TS_TAG);
3119 line.push_str(&fmt!("{}", ts));
3120 line.push_str(KIND_TAG);
3121 line.push_str(kind);
3122 line.push('"');
3123 line.push_str(BODY_TAG);
3124 line.push_str(body);
3125 line.push_str(PREV_TAG);
3126 line.push_str(prev);
3127 line.push('"');
3128 let hash = hex(sha256::digest(line.as_bytes()));
3129 line.push_str(ENTRY_TAG);
3130 line.push_str(&hash);
3131 line.push_str("\"}");
3132 Ok(line)
3133}
3134
3135/// A digest as lower case hexadecimal.
3136///
3137/// # Arguments
3138/// * `d` - The digest.
3139fn hex(d: [u8; 32]) -> String {
3140 B32(d).to_hex_string()
3141}
3142
3143/// Whether a string is sixty four lower case hexadecimal characters.
3144///
3145/// # Arguments
3146/// * `s` - The candidate.
3147fn is_hex64(s: &str) -> bool {
3148 s.len() == HEX_LEN && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
3149}
3150
3151/// The hash a line claims for itself, by position rather than by parsing.
3152///
3153/// # Arguments
3154/// * `line` - The line, without its newline.
3155fn entry_hash_of(line: &str) -> Option<&str> {
3156 if line.len() < ENTRY_SUFFIX {
3157 return None;
3158 }
3159 let at = line.len() - ENTRY_SUFFIX + ENTRY_TAG.len();
3160 line.get(at..line.len() - 2)
3161}
3162
3163/// What one line of a journal says, once taken apart.
3164#[derive(Clone, Debug, Eq, PartialEq)]
3165pub struct Entry {
3166 /// Its place in the chain.
3167 pub seq: u64,
3168 /// UTC milliseconds since the Unix epoch.
3169 pub ts: i64,
3170 /// The event kind.
3171 pub kind: String,
3172 /// The event as canonical JSON.
3173 pub body: String,
3174 /// The previous entry's hash.
3175 pub prev: String,
3176 /// This entry's hash.
3177 pub entry: String,
3178}
3179
3180/// Takes one line apart, without a JSON parser.
3181///
3182/// Everything is located by fixed markers and fixed widths, so a reader in any
3183/// language can do the same with two string operations. Every slice is taken
3184/// with `get`, so a malformed line is reported rather than splitting a character.
3185///
3186/// # Arguments
3187/// * `line` - One line of a journal file, without its newline.
3188///
3189/// # Returns
3190/// The entry, or an error saying what is wrong with the line.
3191pub fn parse_line(line: &str) -> Outcome<Entry> {
3192 if line.len() < ENTRY_SUFFIX + PREV_SUFFIX + SEQ_TAG.len() {
3193 return Err(err!("The line is too short to be an entry."; Invalid, Decode));
3194 }
3195 if !line.starts_with(SEQ_TAG) {
3196 return Err(err!("The line does not open with {}.", SEQ_TAG; Invalid, Decode));
3197 }
3198 if !line.ends_with("\"}") {
3199 return Err(err!("The line does not close with a hash."; Invalid, Decode));
3200 }
3201 // The hash, and the text it covers.
3202 let cut = line.len() - ENTRY_SUFFIX;
3203 match line.get(cut..cut + ENTRY_TAG.len()) {
3204 Some(t) if t == ENTRY_TAG => {},
3205 _ => return Err(err!(
3206 "The line does not carry {} where it must.", ENTRY_TAG; Invalid, Decode)),
3207 }
3208 let entry = match entry_hash_of(line) {
3209 Some(h) if is_hex64(h) => h.to_string(),
3210 _ => return Err(err!(
3211 "The entry hash is not sixty four hexadecimal characters."; Invalid, Decode)),
3212 };
3213 let covered = match line.get(..cut) {
3214 Some(c) => c,
3215 None => return Err(err!(
3216 "The hashed text does not end on a character boundary."; Invalid, Decode)),
3217 };
3218 // The predecessor, which is the last field of the covered text.
3219 if cut < PREV_SUFFIX {
3220 return Err(err!("The line carries no predecessor."; Invalid, Decode));
3221 }
3222 let pcut = cut - PREV_SUFFIX;
3223 match covered.get(pcut..pcut + PREV_TAG.len()) {
3224 Some(t) if t == PREV_TAG => {},
3225 _ => return Err(err!(
3226 "The line does not carry {} where it must.", PREV_TAG; Invalid, Decode)),
3227 }
3228 let prev = match covered.get(pcut + PREV_TAG.len()..covered.len() - 1) {
3229 Some(h) if is_hex64(h) => h.to_string(),
3230 _ => return Err(err!(
3231 "The previous hash is not sixty four hexadecimal characters."; Invalid, Decode)),
3232 };
3233 // The head fields, read forwards.
3234 let rest = &covered[SEQ_TAG.len()..];
3235 let (seq, rest) = match take_int(rest, TS_TAG) {
3236 Some(p) => p,
3237 None => return Err(err!("The sequence number is not an integer."; Invalid, Decode)),
3238 };
3239 let (ts, rest) = match take_int(rest, KIND_TAG) {
3240 Some(p) => p,
3241 None => return Err(err!("The timestamp is not an integer."; Invalid, Decode)),
3242 };
3243 let kind_end = match rest.find('"') {
3244 Some(n) => n,
3245 None => return Err(err!("The kind is not closed."; Invalid, Decode)),
3246 };
3247 let kind = rest[..kind_end].to_string();
3248 let rest = &rest[kind_end + 1..];
3249 if !rest.starts_with(BODY_TAG) {
3250 return Err(err!(
3251 "The line does not carry {} where it must.", BODY_TAG; Invalid, Decode));
3252 }
3253 // Where `rest` begins within the covered text, so the body's end can be
3254 // taken from the predecessor's start.
3255 let off = covered.len() - rest.len();
3256 if pcut < off + BODY_TAG.len() {
3257 return Err(err!("The body runs past the predecessor."; Invalid, Decode));
3258 }
3259 let body = match rest.get(BODY_TAG.len()..pcut - off) {
3260 Some(b) => b.to_string(),
3261 None => return Err(err!(
3262 "The body does not end on a character boundary."; Invalid, Decode)),
3263 };
3264 let seq = match u64::try_from(seq) {
3265 Ok(n) => n,
3266 Err(_) => return Err(err!("The sequence number is negative."; Invalid, Decode)),
3267 };
3268 Ok(Entry { seq, ts, kind, body, prev, entry })
3269}
3270
3271/// Reads a decimal integer up to a marker, returning it and what follows.
3272///
3273/// # Arguments
3274/// * `s` - The text to read from.
3275/// * `mark` - The marker the integer runs up to.
3276fn take_int<'a>(s: &'a str, mark: &str) -> Option<(i64, &'a str)> {
3277 let end = match s.find(mark) {
3278 Some(n) => n,
3279 None => return None,
3280 };
3281 let n = match s[..end].parse::<i64>() {
3282 Ok(n) => n,
3283 Err(_) => return None,
3284 };
3285 Some((n, &s[end + mark.len()..]))
3286}
3287
3288/// Recomputes an entry's hash from the line it came on.
3289///
3290/// # Arguments
3291/// * `line` - The line, without its newline.
3292pub fn recompute(line: &str) -> Option<String> {
3293 if line.len() < ENTRY_SUFFIX {
3294 return None;
3295 }
3296 let cut = line.len() - ENTRY_SUFFIX;
3297 match line.get(..cut) {
3298 Some(c) => Some(hex(sha256::digest(c.as_bytes()))),
3299 None => None,
3300 }
3301}
3302
3303// ┌───────────────────────────────────────────────────────────────┐
3304// │ Verification │
3305// └───────────────────────────────────────────────────────────────┘
3306
3307/// What a walk of a journal found.
3308#[derive(Clone, Debug, Eq, PartialEq)]
3309pub enum Verdict {
3310 /// Every entry chains onto the one before it.
3311 Intact {
3312 /// How many entries were read.
3313 entries: u64,
3314 /// Where the chain has got to.
3315 chain: Chain,
3316 },
3317 /// The chain does not hold, and this is the first place it does not.
3318 Broken {
3319 /// Which file.
3320 file: PathBuf,
3321 /// Which line of it, counting from one, as an editor would.
3322 ///
3323 /// Zero where the fault is not on a line: a missing high-water mark, a
3324 /// history shorter than the mark vouches for, or a stray file.
3325 line: usize,
3326 /// The sequence number the entry claims, where the line parsed at all.
3327 seq: Option<u64>,
3328 /// What is wrong, in a sentence.
3329 reason: String,
3330 },
3331}
3332
3333impl Verdict {
3334
3335 /// Whether the chain held.
3336 pub fn is_intact(&self) -> bool {
3337 matches!(self, Self::Intact {..})
3338 }
3339}
3340
3341/// What reading a file for resumption found.
3342struct Scan {
3343 /// Where the chain had got to at the last entry that verified.
3344 chain: Chain,
3345 /// How many bytes the good prefix occupies.
3346 bytes: u64,
3347 /// Trailing bytes after the last complete line, from a torn write.
3348 torn_bytes: u64,
3349 /// The line at which verification first failed, counting from one.
3350 broken_at: Option<u64>,
3351 /// When the last entry that verified was stamped.
3352 ///
3353 /// Carried so that a hand restarted on a later day rolls over rather than
3354 /// appending into the previous day's file, which is what the rotation rule
3355 /// says it does.
3356 last_ts: Option<i64>,
3357}
3358
3359/// Reads a file for resumption, stopping at the first entry that does not hold.
3360///
3361/// The file's own first entry supplies the starting point, since resuming does
3362/// not need to know how the history before this file went.
3363///
3364/// # Arguments
3365/// * `path` - The file.
3366fn scan_file(path: &Path) -> Outcome<Scan> {
3367 let text = match res!(read_body(path)) {
3368 Body::Text(t) => t,
3369 Body::NotUtf8 { line } => return Ok(Scan {
3370 chain: Chain::genesis(),
3371 bytes: 0,
3372 torn_bytes: 0,
3373 broken_at: Some(line),
3374 last_ts: None,
3375 }),
3376 };
3377 let (whole, torn) = split_tail(&text);
3378
3379 let mut chain = Chain::genesis();
3380 let mut first = true;
3381 let mut bytes = 0u64;
3382 let mut broken_at = None;
3383 let mut last_ts = None;
3384
3385 for (i, line) in split_lines(whole).into_iter().enumerate() {
3386 let e = match parse_line(line) {
3387 Ok(e) => e,
3388 Err(_) => { broken_at = Some((i + 1) as u64); break; },
3389 };
3390 match recompute(line) {
3391 Some(h) if h == e.entry => {},
3392 _ => { broken_at = Some((i + 1) as u64); break; },
3393 }
3394 if first {
3395 chain = Chain { seq: e.seq, head: e.prev.clone() };
3396 first = false;
3397 }
3398 if e.prev != chain.head || e.seq != chain.seq {
3399 broken_at = Some((i + 1) as u64);
3400 break;
3401 }
3402 last_ts = Some(e.ts);
3403 chain = Chain { seq: e.seq + 1, head: e.entry };
3404 bytes += (line.len() + 1) as u64;
3405 }
3406 Ok(Scan { chain, bytes, torn_bytes: torn, broken_at, last_ts })
3407}
3408
3409/// A journal file's contents, or the news that they are not text.
3410enum Body {
3411 /// The file, as text.
3412 Text(String),
3413 /// The file holds bytes that are not UTF-8.
3414 NotUtf8 {
3415 /// Which line they are on, counting from one.
3416 line: u64,
3417 },
3418}
3419
3420/// Reads a whole file, saying so rather than failing where it is not text.
3421///
3422/// A journal that is not text is a journal somebody has interfered with, and
3423/// the right answer is [`Verdict::Broken`] rather than an error: an error at
3424/// this layer means the hand can never verify and, under "journal before
3425/// acting", can never act.
3426///
3427/// # Arguments
3428/// * `path` - The file.
3429fn read_body(path: &Path) -> Outcome<Body> {
3430 let bytes = res!(fs::read(path), IO, File, Path, Read);
3431 match String::from_utf8(bytes) {
3432 Ok(t) => Ok(Body::Text(t)),
3433 Err(e) => {
3434 let at = e.utf8_error().valid_up_to();
3435 let n = e.as_bytes()[..at].iter().filter(|b| **b == b'\n').count();
3436 Ok(Body::NotUtf8 { line: (n + 1) as u64 })
3437 },
3438 }
3439}
3440
3441/// Reads a whole file as text.
3442///
3443/// Only the tests need this now: everything else goes through [`read_body`],
3444/// which reports a file that is not text rather than failing on it.
3445///
3446/// # Arguments
3447/// * `path` - The file.
3448#[cfg(test)]
3449fn read_text(path: &Path) -> Outcome<String> {
3450 use std::io::Read;
3451 let mut f = res!(File::open(path), IO, File, Path);
3452 let mut s = String::new();
3453 res!(f.read_to_string(&mut s), IO, File, Read);
3454 Ok(s)
3455}
3456
3457/// Splits text into lines the way `sed` does, and `str::lines` does not.
3458///
3459/// `str::lines` strips a trailing `\r`, so a journal converted to CRLF verified
3460/// as intact in Rust while the documented `sed 's/…//' | sha256sum` mismatched
3461/// every line. Two independent checks that disagree is the one failure this
3462/// product cannot afford, and the shell is the one that is right: a `\r` is a
3463/// byte of the line and the hash covers it.
3464///
3465/// # Arguments
3466/// * `whole` - The newline-terminated prefix of a file.
3467fn split_lines(whole: &str) -> Vec<&str> {
3468 let mut out = Vec::new();
3469 for l in whole.split_inclusive('\n') {
3470 out.push(match l.strip_suffix('\n') {
3471 Some(s) => s,
3472 None => l,
3473 });
3474 }
3475 out
3476}
3477
3478/// Splits a file's complete lines from any fragment left by a torn write.
3479///
3480/// # Arguments
3481/// * `text` - The file's contents.
3482///
3483/// # Returns
3484/// The newline-terminated prefix, and how many bytes follow it.
3485fn split_tail(text: &str) -> (&str, u64) {
3486 match text.rfind('\n') {
3487 Some(n) => (&text[..n + 1], (text.len() - n - 1) as u64),
3488 None => ("", text.len() as u64),
3489 }
3490}
3491
3492/// Walks one journal file and says whether its chain is intact.
3493///
3494/// # Arguments
3495/// * `path` - The file.
3496/// * `from` - The state the chain should arrive in, or `None` to check the file
3497/// only against itself, taking its first entry's `prev` and `seq` as
3498/// the starting point. A file checked alone can say that nobody has
3499/// edited it; only [`verify_dir`] can say that it follows on from the
3500/// file before it.
3501///
3502/// # Returns
3503/// [`Verdict::Intact`] with the state the chain reached, or [`Verdict::Broken`]
3504/// naming the first line that does not hold.
3505pub fn verify_file(path: &Path, from: Option<&Chain>) -> Outcome<Verdict> {
3506 let text = match res!(read_body(path)) {
3507 Body::Text(t) => t,
3508 Body::NotUtf8 { line } => return Ok(Verdict::Broken {
3509 file: path.to_path_buf(),
3510 line: line as usize,
3511 seq: None,
3512 reason: "the file holds bytes that are not text, so it is not a \
3513 journal any more".to_string(),
3514 }),
3515 };
3516 let (whole, torn) = split_tail(&text);
3517
3518 let mut chain = match from {
3519 Some(c) => c.clone(),
3520 None => Chain::genesis(),
3521 };
3522 let mut first = from.is_none();
3523 let mut count = 0u64;
3524
3525 for (i, line) in split_lines(whole).into_iter().enumerate() {
3526 let no = i + 1;
3527 if line.is_empty() {
3528 return Ok(Verdict::Broken {
3529 file: path.to_path_buf(),
3530 line: no,
3531 seq: None,
3532 reason: "the line is blank, and a journal has no blank lines".to_string(),
3533 });
3534 }
3535 let e = match parse_line(line) {
3536 Ok(e) => e,
3537 Err(m) => return Ok(Verdict::Broken {
3538 file: path.to_path_buf(),
3539 line: no,
3540 seq: None,
3541 reason: fmt!("the line will not read back: {}", m),
3542 }),
3543 };
3544 match recompute(line) {
3545 Some(h) if h == e.entry => {},
3546 Some(h) => return Ok(Verdict::Broken {
3547 file: path.to_path_buf(),
3548 line: no,
3549 seq: Some(e.seq),
3550 reason: fmt!(
3551 "the entry hash does not match the text it covers: the line \
3552 says {} and its own bytes give {}", e.entry, h),
3553 }),
3554 None => return Ok(Verdict::Broken {
3555 file: path.to_path_buf(),
3556 line: no,
3557 seq: Some(e.seq),
3558 reason: "the entry hash cannot be recomputed from the line".to_string(),
3559 }),
3560 }
3561 if first {
3562 chain = Chain { seq: e.seq, head: e.prev.clone() };
3563 first = false;
3564 }
3565 if e.seq != chain.seq {
3566 return Ok(Verdict::Broken {
3567 file: path.to_path_buf(),
3568 line: no,
3569 seq: Some(e.seq),
3570 reason: fmt!(
3571 "the entry is numbered {} where the chain had reached {}, so \
3572 an entry is missing or has been moved", e.seq, chain.seq),
3573 });
3574 }
3575 if e.prev != chain.head {
3576 return Ok(Verdict::Broken {
3577 file: path.to_path_buf(),
3578 line: no,
3579 seq: Some(e.seq),
3580 reason: fmt!(
3581 "the entry points at {} where the entry before it hashed to \
3582 {}, so the chain has been rewritten at or before here",
3583 e.prev, chain.head),
3584 });
3585 }
3586 // A `gap` entry is the record saying, in its own words, that history it
3587 // once vouched for is no longer there. It is written by
3588 // [`Journal::open`] and never by anything else, and the whole point of
3589 // writing it is that the verdict from here on is Broken: the entry
3590 // cannot be removed without breaking the chain of everything after it,
3591 // so the loss stays visible across every later restart.
3592 if e.kind == "gap" {
3593 return Ok(Verdict::Broken {
3594 file: path.to_path_buf(),
3595 line: no,
3596 seq: Some(e.seq),
3597 reason: fmt!(
3598 "the record itself says history was lost here: {}", e.body),
3599 });
3600 }
3601 chain = Chain { seq: e.seq + 1, head: e.entry };
3602 count += 1;
3603 }
3604
3605 if torn > 0 {
3606 return Ok(Verdict::Broken {
3607 file: path.to_path_buf(),
3608 line: count as usize + 1,
3609 seq: None,
3610 reason: fmt!(
3611 "{} bytes follow the last complete entry with no newline, which \
3612 is a torn write or a truncation", torn),
3613 });
3614 }
3615 Ok(Verdict::Intact { entries: count, chain })
3616}
3617
3618/// Walks a whole journal directory as one chain.
3619///
3620/// The files are taken in index order and the chain is carried across each
3621/// boundary, so the history verifies end to end. This is also what catches whole
3622/// lines being lopped off the end of a rotated file: the next file's first entry
3623/// no longer follows on by sequence number.
3624///
3625/// # Arguments
3626/// * `dir` - Where the files live.
3627///
3628/// # Returns
3629/// [`Verdict::Intact`] over the whole history, or [`Verdict::Broken`] naming the
3630/// file and line where it first fails.
3631pub fn verify_dir(dir: &Path) -> Outcome<Verdict> {
3632 let surv = res!(survey(dir));
3633
3634 // Anything named like a journal file and not written by the journal is
3635 // reported rather than skipped. The old walk ignored what it did not
3636 // recognise, which is how one planted name steered every later entry into a
3637 // file no verifier read.
3638 if let Some((path, shape)) = surv.stray.first() {
3639 return Ok(Verdict::Broken {
3640 file: path.clone(),
3641 line: 0,
3642 seq: None,
3643 reason: fmt!(
3644 "a {} named like a journal file is in the directory and the \
3645 journal did not write it, so what else is here cannot be taken \
3646 at face value", shape.name()),
3647 });
3648 }
3649
3650 let mut chain = Chain::genesis();
3651 let mut total = 0u64;
3652 for (_, path) in surv.files.iter() {
3653 match res!(verify_file(path, Some(&chain))) {
3654 Verdict::Intact { entries, chain: c } => {
3655 total += entries;
3656 chain = c;
3657 },
3658 broken => return Ok(broken),
3659 }
3660 }
3661
3662 // And now the part a chain cannot do for itself: is anything missing from
3663 // the *end*? A shorter history is a perfectly good chain, so only something
3664 // kept outside the rotated files can answer it.
3665 let here = surv.files.last().map(|(n, _)| *n);
3666 let last = match surv.files.last() {
3667 Some((_, p)) => p.clone(),
3668 None => dir.to_path_buf(),
3669 };
3670 match res!(read_mark(dir)) {
3671 MarkState::Absent => {
3672 if total > 0 {
3673 return Ok(Verdict::Broken {
3674 file: mark_path(dir),
3675 line: 0,
3676 seq: None,
3677 reason: fmt!(
3678 "the history holds {} entries and there is no high-water \
3679 mark to say whether that is all of them; a chain cannot \
3680 detect its own truncation", total),
3681 });
3682 }
3683 },
3684 MarkState::Damaged(why) => return Ok(Verdict::Broken {
3685 file: mark_path(dir),
3686 line: 0,
3687 seq: None,
3688 reason: fmt!("the high-water mark does not read back: {}", why),
3689 }),
3690 MarkState::At(m) => {
3691 if chain.seq < m.seq {
3692 return Ok(Verdict::Broken {
3693 file: last,
3694 line: 0,
3695 seq: Some(chain.seq),
3696 reason: fmt!(
3697 "the history reaches entry {} and the high-water mark \
3698 vouches for {}, so {} entries have been removed from the \
3699 end", chain.seq, m.seq, m.seq - chain.seq),
3700 });
3701 }
3702 if chain.seq == m.seq && chain.head != m.head {
3703 return Ok(Verdict::Broken {
3704 file: last,
3705 line: 0,
3706 seq: Some(chain.seq),
3707 reason: fmt!(
3708 "the history ends at {} and the high-water mark names \
3709 {}, so it has been rewritten", chain.head, m.head),
3710 });
3711 }
3712 if let Some(h) = here {
3713 if h < m.files {
3714 return Ok(Verdict::Broken {
3715 file: dir.join(file_name(m.files)),
3716 line: 0,
3717 seq: None,
3718 reason: fmt!(
3719 "the rotation had reached '{}' and the newest file \
3720 here is '{}', so whole files have been removed",
3721 file_name(m.files), file_name(h)),
3722 });
3723 }
3724 }
3725 },
3726 }
3727 Ok(Verdict::Intact { entries: total, chain })
3728}
3729
3730// ┌───────────────────────────────────────────────────────────────┐
3731// │ Tests │
3732// └───────────────────────────────────────────────────────────────┘
3733
3734#[cfg(test)]
3735mod tests {
3736 use super::*;
3737
3738 /// A directory to work in, under the build's own target tree.
3739 ///
3740 /// Never `/tmp`: it is a tmpfs here, and a test that fills it takes the
3741 /// machine's memory with it.
3742 ///
3743 /// # Arguments
3744 /// * `name` - A name unique to the test.
3745 fn scratch(name: &str) -> Outcome<PathBuf> {
3746 let base = match std::env::var("CARGO_TARGET_DIR") {
3747 Ok(v) if !v.is_empty() => PathBuf::from(v),
3748 _ => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"),
3749 };
3750 let dir = base.join("journal-tests").join(name);
3751 if dir.exists() {
3752 res!(fs::remove_dir_all(&dir));
3753 }
3754 res!(fs::create_dir_all(&dir));
3755 Ok(dir)
3756 }
3757
3758 /// A configuration with a stopped clock, so a test owns the day boundary.
3759 ///
3760 /// # Arguments
3761 /// * `dir` - Where the files go.
3762 fn cfg_at(dir: &Path) -> Cfg {
3763 let mut c = Cfg::at(dir);
3764 c.clock = Clock::fixed(1_754_000_000_000);
3765 c
3766 }
3767
3768 /// A request with something in every field the journal reads.
3769 fn sample_exec() -> Req {
3770 Req::Exec {
3771 id: "run-1".to_string(),
3772 argv: vec!["cargo".to_string(), "test".to_string()],
3773 cwd: "/home/u/proj".to_string(),
3774 env: vec![("PATH".to_string(), "/usr/bin".to_string())],
3775 stdin: None,
3776 timeout_ms: 30_000,
3777 capture: Capture::Both,
3778 fence: FenceSpec {
3779 rw: vec!["/home/u/proj".to_string()],
3780 ro: vec!["/usr".to_string()],
3781 deny: vec!["/home/u/proj/.daimond".to_string()],
3782 net: false,
3783 },
3784 toolkits: Vec::new(),
3785 }
3786 }
3787
3788 /// Reads a journal file as lines.
3789 ///
3790 /// # Arguments
3791 /// * `path` - The file.
3792 fn lines_of(path: &Path) -> Outcome<Vec<String>> {
3793 let text = res!(read_text(path));
3794 Ok(text.lines().map(|s| s.to_string()).collect())
3795 }
3796
3797 /// Writes lines back over a file, newline terminated.
3798 ///
3799 /// # Arguments
3800 /// * `path` - The file.
3801 /// * `lines` - What it should now say.
3802 fn write_lines(path: &Path, lines: &[String]) -> Outcome<()> {
3803 let mut f = res!(File::create(path), IO, File);
3804 for l in lines {
3805 res!(f.write_all(l.as_bytes()), IO, File);
3806 res!(f.write_all(b"\n"), IO, File);
3807 }
3808 Ok(())
3809 }
3810
3811 /// Writes a short journal and returns the directory and the file.
3812 ///
3813 /// # Arguments
3814 /// * `name` - A name unique to the test.
3815 fn seeded(name: &str) -> Outcome<(PathBuf, PathBuf)> {
3816 let dir = res!(scratch(name));
3817 let mut j = res!(Journal::open(cfg_at(&dir)));
3818 let mechs = vec!["landlock".to_string(), "unshare-net".to_string()];
3819 match Event::from_req(&sample_exec(), &mechs) {
3820 Some(ev) => { res!(j.append(&ev)); },
3821 None => return Err(err!("The exec request produced no event."; Bug)),
3822 }
3823 res!(j.append(&Event::Started { id: "run-1".to_string(), pid: 4242 }));
3824 res!(j.append(&Event::Ended {
3825 id: "run-1".to_string(),
3826 exit: 0,
3827 timed_out: false,
3828 killed: false,
3829 out_bytes: 128,
3830 err_bytes: 0,
3831 }));
3832 res!(j.append(&Event::Refused {
3833 id: "run-2".to_string(),
3834 reason: "That path is outside every root this Diamond was granted.".to_string(),
3835 }));
3836 res!(j.flush());
3837 let path = j.path().to_path_buf();
3838 drop(j);
3839 Ok((dir, path))
3840 }
3841
3842 // ── The good case, so the broken ones mean something ──────────────
3843
3844 /// A journal written normally verifies, from genesis to its head.
3845 #[test]
3846 fn test_a_written_journal_verifies_00() -> Outcome<()> {
3847 let (dir, path) = res!(seeded("verifies_00"));
3848 let v = res!(verify_file(&path, Some(&Chain::genesis())));
3849 assert!(v.is_intact(), "a freshly written journal should verify: {:?}", v);
3850 match v {
3851 Verdict::Intact { entries, chain } => {
3852 assert_eq!(entries, 4);
3853 assert_eq!(chain.seq, 4);
3854 assert!(is_hex64(&chain.head));
3855 },
3856 other => return Err(err!("Expected Intact, got {:?}", other; Bug)),
3857 }
3858 assert!(res!(verify_dir(&dir)).is_intact());
3859 Ok(())
3860 }
3861
3862 /// The entry hash is the SHA-256 of the line's own text up to `,"entry":"`.
3863 ///
3864 /// Computed here independently of the journal's own opinion, so the check is
3865 /// not merely self-consistent.
3866 #[test]
3867 fn test_the_entry_hash_covers_the_line_prefix_00() -> Outcome<()> {
3868 let (_dir, path) = res!(seeded("prefix_00"));
3869 let lines = res!(lines_of(&path));
3870 assert_eq!(lines.len(), 4);
3871 for l in lines.iter() {
3872 let cut = l.len() - ENTRY_SUFFIX;
3873 let want = hex(sha256::digest(l[..cut].as_bytes()));
3874 let got = &l[cut + ENTRY_TAG.len()..l.len() - 2];
3875 assert_eq!(want, got, "the hash must cover exactly the text before it");
3876 }
3877 Ok(())
3878 }
3879
3880 // ── Tampering: every check proved on input that breaks it ─────────
3881
3882 /// Editing a middle entry is caught, and that entry is named.
3883 #[test]
3884 fn test_a_tampered_middle_line_is_named_00() -> Outcome<()> {
3885 let (_dir, path) = res!(seeded("middle_00"));
3886 let mut lines = res!(lines_of(&path));
3887 // Line two is the `started` entry; move the pid.
3888 lines[1] = lines[1].replace("\"pid\":4242", "\"pid\":4243");
3889 assert!(lines[1].contains("4243"), "the tamper must actually have landed");
3890 res!(write_lines(&path, &lines));
3891
3892 match res!(verify_file(&path, Some(&Chain::genesis()))) {
3893 Verdict::Broken { line, seq, reason, .. } => {
3894 assert_eq!(line, 2, "the verifier must name the line that was edited");
3895 assert_eq!(seq, Some(1));
3896 assert!(reason.contains("does not match"), "reason was: {}", reason);
3897 },
3898 other => return Err(err!(
3899 "An edited entry must not verify, but the verdict was {:?}", other; Bug)),
3900 }
3901 Ok(())
3902 }
3903
3904 /// Editing the last entry is caught too, which is where a lazy verifier
3905 /// stops looking.
3906 #[test]
3907 fn test_a_tampered_last_line_is_named_00() -> Outcome<()> {
3908 let (_dir, path) = res!(seeded("last_00"));
3909 let mut lines = res!(lines_of(&path));
3910 let n = lines.len();
3911 lines[n - 1] = lines[n - 1].replace("outside every root", "inside every root");
3912 res!(write_lines(&path, &lines));
3913
3914 match res!(verify_file(&path, Some(&Chain::genesis()))) {
3915 Verdict::Broken { line, .. } => assert_eq!(line, n),
3916 other => return Err(err!(
3917 "An edited last entry must not verify, got {:?}", other; Bug)),
3918 }
3919 Ok(())
3920 }
3921
3922 /// Editing an entry *and* recomputing its own hash still breaks the chain,
3923 /// at the entry after it. This is the check that earns the word "chain".
3924 #[test]
3925 fn test_a_rehashed_middle_line_still_breaks_the_next_00() -> Outcome<()> {
3926 let (_dir, path) = res!(seeded("rehash_00"));
3927 let mut lines = res!(lines_of(&path));
3928 let edited = lines[1].replace("\"pid\":4242", "\"pid\":4243");
3929 let cut = edited.len() - ENTRY_SUFFIX;
3930 let fresh = hex(sha256::digest(edited[..cut].as_bytes()));
3931 lines[1] = fmt!("{}{}{}\"}}", &edited[..cut], ENTRY_TAG, fresh);
3932 // The doctored line is now internally consistent, which is the premise.
3933 match recompute(&lines[1]) {
3934 Some(h) => {
3935 let claimed = match entry_hash_of(&lines[1]) {
3936 Some(c) => c.to_string(),
3937 None => return Err(err!("The doctored line has no hash."; Bug)),
3938 };
3939 assert_eq!(h, claimed, "the forgery must be self-consistent to be a test");
3940 },
3941 None => return Err(err!("The doctored line will not rehash."; Bug)),
3942 }
3943 res!(write_lines(&path, &lines));
3944
3945 match res!(verify_file(&path, Some(&Chain::genesis()))) {
3946 Verdict::Broken { line, reason, .. } => {
3947 assert_eq!(line, 3, "the break must surface at the entry after the forgery");
3948 assert!(reason.contains("points at"), "reason was: {}", reason);
3949 },
3950 other => return Err(err!(
3951 "A self-consistent forgery must still break the chain, got {:?}", other; Bug)),
3952 }
3953 Ok(())
3954 }
3955
3956 /// Deleting a middle entry is caught by the sequence number.
3957 #[test]
3958 fn test_a_removed_line_is_named_00() -> Outcome<()> {
3959 let (_dir, path) = res!(seeded("removed_00"));
3960 let mut lines = res!(lines_of(&path));
3961 lines.remove(1);
3962 res!(write_lines(&path, &lines));
3963
3964 match res!(verify_file(&path, Some(&Chain::genesis()))) {
3965 Verdict::Broken { line, seq, reason, .. } => {
3966 assert_eq!(line, 2);
3967 assert_eq!(seq, Some(2));
3968 assert!(reason.contains("numbered"), "reason was: {}", reason);
3969 },
3970 other => return Err(err!("A deleted entry must be caught, got {:?}", other; Bug)),
3971 }
3972 Ok(())
3973 }
3974
3975 /// Swapping two entries is caught.
3976 #[test]
3977 fn test_a_reordered_pair_is_named_00() -> Outcome<()> {
3978 let (_dir, path) = res!(seeded("reorder_00"));
3979 let mut lines = res!(lines_of(&path));
3980 lines.swap(1, 2);
3981 res!(write_lines(&path, &lines));
3982
3983 match res!(verify_file(&path, Some(&Chain::genesis()))) {
3984 Verdict::Broken { line, .. } => assert_eq!(line, 2),
3985 other => return Err(err!("Reordering must be caught, got {:?}", other; Bug)),
3986 }
3987 Ok(())
3988 }
3989
3990 /// A file cut mid-entry is reported as torn, not read as though it were whole.
3991 #[test]
3992 fn test_a_torn_final_line_is_named_00() -> Outcome<()> {
3993 let (_dir, path) = res!(seeded("torn_00"));
3994 let text = res!(read_text(&path));
3995 let keep = text.len() - 40;
3996 let mut f = res!(File::create(&path), IO, File);
3997 res!(f.write_all(text[..keep].as_bytes()), IO, File);
3998 drop(f);
3999
4000 match res!(verify_file(&path, Some(&Chain::genesis()))) {
4001 Verdict::Broken { reason, .. } => {
4002 assert!(reason.contains("torn write"), "reason was: {}", reason);
4003 },
4004 other => return Err(err!("A torn file must not verify, got {:?}", other; Bug)),
4005 }
4006 Ok(())
4007 }
4008
4009 /// A line whose body has been lengthened without touching the tail is caught,
4010 /// which is the case a fixed-offset reader would get wrong.
4011 #[test]
4012 fn test_a_lengthened_body_is_caught_00() -> Outcome<()> {
4013 let (_dir, path) = res!(seeded("lengthen_00"));
4014 let mut lines = res!(lines_of(&path));
4015 lines[0] = lines[0].replace("\"cwd\":\"/home/u/proj\"", "\"cwd\":\"/home/u/proj/deeper\"");
4016 res!(write_lines(&path, &lines));
4017
4018 match res!(verify_file(&path, Some(&Chain::genesis()))) {
4019 Verdict::Broken { line, .. } => assert_eq!(line, 1),
4020 other => return Err(err!("A lengthened body must be caught, got {:?}", other; Bug)),
4021 }
4022 Ok(())
4023 }
4024
4025 // ── Rotation ──────────────────────────────────────────────────────
4026
4027 /// The chain carries across a size rotation, and the whole history verifies.
4028 #[test]
4029 fn test_the_chain_survives_a_size_rotation_00() -> Outcome<()> {
4030 let dir = res!(scratch("rotate_size_00"));
4031 let mut c = cfg_at(&dir);
4032 c.max_bytes = 900;
4033 let mut j = res!(Journal::open(c));
4034 for i in 0..40u32 {
4035 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: 1000 + i }));
4036 }
4037 res!(j.flush());
4038 drop(j);
4039
4040 let files = res!(journal_files(&dir));
4041 assert!(files.len() > 1, "the journal should have rolled over, files: {}", files.len());
4042
4043 let v = res!(verify_dir(&dir));
4044 assert!(v.is_intact(), "the chain must carry across rotation: {:?}", v);
4045 match v {
4046 // Forty events, plus one rotation entry per boundary.
4047 Verdict::Intact { entries, chain } => {
4048 assert_eq!(entries as usize, 40 + files.len() - 1);
4049 assert_eq!(chain.seq, entries);
4050 },
4051 other => return Err(err!("Expected Intact, got {:?}", other; Bug)),
4052 }
4053 Ok(())
4054 }
4055
4056 /// The chain carries across a change of UTC day.
4057 #[test]
4058 fn test_the_chain_survives_a_day_rotation_00() -> Outcome<()> {
4059 let dir = res!(scratch("rotate_day_00"));
4060 let c = cfg_at(&dir);
4061 let clk = c.clock.clone();
4062 let mut j = res!(Journal::open(c));
4063 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4064 res!(clk.advance(DAY_MS));
4065 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
4066 res!(j.flush());
4067 drop(j);
4068
4069 let files = res!(journal_files(&dir));
4070 assert_eq!(files.len(), 2, "a change of UTC day must start a new file");
4071 assert!(res!(verify_dir(&dir)).is_intact());
4072 Ok(())
4073 }
4074
4075 /// Lopping whole entries off the end of a *rotated* file is caught, because
4076 /// the next file no longer follows on by sequence number.
4077 #[test]
4078 fn test_a_truncated_rotated_file_is_named_00() -> Outcome<()> {
4079 let dir = res!(scratch("rotate_trunc_00"));
4080 let mut c = cfg_at(&dir);
4081 c.max_bytes = 900;
4082 let mut j = res!(Journal::open(c));
4083 for i in 0..40u32 {
4084 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: 1000 + i }));
4085 }
4086 res!(j.flush());
4087 drop(j);
4088
4089 let files = res!(journal_files(&dir));
4090 assert!(files.len() > 1);
4091 // Drop the last entry of the first file, tidily, newline and all.
4092 let first = files[0].1.clone();
4093 let mut lines = res!(lines_of(&first));
4094 lines.pop();
4095 res!(write_lines(&first, &lines));
4096
4097 match res!(verify_dir(&dir)) {
4098 Verdict::Broken { file, line, reason, .. } => {
4099 assert_eq!(file, files[1].1, "the break should surface in the next file");
4100 assert_eq!(line, 1);
4101 assert!(reason.contains("numbered"), "reason was: {}", reason);
4102 },
4103 other => return Err(err!(
4104 "Truncating a rotated file must be caught, got {:?}", other; Bug)),
4105 }
4106 Ok(())
4107 }
4108
4109 /// Reopening continues the chain rather than starting a second one.
4110 #[test]
4111 fn test_reopening_continues_the_chain_00() -> Outcome<()> {
4112 let dir = res!(scratch("reopen_00"));
4113 let head = {
4114 let mut j = res!(Journal::open(cfg_at(&dir)));
4115 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4116 res!(j.flush());
4117 j.chain().clone()
4118 };
4119 let mut j = res!(Journal::open(cfg_at(&dir)));
4120 assert_eq!(j.chain(), &head, "a reopened journal must resume where it stopped");
4121 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
4122 res!(j.flush());
4123 drop(j);
4124 assert!(res!(verify_dir(&dir)).is_intact());
4125 Ok(())
4126 }
4127
4128 /// A torn tail found on opening is left alone and recorded, not tidied away.
4129 #[test]
4130 fn test_a_torn_tail_is_recorded_not_repaired_00() -> Outcome<()> {
4131 let dir = res!(scratch("torn_open_00"));
4132 {
4133 let mut j = res!(Journal::open(cfg_at(&dir)));
4134 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4135 res!(j.flush());
4136 }
4137 let files = res!(journal_files(&dir));
4138 let first = files[0].1.clone();
4139 let before = res!(read_text(&first));
4140 // A kill mid-write leaves a fragment with no newline.
4141 let fragment = b"{\"seq\":1,\"ts\":17540000";
4142 let mut f = res!(OpenOptions::new().append(true).open(&first), IO, File);
4143 res!(f.write_all(fragment), IO, File);
4144 drop(f);
4145
4146 let mut j = res!(Journal::open(cfg_at(&dir)));
4147 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
4148 res!(j.flush());
4149 let live = j.path().to_path_buf();
4150 drop(j);
4151
4152 assert_ne!(live, first, "a damaged file must not be appended to");
4153 let after = res!(read_text(&first));
4154 assert!(after.starts_with(&before), "the damaged file must be left as it is");
4155 let lines = res!(lines_of(&live));
4156 assert!(lines[0].contains("\"kind\":\"rotated\""), "line was: {}", lines[0]);
4157 assert!(lines[0].contains(&fmt!("\"torn_bytes\":{}", fragment.len())),
4158 "line was: {}", lines[0]);
4159 // The new file alone is a sound chain from its own starting point.
4160 assert!(res!(verify_file(&live, None)).is_intact());
4161 Ok(())
4162 }
4163
4164 // ── Refusals ──────────────────────────────────────────────────────
4165
4166 /// A refusal is recorded, with its reason, and verifies like anything else.
4167 #[test]
4168 fn test_a_refusal_is_recorded_00() -> Outcome<()> {
4169 let (_dir, path) = res!(seeded("refusal_00"));
4170 let lines = res!(lines_of(&path));
4171 let last = match lines.last() {
4172 Some(l) => l,
4173 None => return Err(err!("The journal is empty."; Bug)),
4174 };
4175 assert!(last.contains("\"kind\":\"refused\""));
4176 assert!(last.contains("outside every root"));
4177 let e = res!(parse_line(last));
4178 assert_eq!(e.kind, "refused");
4179 assert!(e.body.contains("\"id\":\"run-2\""));
4180 Ok(())
4181 }
4182
4183 // ── Where the journal lives ───────────────────────────────────────
4184
4185 /// A fence that grants write access over the journal is refused.
4186 #[test]
4187 fn test_a_journal_inside_a_writable_fence_root_is_refused_00() -> Outcome<()> {
4188 let dir = res!(scratch("fence_rw_00"));
4189 let j = res!(Journal::open(cfg_at(&dir)));
4190
4191 // The exact mistake the earlier design would have made: the journal
4192 // sitting inside the directory the daimon is handed a pen for.
4193 let inside = FenceSpec {
4194 rw: vec![dir.to_string_lossy().to_string()],
4195 ..Default::default()
4196 };
4197 match j.check_fence(&inside) {
4198 Ok(()) => return Err(err!(
4199 "A fence granting write access over the journal must be refused."; Bug)),
4200 Err(e) => {
4201 let s = fmt!("{}", e);
4202 assert!(s.contains("delete the entry"), "message was: {}", s);
4203 },
4204 }
4205
4206 // A parent of the journal is just as bad.
4207 let parent = match dir.parent() {
4208 Some(p) => p.to_string_lossy().to_string(),
4209 None => return Err(err!("The scratch directory has no parent."; Bug)),
4210 };
4211 let above = FenceSpec { rw: vec![parent], ..Default::default() };
4212 assert!(j.check_fence(&above).is_err(), "a grant above the journal must be refused");
4213
4214 // A read grant is refused too: it leaks every command every Diamond ran.
4215 let ro = FenceSpec {
4216 ro: vec![dir.to_string_lossy().to_string()],
4217 ..Default::default()
4218 };
4219 assert!(j.check_fence(&ro).is_err(), "a read grant over the journal must be refused");
4220 Ok(())
4221 }
4222
4223 /// A fence clear of the journal passes, and a sibling whose name merely
4224 /// starts the same way is not mistaken for a parent.
4225 #[test]
4226 fn test_a_fence_clear_of_the_journal_passes_00() -> Outcome<()> {
4227 let dir = res!(scratch("fence_ok_00"));
4228 let j = res!(Journal::open(cfg_at(&dir)));
4229 let sibling = fmt!("{}-elsewhere", dir.to_string_lossy());
4230 let ok = FenceSpec {
4231 rw: vec![sibling],
4232 ro: vec!["/usr".to_string()],
4233 deny: vec![],
4234 net: false,
4235 };
4236 res!(j.check_fence(&ok));
4237 Ok(())
4238 }
4239
4240 /// A relative fence root is refused rather than compared, because a
4241 /// comparison that cannot be made must not be assumed to have passed.
4242 #[test]
4243 fn test_a_relative_fence_root_is_refused_00() -> Outcome<()> {
4244 let dir = res!(scratch("fence_rel_00"));
4245 let j = res!(Journal::open(cfg_at(&dir)));
4246 let rel = FenceSpec { rw: vec!["proj".to_string()], ..Default::default() };
4247 match j.check_fence(&rel) {
4248 Ok(()) => Err(err!("A relative fence root must be refused."; Bug)),
4249 Err(e) => {
4250 let s = fmt!("{}", e);
4251 assert!(s.contains("absolute"), "message was: {}", s);
4252 Ok(())
4253 },
4254 }
4255 }
4256
4257 /// The guard adds the journal root to a fence's deny list, once.
4258 #[test]
4259 fn test_fence_guard_denies_the_journal_root_00() -> Outcome<()> {
4260 let dir = res!(scratch("fence_guard_00"));
4261 let j = res!(Journal::open(cfg_at(&dir)));
4262 let g = j.fence_guard(&FenceSpec::default());
4263 assert!(g.deny.iter().any(|d| Path::new(d) == dir));
4264 let g2 = j.fence_guard(&g);
4265 assert_eq!(g.deny.len(), g2.deny.len(), "the guard must not add the root twice");
4266 Ok(())
4267 }
4268
4269 // ── Secrets ───────────────────────────────────────────────────────
4270
4271 /// An environment value never reaches the file, and its key does.
4272 #[test]
4273 fn test_env_values_never_reach_the_journal_00() -> Outcome<()> {
4274 let dir = res!(scratch("env_00"));
4275 let mut j = res!(Journal::open(cfg_at(&dir)));
4276 // A synthetic value with a credential's shape and none of its danger.
4277 let value = "NOT-A-REAL-SECRET-abcdefghijklmnopqrstuvwxyz012345"; // allowlist secret
4278 let req = Req::Exec {
4279 id: "run-1".to_string(),
4280 argv: vec!["aws".to_string(), "s3".to_string(), "ls".to_string()],
4281 cwd: "/home/u".to_string(),
4282 env: vec![
4283 ("AWS_SECRET_ACCESS_KEY".to_string(), value.to_string()),
4284 ("PATH".to_string(), "/usr/bin".to_string()),
4285 ],
4286 stdin: None,
4287 timeout_ms: 1000,
4288 capture: Capture::Both,
4289 fence: FenceSpec::default(),
4290 toolkits: Vec::new(),
4291 };
4292 match Event::from_req(&req, &[]) {
4293 Some(ev) => { res!(j.append(&ev)); },
4294 None => return Err(err!("The exec request produced no event."; Bug)),
4295 }
4296 res!(j.flush());
4297 let path = j.path().to_path_buf();
4298 drop(j);
4299
4300 let text = res!(read_text(&path));
4301 assert!(!text.contains(value), "an environment value must never be written down");
4302 assert!(text.contains("AWS_SECRET_ACCESS_KEY"), "the key is the accountable part");
4303 assert!(text.contains("\"env_keys\""));
4304 Ok(())
4305 }
4306
4307 /// Standard input is recorded by length and never by content.
4308 #[test]
4309 fn test_stdin_is_recorded_only_by_length_00() -> Outcome<()> {
4310 let dir = res!(scratch("stdin_00"));
4311 let mut j = res!(Journal::open(cfg_at(&dir)));
4312 let given = "passphrase-that-must-not-be-written"; // allowlist secret
4313 let req = Req::Exec {
4314 id: "run-1".to_string(),
4315 argv: vec!["gpg".to_string()],
4316 cwd: "/home/u".to_string(),
4317 env: vec![],
4318 stdin: Some(given.to_string()),
4319 timeout_ms: 1000,
4320 capture: Capture::Both,
4321 fence: FenceSpec::default(),
4322 toolkits: Vec::new(),
4323 };
4324 match Event::from_req(&req, &[]) {
4325 Some(ev) => { res!(j.append(&ev)); },
4326 None => return Err(err!("The exec request produced no event."; Bug)),
4327 }
4328 res!(j.flush());
4329 let path = j.path().to_path_buf();
4330 drop(j);
4331
4332 let text = res!(read_text(&path));
4333 assert!(!text.contains(given), "standard input must never be written down");
4334 assert!(text.contains(&fmt!("\"stdin_bytes\":{}", given.len())));
4335 Ok(())
4336 }
4337
4338 /// Credential shapes in the argument vector are taken out and counted.
4339 #[test]
4340 fn test_a_credential_shaped_argument_is_redacted_00() -> Outcome<()> {
4341 // Synthetic strings with published keys' shapes, assembled at run time.
4342 let key = fmt!("{}{}", "sk-", "0000000000000000000000000000"); // allowlist secret
4343 let bearer = fmt!("Authorization: Bearer {}", key);
4344 let argv = vec![
4345 "curl".to_string(),
4346 "-H".to_string(),
4347 bearer,
4348 "--token".to_string(),
4349 "hunter2".to_string(),
4350 fmt!("--api-key={}", key),
4351 key.clone(),
4352 "https://example.com".to_string(),
4353 ];
4354 let (out, cut) = redact_argv(&argv);
4355 assert_eq!(cut, 4, "four arguments carried something worth removing");
4356 assert_eq!(out[0], "curl");
4357 assert_eq!(out[1], "-H");
4358 assert_eq!(out[2], fmt!("Authorization: {}", REDACTED));
4359 assert_eq!(out[3], "--token");
4360 assert_eq!(out[4], REDACTED);
4361 assert_eq!(out[5], fmt!("--api-key={}", REDACTED));
4362 assert_eq!(out[6], REDACTED);
4363 assert_eq!(out[7], "https://example.com");
4364 let joined = out.join(" ");
4365 assert!(!joined.contains(&key), "the key must not survive the pass");
4366 assert!(!joined.contains("hunter2"), "the flag's value must not survive the pass");
4367 Ok(())
4368 }
4369
4370 /// The redaction pass leaves an ordinary command alone, because
4371 /// over-redaction damages the record it is meant to protect.
4372 #[test]
4373 fn test_an_ordinary_argument_vector_is_untouched_00() -> Outcome<()> {
4374 let argv = vec![
4375 "docker".to_string(),
4376 "run".to_string(),
4377 "-p".to_string(),
4378 "8080:80".to_string(),
4379 "-u".to_string(),
4380 "1000".to_string(),
4381 "alpine".to_string(),
4382 ];
4383 let (out, cut) = redact_argv(&argv);
4384 assert_eq!(cut, 0, "nothing here is a credential");
4385 assert_eq!(out, argv);
4386 Ok(())
4387 }
4388
4389 /// A redacted argument vector reaches the file redacted, and the record says
4390 /// that something was removed.
4391 #[test]
4392 fn test_a_redacted_argv_reaches_the_file_redacted_00() -> Outcome<()> {
4393 let dir = res!(scratch("argv_00"));
4394 let mut j = res!(Journal::open(cfg_at(&dir)));
4395 let key = fmt!("{}{}", "ghp_", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); // allowlist secret
4396 let req = Req::Exec {
4397 id: "run-1".to_string(),
4398 argv: vec!["gh".to_string(), "auth".to_string(), key.clone()],
4399 cwd: "/home/u".to_string(),
4400 env: vec![],
4401 stdin: None,
4402 timeout_ms: 1000,
4403 capture: Capture::None,
4404 fence: FenceSpec::default(),
4405 toolkits: Vec::new(),
4406 };
4407 match Event::from_req(&req, &[]) {
4408 Some(ev) => { res!(j.append(&ev)); },
4409 None => return Err(err!("The exec request produced no event."; Bug)),
4410 }
4411 res!(j.flush());
4412 let path = j.path().to_path_buf();
4413 drop(j);
4414
4415 let text = res!(read_text(&path));
4416 assert!(!text.contains(&key), "a published key shape must not reach the file");
4417 assert!(text.contains(REDACTED));
4418 assert!(text.contains("\"redactions\":1"), "the record must say something was removed");
4419 Ok(())
4420 }
4421
4422 // ── The record's own shape ────────────────────────────────────────
4423
4424 /// The exec entry says which fence mechanisms were actually in force, which
4425 /// is a different fact from which were asked for.
4426 #[test]
4427 fn test_the_exec_entry_records_the_mechanisms_in_force_00() -> Outcome<()> {
4428 let (_dir, path) = res!(seeded("mechs_00"));
4429 let lines = res!(lines_of(&path));
4430 assert!(lines[0].contains("\"mechs\":[\"landlock\",\"unshare-net\"]"),
4431 "line was: {}", lines[0]);
4432 assert!(lines[0].contains("\"net\":false"), "line was: {}", lines[0]);
4433 Ok(())
4434 }
4435
4436 /// A body is canonical JSON, so a second implementation gets the same bytes.
4437 #[test]
4438 fn test_a_body_is_canonical_json_00() -> Outcome<()> {
4439 let ev = Event::Refused {
4440 id: "x".to_string(),
4441 reason: "no".to_string(),
4442 };
4443 let body = res!(ev.body());
4444 assert_eq!(body, "{\"id\":\"x\",\"reason\":\"no\",\"redactions\":0}",
4445 "keys sorted, no spaces, and every body says how much was taken out");
4446 // Re-canonicalising what was written gives the identical bytes.
4447 let back = res!(Dat::decode_string(&body));
4448 assert_eq!(res!(back.json_canonical()), body);
4449 Ok(())
4450 }
4451
4452 /// A reason carrying a quote, a backslash, a newline and a control character
4453 /// still reads back, and does not become two lines.
4454 #[test]
4455 fn test_an_awkward_reason_still_reads_back_00() -> Outcome<()> {
4456 let dir = res!(scratch("escape_00"));
4457 let mut j = res!(Journal::open(cfg_at(&dir)));
4458 let reason = "It said \"no\" \\ then\nstopped.\u{7}";
4459 res!(j.append(&Event::Refused {
4460 id: "run-1".to_string(),
4461 reason: reason.to_string(),
4462 }));
4463 res!(j.flush());
4464 let path = j.path().to_path_buf();
4465 drop(j);
4466
4467 assert!(res!(verify_file(&path, Some(&Chain::genesis()))).is_intact());
4468 let lines = res!(lines_of(&path));
4469 assert_eq!(lines.len(), 1, "an embedded newline must not become a second line");
4470 let e = res!(parse_line(&lines[0]));
4471 let back = res!(Dat::decode_string(&e.body));
4472 assert_eq!(res!(back.json_canonical()), e.body);
4473 Ok(())
4474 }
4475
4476 /// A body carrying multi-byte text reads back, which is the case a
4477 /// byte-offset reader would split a character on.
4478 #[test]
4479 fn test_a_multibyte_body_reads_back_00() -> Outcome<()> {
4480 let dir = res!(scratch("utf8_00"));
4481 let mut j = res!(Journal::open(cfg_at(&dir)));
4482 res!(j.append(&Event::Refused {
4483 id: "run-1".to_string(),
4484 reason: "Le répertoire « données » n'est pas accessible — 日本語も.".to_string(),
4485 }));
4486 res!(j.flush());
4487 let path = j.path().to_path_buf();
4488 drop(j);
4489
4490 assert!(res!(verify_file(&path, Some(&Chain::genesis()))).is_intact());
4491 let lines = res!(lines_of(&path));
4492 let e = res!(parse_line(&lines[0]));
4493 assert!(e.body.contains("日本語"), "body was: {}", e.body);
4494 Ok(())
4495 }
4496
4497 /// Batching really does hold entries back, which is the tradeoff the default
4498 /// exists to avoid.
4499 #[test]
4500 fn test_batching_holds_entries_back_00() -> Outcome<()> {
4501 let dir = res!(scratch("batch_00"));
4502 let mut c = cfg_at(&dir);
4503 c.durability = Durability::Batched(4);
4504 let mut j = res!(Journal::open(c));
4505 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4506 let path = j.path().to_path_buf();
4507 let md = res!(fs::metadata(&path), IO, File);
4508 assert_eq!(md.len(), 0, "a batched journal has not written the entry yet");
4509 res!(j.flush());
4510 let md = res!(fs::metadata(&path), IO, File);
4511 assert!(md.len() > 0, "flushing must write it");
4512 drop(j);
4513 Ok(())
4514 }
4515
4516 /// The default writes each entry as it is made, which is the property a
4517 /// crash-time record depends on.
4518 #[test]
4519 fn test_the_default_writes_every_entry_at_once_00() -> Outcome<()> {
4520 let dir = res!(scratch("durable_00"));
4521 let mut j = res!(Journal::open(cfg_at(&dir)));
4522 res!(j.append(&Event::Refused {
4523 id: "run-1".to_string(),
4524 reason: "declined".to_string(),
4525 }));
4526 let path = j.path().to_path_buf();
4527 // Read it without flushing and without dropping the journal.
4528 let text = res!(read_text(&path));
4529 assert!(text.contains("\"kind\":\"refused\""),
4530 "the refusal must be on disk before the next thing happens");
4531 drop(j);
4532 Ok(())
4533 }
4534
4535 /// An empty directory is a chain that has not started, not a broken one.
4536 #[test]
4537 fn test_an_empty_directory_verifies_00() -> Outcome<()> {
4538 let dir = res!(scratch("empty_00"));
4539 match res!(verify_dir(&dir)) {
4540 Verdict::Intact { entries, chain } => {
4541 assert_eq!(entries, 0);
4542 assert_eq!(chain, Chain::genesis());
4543 },
4544 other => return Err(err!("An empty journal must verify, got {:?}", other; Bug)),
4545 }
4546 Ok(())
4547 }
4548
4549 // ── The adversarial review of 2026-08-02, reproduced ──────────────
4550 //
4551 // Every test below was written against the code as the review found it,
4552 // watched to fail, and only then fixed. They are kept in review order so a
4553 // later reader can match a test to the finding it came from.
4554
4555 /// §2.1 Deleting whole files off the end of a history must not verify.
4556 #[test]
4557 fn test_a_truncated_history_is_caught_00() -> Outcome<()> {
4558 let dir = res!(scratch("trunc_hist_00"));
4559 let mut c = cfg_at(&dir);
4560 c.max_bytes = 700;
4561 {
4562 let mut j = res!(Journal::open(c));
4563 for i in 0..60u32 {
4564 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: 1000 + i }));
4565 }
4566 res!(j.flush());
4567 }
4568 let files = res!(journal_files(&dir));
4569 assert!(files.len() >= 4, "need several files to lop three off, got {}", files.len());
4570 for (_, p) in files.iter().rev().take(3) {
4571 res!(fs::remove_file(p), IO, File);
4572 }
4573 match res!(verify_dir(&dir)) {
4574 Verdict::Broken {..} => {},
4575 other => return Err(err!(
4576 "A history missing its last three files must not verify, got {:?}", other; Bug)),
4577 }
4578 Ok(())
4579 }
4580
4581 /// §2.1 And a hand reopened on that history must not go on as though nothing
4582 /// had happened.
4583 #[test]
4584 fn test_a_truncated_history_does_not_resume_intact_00() -> Outcome<()> {
4585 let dir = res!(scratch("trunc_resume_00"));
4586 let mut c = cfg_at(&dir);
4587 c.max_bytes = 700;
4588 {
4589 let mut j = res!(Journal::open(c.clone()));
4590 for i in 0..60u32 {
4591 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: 1000 + i }));
4592 }
4593 res!(j.flush());
4594 }
4595 let files = res!(journal_files(&dir));
4596 for (_, p) in files.iter().rev().take(3) {
4597 res!(fs::remove_file(p), IO, File);
4598 }
4599 {
4600 let mut j = res!(Journal::open(c));
4601 res!(j.append(&Event::Started { id: "after".to_string(), pid: 7 }));
4602 res!(j.flush());
4603 }
4604 match res!(verify_dir(&dir)) {
4605 Verdict::Broken {..} => {},
4606 other => return Err(err!(
4607 "Appending onto a truncated history must not restore it to \
4608 intact, got {:?}", other; Bug)),
4609 }
4610 Ok(())
4611 }
4612
4613 /// §2.1 Blanking the final file to zero bytes erases its entries just as
4614 /// surely as deleting it.
4615 #[test]
4616 fn test_a_blanked_final_file_is_caught_00() -> Outcome<()> {
4617 let dir = res!(scratch("blank_00"));
4618 let mut c = cfg_at(&dir);
4619 c.max_bytes = 700;
4620 {
4621 let mut j = res!(Journal::open(c));
4622 for i in 0..40u32 {
4623 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: 1000 + i }));
4624 }
4625 res!(j.flush());
4626 }
4627 let files = res!(journal_files(&dir));
4628 let last = match files.last() {
4629 Some((_, p)) => p.clone(),
4630 None => return Err(err!("No journal files."; Bug)),
4631 };
4632 res!(File::create(&last), IO, File);
4633 match res!(verify_dir(&dir)) {
4634 Verdict::Broken {..} => {},
4635 other => return Err(err!(
4636 "A blanked final file must not verify, got {:?}", other; Bug)),
4637 }
4638 Ok(())
4639 }
4640
4641 /// §2.2 A file planted with the highest name the format allows must not push
4642 /// the hand into writing somewhere nothing ever reads.
4643 #[test]
4644 fn test_a_planted_high_index_file_is_not_written_past_00() -> Outcome<()> {
4645 let dir = res!(scratch("plant_high_00"));
4646 {
4647 let mut j = res!(Journal::open(cfg_at(&dir)));
4648 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4649 res!(j.flush());
4650 }
4651 // Any process running as the user can drop this in.
4652 let plant = dir.join("hand-99999999.jsonl");
4653 res!(fs::write(&plant, b"not a journal at all\n"), IO, File);
4654
4655 let live = {
4656 let mut j = res!(Journal::open(cfg_at(&dir)));
4657 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
4658 res!(j.flush());
4659 j.path().to_path_buf()
4660 };
4661 let name = live.to_string_lossy().to_string();
4662 assert!(!name.contains("hand-100000000"),
4663 "the hand must not write to a name outside the format: {}", name);
4664 // Whatever the hand just wrote must be part of what a verifier reads.
4665 let files = res!(journal_files(&dir));
4666 assert!(files.iter().any(|(_, p)| *p == live),
4667 "the live file must be one a verifier walks: {:?}", files);
4668 Ok(())
4669 }
4670
4671 /// §2.2 An empty plant must not fork a second chain from zero and leave the
4672 /// record broken for ever.
4673 #[test]
4674 fn test_an_empty_plant_does_not_fork_the_chain_00() -> Outcome<()> {
4675 let dir = res!(scratch("plant_empty_00"));
4676 {
4677 let mut j = res!(Journal::open(cfg_at(&dir)));
4678 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4679 res!(j.flush());
4680 }
4681 res!(fs::write(dir.join("hand-00000042.jsonl"), b""), IO, File);
4682 {
4683 let mut j = res!(Journal::open(cfg_at(&dir)));
4684 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
4685 res!(j.flush());
4686 }
4687 match res!(verify_dir(&dir)) {
4688 Verdict::Intact {..} => Ok(()),
4689 other => Err(err!(
4690 "A planted empty file must not break the record, got {:?}", other; Bug)),
4691 }
4692 }
4693
4694 /// §2.2 A *directory* with a journal file's name must not stop the hand
4695 /// dead, because under "journal before acting" that means it can never act.
4696 #[test]
4697 fn test_a_planted_directory_does_not_stop_the_hand_00() -> Outcome<()> {
4698 let dir = res!(scratch("plant_dir_00"));
4699 {
4700 let mut j = res!(Journal::open(cfg_at(&dir)));
4701 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4702 res!(j.flush());
4703 }
4704 res!(fs::create_dir(dir.join("hand-00000077.jsonl")), IO, File);
4705
4706 let mut j = res!(Journal::open(cfg_at(&dir)));
4707 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
4708 res!(j.flush());
4709 drop(j);
4710 // And the verifier must have an opinion rather than an error.
4711 res!(verify_dir(&dir));
4712 Ok(())
4713 }
4714
4715 /// §2.3 A refusal that quotes the command it refused -- the natural wording
4716 /// -- must not write the credential down.
4717 #[test]
4718 fn test_a_refusal_quoting_a_command_is_redacted_00() -> Outcome<()> {
4719 let dir = res!(scratch("refuse_quote_00"));
4720 let key = fmt!("{}{}", "ghp_", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); // allowlist secret
4721 let mut j = res!(Journal::open(cfg_at(&dir)));
4722 res!(j.append(&Event::Refused {
4723 id: "run-1".to_string(),
4724 reason: fmt!("I will not run `gh auth login --with-token {}` here.", key),
4725 }));
4726 res!(j.append(&Event::Failed {
4727 id: Some("run-2".to_string()),
4728 message: fmt!("curl exited 1: Authorization: Bearer {}", key),
4729 }));
4730 res!(j.flush());
4731 let path = j.path().to_path_buf();
4732 drop(j);
4733
4734 let text = res!(read_text(&path));
4735 assert!(!text.contains(&key), "a refusal must not write the credential down");
4736 Ok(())
4737 }
4738
4739 /// §2.3 And through the constructors the message loop actually calls, since
4740 /// `Event::from_resp` applied no redaction whatever.
4741 #[test]
4742 fn test_from_resp_and_from_req_redact_00() -> Outcome<()> {
4743 let dir = res!(scratch("from_resp_00"));
4744 let key = fmt!("{}{}", "ghp_", "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE"); // allowlist secret
4745 let mut j = res!(Journal::open(cfg_at(&dir)));
4746 let resps = vec![
4747 Resp::Refused {
4748 id: "run-1".to_string(),
4749 reason: fmt!("refusing `gh auth login --with-token {}`", key),
4750 },
4751 Resp::Error {
4752 id: Some(fmt!("run-{}", key)),
4753 message: fmt!("the token {} was rejected", key),
4754 },
4755 Resp::Started { id: fmt!("run-{}", key), pid: 3 },
4756 Resp::Ended {
4757 id: fmt!("run-{}", key), exit: 1, timed_out: false, killed: false,
4758 out_bytes: 0, err_bytes: 0,
4759 },
4760 ];
4761 for r in resps.iter() {
4762 match Event::from_resp(r) {
4763 Some(ev) => { res!(j.append(&ev)); },
4764 None => return Err(err!("A response produced no event."; Bug)),
4765 }
4766 }
4767 match Event::from_req(&Req::Hello { proto: 1, client: fmt!("app/{}", key) }, &[]) {
4768 Some(ev) => { res!(j.append(&ev)); },
4769 None => return Err(err!("Hello produced no event."; Bug)),
4770 }
4771 res!(j.flush());
4772 let path = j.path().to_path_buf();
4773 drop(j);
4774
4775 let text = res!(read_text(&path));
4776 assert!(!text.contains(&key), "no response field may carry a credential");
4777 assert!(text.contains(REDACTED), "and the record must say something was removed");
4778 Ok(())
4779 }
4780
4781 /// §2.3 Every other free-text field the review found unguarded.
4782 #[test]
4783 fn test_every_free_text_field_is_redacted_00() -> Outcome<()> {
4784 let dir = res!(scratch("free_text_00"));
4785 let key = fmt!("{}{}", "sk-", "live0000000000000000000000000"); // allowlist secret
4786 let mut j = res!(Journal::open(cfg_at(&dir)));
4787
4788 res!(j.append(&Event::Opened { proto: 1, client: fmt!("daimond/{}", key) }));
4789 let req = Req::Exec {
4790 id: fmt!("run-{}", key),
4791 argv: vec!["cargo".to_string()],
4792 cwd: fmt!("/home/u/{}", key),
4793 env: vec![(fmt!("TOK_{}", key), "v".to_string())],
4794 stdin: None,
4795 timeout_ms: 1,
4796 capture: Capture::None,
4797 fence: FenceSpec {
4798 rw: vec![fmt!("/home/u/{}", key)],
4799 ro: vec![fmt!("/opt/{}", key)],
4800 deny: vec![fmt!("/srv/{}", key)],
4801 net: false,
4802 },
4803 toolkits: Vec::new(),
4804 };
4805 match Event::from_req(&req, &[fmt!("landlock-{}", key)]) {
4806 Some(ev) => { res!(j.append(&ev)); },
4807 None => return Err(err!("The exec request produced no event."; Bug)),
4808 }
4809 res!(j.append(&Event::Started { id: fmt!("run-{}", key), pid: 1 }));
4810 res!(j.append(&Event::Signalled { id: fmt!("run-{}", key), sig: Sig::Term }));
4811 res!(j.append(&Event::Ended {
4812 id: fmt!("run-{}", key), exit: 0, timed_out: false, killed: false,
4813 out_bytes: 0, err_bytes: 0,
4814 }));
4815 res!(j.append(&Event::Closed { reason: fmt!("dropped while holding {}", key) }));
4816 res!(j.flush());
4817 let path = j.path().to_path_buf();
4818 drop(j);
4819
4820 let text = res!(read_text(&path));
4821 assert!(!text.contains(&key), "no field may carry a credential value");
4822 Ok(())
4823 }
4824
4825 /// §2.4 The twelve credential shapes that all came back `cut=0`.
4826 #[test]
4827 fn test_real_credential_shapes_are_redacted_00() -> Outcome<()> {
4828 let gh = fmt!("{}{}", "ghp_", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); // allowlist secret
4829 let sk = fmt!("{}{}", "sk-", "0123456789abcdef0123456789"); // allowlist secret
4830 let cases: Vec<(Vec<String>, Vec<String>)> = vec![
4831 (vec![fmt!("https://oauth2:{}@github.com/o/r.git", gh)], vec![gh.clone()]),
4832 (vec!["psql".to_string(), "postgres://u:hunter2@db/app".to_string()],
4833 vec!["hunter2".to_string()]),
4834 (vec![fmt!("--header=Authorization: Bearer {}", sk)], vec![sk.clone()]),
4835 (vec!["--PASSWORD=hunter2".to_string()], vec!["hunter2".to_string()]),
4836 (vec!["--Token".to_string(), "hunter2".to_string()], vec!["hunter2".to_string()]),
4837 (vec!["--secret-access-key".to_string(), "hunter2".to_string()],
4838 vec!["hunter2".to_string()]),
4839 (vec!["--private-key".to_string(), "hunter2".to_string()],
4840 vec!["hunter2".to_string()]),
4841 (vec!["mysql".to_string(), "-phunter2".to_string()], vec!["hunter2".to_string()]),
4842 (vec!["--api_key=hunter2".to_string()], vec!["hunter2".to_string()]),
4843 (vec!["env".to_string(), "PGPASSWORD=hunter2".to_string()],
4844 vec!["hunter2".to_string()]),
4845 (vec![fmt!("https://api.example.com/v1?api_key={}", sk)], vec![sk.clone()]),
4846 (vec!["-H".to_string(), "X-Api-Key: hunter2".to_string()],
4847 vec!["hunter2".to_string()]),
4848 ];
4849 for (argv, must_go) in cases.iter() {
4850 let (out, cut) = redact_argv(argv);
4851 let joined = out.join(" ");
4852 for s in must_go.iter() {
4853 assert!(!joined.contains(s.as_str()),
4854 "'{}' survived redaction of {:?} -> {:?}", s, argv, out);
4855 }
4856 assert!(cut > 0, "nothing was counted as removed from {:?}", argv);
4857 }
4858 Ok(())
4859 }
4860
4861 /// §2.5 Two hands on one directory must not both think they own the chain.
4862 #[test]
4863 fn test_a_second_journal_on_one_directory_is_refused_00() -> Outcome<()> {
4864 let dir = res!(scratch("lock_00"));
4865 let mut first = res!(Journal::open(cfg_at(&dir)));
4866 res!(first.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4867 res!(first.flush());
4868 match Journal::open(cfg_at(&dir)) {
4869 Ok(_) => return Err(err!(
4870 "A second journal on one directory must be refused."; Bug)),
4871 Err(e) => {
4872 let s = fmt!("{}", e);
4873 // The reader of this one is a PERSON: it is the sentence a browser gets
4874 // when the Terminal will not open, so it has to say what happened and what
4875 // to do rather than describe the record's invariant.
4876 assert!(s.contains("already open in another browser window"),
4877 "the refusal does not say what happened: {}", s);
4878 assert!(s.contains("Close the other window and try again"),
4879 "the refusal names nothing to do about it: {}", s);
4880 },
4881 }
4882 drop(first);
4883 // And the lock must be released when the first journal goes away.
4884 let _second = res!(Journal::open(cfg_at(&dir)));
4885 Ok(())
4886 }
4887
4888 /// §2.6 A write that reaches nothing must be an error, so that "journal
4889 /// before acting" refuses the command rather than running it unrecorded.
4890 #[test]
4891 fn test_a_write_that_reaches_nothing_is_an_error_00() -> Outcome<()> {
4892 let dir = res!(scratch("gone_00"));
4893 let mut j = res!(Journal::open(cfg_at(&dir)));
4894 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4895 res!(fs::remove_dir_all(&dir), IO, File);
4896 match j.append(&Event::Refused {
4897 id: "run-2".to_string(),
4898 reason: "no".to_string(),
4899 }) {
4900 Ok(_) => Err(err!(
4901 "An entry that reached nothing must not be reported as written."; Bug)),
4902 Err(_) => Ok(()),
4903 }
4904 }
4905
4906 /// §2.7 The Rust verifier and the documented shell verifier must agree, and
4907 /// a CRLF conversion is where they parted company.
4908 #[test]
4909 fn test_crlf_disagrees_with_no_verifier_00() -> Outcome<()> {
4910 let (dir, path) = res!(seeded("crlf_00"));
4911 let text = res!(read_text(&path));
4912 let crlf = text.replace('\n', "\r\n");
4913 res!(fs::write(&path, crlf.as_bytes()), IO, File);
4914 match res!(verify_file(&path, Some(&Chain::genesis()))) {
4915 Verdict::Broken {..} => {},
4916 other => return Err(err!(
4917 "A CRLF journal mismatches every line under `sed | sha256sum`, so \
4918 the Rust verifier must not call it intact, got {:?}", other; Bug)),
4919 }
4920 let _ = dir;
4921 Ok(())
4922 }
4923
4924 /// §2.8 A journal file is readable by its owner and by nobody else.
4925 #[cfg(unix)]
4926 #[test]
4927 fn test_journal_files_are_private_00() -> Outcome<()> {
4928 use std::os::unix::fs::PermissionsExt;
4929 let (_dir, path) = res!(seeded("perm_00"));
4930 let md = res!(fs::metadata(&path), IO, File);
4931 let mode = md.permissions().mode() & 0o777;
4932 assert_eq!(mode, 0o600, "a journal file must not be group or world readable");
4933 Ok(())
4934 }
4935
4936 /// §2.8 An operator's own directory must not be chmodded to 0700 behind
4937 /// their back.
4938 #[cfg(unix)]
4939 #[test]
4940 fn test_an_operator_directory_is_not_chmodded_00() -> Outcome<()> {
4941 use std::os::unix::fs::PermissionsExt;
4942 let base = res!(scratch("chmod_00"));
4943 let dir = base.join("shared");
4944 res!(fs::create_dir(&dir), IO, File);
4945 // Something of the operator's that is not a journal.
4946 res!(fs::write(dir.join("notes.txt"), b"mine"), IO, File);
4947 let mut p = res!(fs::metadata(&dir), IO, File).permissions();
4948 p.set_mode(0o755);
4949 res!(fs::set_permissions(&dir, p), IO, File);
4950
4951 let opened = Journal::open(cfg_at(&dir));
4952 let mode = res!(fs::metadata(&dir), IO, File).permissions().mode() & 0o777;
4953 assert_eq!(mode, 0o755,
4954 "the journal must not silently tighten a directory it did not make");
4955 // Refusing is a fine answer; quietly re-permissioning is not.
4956 drop(opened);
4957 Ok(())
4958 }
4959
4960 /// A directory holding only the record and `root.txt` is one the hand made.
4961 ///
4962 /// The documented install puts `root.txt` beside the journal, so counting
4963 /// it as somebody else's file made the ordinary layout a directory the hand
4964 /// would neither tighten nor use -- a refusal produced by following the
4965 /// instructions.
4966 #[cfg(unix)]
4967 #[test]
4968 fn test_root_txt_beside_the_journal_is_ours_00() -> Outcome<()> {
4969 use std::os::unix::fs::PermissionsExt;
4970 let base = res!(scratch("chmod_root_00"));
4971 let dir = base.join("journal");
4972 res!(fs::create_dir(&dir), IO, File);
4973 res!(fs::write(dir.join(crate::ROOT_FILE), b"/home/u/work\n"), IO, File);
4974 let mut p = res!(fs::metadata(&dir), IO, File).permissions();
4975 p.set_mode(0o755);
4976 res!(fs::set_permissions(&dir, p), IO, File);
4977
4978 let opened = Journal::open(cfg_at(&dir));
4979 assert!(opened.is_ok(), "the hand must open a directory holding only its own files");
4980 let mode = res!(fs::metadata(&dir), IO, File).permissions().mode() & 0o777;
4981 assert_eq!(mode, 0o700,
4982 "a directory holding only the record and root.txt is the hand's, and is tightened");
4983 drop(opened);
4984 Ok(())
4985 }
4986
4987 /// §2.8 A hand restarted on a later day starts a new file rather than
4988 /// appending into the previous day's.
4989 #[test]
4990 fn test_a_restart_on_a_later_day_rotates_00() -> Outcome<()> {
4991 let dir = res!(scratch("day_restart_00"));
4992 let c = cfg_at(&dir);
4993 {
4994 let mut j = res!(Journal::open(c.clone()));
4995 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
4996 res!(j.flush());
4997 }
4998 let mut later = cfg_at(&dir);
4999 later.clock = Clock::fixed(1_754_000_000_000 + 2 * DAY_MS);
5000 {
5001 let mut j = res!(Journal::open(later));
5002 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
5003 res!(j.flush());
5004 }
5005 let files = res!(journal_files(&dir));
5006 assert_eq!(files.len(), 2,
5007 "a restart two days later must not append into the old day's file");
5008 assert!(res!(verify_dir(&dir)).is_intact());
5009 Ok(())
5010 }
5011
5012 // ── Attacks on the fixes themselves ───────────────────────────────
5013
5014 /// Removing the mark along with the files does not restore the verdict:
5015 /// a history with entries and nothing vouching for its length is broken.
5016 #[test]
5017 fn test_deleting_the_mark_too_is_still_caught_00() -> Outcome<()> {
5018 let dir = res!(scratch("mark_gone_00"));
5019 let mut c = cfg_at(&dir);
5020 c.max_bytes = 700;
5021 {
5022 let mut j = res!(Journal::open(c));
5023 for i in 0..40u32 {
5024 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: i }));
5025 }
5026 res!(j.flush());
5027 }
5028 let files = res!(journal_files(&dir));
5029 for (_, p) in files.iter().rev().take(2) {
5030 res!(fs::remove_file(p), IO, File);
5031 }
5032 res!(fs::remove_file(mark_path(&dir)), IO, File);
5033 match res!(verify_dir(&dir)) {
5034 Verdict::Broken { reason, .. } => {
5035 assert!(reason.contains("no high-water mark"), "reason was: {}", reason);
5036 },
5037 other => return Err(err!(
5038 "Deleting the mark must not launder a truncation, got {:?}", other; Bug)),
5039 }
5040 Ok(())
5041 }
5042
5043 /// And an edited mark is caught by its own hash rather than believed.
5044 #[test]
5045 fn test_an_edited_mark_is_caught_00() -> Outcome<()> {
5046 let dir = res!(scratch("mark_edit_00"));
5047 {
5048 let mut j = res!(Journal::open(cfg_at(&dir)));
5049 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
5050 res!(j.flush());
5051 }
5052 let path = mark_path(&dir);
5053 let text = res!(read_text(&path));
5054 res!(fs::write(&path, text.replace("\"seq\":1", "\"seq\":9").as_bytes()), IO, File);
5055 match res!(verify_dir(&dir)) {
5056 Verdict::Broken { reason, .. } => {
5057 assert!(reason.contains("does not read back"), "reason was: {}", reason);
5058 },
5059 other => return Err(err!("An edited mark must be caught, got {:?}", other; Bug)),
5060 }
5061 Ok(())
5062 }
5063
5064 /// A gap, once recorded, stays recorded: restarting the hand does not put a
5065 /// truncated history back to intact.
5066 #[test]
5067 fn test_a_recorded_gap_survives_a_restart_00() -> Outcome<()> {
5068 let dir = res!(scratch("gap_sticky_00"));
5069 let mut c = cfg_at(&dir);
5070 c.max_bytes = 700;
5071 {
5072 let mut j = res!(Journal::open(c.clone()));
5073 for i in 0..40u32 {
5074 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: i }));
5075 }
5076 res!(j.flush());
5077 }
5078 let files = res!(journal_files(&dir));
5079 for (_, p) in files.iter().rev().take(2) {
5080 res!(fs::remove_file(p), IO, File);
5081 }
5082 for _ in 0..3 {
5083 let mut j = res!(Journal::open(c.clone()));
5084 res!(j.append(&Event::Started { id: "after".to_string(), pid: 9 }));
5085 res!(j.flush());
5086 drop(j);
5087 match res!(verify_dir(&dir)) {
5088 Verdict::Broken {..} => {},
5089 other => return Err(err!(
5090 "A recorded gap must not wash out with a restart, got {:?}", other; Bug)),
5091 }
5092 }
5093 // And the record says so in its own words.
5094 let mut said = false;
5095 for (_, p) in res!(journal_files(&dir)).iter() {
5096 if res!(read_text(p)).contains("\"kind\":\"gap\"") {
5097 said = true;
5098 }
5099 }
5100 assert!(said, "the record must carry the gap it detected");
5101 Ok(())
5102 }
5103
5104 /// Every credential shape brought against the redactor, in argv and in the
5105 /// free text of a refusal alike.
5106 #[test]
5107 fn test_more_credential_shapes_are_redacted_00() -> Outcome<()> {
5108 let jwt = fmt!("{}{}", "eyJ", "hbGciOiJIUzI1NiJ9.e30.abcdefghijkl"); // allowlist secret
5109 let aws = fmt!("{}{}", "AKIA", "IOSFODNN7EXAMPLE"); // allowlist secret
5110 let cases: Vec<(Vec<String>, &str)> = vec![
5111 (vec!["curl".to_string(), "-u".to_string(), "me:hunter2".to_string()], "hunter2"),
5112 (vec!["curl".to_string(), "--user=me:hunter2".to_string()], "hunter2"),
5113 (vec!["curl".to_string(), "-d".to_string(),
5114 "grant_type=password&client_secret=hunter2".to_string()], "hunter2"),
5115 (vec!["curl".to_string(), "-d".to_string(), "password=hunter2".to_string()],
5116 "hunter2"),
5117 (vec!["-H".to_string(), fmt!("Cookie: session={}", jwt)], jwt.as_str()),
5118 (vec![fmt!("Authorization:Bearer {}", jwt)], jwt.as_str()),
5119 (vec!["aws".to_string(), "--access-key".to_string(), aws.clone()], aws.as_str()),
5120 (vec![fmt!("/home/u/{}/build", aws)], aws.as_str()),
5121 (vec!["--client-secret".to_string(), "hunter2".to_string()], "hunter2"),
5122 (vec![fmt!("--auth-token={}", jwt)], jwt.as_str()),
5123 ];
5124 for (argv, must_go) in cases.iter() {
5125 let (out, cut) = redact_argv(argv);
5126 let joined = out.join(" ");
5127 assert!(!joined.contains(must_go),
5128 "'{}' survived redaction of {:?} -> {:?}", must_go, argv, out);
5129 assert!(cut > 0, "nothing was counted as removed from {:?}", argv);
5130 }
5131 // The shapes found by sweeping a wider net over real tools, each of
5132 // which came back untouched before the rule that catches it was written.
5133 let odd: Vec<(Vec<String>, &str)> = vec![
5134 (vec!["curl".to_string(), fmt!("--oauth2-bearer={}", jwt)], jwt.as_str()),
5135 (vec!["npm".to_string(), "config".to_string(), "set".to_string(),
5136 fmt!("//registry.npmjs.org/:_authToken={}", jwt)], jwt.as_str()),
5137 (vec!["ssh-add".to_string(), fmt!("--passphrase {}", jwt)], jwt.as_str()),
5138 (vec!["mongo".to_string(), fmt!("mongodb+srv://u:hunter2@h/db")], "hunter2"),
5139 (vec!["curl".to_string(), fmt!("--cookie=session={}", jwt)], jwt.as_str()),
5140 ];
5141 for (argv, must_go) in odd.iter() {
5142 let (out, cut) = redact_argv(argv);
5143 let joined = out.join(" ");
5144 assert!(!joined.contains(must_go),
5145 "'{}' survived redaction of {:?} -> {:?}", must_go, argv, out);
5146 assert!(cut > 0, "nothing was counted as removed from {:?}", argv);
5147 }
5148 // The same shapes inside a sentence, which is how a refusal quotes them.
5149 let prose = vec![
5150 fmt!("Refusing `curl -H 'Authorization: Bearer {}'` in a tainted turn.", jwt),
5151 fmt!("The command set --password hunter2 and I will not run it."),
5152 fmt!("Cannot reach {} from here.", aws),
5153 fmt!("It wanted https://oauth2:{}@github.com and the fence says no.", jwt),
5154 ];
5155 let prose2 = vec![
5156 fmt!("It tried Basic {} against the gateway.", jwt),
5157 fmt!("Refused: curl -u me:hunter2 https://h"),
5158 ];
5159 for p in prose2.iter() {
5160 let (out, _) = redact_text(p);
5161 assert!(!out.contains(jwt.as_str()) && !out.contains("hunter2"),
5162 "a credential survived '{}' -> '{}'", p, out);
5163 }
5164 for p in prose.iter() {
5165 let (out, cut) = redact_text(p);
5166 assert!(!out.contains(jwt.as_str()) && !out.contains(aws.as_str())
5167 && !out.contains("hunter2"),
5168 "a credential survived '{}' -> '{}'", p, out);
5169 assert!(cut > 0, "nothing was counted as removed from '{}'", p);
5170 }
5171 Ok(())
5172 }
5173
5174 /// Redaction is idempotent, so running it twice never says that two values
5175 /// were removed where one was.
5176 #[test]
5177 fn test_redaction_is_idempotent_00() -> Outcome<()> {
5178 let key = fmt!("{}{}", "ghp_", "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); // allowlist secret
5179 let argv = vec![
5180 "gh".to_string(),
5181 "--token".to_string(),
5182 "hunter2".to_string(),
5183 fmt!("--api-key={}", key),
5184 key.clone(),
5185 fmt!("Authorization: Bearer {}", key),
5186 ];
5187 let (once, a) = redact_argv(&argv);
5188 let (twice, b) = redact_argv(&once);
5189 assert_eq!(once, twice, "a second pass must change nothing");
5190 assert_eq!(b, 0, "a second pass must count nothing, but counted {} (first {})", b, a);
5191 let (t1, c1) = redact_text("Bearer hunter2 and --password hunter2");
5192 let (t2, c2) = redact_text(&t1);
5193 assert_eq!(t1, t2);
5194 assert_eq!(c2, 0, "a second pass over text must count nothing (first {})", c1);
5195 Ok(())
5196 }
5197
5198 /// Over-redaction is a real cost, so the shapes that merely look alike are
5199 /// left as they are.
5200 #[test]
5201 fn test_ordinary_arguments_survive_redaction_00() -> Outcome<()> {
5202 let argv = vec![
5203 "docker".to_string(),
5204 "run".to_string(),
5205 "-p8080:80".to_string(),
5206 "-p".to_string(),
5207 "127.0.0.1:5432:5432/tcp".to_string(),
5208 "-u".to_string(),
5209 "1000".to_string(),
5210 "--key-file".to_string(),
5211 "/etc/ssl/x.pem".to_string(),
5212 "--user".to_string(),
5213 "bob".to_string(),
5214 "cargo".to_string(),
5215 "test".to_string(),
5216 "--package".to_string(),
5217 "sk-headless".to_string(),
5218 "PATH=/usr/bin".to_string(),
5219 "https://example.com/a?page=2".to_string(),
5220 ];
5221 let (out, cut) = redact_argv(&argv);
5222 assert_eq!(cut, 0, "nothing here is a credential, but {:?} was produced", out);
5223 assert_eq!(out, argv);
5224 let (text, c) = redact_text(
5225 "The build failed in /home/u/proj and the bypass flag was ignored.");
5226 assert_eq!(c, 0, "ordinary prose must survive: {}", text);
5227 Ok(())
5228 }
5229
5230 /// A journal file holding bytes that are not text is a verdict, not an
5231 /// error, because an error means the hand can never act again.
5232 #[test]
5233 fn test_a_file_that_is_not_text_is_a_verdict_00() -> Outcome<()> {
5234 let (dir, path) = res!(seeded("binary_00"));
5235 res!(fs::write(&path, [0x7bu8, 0xff, 0xfe, 0x0a]), IO, File);
5236 match res!(verify_dir(&dir)) {
5237 Verdict::Broken {..} => Ok(()),
5238 other => Err(err!("Binary rubbish must be Broken, got {:?}", other; Bug)),
5239 }
5240 }
5241
5242 /// A crash between rotating and writing the first entry of the new file
5243 /// leaves an empty newest file, and that must not read as history lost.
5244 /// A tamper-evident log that cries wolf is worth nothing.
5245 #[test]
5246 fn test_an_empty_newest_file_is_not_a_gap_00() -> Outcome<()> {
5247 let dir = res!(scratch("empty_newest_00"));
5248 let c = cfg_at(&dir);
5249 {
5250 let mut j = res!(Journal::open(c.clone()));
5251 for i in 0..5u32 {
5252 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: i }));
5253 }
5254 res!(j.flush());
5255 }
5256 // Exactly what the hand leaves behind when it dies between opening the
5257 // next file and writing that file's first entry: an empty file, and a
5258 // mark that already names it.
5259 let next = res!(journal_files(&dir));
5260 let n = match next.last() {
5261 Some((n, _)) => *n,
5262 None => return Err(err!("No journal files."; Bug)),
5263 };
5264 res!(File::create(dir.join(file_name(n + 1))), IO, File);
5265 let m = match res!(read_mark(&dir)) {
5266 MarkState::At(m) => m,
5267 other => return Err(err!("Expected a mark, got {:?}", other; Bug)),
5268 };
5269 let line = res!(mark_line(&Mark { files: n + 1, ..m }));
5270 res!(fs::write(mark_path(&dir), fmt!("{}\n", line).as_bytes()), IO, File);
5271
5272 let mut j = res!(Journal::open(c));
5273 res!(j.append(&Event::Started { id: "after".to_string(), pid: 9 }));
5274 res!(j.flush());
5275 drop(j);
5276
5277 match res!(verify_dir(&dir)) {
5278 Verdict::Intact { entries, .. } => {
5279 assert_eq!(entries, 6, "no entry should be lost or invented");
5280 for (_, p) in res!(journal_files(&dir)).iter() {
5281 assert!(!res!(read_text(p)).contains("\"kind\":\"gap\""),
5282 "an interrupted rotation is not a loss of history");
5283 }
5284 Ok(())
5285 },
5286 other => Err(err!(
5287 "An empty file left by a rotation must not read as a gap, got {:?}",
5288 other; Bug)),
5289 }
5290 }
5291
5292 /// A directory planted where the lock or the mark goes does not stop the
5293 /// hand, which is the denial of service one `mkdir` would otherwise buy.
5294 #[test]
5295 fn test_planted_furniture_does_not_stop_the_hand_00() -> Outcome<()> {
5296 for name in ["lock", "head.json", "head.json.new"] {
5297 let dir = res!(scratch(&fmt!("furniture_{}", name.replace('.', "_"))));
5298 {
5299 let mut j = res!(Journal::open(cfg_at(&dir)));
5300 res!(j.append(&Event::Started { id: "a".to_string(), pid: 1 }));
5301 res!(j.flush());
5302 }
5303 res!(fs::remove_file(dir.join(name)).or_else(|_| Ok::<(), std::io::Error>(())),
5304 IO, File);
5305 res!(fs::create_dir(dir.join(name)), IO, File);
5306
5307 let mut j = res!(Journal::open(cfg_at(&dir)));
5308 res!(j.append(&Event::Started { id: "b".to_string(), pid: 2 }));
5309 res!(j.flush());
5310 drop(j);
5311 // A verdict, either way, rather than an error.
5312 res!(verify_dir(&dir));
5313 }
5314 Ok(())
5315 }
5316
5317 /// The residual, stated as a test so it cannot quietly get worse: a
5318 /// truncation is caught unless the mark is forged to match it, and a forged
5319 /// mark is what an attacker with write access to the directory must now
5320 /// produce. The chain alone never could have caught this; the mark makes it
5321 /// cost a second, consistent forgery.
5322 #[test]
5323 fn test_truncation_needs_a_forged_mark_00() -> Outcome<()> {
5324 let dir = res!(scratch("forge_00"));
5325 let mut c = cfg_at(&dir);
5326 c.max_bytes = 700;
5327 {
5328 let mut j = res!(Journal::open(c));
5329 for i in 0..40u32 {
5330 res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: i }));
5331 }
5332 res!(j.flush());
5333 }
5334 let files = res!(journal_files(&dir));
5335 for (_, p) in files.iter().rev().take(2) {
5336 res!(fs::remove_file(p), IO, File);
5337 }
5338 // Truncated, mark untouched: caught.
5339 assert!(!res!(verify_dir(&dir)).is_intact(), "a bare truncation must be caught");
5340
5341 // Now forge the mark to agree with the shortened history, which is the
5342 // work the mark forces on anyone who wants the truncation to pass.
5343 let left = res!(journal_files(&dir));
5344 let mut chain = Chain::genesis();
5345 let mut top = 0u32;
5346 for (n, p) in left.iter() {
5347 match res!(verify_file(p, Some(&chain))) {
5348 Verdict::Intact { chain: ch, .. } => { chain = ch; top = *n; },
5349 other => return Err(err!("The kept files should verify: {:?}", other; Bug)),
5350 }
5351 }
5352 let line = res!(mark_line(&Mark { seq: chain.seq, files: top, head: chain.head }));
5353 res!(fs::write(mark_path(&dir), fmt!("{}\n", line).as_bytes()), IO, File);
5354 assert!(res!(verify_dir(&dir)).is_intact(),
5355 "a consistent forgery of both is the residual the module doc states");
5356 Ok(())
5357 }
5358
5359
5360 /// The rotation stops rather than writing a name outside its own format.
5361 #[test]
5362 fn test_the_rotation_will_not_leave_its_name_format_00() -> Outcome<()> {
5363 assert!(next_idx(MAX_IDX).is_err(), "the last name must be the last name");
5364 match next_idx(MAX_IDX - 1) {
5365 Ok(n) => assert_eq!(n, MAX_IDX),
5366 Err(e) => return Err(err!("The last-but-one index must roll: {}", e; Bug)),
5367 }
5368 Ok(())
5369 }
5370
5371 // ── The external oracle ───────────────────────────────────────────
5372
5373 /// Every entry hash is reproduced by coreutils, not by this module.
5374 ///
5375 /// The pipeline is the one the module doc documents, run by a real shell over
5376 /// a journal full of the text most likely to break a byte-exact rule: emoji,
5377 /// DEL, U+2029, CJK, quotes, backslashes, tabs and a three-hundred element
5378 /// argument vector. A check that only agrees with itself proves nothing.
5379 #[test]
5380 fn test_coreutils_reproduces_every_entry_hash_00() -> Outcome<()> {
5381 use std::process::Command;
5382 // Skip where the tools are not installed rather than fail a build.
5383 match Command::new("sha256sum").arg("--version").output() {
5384 Ok(o) if o.status.success() => {},
5385 _ => return Ok(()),
5386 }
5387 let dir = res!(scratch("oracle_00"));
5388 let mut j = res!(Journal::open(cfg_at(&dir)));
5389 let awkward = "quote\" backslash\\ tab\t emoji😀 del\u{7f} sep\u{2029} 日本語";
5390 for i in 0..200u32 {
5391 match i % 4 {
5392 0 => { res!(j.append(&Event::Refused {
5393 id: fmt!("run-{}", i),
5394 reason: fmt!("{} #{}", awkward, i),
5395 })); },
5396 1 => {
5397 let argv: Vec<String> = (0..300).map(|k| fmt!("arg{}{}", k, awkward)).collect();
5398 res!(j.append(&Event::Exec {
5399 id: fmt!("run-{}", i),
5400 argv,
5401 redactions: 0,
5402 cwd: fmt!("/home/u/{}", awkward),
5403 env_keys: vec!["PATH".to_string()],
5404 stdin_bytes: 12,
5405 timeout_ms: 1000,
5406 capture: Capture::Both,
5407 fence: FenceSpec::default(),
5408 mechs: vec!["landlock".to_string()],
5409 }));
5410 },
5411 2 => { res!(j.append(&Event::Started { id: fmt!("run-{}", i), pid: i })); },
5412 _ => { res!(j.append(&Event::Closed { reason: fmt!("{}", awkward) })); },
5413 }
5414 }
5415 res!(j.flush());
5416 let path = j.path().to_path_buf();
5417 drop(j);
5418
5419 // The documented idiom, run by a real shell: strip the trailing hash and
5420 // hash what is left.
5421 let script = fmt!(
5422 "set -e; sed 's/,\"entry\":\"[0-9a-f]*\"}}$//' '{}' | \
5423 while IFS= read -r l; do printf '%s' \"$l\" | sha256sum | cut -d' ' -f1; done",
5424 path.to_string_lossy());
5425 let out = res!(Command::new("sh").arg("-c").arg(&script).output(), IO, File);
5426 if !out.status.success() {
5427 return Err(err!(
5428 "The shell verifier would not run: {}",
5429 String::from_utf8_lossy(&out.stderr); Test, IO));
5430 }
5431 let theirs: Vec<String> = String::from_utf8_lossy(&out.stdout)
5432 .lines().map(|s| s.trim().to_string()).collect();
5433 let ours: Vec<String> = res!(lines_of(&path)).iter()
5434 .filter_map(|l| entry_hash_of(l).map(|h| h.to_string()))
5435 .collect();
5436 assert_eq!(ours.len(), 200, "every entry should have been written");
5437 assert_eq!(theirs.len(), ours.len(), "the shell read a different number of lines");
5438 for (i, (a, b)) in ours.iter().zip(theirs.iter()).enumerate() {
5439 assert_eq!(a, b, "coreutils and this module disagree on line {}", i + 1);
5440 }
5441 Ok(())
5442 }
5443
5444 /// A line the format does not allow is refused at the point of building it,
5445 /// rather than written and discovered later.
5446 #[test]
5447 fn test_a_line_that_cannot_be_read_back_is_not_written_00() -> Outcome<()> {
5448 assert!(build_line(0, 0, "Exec", "{}", GENESIS).is_err(),
5449 "an upper case kind would need escaping");
5450 assert!(build_line(0, 0, "exec", "{}", "not-a-hash").is_err(),
5451 "a malformed predecessor must be refused");
5452 res!(build_line(0, 0, "exec", "{}", GENESIS));
5453 Ok(())
5454 }
5455}
5456
5457/// How many bytes of text one file op carries, which is what the journal keeps of it.
5458///
5459/// # Arguments
5460/// * `op` - The operation, whose text is measured and never recorded.
5461fn op_bytes(op: &FileOp) -> u64 {
5462 match op {
5463 FileOp::Write { content, .. } => content.len() as u64,
5464 FileOp::Edit { old, new, .. } => (old.len() + new.len()) as u64,
5465 FileOp::Read { .. } | FileOp::Move { .. }
5466 | FileOp::List { .. } | FileOp::MkDir { .. } => 0,
5467 // A walk carries a pattern rather than a payload. It is measured all the same, so a
5468 // reader of the record can tell a search of eight files from a search of eight thousand
5469 // by what came back rather than by what went out.
5470 FileOp::Search { query, glob, .. } => (query.len() + glob.len()) as u64,
5471 FileOp::Glob { pattern, .. } => pattern.len() as u64,
5472 }
5473}