1use 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
36pub const KEYS_DIR: &str = "keys";
40pub const PRIVATE_KEY_FILE: &str = "record-seal.key";
42pub const PUBLIC_KEY_FILE: &str = "record-seal.pub";
44pub const KEYS_DIR_MODE: u32 = 0o700;
46pub const PRIVATE_KEY_MODE: u32 = 0o600;
48pub const PUBLIC_KEY_MODE: u32 = 0o644;
50
51pub const SEAL_FORMAT: &str = "murmur.seal/1";
53pub const SEAL_SUFFIX: &str = ".seal";
55pub const SEAL_ALGORITHM: &str = "ed25519";
57pub const GENESIS: &str = "genesis";
59pub const PREV_HASH_KEY: &str = "prev_hash";
61pub const CHAIN_INLINE: &str = "inline";
63pub const CHAIN_LISTED: &str = "listed";
65
66pub const TRACE_STREAM: &str = crate::trace::TRACE_FILE_NAME;
68pub const EVAL_STREAM: &str = "hooks/eval.jsonl";
70pub const COMPLETION_STREAM: &str = crate::delegation::COMPLETION_FILE;
73pub const CONVERSATION_STREAM_PREFIX: &str = "conversation:";
75pub const CONVERSATIONS_DIR: &str = crate::conversation::CONVERSATION_ROOT_DIR;
77pub const CLI_SEALER: &str = "mur conversation";
80pub const REPORT_SCHEMA: u32 = 1;
82
83const FLOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(500);
87
88#[must_use]
90pub fn line_hash(bytes: &[u8]) -> String {
91 format!("sha256:{}", murmur_artifact::sha256_hex(bytes))
92}
93
94#[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#[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#[must_use]
109pub fn listed_head(line_hashes: &[String]) -> String {
110 line_hash(line_hashes.join("\n").as_bytes())
111}
112
113#[must_use]
115pub fn keys_dir(murmur_home: &Path) -> PathBuf {
116 murmur_home.join(KEYS_DIR)
117}
118
119#[must_use]
121pub fn private_key_path(murmur_home: &Path) -> PathBuf {
122 keys_dir(murmur_home).join(PRIVATE_KEY_FILE)
123}
124
125#[must_use]
127pub fn public_key_path(murmur_home: &Path) -> PathBuf {
128 keys_dir(murmur_home).join(PUBLIC_KEY_FILE)
129}
130
131#[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#[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 #[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 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 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 #[must_use]
202 pub fn key_id(&self) -> &str {
203 &self.key_id
204 }
205
206 #[must_use]
208 pub fn path(&self) -> Option<&Path> {
209 self.path.as_deref()
210 }
211
212 #[must_use]
214 pub fn to_hex(&self) -> String {
215 hex(&self.raw)
216 }
217
218 #[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
230pub struct HostKey {
235 pair: Ed25519KeyPair,
236 public: PublicKey,
237}
238
239impl std::fmt::Debug for HostKey {
240 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
248static HOST_KEYS: LazyLock<Mutex<HashMap<PathBuf, Arc<HostKey>>>> =
250 LazyLock::new(|| Mutex::new(HashMap::new()));
251
252impl HostKey {
253 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 #[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
332fn 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
385fn 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#[cfg(test)]
436thread_local! {
437 static TEST_SEAL_HOME: std::cell::RefCell<Option<PathBuf>> =
438 const { std::cell::RefCell::new(None) };
439}
440
441#[cfg(test)]
443pub(crate) fn set_test_seal_home(home: Option<PathBuf>) {
444 TEST_SEAL_HOME.with(|cell| *cell.borrow_mut() = home);
445}
446
447fn 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
463static KEY_FAILURES_REPORTED: LazyLock<Mutex<HashSet<String>>> =
465 LazyLock::new(|| Mutex::new(HashSet::new()));
466
467fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct ConversationPointer {
496 pub record: String,
497 pub context_id: String,
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502pub struct Seal {
503 pub format: String,
505 pub stream: String,
506 pub chain: String,
508 pub session_id: String,
509 pub lines: u64,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub line_hashes: Option<Vec<String>>,
513 pub head: String,
514 pub sealed_at: u64,
516 pub key_id: String,
517 pub algorithm: String,
518 pub signature: String,
519 #[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 #[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
564pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
588pub struct ChainHead {
589 pub head: String,
590 pub lines: u64,
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct SealedHead {
596 pub head: String,
598 pub lines: u64,
600 pub key_id: Option<String>,
602}
603
604#[derive(Debug)]
605struct ChainState {
606 head: String,
607 lines: u64,
608 len: Option<u64>,
610 torn: Option<String>,
612 lock_contended: bool,
615}
616
617pub struct InlineChain {
624 file: std::fs::File,
625 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
639static CHAINS: LazyLock<ChainRegistry> = LazyLock::new(|| Mutex::new(HashMap::new()));
642
643type ChainRegistry = Mutex<HashMap<(u64, u64), Weak<InlineChain>>>;
645
646impl InlineChain {
647 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 #[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 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 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 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 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 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
775fn 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
805struct FileLock<'a>(Option<&'a std::fs::File>);
808
809impl<'a> FileLock<'a> {
810 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 complete: Vec<&'a [u8]>,
845 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
862pub 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
921pub 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
966fn 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
981fn 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
989pub 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
1016static APPENDED_CONVERSATIONS: LazyLock<Mutex<HashMap<PathBuf, Vec<PathBuf>>>> =
1018 LazyLock::new(|| Mutex::new(HashMap::new()));
1019
1020pub 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#[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
1043pub 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
1063pub 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, ¤t) {
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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1115#[serde(rename_all = "lowercase")]
1116pub enum Verdict {
1117 Absent,
1119 Verified,
1121 Unverifiable,
1123 Unsealed,
1125 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1144pub struct StreamReport {
1145 pub stream: String,
1146 pub verdict: Verdict,
1147 pub lines: u64,
1149 pub sealed_lines: Option<u64>,
1151 pub chain_intact_through: Option<u64>,
1153 pub first_bad_line: Option<u64>,
1155 pub key_id: Option<String>,
1157 pub detail: String,
1158 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1184pub struct PublicKeyReport {
1185 pub path: Option<String>,
1186 pub key_id: Option<String>,
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct RecordReport {
1192 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#[must_use]
1203pub fn worst(verdicts: impl IntoIterator<Item = Verdict>) -> Verdict {
1204 verdicts.into_iter().max().unwrap_or(Verdict::Absent)
1205}
1206
1207fn 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
1225fn 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#[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#[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
1495fn 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
1506fn 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
1523fn 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#[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 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;