1use std::fs::{self, File, OpenOptions};
31use std::io::Write;
32use std::path::{Path, PathBuf};
33
34#[cfg(not(target_family = "wasm"))]
39use fs2::FileExt;
40
41use crate::statements::{
42 approval_revocation_record_digest, approval_use_record_digest,
43 journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
44 ReplayCheck, ReplayCheckLevel, TYPE_APPROVAL_REVOCATION, TYPE_APPROVAL_USE,
45 TYPE_JOURNAL_CHECKPOINT,
46};
47
48#[derive(Debug)]
53pub enum JournalError {
54 Io(std::io::Error),
55 Json(serde_json::Error),
56 BrokenChain {
59 index: u64,
60 expected: String,
61 actual: String,
62 },
63 RecordTampered {
66 index: u64,
67 expected: String,
68 actual: String,
69 },
70 MissingRecord {
72 index: u64,
73 },
74 LockBusy,
76 MaxUsesExceeded {
82 grant_id: String,
83 max_uses: u32,
84 current: u32,
85 },
86}
87
88impl std::fmt::Display for JournalError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Io(e) => write!(f, "journal io: {e}"),
92 Self::Json(e) => write!(f, "journal json: {e}"),
93 Self::BrokenChain { index, expected, actual } => write!(
94 f,
95 "journal broken at record {index}: previous_record_digest = {actual}, expected {expected}",
96 ),
97 Self::RecordTampered { index, expected, actual } => write!(
98 f,
99 "journal record {index} tampered: stored digest {expected}, recomputed {actual}",
100 ),
101 Self::MissingRecord { index } => write!(
102 f,
103 "journal record {index} referenced by head but missing on disk",
104 ),
105 Self::LockBusy => write!(f, "journal append lock busy; another process holds it"),
106 Self::MaxUsesExceeded { grant_id, max_uses, current } => write!(
107 f,
108 "approval grant {grant_id} would exceed max_uses ({current}/{max_uses})",
109 ),
110 }
111 }
112}
113
114impl std::error::Error for JournalError {}
115impl From<std::io::Error> for JournalError {
116 fn from(e: std::io::Error) -> Self {
117 Self::Io(e)
118 }
119}
120impl From<serde_json::Error> for JournalError {
121 fn from(e: serde_json::Error) -> Self {
122 Self::Json(e)
123 }
124}
125
126pub struct Journal {
132 pub dir: PathBuf,
134}
135
136impl Journal {
137 pub fn new(dir: impl Into<PathBuf>) -> Self {
138 Self { dir: dir.into() }
139 }
140
141 pub fn records_dir(&self) -> PathBuf {
142 self.dir.join("records")
143 }
144 pub fn heads_dir(&self) -> PathBuf {
145 self.dir.join("heads")
146 }
147 pub fn indexes_dir(&self) -> PathBuf {
148 self.dir.join("indexes")
149 }
150 pub fn locks_dir(&self) -> PathBuf {
151 self.dir.join("locks")
152 }
153 pub fn current_head_path(&self) -> PathBuf {
154 self.heads_dir().join("current.json")
155 }
156 pub fn lock_path(&self) -> PathBuf {
157 self.locks_dir().join("journal.lock")
158 }
159 pub fn meta_path(&self) -> PathBuf {
160 self.dir.join("journal.json")
161 }
162
163 pub fn by_grant_path(&self, grant_id: &str) -> PathBuf {
165 self.indexes_dir()
166 .join("by-grant")
167 .join(format!("{}.txt", safe_name(grant_id)))
168 }
169
170 pub fn by_nonce_path(&self, nonce_digest: &str) -> PathBuf {
172 self.indexes_dir()
173 .join("by-nonce")
174 .join(format!("{}.txt", safe_name(nonce_digest)))
175 }
176
177 pub fn exists(&self) -> bool {
179 self.dir.is_dir()
180 }
181}
182
183fn safe_name(s: &str) -> String {
187 s.chars()
188 .map(|c| match c {
189 ':' | '/' | '\\' | ' ' | '.' => '_',
190 c => c,
191 })
192 .collect()
193}
194
195#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
200pub struct Head {
201 pub index: u64,
203 pub digest: String,
205 pub updated_at: String,
207}
208
209impl Default for Head {
210 fn default() -> Self {
211 Self {
212 index: 0,
213 digest: String::new(),
214 updated_at: String::new(),
215 }
216 }
217}
218
219fn read_head(j: &Journal) -> Result<Head, JournalError> {
220 let path = j.current_head_path();
221 if !path.exists() {
222 return Ok(Head::default());
223 }
224 let bytes = fs::read(&path)?;
225 Ok(serde_json::from_slice(&bytes)?)
226}
227
228fn write_head(j: &Journal, head: &Head) -> Result<(), JournalError> {
229 fs::create_dir_all(j.heads_dir())?;
230 let path = j.current_head_path();
231 let tmp = path.with_extension("json.tmp");
232 let json = serde_json::to_vec_pretty(head)?;
233 fs::write(&tmp, json)?;
234 fs::rename(&tmp, &path)?;
235 Ok(())
236}
237
238#[cfg(not(target_family = "wasm"))]
247fn with_lock<F, T>(j: &Journal, body: F) -> Result<T, JournalError>
248where
249 F: FnOnce() -> Result<T, JournalError>,
250{
251 fs::create_dir_all(j.locks_dir())?;
252 let lock = OpenOptions::new()
253 .read(true)
254 .write(true)
255 .create(true)
256 .truncate(false)
257 .open(j.lock_path())?;
258 if lock.try_lock_exclusive().is_err() {
259 return Err(JournalError::LockBusy);
260 }
261 let result = body();
262 let _ = fs2::FileExt::unlock(&lock);
263 result
264}
265
266#[cfg(target_family = "wasm")]
269fn with_lock<F, T>(_j: &Journal, body: F) -> Result<T, JournalError>
270where
271 F: FnOnce() -> Result<T, JournalError>,
272{
273 body()
274}
275
276pub fn append_use(j: &Journal, mut rec: ApprovalUse) -> Result<Head, JournalError> {
283 rec.type_ = TYPE_APPROVAL_USE.into();
284 with_lock(j, || {
285 let head = read_head(j)?;
286 rec.previous_record_digest = head.digest.clone();
287 rec.record_digest = approval_use_record_digest(&rec);
288 let next_index = head.index + 1;
289 write_record_use(j, next_index, &rec)?;
290 update_indexes_for_use(j, next_index, &rec)?;
291 let new_head = Head {
292 index: next_index,
293 digest: rec.record_digest.clone(),
294 updated_at: rec.created_at.clone(),
295 };
296 write_head(j, &new_head)?;
297 ensure_meta(j)?;
298 Ok(new_head)
299 })
300}
301
302pub fn reserve_use(
321 j: &Journal,
322 mut rec: ApprovalUse,
323 max_uses: Option<u32>,
324) -> Result<Head, JournalError> {
325 rec.type_ = TYPE_APPROVAL_USE.into();
326 with_lock(j, || {
327 let replay = check_replay(j, &rec.grant_id, &rec.nonce_digest, max_uses)?;
331 if let Some(false) = replay.passed {
332 let current = replay.use_number.map(|n| n.saturating_sub(1)).unwrap_or(0);
333 return Err(JournalError::MaxUsesExceeded {
334 grant_id: rec.grant_id.clone(),
335 max_uses: replay.max_uses.unwrap_or(0),
336 current,
337 });
338 }
339 let prior_count = list_uses_for_grant(j, &rec.grant_id)?.len() as u32;
343 rec.use_number = prior_count.saturating_add(1);
344 let head = read_head(j)?;
346 rec.previous_record_digest = head.digest.clone();
347 rec.record_digest = approval_use_record_digest(&rec);
348 let next_index = head.index + 1;
349 write_record_use(j, next_index, &rec)?;
350 update_indexes_for_use(j, next_index, &rec)?;
351 let new_head = Head {
352 index: next_index,
353 digest: rec.record_digest.clone(),
354 updated_at: rec.created_at.clone(),
355 };
356 write_head(j, &new_head)?;
357 ensure_meta(j)?;
358 Ok(new_head)
359 })
360}
361
362pub fn append_revocation(j: &Journal, mut rec: ApprovalRevocation) -> Result<Head, JournalError> {
364 rec.type_ = TYPE_APPROVAL_REVOCATION.into();
365 with_lock(j, || {
366 let head = read_head(j)?;
367 rec.previous_record_digest = head.digest.clone();
368 rec.record_digest = approval_revocation_record_digest(&rec);
369 let next_index = head.index + 1;
370 write_record_revocation(j, next_index, &rec)?;
371 index_grant(j, next_index, &rec.grant_id)?;
372 let new_head = Head {
373 index: next_index,
374 digest: rec.record_digest.clone(),
375 updated_at: rec.created_at.clone(),
376 };
377 write_head(j, &new_head)?;
378 ensure_meta(j)?;
379 Ok(new_head)
380 })
381}
382
383pub fn append_checkpoint(j: &Journal, mut rec: JournalCheckpoint) -> Result<Head, JournalError> {
385 rec.type_ = TYPE_JOURNAL_CHECKPOINT.into();
386 with_lock(j, || {
387 let head = read_head(j)?;
388 rec.previous_record_digest = head.digest.clone();
389 rec.record_digest = journal_checkpoint_record_digest(&rec);
390 let next_index = head.index + 1;
391 write_record_checkpoint(j, next_index, &rec)?;
392 let new_head = Head {
393 index: next_index,
394 digest: rec.record_digest.clone(),
395 updated_at: rec.created_at.clone(),
396 };
397 write_head(j, &new_head)?;
398 ensure_meta(j)?;
399 Ok(new_head)
400 })
401}
402
403fn record_filename(index: u64, type_: &str, digest: &str) -> String {
404 let tail = digest.strip_prefix("sha256:").unwrap_or(digest);
407 let short = &tail[..tail.len().min(16)];
408 format!("{:010}.{type_}.{short}.json", index)
409}
410
411fn write_record_use(j: &Journal, index: u64, rec: &ApprovalUse) -> Result<(), JournalError> {
412 fs::create_dir_all(j.records_dir())?;
413 let name = record_filename(index, "approval-use", &rec.record_digest);
414 let path = j.records_dir().join(&name);
415 let tmp = path.with_extension("json.tmp");
416 let mut f = File::create(&tmp)?;
417 f.write_all(&serde_json::to_vec_pretty(rec)?)?;
418 f.sync_all()?;
419 fs::rename(&tmp, &path)?;
420 Ok(())
421}
422
423fn write_record_revocation(
424 j: &Journal,
425 index: u64,
426 rec: &ApprovalRevocation,
427) -> Result<(), JournalError> {
428 fs::create_dir_all(j.records_dir())?;
429 let name = record_filename(index, "approval-revocation", &rec.record_digest);
430 let path = j.records_dir().join(&name);
431 let tmp = path.with_extension("json.tmp");
432 let mut f = File::create(&tmp)?;
433 f.write_all(&serde_json::to_vec_pretty(rec)?)?;
434 f.sync_all()?;
435 fs::rename(&tmp, &path)?;
436 Ok(())
437}
438
439fn write_record_checkpoint(
440 j: &Journal,
441 index: u64,
442 rec: &JournalCheckpoint,
443) -> Result<(), JournalError> {
444 fs::create_dir_all(j.records_dir())?;
445 let name = record_filename(index, "journal-checkpoint", &rec.record_digest);
446 let path = j.records_dir().join(&name);
447 let tmp = path.with_extension("json.tmp");
448 let mut f = File::create(&tmp)?;
449 f.write_all(&serde_json::to_vec_pretty(rec)?)?;
450 f.sync_all()?;
451 fs::rename(&tmp, &path)?;
452 Ok(())
453}
454
455fn ensure_meta(j: &Journal) -> Result<(), JournalError> {
456 let path = j.meta_path();
457 if path.exists() {
458 return Ok(());
459 }
460 #[derive(serde::Serialize)]
461 struct Meta<'a> {
462 kind: &'a str,
463 version: &'a str,
464 format: &'a str,
465 }
466 let meta = Meta {
467 kind: "approval-use-journal",
468 version: "v1",
469 format: "json-records",
470 };
471 let bytes = serde_json::to_vec_pretty(&meta)?;
472 fs::write(&path, bytes)?;
473 Ok(())
474}
475
476fn append_index(path: &Path, line: &str) -> Result<(), JournalError> {
481 if let Some(parent) = path.parent() {
482 fs::create_dir_all(parent)?;
483 }
484 let mut f = OpenOptions::new().append(true).create(true).open(path)?;
485 writeln!(f, "{line}")?;
486 Ok(())
487}
488
489fn index_grant(j: &Journal, index: u64, grant_id: &str) -> Result<(), JournalError> {
490 append_index(&j.by_grant_path(grant_id), &index.to_string())
491}
492
493fn index_nonce(j: &Journal, index: u64, nonce_digest: &str) -> Result<(), JournalError> {
494 append_index(&j.by_nonce_path(nonce_digest), &index.to_string())
495}
496
497fn update_indexes_for_use(j: &Journal, index: u64, rec: &ApprovalUse) -> Result<(), JournalError> {
498 index_grant(j, index, &rec.grant_id)?;
499 index_nonce(j, index, &rec.nonce_digest)?;
500 Ok(())
501}
502
503pub fn rebuild_indexes(j: &Journal) -> Result<u64, JournalError> {
507 let dir = j.indexes_dir();
508 if dir.is_dir() {
509 fs::remove_dir_all(&dir)?;
513 }
514 let mut rebuilt = 0u64;
515 for (idx, kind, bytes) in iter_records(j)? {
516 match kind.as_str() {
517 "approval-use" => {
518 let rec: ApprovalUse = serde_json::from_slice(&bytes)?;
519 update_indexes_for_use(j, idx, &rec)?;
520 rebuilt += 1;
521 }
522 "approval-revocation" => {
523 let rec: ApprovalRevocation = serde_json::from_slice(&bytes)?;
524 index_grant(j, idx, &rec.grant_id)?;
525 rebuilt += 1;
526 }
527 "journal-checkpoint" => {
528 rebuilt += 1; }
530 _ => {}
531 }
532 }
533 Ok(rebuilt)
534}
535
536fn iter_records(j: &Journal) -> Result<Vec<(u64, String, Vec<u8>)>, JournalError> {
546 let dir = j.records_dir();
547 if !dir.is_dir() {
548 return Ok(Vec::new());
549 }
550 let mut entries: Vec<(u64, String, PathBuf)> = Vec::new();
551 for entry in fs::read_dir(&dir)? {
552 let entry = entry?;
553 let path = entry.path();
554 if path.extension().and_then(|s| s.to_str()) != Some("json") {
555 continue;
556 }
557 let name = match path.file_name().and_then(|n| n.to_str()) {
558 Some(n) => n,
559 None => continue,
560 };
561 let mut parts = name.splitn(4, '.');
563 let idx_str = match parts.next() {
564 Some(s) => s,
565 None => continue,
566 };
567 let kind = match parts.next() {
568 Some(s) => s,
569 None => continue,
570 };
571 let idx = match idx_str.parse::<u64>() {
573 Ok(n) => n,
574 Err(_) => continue,
575 };
576 entries.push((idx, kind.to_string(), path));
577 }
578 entries.sort_by_key(|(idx, _, _)| *idx);
579 let mut out = Vec::with_capacity(entries.len());
580 for (idx, kind, path) in entries {
581 let bytes = fs::read(&path)?;
582 out.push((idx, kind, bytes));
583 }
584 Ok(out)
585}
586
587pub fn verify_integrity(j: &Journal) -> Result<u64, JournalError> {
592 let mut prior_digest = String::new();
593 let mut count = 0u64;
594 let head = read_head(j)?;
595 for (idx, kind, bytes) in iter_records(j)? {
596 match kind.as_str() {
597 "approval-use" => {
598 let rec: ApprovalUse = serde_json::from_slice(&bytes)?;
599 if rec.previous_record_digest != prior_digest {
600 return Err(JournalError::BrokenChain {
601 index: idx,
602 expected: prior_digest,
603 actual: rec.previous_record_digest,
604 });
605 }
606 let recomputed = approval_use_record_digest(&rec);
607 if recomputed != rec.record_digest {
608 return Err(JournalError::RecordTampered {
609 index: idx,
610 expected: rec.record_digest,
611 actual: recomputed,
612 });
613 }
614 prior_digest = rec.record_digest;
615 }
616 "approval-revocation" => {
617 let rec: ApprovalRevocation = serde_json::from_slice(&bytes)?;
618 if rec.previous_record_digest != prior_digest {
619 return Err(JournalError::BrokenChain {
620 index: idx,
621 expected: prior_digest,
622 actual: rec.previous_record_digest,
623 });
624 }
625 let recomputed = approval_revocation_record_digest(&rec);
626 if recomputed != rec.record_digest {
627 return Err(JournalError::RecordTampered {
628 index: idx,
629 expected: rec.record_digest,
630 actual: recomputed,
631 });
632 }
633 prior_digest = rec.record_digest;
634 }
635 "journal-checkpoint" => {
636 let rec: JournalCheckpoint = serde_json::from_slice(&bytes)?;
637 if rec.previous_record_digest != prior_digest {
638 return Err(JournalError::BrokenChain {
639 index: idx,
640 expected: prior_digest,
641 actual: rec.previous_record_digest,
642 });
643 }
644 let recomputed = journal_checkpoint_record_digest(&rec);
645 if recomputed != rec.record_digest {
646 return Err(JournalError::RecordTampered {
647 index: idx,
648 expected: rec.record_digest,
649 actual: recomputed,
650 });
651 }
652 prior_digest = rec.record_digest;
653 }
654 _ => {
655 continue;
659 }
660 }
661 count += 1;
662 }
663 if head.index != 0 && head.digest != prior_digest {
666 return Err(JournalError::MissingRecord { index: head.index });
667 }
668 Ok(count)
669}
670
671pub fn check_replay(
690 j: &Journal,
691 grant_id: &str,
692 nonce_digest: &str,
693 max_uses_hint: Option<u32>,
694) -> Result<ReplayCheck, JournalError> {
695 if !j.exists() {
696 return Ok(ReplayCheck::not_performed());
697 }
698 let index_path = j.by_nonce_path(nonce_digest);
702 let mut current = 0u32;
703 let mut last_max: Option<u32> = None;
704 if index_path.exists() {
705 let raw = fs::read_to_string(&index_path)?;
706 for line in raw.lines() {
707 let idx: u64 = match line.trim().parse() {
708 Ok(n) => n,
709 Err(_) => continue,
710 };
711 if let Some(rec) = load_use_record(j, idx)? {
712 if rec.grant_id == grant_id {
716 current = current.saturating_add(1);
717 last_max = rec.max_uses.or(last_max);
718 }
719 }
720 }
721 }
722 let max_uses = max_uses_hint.or(last_max);
723 let passed = match max_uses {
724 Some(m) => current < m,
725 None => true, };
727 let details = match max_uses {
728 Some(m) => format!("local Approval Use Journal: use {current}/{m}"),
729 None => {
730 format!("local Approval Use Journal: {current} prior use(s); grant has no max_uses")
731 }
732 };
733 Ok(ReplayCheck {
734 level: ReplayCheckLevel::LocalJournal,
735 use_number: Some(current.saturating_add(1)),
736 max_uses,
737 passed: Some(passed),
738 details: Some(details),
739 })
740}
741
742fn load_use_record(j: &Journal, index: u64) -> Result<Option<ApprovalUse>, JournalError> {
743 let dir = j.records_dir();
744 if !dir.is_dir() {
745 return Ok(None);
746 }
747 let prefix = format!("{:010}.approval-use.", index);
748 for entry in fs::read_dir(&dir)? {
749 let entry = entry?;
750 let name = entry.file_name().to_string_lossy().into_owned();
751 if name.starts_with(&prefix) {
752 let bytes = fs::read(entry.path())?;
753 let rec: ApprovalUse = serde_json::from_slice(&bytes)?;
754 return Ok(Some(rec));
755 }
756 }
757 Ok(None)
758}
759
760pub fn find_use_for_action(
778 j: &Journal,
779 grant_id: &str,
780 nonce_digest: &str,
781 max_uses_hint: Option<u32>,
782) -> Result<Option<(ApprovalUse, ReplayCheck)>, JournalError> {
783 if !j.exists() {
784 return Ok(None);
785 }
786 let index_path = j.by_nonce_path(nonce_digest);
787 if !index_path.exists() {
788 return Ok(None);
789 }
790 let raw = fs::read_to_string(&index_path)?;
791 let mut latest: Option<ApprovalUse> = None;
798 for line in raw.lines() {
799 let idx: u64 = match line.trim().parse() {
800 Ok(n) => n,
801 Err(_) => continue,
802 };
803 if let Some(rec) = load_use_record(j, idx)? {
804 if rec.grant_id == grant_id {
805 latest = Some(rec);
806 }
807 }
808 }
809 let Some(rec) = latest else { return Ok(None) };
810
811 let stored_max = rec.max_uses;
812 let max_uses = max_uses_hint.or(stored_max);
813 let passed = match max_uses {
814 Some(m) => rec.use_number <= m,
815 None => true,
816 };
817 let details = match max_uses {
818 Some(m) => format!(
819 "local Approval Use Journal passed, use {}/{}",
820 rec.use_number, m
821 ),
822 None => format!(
823 "local Approval Use Journal: use {} of unbounded grant",
824 rec.use_number
825 ),
826 };
827 Ok(Some((
828 rec.clone(),
829 ReplayCheck {
830 level: ReplayCheckLevel::LocalJournal,
831 use_number: Some(rec.use_number),
832 max_uses,
833 passed: Some(passed),
834 details: Some(details),
835 },
836 )))
837}
838
839pub fn list_uses_for_grant(j: &Journal, grant_id: &str) -> Result<Vec<ApprovalUse>, JournalError> {
842 if !j.exists() {
843 return Ok(Vec::new());
844 }
845 let index_path = j.by_grant_path(grant_id);
846 if !index_path.exists() {
847 return Ok(Vec::new());
848 }
849 let raw = fs::read_to_string(&index_path)?;
850 let mut out = Vec::new();
851 for line in raw.lines() {
852 let idx: u64 = match line.trim().parse() {
853 Ok(n) => n,
854 Err(_) => continue,
855 };
856 if let Some(rec) = load_use_record(j, idx)? {
857 out.push(rec);
858 }
859 }
860 Ok(out)
861}
862
863#[cfg(test)]
868mod tests {
869 use super::*;
870 use tempfile::tempdir;
871
872 fn sample_use(use_id: &str, grant_id: &str, nonce_digest: &str, n: u32) -> ApprovalUse {
873 ApprovalUse {
874 type_: TYPE_APPROVAL_USE.into(),
875 use_id: use_id.into(),
876 grant_id: grant_id.into(),
877 grant_digest: "sha256:00".into(),
878 nonce_digest: nonce_digest.into(),
879 actor: "agent://deployer".into(),
880 action: "deploy.production".into(),
881 subject: "env://production".into(),
882 session_id: None,
883 action_artifact_id: None,
884 receipt_digest: None,
885 use_number: n,
886 max_uses: Some(2),
887 idempotency_key: None,
888 created_at: "2026-04-30T07:00:00Z".into(),
889 expires_at: None,
890 previous_record_digest: String::new(), record_digest: String::new(), signature: None,
893 signature_alg: None,
894 signing_key_id: None,
895 }
896 }
897
898 #[test]
899 fn first_append_creates_layout_and_head() {
900 let dir = tempdir().unwrap();
901 let j = Journal::new(dir.path());
902 let head = append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
903 assert_eq!(head.index, 1);
904 assert!(j.records_dir().is_dir());
905 assert!(j.heads_dir().is_dir());
906 assert!(j.current_head_path().is_file());
907 assert!(j.meta_path().is_file());
908 assert!(j.by_grant_path("g1").is_file());
910 assert!(j.by_nonce_path("sha256:nn1").is_file());
911 }
912
913 #[test]
914 fn second_append_links_previous_record_digest() {
915 let dir = tempdir().unwrap();
916 let j = Journal::new(dir.path());
917 let h1 = append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
918 let h2 = append_use(&j, sample_use("use_2", "g1", "sha256:nn2", 2)).unwrap();
919 assert_eq!(h2.index, 2);
920 let recs = iter_records(&j).unwrap();
922 assert_eq!(recs.len(), 2);
923 let (_, _, bytes) = &recs[1];
924 let r2: ApprovalUse = serde_json::from_slice(bytes).unwrap();
925 assert_eq!(r2.previous_record_digest, h1.digest);
926 }
927
928 #[test]
929 fn verify_integrity_passes_on_intact_chain() {
930 let dir = tempdir().unwrap();
931 let j = Journal::new(dir.path());
932 for i in 1..=5 {
933 let nd = format!("sha256:nn{i}");
934 append_use(&j, sample_use(&format!("use_{i}"), "g1", &nd, i)).unwrap();
935 }
936 assert_eq!(verify_integrity(&j).unwrap(), 5);
937 }
938
939 #[test]
940 fn editing_a_record_breaks_integrity() {
941 let dir = tempdir().unwrap();
942 let j = Journal::new(dir.path());
943 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
944 let entries: Vec<_> = fs::read_dir(j.records_dir()).unwrap().collect();
946 let entry = entries.into_iter().next().unwrap().unwrap();
947 let mut json: serde_json::Value =
948 serde_json::from_slice(&fs::read(entry.path()).unwrap()).unwrap();
949 json["actor"] = "agent://attacker".into();
950 fs::write(entry.path(), serde_json::to_vec_pretty(&json).unwrap()).unwrap();
951
952 let err = verify_integrity(&j).unwrap_err();
953 assert!(
954 matches!(err, JournalError::RecordTampered { .. }),
955 "expected RecordTampered, got {err:?}"
956 );
957 }
958
959 #[test]
960 fn deleting_a_record_breaks_integrity_or_head_continuity() {
961 let dir = tempdir().unwrap();
962 let j = Journal::new(dir.path());
963 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
964 append_use(&j, sample_use("use_2", "g1", "sha256:nn2", 2)).unwrap();
965 let entries: Vec<_> = fs::read_dir(j.records_dir())
967 .unwrap()
968 .map(|e| e.unwrap().path())
969 .collect();
970 let trailing = entries.iter().max().unwrap();
971 fs::remove_file(trailing).unwrap();
972
973 let err = verify_integrity(&j).unwrap_err();
974 assert!(
975 matches!(err, JournalError::MissingRecord { .. }),
976 "expected MissingRecord, got {err:?}"
977 );
978 }
979
980 #[test]
981 fn indexes_can_be_rebuilt_from_records() {
982 let dir = tempdir().unwrap();
983 let j = Journal::new(dir.path());
984 for i in 1..=3 {
985 let nd = format!("sha256:nn{i}");
986 append_use(&j, sample_use(&format!("use_{i}"), "g1", &nd, i)).unwrap();
987 }
988 fs::remove_dir_all(j.indexes_dir()).unwrap();
990
991 let rebuilt = rebuild_indexes(&j).unwrap();
992 assert_eq!(rebuilt, 3);
993 assert!(j.by_grant_path("g1").is_file());
994 assert!(j.by_nonce_path("sha256:nn1").is_file());
995 }
996
997 #[test]
998 fn check_replay_reports_use_count_and_max() {
999 let dir = tempdir().unwrap();
1000 let j = Journal::new(dir.path());
1001 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
1003 append_use(&j, sample_use("use_2", "g1", "sha256:nn1", 2)).unwrap();
1004
1005 let r = check_replay(&j, "g1", "sha256:nn1", Some(2)).unwrap();
1007 assert_eq!(r.level, ReplayCheckLevel::LocalJournal);
1008 assert_eq!(r.use_number, Some(3));
1009 assert_eq!(r.max_uses, Some(2));
1010 assert_eq!(r.passed, Some(false));
1011 }
1012
1013 #[test]
1014 fn check_replay_passes_when_under_max() {
1015 let dir = tempdir().unwrap();
1016 let j = Journal::new(dir.path());
1017 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
1018 let r = check_replay(&j, "g1", "sha256:nn1", Some(2)).unwrap();
1019 assert_eq!(r.use_number, Some(2));
1020 assert_eq!(r.passed, Some(true));
1021 }
1022
1023 #[test]
1024 fn check_replay_no_journal_returns_not_performed() {
1025 let dir = tempdir().unwrap();
1026 let absent = dir.path().join("nope");
1027 let j = Journal::new(&absent);
1028 let r = check_replay(&j, "g1", "sha256:nn1", Some(1)).unwrap();
1029 assert_eq!(r.level, ReplayCheckLevel::NotPerformed);
1030 assert!(r.use_number.is_none());
1031 }
1032
1033 #[test]
1034 fn check_replay_unbounded_grant_passes_with_count() {
1035 let dir = tempdir().unwrap();
1036 let j = Journal::new(dir.path());
1037 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
1038 let mut u = sample_use("use_2", "g2", "sha256:other", 1);
1042 u.max_uses = None;
1043 append_use(&j, u).unwrap();
1044
1045 let r = check_replay(&j, "g2", "sha256:other", None).unwrap();
1046 assert!(r.passed.unwrap());
1047 assert!(r.max_uses.is_none());
1048 }
1049
1050 #[test]
1051 fn list_uses_for_grant_returns_records_in_order() {
1052 let dir = tempdir().unwrap();
1053 let j = Journal::new(dir.path());
1054 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
1055 append_use(&j, sample_use("use_2", "g2", "sha256:nn2", 1)).unwrap();
1056 append_use(&j, sample_use("use_3", "g1", "sha256:nn3", 2)).unwrap();
1057 let g1 = list_uses_for_grant(&j, "g1").unwrap();
1058 assert_eq!(g1.len(), 2);
1059 assert_eq!(g1[0].use_id, "use_1");
1060 assert_eq!(g1[1].use_id, "use_3");
1061 }
1062
1063 #[test]
1064 fn lock_keeps_two_appends_serial() {
1065 let dir = tempdir().unwrap();
1068 let j = Journal::new(dir.path());
1069 fs::create_dir_all(j.locks_dir()).unwrap();
1070 let held = OpenOptions::new()
1071 .read(true)
1072 .write(true)
1073 .create(true)
1074 .truncate(false)
1075 .open(j.lock_path())
1076 .unwrap();
1077 held.try_lock_exclusive().unwrap();
1078
1079 let err = append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap_err();
1080 assert!(matches!(err, JournalError::LockBusy));
1081
1082 let _ = fs2::FileExt::unlock(&held);
1083 }
1084
1085 #[test]
1086 fn revocation_appends_into_chain() {
1087 let dir = tempdir().unwrap();
1088 let j = Journal::new(dir.path());
1089 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
1090 let rev = ApprovalRevocation {
1091 type_: TYPE_APPROVAL_REVOCATION.into(),
1092 revocation_id: "rev_1".into(),
1093 grant_id: "g1".into(),
1094 grant_digest: "sha256:00".into(),
1095 revoker: "human://alice".into(),
1096 reason: Some("rotated key".into()),
1097 created_at: "2026-04-30T07:01:00Z".into(),
1098 previous_record_digest: String::new(),
1099 record_digest: String::new(),
1100 signature: None,
1101 signature_alg: None,
1102 signing_key_id: None,
1103 };
1104 let h = append_revocation(&j, rev).unwrap();
1105 assert_eq!(h.index, 2);
1106 assert_eq!(verify_integrity(&j).unwrap(), 2);
1107 }
1108
1109 #[test]
1110 fn record_files_contain_no_raw_nonce_or_signature_secrets() {
1111 let dir = tempdir().unwrap();
1116 let j = Journal::new(dir.path());
1117 append_use(&j, sample_use("use_1", "g1", "sha256:nn1", 1)).unwrap();
1118 let entries: Vec<_> = fs::read_dir(j.records_dir())
1119 .unwrap()
1120 .map(|e| e.unwrap().path())
1121 .collect();
1122 let bytes = fs::read(&entries[0]).unwrap();
1123 let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1124 let obj = json.as_object().unwrap();
1125 for forbidden in [
1126 "nonce",
1127 "command",
1128 "prompt",
1129 "file_content",
1130 "bearer_token",
1131 "api_key",
1132 ] {
1133 assert!(
1134 !obj.contains_key(forbidden),
1135 "journal record must not contain `{forbidden}`",
1136 );
1137 }
1138 assert!(obj.contains_key("nonce_digest"));
1140 }
1141
1142 #[test]
1145 fn reserve_use_first_call_succeeds_and_stamps_use_number() {
1146 let dir = tempdir().unwrap();
1149 let j = Journal::new(dir.path());
1150 let mut rec = sample_use("use_1", "g1", "sha256:nn1", 0);
1151 rec.use_number = 0;
1152 let head = reserve_use(&j, rec, Some(1)).unwrap();
1153 assert_eq!(head.index, 1);
1154 let stored = list_uses_for_grant(&j, "g1").unwrap();
1155 assert_eq!(stored.len(), 1);
1156 assert_eq!(
1157 stored[0].use_number, 1,
1158 "reserve_use must stamp use_number=1 for the first use"
1159 );
1160 }
1161
1162 #[test]
1163 fn reserve_use_max_uses_1_serial_second_call_rejects() {
1164 let dir = tempdir().unwrap();
1167 let j = Journal::new(dir.path());
1168 reserve_use(&j, sample_use("use_1", "g1", "sha256:nn_a", 0), Some(1)).unwrap();
1169
1170 let err = reserve_use(&j, sample_use("use_2", "g1", "sha256:nn_a", 0), Some(1))
1171 .expect_err("second consume of max_uses=1 grant must fail");
1172 match err {
1173 JournalError::MaxUsesExceeded {
1174 grant_id,
1175 max_uses,
1176 current,
1177 } => {
1178 assert_eq!(grant_id, "g1");
1179 assert_eq!(max_uses, 1);
1180 assert_eq!(current, 1);
1181 }
1182 other => panic!("expected MaxUsesExceeded, got {other:?}"),
1183 }
1184 let stored = list_uses_for_grant(&j, "g1").unwrap();
1186 assert_eq!(stored.len(), 1, "rejected reserve must not append");
1187 }
1188
1189 #[test]
1190 fn reserve_use_max_uses_2_two_uses_pass_third_rejects() {
1191 let dir = tempdir().unwrap();
1193 let j = Journal::new(dir.path());
1194 let mut a = sample_use("use_1", "g1", "sha256:nn_a", 0);
1195 a.max_uses = Some(2);
1196 let mut b = sample_use("use_2", "g1", "sha256:nn_b", 0);
1197 b.max_uses = Some(2);
1198 reserve_use(&j, a, Some(2)).unwrap();
1199 reserve_use(&j, b, Some(2)).unwrap();
1200 let mut c = sample_use("use_3", "g1", "sha256:nn_c", 0);
1203 c.max_uses = Some(2);
1204 reserve_use(&j, c, Some(2)).unwrap();
1205 let mut a2 = sample_use("use_1b", "g1", "sha256:nn_a", 0);
1209 a2.max_uses = Some(2);
1210 reserve_use(&j, a2, Some(2)).unwrap();
1211 let mut a3 = sample_use("use_1c", "g1", "sha256:nn_a", 0);
1213 a3.max_uses = Some(2);
1214 let err = reserve_use(&j, a3, Some(2)).expect_err("third use of same nonce must fail");
1215 assert!(matches!(err, JournalError::MaxUsesExceeded { .. }));
1216 }
1217
1218 #[test]
1231 fn reserve_use_retry_after_lock_busy_does_not_bypass_max_uses() {
1232 let dir = tempdir().unwrap();
1233 let j = Journal::new(dir.path());
1234 reserve_use(&j, sample_use("use_1", "g1", "sha256:nn_retry", 0), Some(1)).unwrap();
1236 for i in 0..5 {
1239 let err = reserve_use(
1240 &j,
1241 sample_use(&format!("use_retry_{i}"), "g1", "sha256:nn_retry", 0),
1242 Some(1),
1243 )
1244 .expect_err("retry must fail");
1245 assert!(matches!(err, JournalError::MaxUsesExceeded { .. }));
1246 }
1247 let stored = list_uses_for_grant(&j, "g1").unwrap();
1248 assert_eq!(
1249 stored.len(),
1250 1,
1251 "exactly one record on disk despite 5 retries"
1252 );
1253 }
1254
1255 #[test]
1256 fn reserve_use_concurrent_max_uses_1_only_one_succeeds() {
1257 use std::sync::atomic::{AtomicUsize, Ordering};
1271 use std::sync::Arc;
1272 use std::thread;
1273
1274 let dir = tempdir().unwrap();
1275 let dir_path = Arc::new(dir.path().to_path_buf());
1276 let success = Arc::new(AtomicUsize::new(0));
1277 let lock_busy = Arc::new(AtomicUsize::new(0));
1278 let max_exceeded = Arc::new(AtomicUsize::new(0));
1279
1280 let mut handles = Vec::new();
1281 for i in 0..8 {
1282 let dir_path = Arc::clone(&dir_path);
1283 let success = Arc::clone(&success);
1284 let lock_busy = Arc::clone(&lock_busy);
1285 let max_exceeded = Arc::clone(&max_exceeded);
1286 handles.push(thread::spawn(move || {
1287 let j = Journal::new(dir_path.as_path());
1288 let rec = sample_use(&format!("use_{i}"), "g1", "sha256:race_nonce", 0);
1289 match reserve_use(&j, rec, Some(1)) {
1290 Ok(_) => {
1291 success.fetch_add(1, Ordering::SeqCst);
1292 }
1293 Err(JournalError::LockBusy) => {
1294 lock_busy.fetch_add(1, Ordering::SeqCst);
1295 }
1296 Err(JournalError::MaxUsesExceeded { .. }) => {
1297 max_exceeded.fetch_add(1, Ordering::SeqCst);
1298 }
1299 Err(other) => panic!("unexpected error: {other:?}"),
1300 }
1301 }));
1302 }
1303 for h in handles {
1304 h.join().unwrap();
1305 }
1306
1307 let s = success.load(Ordering::SeqCst);
1308 let lb = lock_busy.load(Ordering::SeqCst);
1309 let me = max_exceeded.load(Ordering::SeqCst);
1310 assert_eq!(s, 1, "exactly one of 8 concurrent reserves must succeed; got {s} (lock_busy={lb}, max_exceeded={me})");
1311 assert_eq!(s + lb + me, 8, "every thread accounted for");
1312
1313 let stored = list_uses_for_grant(&Journal::new(dir.path()), "g1").unwrap();
1315 let same_nonce = stored
1316 .iter()
1317 .filter(|u| u.nonce_digest == "sha256:race_nonce")
1318 .count();
1319 assert_eq!(
1320 same_nonce, 1,
1321 "exactly one record on disk for the contested nonce"
1322 );
1323 }
1324}