Skip to main content

capsule_runtime/
record_seal.rs

1//! A session's records, made checkable by anyone holding the host's public key and nothing else.
2//!
3//! Three parts, each the one implementation `mur`, the escape-conformance harness and a check
4//! capsule call:
5//!
6//! * **The host key** ([`HostKey`]): an Ed25519 seed in `~/.murmur/keys/record-seal.key` (64 hex
7//!   characters, `0600`, directory `0700`) and its public half in `record-seal.pub` (64 hex
8//!   characters, `0644`). Only the `mur` process reads the seed; no capsule grant at `scoped` or
9//!   `sealed` reaches `~/.murmur/keys`.
10//! * **The chain.** Every `trace.jsonl` line ends with a `"prev_hash"` key naming the
11//!   [`line_hash`] of the line before it ([`GENESIS`] on line 1), written through one
12//!   [`InlineChain`] per file however many appenders hold it. `hooks/eval.jsonl`,
13//!   `completion.json` and a conversation record keep their bytes as written; their chain is the
14//!   list of line hashes in the seal.
15//! * **The seal** ([`Seal`]): `<stream>.seal` beside the stream, signing the chain's head, line
16//!   count, stream name and sealing session with the host key, written temp-and-rename.
17//!
18//! [`verify_session_records`] checks every stream of one records directory with a public key
19//! alone and reports a [`Verdict`] per stream and for the whole record.
20//!
21//! What is not sealed: a records directory's `home/`, `tmp/`, `logs/`, `out/` and `blobs/`
22//! (blobs are named by their own sha256). Deleting a stream together with its seal reads as
23//! [`Verdict::Absent`].
24
25use std::collections::{HashMap, HashSet};
26use std::io::{Read as _, Write as _};
27use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _};
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, LazyLock, Mutex, Weak};
30
31use ring::signature::{Ed25519KeyPair, KeyPair as _, UnparsedPublicKey, ED25519};
32use serde::{Deserialize, Serialize};
33
34use crate::workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE};
35
36// ── Names and formats ─────────────────────────────────────────────────────────
37
38/// `~/.murmur/keys`, relative to the murmur home.
39pub const KEYS_DIR: &str = "keys";
40/// The Ed25519 seed, as 64 lowercase hex characters and a newline.
41pub const PRIVATE_KEY_FILE: &str = "record-seal.key";
42/// The Ed25519 public key, as 64 lowercase hex characters and a newline.
43pub const PUBLIC_KEY_FILE: &str = "record-seal.pub";
44/// Mode of `~/.murmur/keys`.
45pub const KEYS_DIR_MODE: u32 = 0o700;
46/// Mode of [`PRIVATE_KEY_FILE`]; a key file with any group or other bit is refused.
47pub const PRIVATE_KEY_MODE: u32 = 0o600;
48/// Mode of [`PUBLIC_KEY_FILE`].
49pub const PUBLIC_KEY_MODE: u32 = 0o644;
50
51/// The `format` of every seal this build writes, and the first line of its signed message.
52pub const SEAL_FORMAT: &str = "murmur.seal/1";
53/// Appended to a stream's file name to name its seal.
54pub const SEAL_SUFFIX: &str = ".seal";
55/// The only `algorithm` a seal names.
56pub const SEAL_ALGORITHM: &str = "ed25519";
57/// Line 1's `prev_hash`.
58pub const GENESIS: &str = "genesis";
59/// The key every `trace.jsonl` line ends with.
60pub const PREV_HASH_KEY: &str = "prev_hash";
61/// `chain` of a seal whose stream carries its own `prev_hash` on every line.
62pub const CHAIN_INLINE: &str = "inline";
63/// `chain` of a seal that lists every line's hash itself.
64pub const CHAIN_LISTED: &str = "listed";
65
66/// The trace's stream name, which is also its file name in the records directory.
67pub const TRACE_STREAM: &str = crate::trace::TRACE_FILE_NAME;
68/// The scoring hook's stream, relative to the records directory.
69pub const EVAL_STREAM: &str = "hooks/eval.jsonl";
70/// A delegated child's outcome, relative to its records directory. Sealed as one "line": the
71/// whole file's bytes.
72pub const COMPLETION_STREAM: &str = crate::delegation::COMPLETION_FILE;
73/// Prefix of a conversation record's stream name: `conversation:<record>/<context-id>`.
74pub const CONVERSATION_STREAM_PREFIX: &str = "conversation:";
75/// `~/.murmur/conversations`, relative to the murmur home: where conversation records live.
76pub const CONVERSATIONS_DIR: &str = crate::conversation::CONVERSATION_ROOT_DIR;
77/// The `session_id` a conversation seal carries when no session rewrote it: `mur conversation`
78/// truncating a record from the command line.
79pub const CLI_SEALER: &str = "mur conversation";
80/// `schema` of [`RecordReport`], and of `mur trace verify --json`.
81pub const REPORT_SCHEMA: u32 = 1;
82
83/// How long an append waits for another holder's `flock` before writing without it. A capsule
84/// can read `trace.jsonl`, so it can hold a shared lock on it; a writer that waited forever would
85/// hand it the session.
86const FLOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(500);
87
88/// `"sha256:"` and the lowercase hex SHA-256 of `bytes`: one line without its newline.
89#[must_use]
90pub fn line_hash(bytes: &[u8]) -> String {
91    format!("sha256:{}", murmur_artifact::sha256_hex(bytes))
92}
93
94/// The first 16 hex characters of the SHA-256 of a raw 32-byte public key.
95#[must_use]
96pub fn key_id_of(raw_public_key: &[u8]) -> String {
97    murmur_artifact::sha256_hex(raw_public_key)[..16].to_string()
98}
99
100/// The UTF-8 bytes a seal's signature covers:
101/// `murmur.seal/1\n<stream>\n<session_id>\n<lines>\n<head>\n`.
102#[must_use]
103pub fn signed_message(stream: &str, session_id: &str, lines: u64, head: &str) -> Vec<u8> {
104    format!("{SEAL_FORMAT}\n{stream}\n{session_id}\n{lines}\n{head}\n").into_bytes()
105}
106
107/// A `"listed"` seal's head: `"sha256:"` and the hex SHA-256 of the line hashes joined by `\n`.
108#[must_use]
109pub fn listed_head(line_hashes: &[String]) -> String {
110    line_hash(line_hashes.join("\n").as_bytes())
111}
112
113/// `<murmur home>/keys`.
114#[must_use]
115pub fn keys_dir(murmur_home: &Path) -> PathBuf {
116    murmur_home.join(KEYS_DIR)
117}
118
119/// `<murmur home>/keys/record-seal.key`.
120#[must_use]
121pub fn private_key_path(murmur_home: &Path) -> PathBuf {
122    keys_dir(murmur_home).join(PRIVATE_KEY_FILE)
123}
124
125/// `<murmur home>/keys/record-seal.pub`.
126#[must_use]
127pub fn public_key_path(murmur_home: &Path) -> PathBuf {
128    keys_dir(murmur_home).join(PUBLIC_KEY_FILE)
129}
130
131/// The conversation record a session's `trace.jsonl.seal` points at.
132#[must_use]
133pub fn conversation_stream(record: &str, context_id: &str) -> String {
134    format!("{CONVERSATION_STREAM_PREFIX}{record}/{context_id}")
135}
136
137fn hex(bytes: &[u8]) -> String {
138    use std::fmt::Write as _;
139    bytes
140        .iter()
141        .fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
142            let _ = write!(out, "{byte:02x}");
143            out
144        })
145}
146
147fn unhex(text: &str) -> Option<Vec<u8>> {
148    if !text.len().is_multiple_of(2) || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
149        return None;
150    }
151    (0..text.len())
152        .step_by(2)
153        .map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok())
154        .collect()
155}
156
157// ── The host key ──────────────────────────────────────────────────────────────
158
159/// A public key a seal is checked against, with the id seals name it by.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct PublicKey {
162    raw: [u8; 32],
163    key_id: String,
164    path: Option<PathBuf>,
165}
166
167impl PublicKey {
168    /// A key from its 32 raw bytes.
169    #[must_use]
170    pub fn from_raw(raw: [u8; 32]) -> Self {
171        Self {
172            key_id: key_id_of(&raw),
173            raw,
174            path: None,
175        }
176    }
177
178    /// A key from 64 hex characters, surrounding whitespace ignored.
179    pub fn from_hex(text: &str) -> Result<Self, String> {
180        let bytes = unhex(text.trim())
181            .filter(|bytes| bytes.len() == 32)
182            .ok_or_else(|| "is not 64 hex characters naming an Ed25519 public key".to_string())?;
183        let mut raw = [0u8; 32];
184        raw.copy_from_slice(&bytes);
185        Ok(Self::from_raw(raw))
186    }
187
188    /// The key in a `record-seal.pub` file.
189    pub fn load(path: &Path) -> Result<Self, String> {
190        let mut text = String::new();
191        std::fs::File::open(path)
192            .and_then(|file| file.take(1024).read_to_string(&mut text))
193            .map_err(|error| format!("{}: {error}", path.display()))?;
194        let mut key =
195            Self::from_hex(&text).map_err(|reason| format!("{}: {reason}", path.display()))?;
196        key.path = Some(path.to_path_buf());
197        Ok(key)
198    }
199
200    /// First 16 hex characters of the SHA-256 of the raw key.
201    #[must_use]
202    pub fn key_id(&self) -> &str {
203        &self.key_id
204    }
205
206    /// The file the key was loaded from, when it was.
207    #[must_use]
208    pub fn path(&self) -> Option<&Path> {
209        self.path.as_deref()
210    }
211
212    /// The raw key as 64 lowercase hex characters.
213    #[must_use]
214    pub fn to_hex(&self) -> String {
215        hex(&self.raw)
216    }
217
218    /// Whether `signature_hex` is a valid Ed25519 signature of `message` under this key.
219    #[must_use]
220    pub fn verifies(&self, message: &[u8], signature_hex: &str) -> bool {
221        let Some(signature) = unhex(signature_hex).filter(|sig| sig.len() == 64) else {
222            return false;
223        };
224        UnparsedPublicKey::new(&ED25519, &self.raw)
225            .verify(message, &signature)
226            .is_ok()
227    }
228}
229
230/// The host's record-seal key pair. Only the `mur` process holds one.
231///
232/// `ring` keeps the expanded key in its own type, which does not zeroize on drop; the seed it was
233/// built from is zeroized as soon as the key pair exists.
234pub struct HostKey {
235    pair: Ed25519KeyPair,
236    public: PublicKey,
237}
238
239impl std::fmt::Debug for HostKey {
240    /// Names the key by its id and nothing that signs.
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.debug_struct("HostKey")
243            .field("key_id", &self.public.key_id)
244            .finish_non_exhaustive()
245    }
246}
247
248/// One loaded key per murmur home: tests in one process run under different scratch homes.
249static HOST_KEYS: LazyLock<Mutex<HashMap<PathBuf, Arc<HostKey>>>> =
250    LazyLock::new(|| Mutex::new(HashMap::new()));
251
252impl HostKey {
253    /// The key under `murmur_home`, created on first use.
254    ///
255    /// Ensures `keys/` at `0700`; opens the seed without following a link and refuses a file that
256    /// is not regular, not owned by this user, or carries any group or other bit; creates a
257    /// missing one from fresh randomness under a temporary name linked into place, so two racing
258    /// first launches end with one key; and writes `record-seal.pub` when it is missing or does
259    /// not match. The `Err` names the file and the reason, never a byte of the key.
260    pub fn load_or_create(murmur_home: &Path) -> Result<Arc<HostKey>, String> {
261        let mut cache = HOST_KEYS.lock().unwrap_or_else(|p| p.into_inner());
262        if let Some(key) = cache.get(murmur_home) {
263            return Ok(Arc::clone(key));
264        }
265        let key = Arc::new(Self::load_or_create_uncached(murmur_home)?);
266        cache.insert(murmur_home.to_path_buf(), Arc::clone(&key));
267        Ok(key)
268    }
269
270    fn load_or_create_uncached(murmur_home: &Path) -> Result<HostKey, String> {
271        let dir = keys_dir(murmur_home);
272        match std::fs::symlink_metadata(&dir) {
273            Ok(meta) if meta.file_type().is_symlink() || !meta.is_dir() => {
274                return Err(format!("{}: is not a directory", dir.display()));
275            }
276            Ok(_) => {}
277            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
278            Err(error) => return Err(format!("{}: {error}", dir.display())),
279        }
280        crate::state_store::ensure_private_dir(&dir, KEYS_DIR_MODE)
281            .map_err(|reason| format!("{}: {reason}", dir.display()))?;
282        let private = dir.join(PRIVATE_KEY_FILE);
283        let mut seed = match read_seed(&private)? {
284            Some(seed) => seed,
285            None => create_seed(&dir, &private)?,
286        };
287        let pair = Ed25519KeyPair::from_seed_unchecked(&seed);
288        crate::mac_token::zeroize(&mut seed);
289        let pair =
290            pair.map_err(|_| format!("{}: does not hold a usable key", private.display()))?;
291        let mut raw = [0u8; 32];
292        raw.copy_from_slice(pair.public_key().as_ref());
293        let mut public = PublicKey::from_raw(raw);
294        let pub_path = dir.join(PUBLIC_KEY_FILE);
295        let want = format!("{}\n", public.to_hex());
296        let have = std::fs::read_to_string(&pub_path).ok();
297        if have.as_deref() != Some(want.as_str()) || mode_bits(&pub_path) != Some(PUBLIC_KEY_MODE) {
298            crate::retention::StagedRewrite::stage_with_mode(
299                &pub_path,
300                want.as_bytes(),
301                Some(PUBLIC_KEY_MODE),
302            )?
303            .commit()?;
304        }
305        public.path = Some(pub_path);
306        Ok(HostKey { pair, public })
307    }
308
309    #[must_use]
310    pub fn public_key(&self) -> &PublicKey {
311        &self.public
312    }
313
314    #[must_use]
315    pub fn key_id(&self) -> &str {
316        self.public.key_id()
317    }
318
319    /// 128 hex characters: the Ed25519 signature of `message`.
320    #[must_use]
321    pub fn sign_hex(&self, message: &[u8]) -> String {
322        hex(self.pair.sign(message).as_ref())
323    }
324}
325
326fn mode_bits(path: &Path) -> Option<u32> {
327    std::fs::symlink_metadata(path)
328        .ok()
329        .map(|meta| meta.permissions().mode() & 0o777)
330}
331
332/// The seed in `path`, `None` when there is no file, `Err` for a file that may not be used.
333fn read_seed(path: &Path) -> Result<Option<[u8; 32]>, String> {
334    let located = |reason: String| format!("{}: {reason}", path.display());
335    let file = match std::fs::OpenOptions::new()
336        .read(true)
337        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
338        .open(path)
339    {
340        Ok(file) => file,
341        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
342        Err(error) if error.raw_os_error() == Some(libc::ELOOP) => {
343            return Err(located(
344                "is a symbolic link; the record-seal key must be a regular file".into(),
345            ));
346        }
347        Err(error) => return Err(located(error.to_string())),
348    };
349    let meta = file
350        .metadata()
351        .map_err(|error| located(error.to_string()))?;
352    if !meta.is_file() {
353        return Err(located("is not a regular file".into()));
354    }
355    if meta.uid() != crate::murmur_home::current_euid() {
356        return Err(located(format!(
357            "is owned by uid {}, not by this user",
358            meta.uid()
359        )));
360    }
361    let mode = meta.permissions().mode() & 0o777;
362    if mode & 0o077 != 0 {
363        return Err(located(format!(
364            "has mode {mode:04o}; the record-seal key must be 0600, readable by its owner alone"
365        )));
366    }
367    let mut text = Vec::with_capacity(70);
368    file.take(256)
369        .read_to_end(&mut text)
370        .map_err(|error| located(error.to_string()))?;
371    let parsed = std::str::from_utf8(&text)
372        .ok()
373        .and_then(|text| unhex(text.trim()))
374        .filter(|bytes| bytes.len() == 32);
375    crate::mac_token::zeroize(&mut text);
376    let Some(mut bytes) = parsed else {
377        return Err(located("does not hold 64 hex characters".into()));
378    };
379    let mut seed = [0u8; 32];
380    seed.copy_from_slice(&bytes);
381    crate::mac_token::zeroize(&mut bytes);
382    Ok(Some(seed))
383}
384
385/// A fresh seed written to a temporary name and linked into place without replacing an existing
386/// file. A launch that loses the race reads the winner's key.
387fn create_seed(dir: &Path, private: &Path) -> Result<[u8; 32], String> {
388    let located = |path: &Path, reason: String| format!("{}: {reason}", path.display());
389    let mut seed = [0u8; 32];
390    getrandom::fill(&mut seed)
391        .map_err(|error| format!("failed to generate the record-seal key: {error}"))?;
392    let mut nonce = [0u8; 8];
393    let _ = getrandom::fill(&mut nonce);
394    let temp = dir.join(format!(
395        ".{PRIVATE_KEY_FILE}.{}.{}",
396        std::process::id(),
397        hex(&nonce)
398    ));
399    let mut text = format!("{}\n", hex(&seed)).into_bytes();
400    let written = std::fs::OpenOptions::new()
401        .write(true)
402        .create_new(true)
403        .mode(PRIVATE_KEY_MODE)
404        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
405        .open(&temp)
406        .and_then(|mut file| {
407            file.set_permissions(std::fs::Permissions::from_mode(PRIVATE_KEY_MODE))?;
408            file.write_all(&text)?;
409            file.sync_all()
410        });
411    crate::mac_token::zeroize(&mut text);
412    if let Err(error) = written {
413        let _ = std::fs::remove_file(&temp);
414        crate::mac_token::zeroize(&mut seed);
415        return Err(located(&temp, error.to_string()));
416    }
417    let linked = std::fs::hard_link(&temp, private);
418    let _ = std::fs::remove_file(&temp);
419    match linked {
420        Ok(()) => Ok(seed),
421        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
422            crate::mac_token::zeroize(&mut seed);
423            read_seed(private)?
424                .ok_or_else(|| located(private, "vanished after its creation".into()))
425        }
426        Err(error) => {
427            crate::mac_token::zeroize(&mut seed);
428            Err(located(private, error.to_string()))
429        }
430    }
431}
432
433// ── Where seals are signed ────────────────────────────────────────────────────
434
435#[cfg(test)]
436thread_local! {
437    static TEST_SEAL_HOME: std::cell::RefCell<Option<PathBuf>> =
438        const { std::cell::RefCell::new(None) };
439}
440
441/// Seals written by this test thread use `home` as the murmur home; `None` turns sealing off.
442#[cfg(test)]
443pub(crate) fn set_test_seal_home(home: Option<PathBuf>) {
444    TEST_SEAL_HOME.with(|cell| *cell.borrow_mut() = home);
445}
446
447/// The murmur home whose key signs this process's seals, or `None` when sealing is off: a unit
448/// test of this crate outside a scratch home, so no test writes a key into a developer's real
449/// `~/.murmur`.
450fn seal_home() -> Option<Result<PathBuf, String>> {
451    #[cfg(test)]
452    {
453        if let Some(home) = TEST_SEAL_HOME.with(|cell| cell.borrow().clone()) {
454            return Some(Ok(home));
455        }
456        if !crate::murmur_home::in_scratch_home() {
457            return None;
458        }
459    }
460    Some(crate::murmur_home::ensure_murmur_home())
461}
462
463/// Sessions (by id) whose missing key has already been reported: one stderr line per session.
464static KEY_FAILURES_REPORTED: LazyLock<Mutex<HashSet<String>>> =
465    LazyLock::new(|| Mutex::new(HashSet::new()));
466
467/// This process's host key, or `None` when sealing is off or the key is unusable. An unusable key
468/// is reported on stderr once per `session_id`; the session keeps running and its record reads
469/// as unsealed.
470fn host_key_for(session_id: &str) -> Option<Arc<HostKey>> {
471    let home = seal_home()?;
472    let loaded = home.and_then(|home| HostKey::load_or_create(&home));
473    match loaded {
474        Ok(key) => Some(key),
475        Err(reason) => {
476            let first = KEY_FAILURES_REPORTED
477                .lock()
478                .unwrap_or_else(|p| p.into_inner())
479                .insert(session_id.to_string());
480            if first {
481                crate::runtime_err!(
482                    "[capsule-runtime] the record-seal key could not be used ({reason}); session \
483                     {session_id}'s records are not sealed and read as unsealed"
484                );
485            }
486            None
487        }
488    }
489}
490
491// ── The seal file ─────────────────────────────────────────────────────────────
492
493/// `trace.jsonl.seal`'s pointer to the conversation record its session appended to. Not signed.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct ConversationPointer {
496    pub record: String,
497    pub context_id: String,
498}
499
500/// One `<stream>.seal` file.
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502pub struct Seal {
503    /// [`SEAL_FORMAT`].
504    pub format: String,
505    pub stream: String,
506    /// [`CHAIN_INLINE`] or [`CHAIN_LISTED`].
507    pub chain: String,
508    pub session_id: String,
509    pub lines: u64,
510    /// [`CHAIN_LISTED`] only.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub line_hashes: Option<Vec<String>>,
513    pub head: String,
514    /// Milliseconds since the epoch. Not signed.
515    pub sealed_at: u64,
516    pub key_id: String,
517    pub algorithm: String,
518    pub signature: String,
519    /// `trace.jsonl.seal` only. Not signed.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub conversation: Option<ConversationPointer>,
522}
523
524impl Seal {
525    #[allow(clippy::too_many_arguments)]
526    fn sign(
527        key: &HostKey,
528        stream: &str,
529        chain: &str,
530        session_id: &str,
531        lines: u64,
532        head: String,
533        line_hashes: Option<Vec<String>>,
534        conversation: Option<ConversationPointer>,
535    ) -> Self {
536        let signature = key.sign_hex(&signed_message(stream, session_id, lines, &head));
537        Self {
538            format: SEAL_FORMAT.to_string(),
539            stream: stream.to_string(),
540            chain: chain.to_string(),
541            session_id: session_id.to_string(),
542            lines,
543            line_hashes,
544            head,
545            sealed_at: crate::trace::timestamp_ms(),
546            key_id: key.key_id().to_string(),
547            algorithm: SEAL_ALGORITHM.to_string(),
548            signature,
549            conversation,
550        }
551    }
552
553    /// Whether the signature verifies under `key`.
554    #[must_use]
555    pub fn signature_valid(&self, key: &PublicKey) -> bool {
556        self.algorithm == SEAL_ALGORITHM
557            && key.verifies(
558                &signed_message(&self.stream, &self.session_id, self.lines, &self.head),
559                &self.signature,
560            )
561    }
562}
563
564/// The seal beside `stream_file` in `root`, `Ok(None)` when there is none.
565pub fn read_seal(root: &WorkdirRoot, stream_file: &str) -> Result<Option<Seal>, String> {
566    let name = format!("{stream_file}{SEAL_SUFFIX}");
567    match root.read_file(&name, 1 << 26) {
568        Ok(Some(bytes)) => serde_json::from_slice(&bytes)
569            .map(Some)
570            .map_err(|error| format!("{}: {error}", root.display_path(&name))),
571        Ok(None) => Ok(None),
572        Err(error) if error.is_not_found() => Ok(None),
573        Err(error) => Err(error.to_string()),
574    }
575}
576
577fn write_seal(root: &WorkdirRoot, stream_file: &str, seal: &Seal, mode: u32) -> Result<(), String> {
578    let mut body = serde_json::to_string(seal).map_err(|error| error.to_string())?;
579    body.push('\n');
580    root.write_file(format!("{stream_file}{SEAL_SUFFIX}"), body.as_bytes(), mode)
581        .map_err(|error| error.to_string())
582}
583
584// ── The inline chain ──────────────────────────────────────────────────────────
585
586/// A chain's head after its last line: [`GENESIS`] and `0` for an empty file.
587#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
588pub struct ChainHead {
589    pub head: String,
590    pub lines: u64,
591}
592
593/// What a session-end seal hands the OTel `capsule.session` span.
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct SealedHead {
596    /// The `session_end` line's hash.
597    pub head: String,
598    /// Its line number.
599    pub lines: u64,
600    /// `None` when no key signed it.
601    pub key_id: Option<String>,
602}
603
604#[derive(Debug)]
605struct ChainState {
606    head: String,
607    lines: u64,
608    /// The file length after this state's last write, or `None` before the state is read.
609    len: Option<u64>,
610    /// The hash of an unterminated last line, which the next append terminates first.
611    torn: Option<String>,
612    /// Set once a `flock` wait ran out: something else holds the lock, so later appends try it
613    /// once and never wait again.
614    lock_contended: bool,
615}
616
617/// One `trace.jsonl`'s chain, shared by every appender holding the same file.
618///
619/// Every append locks the state, takes the file's `flock`, re-reads the real last line when the
620/// file's length is not what this state last wrote (another process appended, or this is the
621/// first write), terminates a torn last line, and writes its line with `"prev_hash"` in one
622/// `write_all`.
623pub struct InlineChain {
624    file: std::fs::File,
625    /// The directory and name the file was opened by, for reading it back.
626    root: WorkdirRoot,
627    rel: String,
628    state: Mutex<ChainState>,
629}
630
631impl std::fmt::Debug for InlineChain {
632    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633        f.debug_struct("InlineChain")
634            .field("path", &self.root.host_path(&self.rel))
635            .finish_non_exhaustive()
636    }
637}
638
639/// Every live chain by the `(st_dev, st_ino)` of its file. Weak: a chain goes when its last
640/// appender does, and while it lives it holds the file open, so the inode cannot be reused.
641static CHAINS: LazyLock<ChainRegistry> = LazyLock::new(|| Mutex::new(HashMap::new()));
642
643/// [`CHAINS`]' type: each live chain, weakly, by its file's `(st_dev, st_ino)`.
644type ChainRegistry = Mutex<HashMap<(u64, u64), Weak<InlineChain>>>;
645
646impl InlineChain {
647    /// The chain of `rel` in `root`, opened for appending without following a link, and shared
648    /// with every other holder of the same file in this process.
649    pub fn open(root: &WorkdirRoot, rel: &str) -> std::io::Result<Arc<Self>> {
650        let file = root
651            .open_append(rel, DEFAULT_FILE_MODE)
652            .map_err(std::io::Error::from)?;
653        let meta = file.metadata()?;
654        let identity = (meta.dev(), meta.ino());
655        let mut chains = CHAINS.lock().unwrap_or_else(|p| p.into_inner());
656        if let Some(chain) = chains.get(&identity).and_then(Weak::upgrade) {
657            return Ok(chain);
658        }
659        chains.retain(|_, weak| weak.strong_count() > 0);
660        let chain = Arc::new(Self {
661            file,
662            root: root.clone(),
663            rel: rel.to_string(),
664            state: Mutex::new(ChainState {
665                head: GENESIS.to_string(),
666                lines: 0,
667                len: None,
668                torn: None,
669                lock_contended: false,
670            }),
671        });
672        chains.insert(identity, Arc::downgrade(&chain));
673        Ok(chain)
674    }
675
676    /// A chain writing to `file` while reading `rel` in `root` back, outside the registry: a
677    /// test's unwritable sink.
678    #[cfg(test)]
679    pub(crate) fn over_file_for_test(
680        file: std::fs::File,
681        root: &WorkdirRoot,
682        rel: &str,
683    ) -> Arc<Self> {
684        Arc::new(Self {
685            file,
686            root: root.clone(),
687            rel: rel.to_string(),
688            state: Mutex::new(ChainState {
689                head: GENESIS.to_string(),
690                lines: 0,
691                len: None,
692                torn: None,
693                lock_contended: false,
694            }),
695        })
696    }
697
698    /// Appends `event` as one line ending in `"prev_hash"`.
699    pub fn append_event(&self, event: &impl Serialize) -> std::io::Result<()> {
700        let json = serde_json::to_string(event).map_err(std::io::Error::other)?;
701        self.append_json(&json)
702    }
703
704    /// Appends one serialized JSON object as one line ending in `"prev_hash"`.
705    pub fn append_json(&self, json: &str) -> std::io::Result<()> {
706        let body = json.trim_end();
707        if !body.starts_with('{') || !body.ends_with('}') {
708            return Err(std::io::Error::other("a trace line must be a JSON object"));
709        }
710        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
711        let _lock = FileLock::take(&self.file, &mut state.lock_contended);
712        self.sync(&mut state)?;
713        let mut line = Vec::with_capacity(body.len() + 90);
714        let mut lines = state.lines;
715        let prev = match state.torn.take() {
716            Some(torn) => {
717                line.push(b'\n');
718                lines += 1;
719                torn
720            }
721            None => state.head.clone(),
722        };
723        let open = &body[..body.len() - 1];
724        line.extend_from_slice(open.as_bytes());
725        if open.trim() != "{" {
726            line.push(b',');
727        }
728        line.extend_from_slice(format!("\"{PREV_HASH_KEY}\":\"{prev}\"}}").as_bytes());
729        let hash = line_hash(line.strip_prefix(b"\n").unwrap_or(&line));
730        line.push(b'\n');
731        let written = (&self.file).write_all(&line);
732        if let Err(error) = written {
733            // What landed is unknown; the next append reads the file back.
734            state.len = None;
735            return Err(error);
736        }
737        state.head = hash;
738        state.lines = lines + 1;
739        state.len = state.len.map(|len| len + line.len() as u64);
740        Ok(())
741    }
742
743    /// The head and line count as the file holds them now.
744    pub fn head(&self) -> std::io::Result<ChainHead> {
745        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
746        let _lock = FileLock::take(&self.file, &mut state.lock_contended);
747        self.sync(&mut state)?;
748        Ok(ChainHead {
749            head: state.head.clone(),
750            lines: state.lines,
751        })
752    }
753
754    /// Re-reads the head when the file's length is not the one this state wrote.
755    fn sync(&self, state: &mut ChainState) -> std::io::Result<()> {
756        let len = self.file.metadata()?.len();
757        if state.len == Some(len) {
758            return Ok(());
759        }
760        let recovered = recover_head(&self.root, &self.rel, len)?;
761        state.head = recovered.head;
762        state.lines = recovered.lines;
763        state.torn = recovered.torn;
764        state.len = Some(len);
765        Ok(())
766    }
767}
768
769struct Recovered {
770    head: String,
771    lines: u64,
772    torn: Option<String>,
773}
774
775/// The head of the first `len` bytes of `rel`: the last complete line's hash and how many there
776/// are, and the hash of an unterminated tail.
777///
778/// Reads the whole file rather than its tail: the seal names the line count as well as the head,
779/// and recovery happens once per open and once per append by another process, not per line.
780fn recover_head(root: &WorkdirRoot, rel: &str, len: u64) -> std::io::Result<Recovered> {
781    if len == 0 {
782        return Ok(Recovered {
783            head: GENESIS.to_string(),
784            lines: 0,
785            torn: None,
786        });
787    }
788    let bytes = root
789        .read_file(rel, u64::MAX)
790        .map_err(std::io::Error::other)?
791        .unwrap_or_default();
792    let bytes = &bytes[..bytes.len().min(usize::try_from(len).unwrap_or(usize::MAX))];
793    let split = split_lines(bytes);
794    let head = split
795        .complete
796        .last()
797        .map_or_else(|| GENESIS.to_string(), |line| line_hash(line));
798    Ok(Recovered {
799        head,
800        lines: split.complete.len() as u64,
801        torn: split.tail.map(line_hash),
802    })
803}
804
805/// A held exclusive `flock`, released on drop. Taken without blocking for at most
806/// [`FLOCK_WAIT`]; past that the write goes ahead unlocked.
807struct FileLock<'a>(Option<&'a std::fs::File>);
808
809impl<'a> FileLock<'a> {
810    /// `contended` is the chain's record of an earlier wait that ran out: with it set, the lock
811    /// is tried once and not waited for, so a holder that never lets go costs one wait, not one
812    /// per line.
813    fn take(file: &'a std::fs::File, contended: &mut bool) -> Self {
814        use rustix::fs::{flock, FlockOperation};
815        let started = std::time::Instant::now();
816        loop {
817            match flock(file, FlockOperation::NonBlockingLockExclusive) {
818                Ok(()) => return Self(Some(file)),
819                Err(rustix::io::Errno::WOULDBLOCK)
820                    if !*contended && started.elapsed() < FLOCK_WAIT =>
821                {
822                    std::thread::sleep(std::time::Duration::from_millis(2));
823                }
824                Err(rustix::io::Errno::WOULDBLOCK) => {
825                    *contended = true;
826                    return Self(None);
827                }
828                Err(_) => return Self(None),
829            }
830        }
831    }
832}
833
834impl Drop for FileLock<'_> {
835    fn drop(&mut self) {
836        if let Some(file) = self.0 {
837            let _ = rustix::fs::flock(file, rustix::fs::FlockOperation::Unlock);
838        }
839    }
840}
841
842struct Lines<'a> {
843    /// Every `\n`-terminated line, without its newline.
844    complete: Vec<&'a [u8]>,
845    /// Bytes after the last newline, when there are any.
846    tail: Option<&'a [u8]>,
847}
848
849fn split_lines(bytes: &[u8]) -> Lines<'_> {
850    let mut complete = Vec::new();
851    let mut start = 0;
852    for (index, byte) in bytes.iter().enumerate() {
853        if *byte == b'\n' {
854            complete.push(&bytes[start..index]);
855            start = index + 1;
856        }
857    }
858    let tail = (start < bytes.len()).then(|| &bytes[start..]);
859    Lines { complete, tail }
860}
861
862// ── Sealing ───────────────────────────────────────────────────────────────────
863
864/// Seals `stream` (an inline-chained file in `records_root`, normally [`TRACE_STREAM`]) at its
865/// current head, signed for `session_id`.
866///
867/// `conversation` names the record the session appended to; `None` keeps the pointer an earlier
868/// seal of the same file carried. Returns the head sealed, with `key_id: None` when no key was
869/// usable — the stream then has no new seal and reads as unsealed. `Err` only when the stream
870/// could not be read.
871pub fn seal_inline(
872    records_root: &WorkdirRoot,
873    stream: &str,
874    session_id: &str,
875    conversation: Option<ConversationPointer>,
876) -> Result<SealedHead, String> {
877    let chain = InlineChain::open(records_root, stream).map_err(|error| error.to_string())?;
878    let head = chain.head().map_err(|error| error.to_string())?;
879    let Some(key) = host_key_for(session_id) else {
880        return Ok(SealedHead {
881            head: head.head,
882            lines: head.lines,
883            key_id: None,
884        });
885    };
886    let conversation = conversation.or_else(|| {
887        read_seal(records_root, stream)
888            .ok()
889            .flatten()
890            .and_then(|seal| seal.conversation)
891    });
892    let seal = Seal::sign(
893        &key,
894        stream,
895        CHAIN_INLINE,
896        session_id,
897        head.lines,
898        head.head.clone(),
899        None,
900        conversation,
901    );
902    if let Err(reason) = write_seal(records_root, stream, &seal, DEFAULT_FILE_MODE) {
903        crate::runtime_err!(
904            "[capsule-runtime] could not write {}{SEAL_SUFFIX} ({reason}); session {session_id}'s \
905             trace reads as unsealed",
906            records_root.display_path(stream)
907        );
908        return Ok(SealedHead {
909            head: head.head,
910            lines: head.lines,
911            key_id: None,
912        });
913    }
914    Ok(SealedHead {
915        head: head.head,
916        lines: head.lines,
917        key_id: Some(key.key_id().to_string()),
918    })
919}
920
921/// Seals `stream_file` in `dir_root` as a listed chain: every complete line's hash in order (the
922/// whole file as one line when `whole_file`, as for `completion.json`), written to
923/// `<stream_file>.seal` at `mode`. A failure is reported on stderr and leaves the stream reading
924/// as unsealed; it never fails the caller.
925pub fn seal_listed(
926    dir_root: &WorkdirRoot,
927    stream_file: &str,
928    stream_name: &str,
929    session_id: &str,
930    whole_file: bool,
931    mode: u32,
932) {
933    let Some(key) = host_key_for(session_id) else {
934        return;
935    };
936    let bytes = match dir_root.read_file(stream_file, u64::MAX) {
937        Ok(Some(bytes)) => bytes,
938        Ok(None) => return,
939        Err(error) if error.is_not_found() => return,
940        Err(error) => {
941            crate::runtime_err!(
942                "[capsule-runtime] could not read {error} to seal it; it reads as unsealed"
943            );
944            return;
945        }
946    };
947    let line_hashes = listed_line_hashes(&bytes, whole_file);
948    let seal = Seal::sign(
949        &key,
950        stream_name,
951        CHAIN_LISTED,
952        session_id,
953        line_hashes.len() as u64,
954        listed_head(&line_hashes),
955        Some(line_hashes),
956        None,
957    );
958    if let Err(reason) = write_seal(dir_root, stream_file, &seal, mode) {
959        crate::runtime_err!(
960            "[capsule-runtime] could not write {}{SEAL_SUFFIX} ({reason}); it reads as unsealed",
961            dir_root.display_path(stream_file)
962        );
963    }
964}
965
966/// Every line's hash, an unterminated last line included; the whole file's when `whole_file`.
967fn listed_line_hashes(bytes: &[u8], whole_file: bool) -> Vec<String> {
968    if whole_file {
969        return vec![line_hash(bytes)];
970    }
971    let split = split_lines(bytes);
972    split
973        .complete
974        .iter()
975        .copied()
976        .chain(split.tail)
977        .map(line_hash)
978        .collect()
979}
980
981/// `<record>` and `<context-id>` of a conversation record directory
982/// `~/.murmur/conversations/<record>/<context-id>`.
983fn conversation_names(dir: &Path) -> Option<(String, String)> {
984    let context_id = dir.file_name()?.to_str()?.to_string();
985    let record = dir.parent()?.file_name()?.to_str()?.to_string();
986    Some((record, context_id))
987}
988
989/// Seals the conversation record in `dir` (`.../<record>/<context-id>/conversation.jsonl`) for
990/// `session_id`. A failure is reported and leaves the record reading as unsealed.
991pub fn seal_conversation(dir: &Path, session_id: &str) {
992    let Some((record, context_id)) = conversation_names(dir) else {
993        return;
994    };
995    let root = match WorkdirRoot::open(dir) {
996        Ok(root) => root,
997        Err(error) if error.is_not_found() => return,
998        Err(error) => {
999            crate::runtime_err!(
1000                "[capsule-runtime] could not open {} to seal its conversation record ({error})",
1001                dir.display()
1002            );
1003            return;
1004        }
1005    };
1006    seal_listed(
1007        &root,
1008        crate::conversation::RECORD_FILE_NAME,
1009        &conversation_stream(&record, &context_id),
1010        session_id,
1011        false,
1012        crate::murmur_home::PRIVATE_FILE_MODE,
1013    );
1014}
1015
1016/// Conversation record directories each session appended to, by the session's records directory.
1017static APPENDED_CONVERSATIONS: LazyLock<Mutex<HashMap<PathBuf, Vec<PathBuf>>>> =
1018    LazyLock::new(|| Mutex::new(HashMap::new()));
1019
1020/// Notes that the session whose records directory is `records_dir` appended to the conversation
1021/// record in `conversation_dir`, so [`seal_session_teardown`] seals it.
1022pub fn note_conversation_append(records_dir: &Path, conversation_dir: &Path) {
1023    let mut noted = APPENDED_CONVERSATIONS
1024        .lock()
1025        .unwrap_or_else(|p| p.into_inner());
1026    let dirs = noted.entry(records_dir.to_path_buf()).or_default();
1027    if !dirs.iter().any(|dir| dir == conversation_dir) {
1028        dirs.push(conversation_dir.to_path_buf());
1029    }
1030}
1031
1032/// The first conversation record the session in `records_dir` appended to.
1033#[must_use]
1034pub fn appended_conversation(records_dir: &Path) -> Option<ConversationPointer> {
1035    let noted = APPENDED_CONVERSATIONS
1036        .lock()
1037        .unwrap_or_else(|p| p.into_inner());
1038    let dir = noted.get(records_dir)?.first()?;
1039    let (record, context_id) = conversation_names(dir)?;
1040    Some(ConversationPointer { record, context_id })
1041}
1042
1043/// The seal a session writes once `session_end` is on disk: `trace.jsonl` at its head, naming
1044/// the conversation record the session appended to. Returns the head for the OTel session span.
1045pub fn seal_session_end(records_root: &WorkdirRoot, session_id: &str) -> Option<SealedHead> {
1046    match seal_inline(
1047        records_root,
1048        TRACE_STREAM,
1049        session_id,
1050        appended_conversation(records_root.path()),
1051    ) {
1052        Ok(head) => Some(head),
1053        Err(reason) => {
1054            crate::runtime_err!(
1055                "[capsule-runtime] could not seal {} ({reason}); it reads as unsealed",
1056                records_root.display_path(TRACE_STREAM)
1057            );
1058            None
1059        }
1060    }
1061}
1062
1063/// The seals a session writes at teardown, once its door is shut and every hook has drained:
1064/// `trace.jsonl` again when a line landed after its last seal, `hooks/eval.jsonl` when it
1065/// exists, and every conversation record the session appended to.
1066pub fn seal_session_teardown(records_root: &WorkdirRoot, session_id: &str) {
1067    if records_root
1068        .entry_kind(TRACE_STREAM)
1069        .ok()
1070        .flatten()
1071        .is_some()
1072    {
1073        let sealed = read_seal(records_root, TRACE_STREAM).ok().flatten();
1074        let current = InlineChain::open(records_root, TRACE_STREAM)
1075            .and_then(|chain| chain.head())
1076            .ok();
1077        let stale = match (&sealed, &current) {
1078            (Some(seal), Some(head)) => seal.lines != head.lines || seal.head != head.head,
1079            _ => true,
1080        };
1081        if stale {
1082            seal_session_end(records_root, session_id);
1083        }
1084    }
1085    if records_root
1086        .entry_kind(EVAL_STREAM)
1087        .ok()
1088        .flatten()
1089        .is_some()
1090    {
1091        seal_listed(
1092            records_root,
1093            EVAL_STREAM,
1094            EVAL_STREAM,
1095            session_id,
1096            false,
1097            DEFAULT_FILE_MODE,
1098        );
1099    }
1100    let dirs = APPENDED_CONVERSATIONS
1101        .lock()
1102        .unwrap_or_else(|p| p.into_inner())
1103        .get(records_root.path())
1104        .cloned()
1105        .unwrap_or_default();
1106    for dir in dirs {
1107        seal_conversation(&dir, session_id);
1108    }
1109}
1110
1111// ── Verifying ─────────────────────────────────────────────────────────────────
1112
1113/// How a stream, or a whole record, reads. Ordered best to worst; the worst wins.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1115#[serde(rename_all = "lowercase")]
1116pub enum Verdict {
1117    /// The stream does not exist; it does not count toward the record's verdict.
1118    Absent,
1119    /// Chain intact, a seal covers every line, signature valid under the given key.
1120    Verified,
1121    /// Written before records were sealed, no public key, or sealed by another key.
1122    Unverifiable,
1123    /// Chain intact but no seal covers exactly the lines the stream holds.
1124    Unsealed,
1125    /// A line, the chain, or the seal no longer matches.
1126    Changed,
1127}
1128
1129impl Verdict {
1130    #[must_use]
1131    pub fn as_str(self) -> &'static str {
1132        match self {
1133            Self::Absent => "absent",
1134            Self::Verified => "verified",
1135            Self::Unverifiable => "unverifiable",
1136            Self::Unsealed => "unsealed",
1137            Self::Changed => "changed",
1138        }
1139    }
1140}
1141
1142/// One stream's verdict.
1143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1144pub struct StreamReport {
1145    pub stream: String,
1146    pub verdict: Verdict,
1147    /// Complete lines the stream holds (`1` for `completion.json`).
1148    pub lines: u64,
1149    /// Lines the seal covers, `None` with no seal.
1150    pub sealed_lines: Option<u64>,
1151    /// The last line through which the chain, or the seal's listed hashes, hold.
1152    pub chain_intact_through: Option<u64>,
1153    /// `changed` only: the first line that no longer hashes to what was recorded.
1154    pub first_bad_line: Option<u64>,
1155    /// The seal's key id.
1156    pub key_id: Option<String>,
1157    pub detail: String,
1158    /// Findings that change nothing, such as a torn line the chain still covers.
1159    pub notes: Vec<String>,
1160}
1161
1162impl StreamReport {
1163    fn new(stream: &str, verdict: Verdict, lines: u64, detail: impl Into<String>) -> Self {
1164        Self {
1165            stream: stream.to_string(),
1166            verdict,
1167            lines,
1168            sealed_lines: None,
1169            chain_intact_through: None,
1170            first_bad_line: None,
1171            key_id: None,
1172            detail: detail.into(),
1173            notes: Vec::new(),
1174        }
1175    }
1176
1177    fn absent(stream: &str) -> Self {
1178        Self::new(stream, Verdict::Absent, 0, "no such stream")
1179    }
1180}
1181
1182/// The key a record was checked against.
1183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1184pub struct PublicKeyReport {
1185    pub path: Option<String>,
1186    pub key_id: Option<String>,
1187}
1188
1189/// A whole records directory's verdict: `mur trace verify --json`.
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct RecordReport {
1192    /// [`REPORT_SCHEMA`].
1193    pub schema: u32,
1194    pub session_id: Option<String>,
1195    pub records_dir: String,
1196    pub public_key: PublicKeyReport,
1197    pub verdict: Verdict,
1198    pub streams: Vec<StreamReport>,
1199}
1200
1201/// The worst of `verdicts`, ignoring [`Verdict::Absent`]; `Absent` when every one is.
1202#[must_use]
1203pub fn worst(verdicts: impl IntoIterator<Item = Verdict>) -> Verdict {
1204    verdicts.into_iter().max().unwrap_or(Verdict::Absent)
1205}
1206
1207/// The `prev_hash` a line ends with, when its last key is `"prev_hash"` with a string value.
1208fn trailing_prev_hash(line: &[u8]) -> Option<&str> {
1209    let text = std::str::from_utf8(line).ok()?;
1210    let body = text.strip_suffix("\"}")?;
1211    let marker = format!("\"{PREV_HASH_KEY}\":\"");
1212    let at = body.rfind(&marker)?;
1213    let before = body[..at].trim_end();
1214    if !(before.ends_with(',') || before.ends_with('{')) {
1215        return None;
1216    }
1217    let value = &body[at + marker.len()..];
1218    (!value.contains('"') && !value.contains('\\')).then_some(value)
1219}
1220
1221fn is_json_object(line: &[u8]) -> bool {
1222    line.first() == Some(&b'{') && serde_json::from_slice::<serde::de::IgnoredAny>(line).is_ok()
1223}
1224
1225/// Checks a seal's key and signature: `Err` carries the verdict and detail when it does not hold.
1226fn check_seal_key(
1227    seal: &Seal,
1228    key: Option<&PublicKey>,
1229    stream: &str,
1230) -> Result<(), (Verdict, String)> {
1231    if seal.format != SEAL_FORMAT {
1232        return Err((
1233            Verdict::Unverifiable,
1234            format!("the seal's format is '{}', not {SEAL_FORMAT}", seal.format),
1235        ));
1236    }
1237    let Some(key) = key else {
1238        return Err((
1239            Verdict::Unverifiable,
1240            format!(
1241                "no public key is available to check the seal (sealed by key {})",
1242                seal.key_id
1243            ),
1244        ));
1245    };
1246    if seal.key_id != key.key_id() {
1247        return Err((
1248            Verdict::Unverifiable,
1249            format!(
1250                "sealed by key {}, not by the key given ({})",
1251                seal.key_id,
1252                key.key_id()
1253            ),
1254        ));
1255    }
1256    if !seal.signature_valid(key) {
1257        return Err((
1258            Verdict::Changed,
1259            format!(
1260                "the seal's signature does not verify under key {}",
1261                key.key_id()
1262            ),
1263        ));
1264    }
1265    if seal.stream != stream {
1266        return Err((
1267            Verdict::Changed,
1268            format!("the seal names stream '{}', not '{stream}'", seal.stream),
1269        ));
1270    }
1271    Ok(())
1272}
1273
1274/// Checks an inline-chained stream's bytes against its seal.
1275///
1276/// `expected_session` is the session the record belongs to; a seal signed for another session is
1277/// `changed`.
1278#[must_use]
1279pub fn verify_inline(
1280    stream: &str,
1281    bytes: &[u8],
1282    seal: Option<&Seal>,
1283    key: Option<&PublicKey>,
1284    expected_session: Option<&str>,
1285) -> StreamReport {
1286    let split = split_lines(bytes);
1287    let total = split.complete.len() as u64;
1288    let mut report = StreamReport::new(stream, Verdict::Verified, total, "");
1289    report.sealed_lines = seal.map(|seal| seal.lines);
1290    report.key_id = seal.map(|seal| seal.key_id.clone());
1291
1292    let mut hashes: Vec<String> = Vec::with_capacity(split.complete.len());
1293    let mut intact = 0u64;
1294    for (index, line) in split.complete.iter().enumerate() {
1295        let number = index as u64 + 1;
1296        let hash = line_hash(line);
1297        let expected = if index == 0 {
1298            GENESIS.to_string()
1299        } else {
1300            hashes[index - 1].clone()
1301        };
1302        match trailing_prev_hash(line) {
1303            Some(prev) if prev == expected => {}
1304            Some(_) if index == 0 => {
1305                report.verdict = Verdict::Changed;
1306                report.first_bad_line = Some(1);
1307                report.detail = "line 1 changed: its prev_hash is not \"genesis\"".to_string();
1308                report.chain_intact_through = Some(0);
1309                return report;
1310            }
1311            Some(_) => {
1312                report.verdict = Verdict::Changed;
1313                report.first_bad_line = Some(number - 1);
1314                report.detail = format!(
1315                    "line {} changed, or line {number}'s prev_hash did: line {number} names a \
1316                     hash line {} no longer has",
1317                    number - 1,
1318                    number - 1
1319                );
1320                report.chain_intact_through = Some(number.saturating_sub(2));
1321                return report;
1322            }
1323            None if !is_json_object(line) => {
1324                report.notes.push(format!(
1325                    "line {number} is not a JSON object (a writer stopped mid-line); the chain \
1326                     continues past it"
1327                ));
1328            }
1329            None if index == 0 => {
1330                report.verdict = Verdict::Unverifiable;
1331                report.detail =
1332                    "written before records were sealed: line 1 carries no prev_hash".to_string();
1333                return report;
1334            }
1335            None => {
1336                report.verdict = Verdict::Changed;
1337                report.first_bad_line = Some(number);
1338                report.detail = format!("line {number} changed: it carries no prev_hash");
1339                report.chain_intact_through = Some(number - 1);
1340                return report;
1341            }
1342        }
1343        hashes.push(hash);
1344        intact = number;
1345    }
1346    report.chain_intact_through = Some(intact);
1347    if split.tail.is_some() {
1348        report.notes.push(format!(
1349            "an incomplete line follows line {total} (a writer stopped mid-line)"
1350        ));
1351    }
1352
1353    let Some(seal) = seal else {
1354        report.verdict = Verdict::Unsealed;
1355        report.detail = format!("no seal; the chain is intact through line {intact}");
1356        return report;
1357    };
1358    if let Err((verdict, detail)) = check_seal_key(seal, key, stream) {
1359        report.verdict = verdict;
1360        report.detail = detail;
1361        return report;
1362    }
1363    if let (Some(expected), false) = (expected_session, seal.session_id.is_empty()) {
1364        if seal.session_id != expected {
1365            report.verdict = Verdict::Changed;
1366            report.detail = format!(
1367                "the seal was signed for session {}, not {expected}",
1368                seal.session_id
1369            );
1370            return report;
1371        }
1372    }
1373    if seal.chain != CHAIN_INLINE {
1374        report.verdict = Verdict::Changed;
1375        report.detail = format!("the seal's chain is '{}', not inline", seal.chain);
1376        return report;
1377    }
1378    let sealed = seal.lines;
1379    if sealed > total {
1380        report.verdict = Verdict::Unsealed;
1381        report.detail = format!(
1382            "the seal covers {sealed} lines but the stream holds {total}: lines were cut off; \
1383             the chain is intact through line {intact}"
1384        );
1385        return report;
1386    }
1387    let head_at = if sealed == 0 {
1388        GENESIS.to_string()
1389    } else {
1390        hashes[usize::try_from(sealed - 1).unwrap_or(0)].clone()
1391    };
1392    if head_at != seal.head {
1393        report.verdict = Verdict::Changed;
1394        report.first_bad_line = Some(sealed.max(1));
1395        report.detail = format!(
1396            "line {sealed} no longer hashes to the seal's head: line {sealed} changed, or the \
1397             chain was rewritten before line {sealed}"
1398        );
1399        return report;
1400    }
1401    if sealed < total || split.tail.is_some() {
1402        report.verdict = Verdict::Unsealed;
1403        report.detail = format!(
1404            "the seal covers lines 1-{sealed}; {} line(s) were appended after it; the chain is \
1405             intact through line {intact}",
1406            total - sealed + u64::from(split.tail.is_some())
1407        );
1408        return report;
1409    }
1410    report.detail = format!("{total} lines sealed by key {}", seal.key_id);
1411    report
1412}
1413
1414/// Checks a listed-chain stream's bytes against its seal. `whole_file` treats the file as one
1415/// line, as `completion.json` is sealed.
1416#[must_use]
1417pub fn verify_listed(
1418    stream: &str,
1419    bytes: &[u8],
1420    whole_file: bool,
1421    seal: Option<&Seal>,
1422    key: Option<&PublicKey>,
1423    expected_session: Option<&str>,
1424) -> StreamReport {
1425    let hashes = listed_line_hashes(bytes, whole_file);
1426    let total = hashes.len() as u64;
1427    let mut report = StreamReport::new(stream, Verdict::Verified, total, "");
1428    let Some(seal) = seal else {
1429        report.verdict = Verdict::Unsealed;
1430        report.detail = format!("no seal covers its {total} line(s)");
1431        return report;
1432    };
1433    report.sealed_lines = Some(seal.lines);
1434    report.key_id = Some(seal.key_id.clone());
1435    if let Err((verdict, detail)) = check_seal_key(seal, key, stream) {
1436        report.verdict = verdict;
1437        report.detail = detail;
1438        return report;
1439    }
1440    if let Some(expected) = expected_session {
1441        if seal.session_id != expected {
1442            report.verdict = Verdict::Changed;
1443            report.detail = format!(
1444                "the seal was signed for session {}, not {expected}",
1445                seal.session_id
1446            );
1447            return report;
1448        }
1449    }
1450    let listed = seal.line_hashes.clone().unwrap_or_default();
1451    if seal.chain != CHAIN_LISTED
1452        || listed.len() as u64 != seal.lines
1453        || listed_head(&listed) != seal.head
1454    {
1455        report.verdict = Verdict::Changed;
1456        report.detail = "the seal's listed hashes do not match its signed head".to_string();
1457        return report;
1458    }
1459    let mut intact = 0u64;
1460    for (index, (have, want)) in hashes.iter().zip(&listed).enumerate() {
1461        if have != want {
1462            let number = index as u64 + 1;
1463            report.verdict = Verdict::Changed;
1464            report.first_bad_line = Some(number);
1465            report.chain_intact_through = Some(intact);
1466            report.detail = if whole_file {
1467                format!("{stream} changed: its bytes no longer hash to what the seal recorded")
1468            } else {
1469                format!("line {number} changed: it no longer hashes to what the seal recorded")
1470            };
1471            return report;
1472        }
1473        intact = index as u64 + 1;
1474    }
1475    report.chain_intact_through = Some(intact);
1476    if seal.lines > total {
1477        report.verdict = Verdict::Unsealed;
1478        report.detail = format!(
1479            "the seal covers {} lines but the stream holds {total}: lines were cut off",
1480            seal.lines
1481        );
1482    } else if seal.lines < total {
1483        report.verdict = Verdict::Unsealed;
1484        report.detail = format!(
1485            "the seal covers lines 1-{}; {} line(s) were appended after it",
1486            seal.lines,
1487            total - seal.lines
1488        );
1489    } else {
1490        report.detail = format!("{total} line(s) sealed by key {}", seal.key_id);
1491    }
1492    report
1493}
1494
1495/// The `session_id` line 1 of a trace names.
1496fn trace_session_id(bytes: &[u8]) -> Option<String> {
1497    let first = split_lines(bytes).complete.into_iter().next()?;
1498    let value: serde_json::Value = serde_json::from_slice(first).ok()?;
1499    value.get("session_id")?.as_str().map(str::to_string)
1500}
1501
1502fn single_segment(name: &str) -> bool {
1503    !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0')
1504}
1505
1506/// Whether a running-session record for `session_id` names a live process.
1507fn session_is_live(session_id: &str) -> bool {
1508    crate::running::list().is_ok_and(|records| {
1509        records.iter().any(|record| {
1510            record.session_id == session_id && crate::running::verify(record).is_running()
1511        })
1512    })
1513}
1514
1515fn read_stream(root: &WorkdirRoot, rel: &str) -> Result<Option<Vec<u8>>, String> {
1516    match root.read_file(rel, u64::MAX) {
1517        Ok(found) => Ok(found),
1518        Err(error) if error.is_not_found() => Ok(None),
1519        Err(error) => Err(error.to_string()),
1520    }
1521}
1522
1523/// A conversation record found under `conversation_root` by the name the trace's seal points at,
1524/// but sealed by another key than the trace: a record of the same capsule and context id on the
1525/// host doing the checking, not the one the trace's host appended to. A host seals every stream
1526/// with its one key, so the record the pointer meant is not here.
1527fn other_hosts_conversation(name: &str, found_key: &str, trace_key: &str) -> StreamReport {
1528    let mut report = StreamReport::absent(name);
1529    report.detail = format!(
1530        "not here: the record of this name was sealed by key {found_key}, and the trace by key \
1531         {trace_key}"
1532    );
1533    report
1534}
1535
1536/// Checks every stream of the records directory `records_dir` with `public_key` alone.
1537///
1538/// `trace.jsonl`, `hooks/eval.jsonl` and `completion.json` are read from `records_dir`; the
1539/// conversation record `trace.jsonl.seal` points at is read from
1540/// `<conversation_root>/<record>/<context-id>/conversation.jsonl`, `absent` when it is not there
1541/// or `conversation_root` is `None`.
1542#[must_use]
1543pub fn verify_session_records(
1544    records_dir: &Path,
1545    public_key: Option<&PublicKey>,
1546    conversation_root: Option<&Path>,
1547) -> RecordReport {
1548    let mut streams = Vec::new();
1549    let mut session_id = None;
1550    let public = PublicKeyReport {
1551        path: public_key
1552            .and_then(PublicKey::path)
1553            .map(|path| path.display().to_string()),
1554        key_id: public_key.map(|key| key.key_id().to_string()),
1555    };
1556    let root = match WorkdirRoot::open(records_dir) {
1557        Ok(root) => root,
1558        Err(error) => {
1559            let mut report = StreamReport::absent(TRACE_STREAM);
1560            report.detail = error.to_string();
1561            return RecordReport {
1562                schema: REPORT_SCHEMA,
1563                session_id,
1564                records_dir: records_dir.display().to_string(),
1565                public_key: public,
1566                verdict: Verdict::Absent,
1567                streams: vec![report],
1568            };
1569        }
1570    };
1571    let error_report = |stream: &str, reason: String| {
1572        let mut report = StreamReport::new(stream, Verdict::Unverifiable, 0, reason);
1573        report.detail = format!("could not be read: {}", report.detail);
1574        report
1575    };
1576
1577    let mut pointer = None;
1578    // The key that sealed the trace: the host's one key, which seals its conversations too.
1579    let mut trace_key_id = None;
1580    match read_stream(&root, TRACE_STREAM) {
1581        Ok(None) => streams.push(StreamReport::absent(TRACE_STREAM)),
1582        Err(reason) => streams.push(error_report(TRACE_STREAM, reason)),
1583        Ok(Some(bytes)) => {
1584            session_id = trace_session_id(&bytes);
1585            let seal = read_seal(&root, TRACE_STREAM);
1586            let mut report = match &seal {
1587                Err(reason) => {
1588                    let mut report = verify_inline(TRACE_STREAM, &bytes, None, public_key, None);
1589                    if report.verdict == Verdict::Unsealed {
1590                        report.verdict = Verdict::Changed;
1591                        report.detail = format!("the seal cannot be read: {reason}");
1592                    }
1593                    report
1594                }
1595                Ok(seal) => {
1596                    pointer = seal.as_ref().and_then(|seal| seal.conversation.clone());
1597                    trace_key_id = seal.as_ref().map(|seal| seal.key_id.clone());
1598                    verify_inline(
1599                        TRACE_STREAM,
1600                        &bytes,
1601                        seal.as_ref(),
1602                        public_key,
1603                        session_id.as_deref(),
1604                    )
1605                }
1606            };
1607            if report.verdict == Verdict::Unsealed {
1608                match session_id.as_deref() {
1609                    Some(id) if session_is_live(id) => {
1610                        report.detail.push_str("; the session is still running");
1611                    }
1612                    _ if report.sealed_lines.is_none() => {
1613                        report.detail =
1614                            format!("the session ended without a seal; {}", report.detail);
1615                    }
1616                    _ => {}
1617                }
1618            }
1619            streams.push(report);
1620        }
1621    }
1622
1623    for (rel, whole_file) in [(EVAL_STREAM, false), (COMPLETION_STREAM, true)] {
1624        let report = match read_stream(&root, rel) {
1625            Ok(None) => StreamReport::absent(rel),
1626            Err(reason) => error_report(rel, reason),
1627            Ok(Some(bytes)) => match read_seal(&root, rel) {
1628                Ok(seal) => verify_listed(
1629                    rel,
1630                    &bytes,
1631                    whole_file,
1632                    seal.as_ref(),
1633                    public_key,
1634                    session_id.as_deref(),
1635                ),
1636                Err(reason) => {
1637                    let mut report = StreamReport::new(rel, Verdict::Changed, 0, "");
1638                    report.detail = format!("the seal cannot be read: {reason}");
1639                    report
1640                }
1641            },
1642        };
1643        streams.push(report);
1644    }
1645
1646    if let Some(pointer) = pointer {
1647        let name = conversation_stream(&pointer.record, &pointer.context_id);
1648        let report = match conversation_root {
1649            Some(conversations)
1650                if single_segment(&pointer.record) && single_segment(&pointer.context_id) =>
1651            {
1652                let dir = conversations
1653                    .join(&pointer.record)
1654                    .join(&pointer.context_id);
1655                match WorkdirRoot::open(&dir) {
1656                    Err(_) => StreamReport::absent(&name),
1657                    Ok(dir_root) => {
1658                        match read_stream(&dir_root, crate::conversation::RECORD_FILE_NAME) {
1659                            Ok(None) => StreamReport::absent(&name),
1660                            Err(reason) => error_report(&name, reason),
1661                            Ok(Some(bytes)) => {
1662                                match read_seal(&dir_root, crate::conversation::RECORD_FILE_NAME) {
1663                                    Ok(Some(seal))
1664                                        if trace_key_id
1665                                            .as_deref()
1666                                            .is_some_and(|id| id != seal.key_id) =>
1667                                    {
1668                                        other_hosts_conversation(
1669                                            &name,
1670                                            &seal.key_id,
1671                                            trace_key_id.as_deref().unwrap_or_default(),
1672                                        )
1673                                    }
1674                                    Ok(seal) => verify_listed(
1675                                        &name,
1676                                        &bytes,
1677                                        false,
1678                                        seal.as_ref(),
1679                                        public_key,
1680                                        None,
1681                                    ),
1682                                    Err(reason) => {
1683                                        let mut report =
1684                                            StreamReport::new(&name, Verdict::Changed, 0, "");
1685                                        report.detail =
1686                                            format!("the seal cannot be read: {reason}");
1687                                        report
1688                                    }
1689                                }
1690                            }
1691                        }
1692                    }
1693                }
1694            }
1695            _ => StreamReport::absent(&name),
1696        };
1697        streams.push(report);
1698    }
1699
1700    RecordReport {
1701        schema: REPORT_SCHEMA,
1702        session_id,
1703        records_dir: records_dir.display().to_string(),
1704        public_key: public,
1705        verdict: worst(streams.iter().map(|stream| stream.verdict)),
1706        streams,
1707    }
1708}
1709
1710#[cfg(test)]
1711mod tests;