1use crate::authorize::Decision;
10use crate::decision::canonical::canonical_json;
11use crate::decision::chain::{ChainId, HashChain, HASH_LEN};
12use crate::decision::record::DecisionRecord;
13use crate::error::{Error, Result};
14use std::fs::{File, OpenOptions};
15use std::io::{BufRead, BufReader, BufWriter, Write};
16use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18
19#[derive(Debug, Clone)]
28pub struct RotationConfig {
29 pub max_bytes: u64,
31}
32
33impl RotationConfig {
34 pub fn parse(value: &str) -> std::result::Result<Self, String> {
36 match value.parse::<u64>() {
37 Ok(max_bytes) if max_bytes > 0 => Ok(Self { max_bytes }),
38 _ => Err("must be a positive integer when set".to_owned()),
39 }
40 }
41
42 pub fn try_from_env() -> std::result::Result<Option<Self>, String> {
45 match std::env::var("AGENTGUARD_AUDIT_MAX_BYTES") {
46 Ok(value) => Self::parse(&value).map(Some),
47 Err(std::env::VarError::NotPresent) => Ok(None),
48 Err(std::env::VarError::NotUnicode(_)) => Err("must be valid Unicode".to_owned()),
49 }
50 }
51
52 #[deprecated(note = "use try_from_env to report invalid configuration")]
54 pub fn from_env() -> Option<Self> {
55 Self::try_from_env().ok().flatten()
56 }
57}
58
59pub struct DecisionLog {
65 mode: LogMode,
66 path: PathBuf,
70 chain_id_path: PathBuf,
73 rotation: Option<RotationConfig>,
76 rotation_lock: Mutex<()>,
79}
80
81enum LogMode {
82 Plain(Mutex<Option<File>>),
83 Chained {
84 file: Mutex<Option<File>>,
85 chain: HashChain,
86 },
87}
88
89impl DecisionLog {
90 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
102 Self::open_internal(path.into(), None, None)
103 }
104
105 pub fn open_with_chain(path: impl Into<PathBuf>, root_key: &[u8]) -> Result<Self> {
117 Self::open_internal(path.into(), Some(root_key.to_vec()), None)
118 }
119
120 pub fn open_with_rotation(
124 path: impl Into<PathBuf>,
125 root_key: Option<&[u8]>,
126 rotation: RotationConfig,
127 ) -> Result<Self> {
128 Self::open_internal(path.into(), root_key.map(|k| k.to_vec()), Some(rotation))
129 }
130
131 fn open_internal(
132 path: PathBuf,
133 root_key: Option<Vec<u8>>,
134 rotation: Option<RotationConfig>,
135 ) -> Result<Self> {
136 if let Some(parent) = path.parent() {
137 std::fs::create_dir_all(parent)?;
138 }
139 let f = open_audit_file(&path)?;
140 let chain_id_path = chain_id_sidecar_path(&path);
141 match root_key {
142 None => Ok(Self {
143 mode: LogMode::Plain(Mutex::new(Some(f))),
144 path,
145 chain_id_path,
146 rotation,
147 rotation_lock: Mutex::new(()),
148 }),
149 Some(key) => {
150 let chain = HashChain::new(&key);
151 let mut persisted_paths = rotated_logs(&path)?;
156 persisted_paths.push(path.clone());
157 verify_chain_paths(&persisted_paths, &key).map_err(|e| {
158 Error::Other(format!(
159 "refusing to open invalid chained audit log {}: {e}",
160 path.display()
161 ))
162 })?;
163 let head_path = if std::fs::metadata(&path)
169 .map(|metadata| metadata.len() == 0)
170 .unwrap_or(true)
171 {
172 latest_rotated_log(&path)?.unwrap_or_else(|| path.clone())
173 } else {
174 path.clone()
175 };
176 chain.load_head_from_file(&head_path).map_err(|e| {
179 Error::Other(format!(
180 "refusing to open corrupt chained audit log {}: {e}",
181 head_path.display()
182 ))
183 })?;
184 let prior_sidecar = if head_path == path {
192 chain_id_path.clone()
193 } else {
194 chain_id_sidecar_path(&head_path)
195 };
196 if let Some(id) = read_chain_id_sidecar(&prior_sidecar) {
197 chain.adopt_id(id);
198 } else {
199 let id = chain.id();
200 write_chain_id_sidecar(&chain_id_path, id).map_err(|error| {
201 Error::Io(format!(
202 "persist audit chain id {}: {error}",
203 chain_id_path.display()
204 ))
205 })?;
206 }
207 Ok(Self {
208 mode: LogMode::Chained {
209 file: Mutex::new(Some(f)),
210 chain,
211 },
212 path,
213 chain_id_path,
214 rotation,
215 rotation_lock: Mutex::new(()),
216 })
217 }
218 }
219 }
220
221 pub fn default_path() -> PathBuf {
222 PathBuf::from(".audit/decisions.jsonl")
223 }
224
225 fn rotate_if_needed(&self) -> Result<()> {
229 let _rotation_guard = self.rotation_lock.lock().unwrap_or_else(|e| e.into_inner());
230 let rotation = match &self.rotation {
231 Some(r) => r,
232 None => return Ok(()),
233 };
234 let size = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
235 if size < rotation.max_bytes {
236 return Ok(());
237 }
238 self.rotate_inner()
239 }
240
241 pub fn rotate(&self) -> Result<()> {
246 let _rotation_guard = self.rotation_lock.lock().unwrap_or_else(|e| e.into_inner());
247 self.rotate_inner()
248 }
249
250 fn rotate_inner(&self) -> Result<()> {
251 let now = chrono::Utc::now();
252 let ts = format!(
253 "{}{:06}Z",
254 now.format("%Y%m%dT%H%M%S"),
255 now.timestamp_subsec_micros()
256 );
257 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
258 let stem = self
259 .path
260 .file_stem()
261 .and_then(|s| s.to_str())
262 .unwrap_or("decisions");
263 let ext = self
264 .path
265 .extension()
266 .and_then(|s| s.to_str())
267 .unwrap_or("jsonl");
268 let mut rotated = parent.join(format!("{stem}-{ts}.{ext}"));
269 let mut suffix = 1u32;
270 while rotated.exists() {
271 rotated = parent.join(format!("{stem}-{ts}-{suffix}.{ext}"));
272 suffix = suffix.saturating_add(1);
273 }
274
275 match &self.mode {
279 LogMode::Plain(file) => {
280 let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
281 *guard = None;
282 *guard = Some(self.reopen_rotated(&rotated)?);
283 }
284 LogMode::Chained { file, chain } => {
285 let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
286 *guard = None;
287 let f = self.reopen_rotated(&rotated)?;
288 chain
293 .load_head_from_file(&rotated)
294 .map_err(|e| Error::Io(format!("reload chain after rotate: {e}")))?;
295 if let Some(id) = read_chain_id_sidecar(&chain_id_sidecar_path(&rotated)) {
296 chain.adopt_id(id);
297 }
298 *guard = Some(f);
299 }
300 }
301 tracing::info!(
302 active = %self.path.display(),
303 rotated = %rotated.display(),
304 "rotated audit log"
305 );
306 Ok(())
307 }
308
309 fn reopen_rotated(&self, rotated: &Path) -> Result<File> {
310 let rotated_sidecar = chain_id_sidecar_path(rotated);
311 let require_chain_id = matches!(&self.mode, LogMode::Chained { .. });
312 copy_chain_id_sidecar(&self.chain_id_path, &rotated_sidecar, require_chain_id)?;
313 std::fs::rename(&self.path, rotated).map_err(|e| {
314 Error::Io(format!(
315 "rotate {} -> {}: {}",
316 self.path.display(),
317 rotated.display(),
318 e
319 ))
320 })?;
321 open_audit_file(&self.path).map_err(Error::from)
322 }
323
324 pub fn path(&self) -> &Path {
327 &self.path
328 }
329
330 pub fn chain_id(&self) -> Option<ChainId> {
331 match &self.mode {
332 LogMode::Plain(_) => None,
333 LogMode::Chained { chain, .. } => Some(chain.id()),
334 }
335 }
336
337 pub fn is_healthy(&self) -> bool {
341 match &self.mode {
342 LogMode::Plain(file) | LogMode::Chained { file, .. } => {
343 file.lock().map(|guard| guard.is_some()).unwrap_or(false)
344 }
345 }
346 }
347
348 #[tracing::instrument(skip_all, fields(path = %self.path.display()))]
370 pub fn append(&self, rec: &DecisionRecord) -> Result<()> {
371 let canonical = canonical_json(rec)?;
372 self.rotate_if_needed()?;
375 match &self.mode {
376 LogMode::Plain(file) => {
377 let mut line = serde_json::to_vec(rec)?;
378 line.push(b'\n');
379 let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
380 let Some(f) = guard.as_mut() else {
381 return Err(Error::Io(
382 "audit log is unavailable after a prior storage failure".into(),
383 ));
384 };
385 let write_result = write_line_durably(f, &line);
386 if let Err(error) = write_result {
387 *guard = None;
391 return Err(Error::from(error));
392 }
393 }
394 LogMode::Chained { file, chain } => {
395 let chain_id = chain.id();
399 if read_chain_id_sidecar(&self.chain_id_path) != Some(chain_id) {
403 if let Err(error) = write_chain_id_sidecar(&self.chain_id_path, chain_id) {
404 let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
405 *guard = None;
406 return Err(Error::Io(format!(
407 "persist audit chain id {}: {error}",
408 self.chain_id_path.display()
409 )));
410 }
411 }
412 let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
413 let Some(f) = guard.as_mut() else {
414 return Err(Error::Io(
415 "audit log is unavailable after a prior storage failure".into(),
416 ));
417 };
418 if let Err(error) =
423 chain.try_append_with_io(&canonical, |prev, new_hash| -> std::io::Result<()> {
424 let chained = ChainedRecord {
425 prev_hash: hex::encode(prev),
426 record_hash: hex::encode(new_hash),
427 chain_id,
428 record: rec.clone(),
429 };
430 let mut line_with_newline = serde_json::to_vec(&chained)?;
431 line_with_newline.push(b'\n');
432 write_line_durably(f, &line_with_newline)
437 })
438 {
439 *guard = None;
440 return Err(Error::from(error));
441 }
442 }
443 }
444 Ok(())
445 }
446
447 pub fn append_decision(&self, d: &Decision) -> Result<()> {
455 let rec = DecisionRecord::from_decision(d, None, None);
456 self.append(&rec)
457 }
458
459 pub fn read_all(path: impl AsRef<Path>) -> Result<Vec<DecisionRecord>> {
468 let path = path.as_ref();
469 let mut paths = rotated_logs(path)?;
470 paths.push(path.to_path_buf());
471 Self::read_all_mixed_paths(&paths)
472 }
473
474 pub fn read_all_chained(path: impl AsRef<Path>) -> Result<Vec<ChainedRecord>> {
482 let f = open_existing_audit_file(path.as_ref())?;
483 let r = BufReader::new(f);
484 let mut out = Vec::new();
485 for line in r.lines() {
486 let line = line?;
487 if line.trim().is_empty() {
488 continue;
489 }
490 let rec: ChainedRecord = serde_json::from_str(&line)?;
491 out.push(rec);
492 }
493 Ok(out)
494 }
495
496 fn read_all_mixed_paths(paths: &[PathBuf]) -> Result<Vec<DecisionRecord>> {
500 let mut out = Vec::new();
501 for path in paths {
502 let f = open_existing_audit_file(path)?;
503 let r = BufReader::new(f);
504 for (idx, line) in r.lines().enumerate() {
505 let line = line?;
506 if line.trim().is_empty() {
507 continue;
508 }
509 let rec: DecisionRecord = match serde_json::from_str(&line) {
515 Ok(r) => r,
516 Err(plain_err) => match serde_json::from_str::<ChainedRecord>(&line) {
517 Ok(chained) => chained.record,
518 Err(chained_err) => {
519 return Err(Error::Json(format!(
520 "{} line {}: not a DecisionRecord ({plain_err}); \
521 also not a ChainedRecord ({chained_err})",
522 path.display(),
523 idx + 1
524 )));
525 }
526 },
527 };
528 out.push(rec);
529 }
530 }
531 Ok(out)
532 }
533
534 pub fn verify_chain(path: impl AsRef<Path>, root_key: &[u8]) -> Result<ChainId> {
558 let path = path.as_ref();
559 let mut paths = rotated_logs(path)?;
560 paths.push(path.to_path_buf());
561 verify_chain_paths(&paths, root_key)
562 }
563}
564
565fn verify_chain_paths(paths: &[PathBuf], root_key: &[u8]) -> Result<ChainId> {
566 let mut chain_id = None;
567 let mut expected_prev = [0u8; HASH_LEN];
568 let mut record_number = 0usize;
569 let chain = HashChain::new(root_key);
570 for path in paths {
571 let file = open_existing_audit_file(path)?;
572 let reader = BufReader::new(file);
573 for (line_number, line) in reader.lines().enumerate() {
574 let line = line?;
575 if line.trim().is_empty() {
576 continue;
577 }
578 let record: ChainedRecord = serde_json::from_str(&line).map_err(|error| {
579 Error::Json(format!(
580 "{} line {}: {error}",
581 path.display(),
582 line_number + 1
583 ))
584 })?;
585 if let Some(expected) = chain_id {
586 if record.chain_id != expected {
587 return Err(Error::Other(format!(
588 "chain id mismatch in {}: expected {}, got {}",
589 path.display(),
590 expected,
591 record.chain_id
592 )));
593 }
594 } else {
595 chain_id = Some(record.chain_id);
596 }
597 let canonical = canonical_json(&record.record)?;
598 let prev = parse_hex32(&record.prev_hash)?;
599 let hash = parse_hex32(&record.record_hash)?;
600 if prev != expected_prev {
601 return Err(Error::Other(format!(
602 "record {}: prev_hash mismatch: expected {}, got {}",
603 record_number,
604 hex::encode(expected_prev),
605 hex::encode(prev)
606 )));
607 }
608 chain
609 .verify(&canonical, &prev, &hash)
610 .map_err(|error| Error::Other(format!("record {}: {error}", record_number)))?;
611 expected_prev = hash;
612 record_number += 1;
613 }
614 }
615 Ok(chain_id.unwrap_or_default())
616}
617
618fn parse_hex32(s: &str) -> Result<[u8; HASH_LEN]> {
619 let bytes = hex::decode(s).map_err(|e| Error::Other(format!("hex: {}", e)))?;
620 if bytes.len() != HASH_LEN {
621 return Err(Error::Other(format!(
622 "expected {} bytes, got {}",
623 HASH_LEN,
624 bytes.len()
625 )));
626 }
627 let mut arr = [0u8; HASH_LEN];
628 arr.copy_from_slice(&bytes);
629 Ok(arr)
630}
631
632fn chain_id_sidecar_path(log_path: &Path) -> PathBuf {
638 let parent = log_path.parent().unwrap_or_else(|| Path::new("."));
639 let name = log_path
640 .file_name()
641 .and_then(|s| s.to_str())
642 .unwrap_or("audit");
643 parent.join(format!(".{}.chainid", name))
644}
645
646fn copy_chain_id_sidecar(active: &Path, rotated: &Path, required: bool) -> Result<()> {
651 match std::fs::symlink_metadata(active) {
652 Ok(metadata) if metadata.file_type().is_file() => {}
653 Ok(_) => {
654 return Err(Error::Io(format!(
655 "chain identity sidecar must be a regular file: {}",
656 active.display()
657 )))
658 }
659 Err(error) if error.kind() == std::io::ErrorKind::NotFound && !required => return Ok(()),
660 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
661 return Err(Error::Io(format!(
662 "required chain identity sidecar is missing: {}",
663 active.display()
664 )))
665 }
666 Err(error) => {
667 return Err(Error::Io(format!(
668 "inspect chain identity sidecar {}: {error}",
669 active.display()
670 )))
671 }
672 }
673
674 let id = read_chain_id_sidecar(active).ok_or_else(|| {
675 Error::Io(format!(
676 "chain identity sidecar is invalid: {}",
677 active.display()
678 ))
679 })?;
680 write_chain_id_sidecar(rotated, id).map_err(|error| {
681 Error::Io(format!(
682 "persist rotated chain identity sidecar {}: {error}",
683 rotated.display()
684 ))
685 })
686}
687
688fn rotated_logs(log_path: &Path) -> Result<Vec<PathBuf>> {
692 let parent = log_path.parent().unwrap_or_else(|| Path::new("."));
693 let stem = log_path
694 .file_stem()
695 .and_then(|s| s.to_str())
696 .unwrap_or("decisions");
697 let extension = log_path
698 .extension()
699 .and_then(|s| s.to_str())
700 .unwrap_or("jsonl");
701 let prefix = format!("{stem}-");
702 let mut candidates = Vec::new();
703 for entry in std::fs::read_dir(parent)? {
704 let entry = entry?;
705 let path = entry.path();
706 let rotation_suffix = path
707 .file_stem()
708 .and_then(|s| s.to_str())
709 .and_then(|stem| stem.strip_prefix(&prefix));
710 if rotation_suffix.is_some_and(is_rotation_filename)
711 && path.extension().and_then(|s| s.to_str()) == Some(extension)
712 && entry.file_type()?.is_file()
713 {
714 candidates.push(path);
715 }
716 }
717 candidates.sort_by_key(|path| rotation_order(path, &prefix));
718 Ok(candidates)
719}
720
721fn rotation_order(path: &Path, prefix: &str) -> (String, u32) {
722 let suffix = path
723 .file_stem()
724 .and_then(|stem| stem.to_str())
725 .and_then(|stem| stem.strip_prefix(prefix))
726 .unwrap_or_default();
727 let (timestamp, collision) = suffix
728 .split_once('-')
729 .map_or((suffix, None), |(timestamp, collision)| {
730 (timestamp, Some(collision))
731 });
732 let collision = collision
733 .and_then(|value| value.parse::<u32>().ok())
734 .unwrap_or(0);
735 (timestamp.to_string(), collision)
736}
737
738fn latest_rotated_log(log_path: &Path) -> Result<Option<PathBuf>> {
739 Ok(rotated_logs(log_path)?.pop())
740}
741
742fn open_existing_audit_file(path: &Path) -> std::io::Result<File> {
747 validate_audit_path(path)?;
748 let file = File::open(path)?;
749 if !file.metadata()?.is_file() {
750 return Err(std::io::Error::new(
751 std::io::ErrorKind::InvalidInput,
752 format!("audit path must be a regular file: {}", path.display()),
753 ));
754 }
755 Ok(file)
756}
757
758fn open_audit_file(path: &Path) -> std::io::Result<File> {
761 validate_audit_path(path)?;
762 let mut options = OpenOptions::new();
763 options.create(true).append(true);
764 #[cfg(unix)]
765 {
766 use std::os::unix::fs::OpenOptionsExt;
767 options.mode(0o600);
768 }
769 let file = options.open(path)?;
770 if !file.metadata()?.is_file() {
771 return Err(std::io::Error::new(
772 std::io::ErrorKind::InvalidInput,
773 format!("audit path must be a regular file: {}", path.display()),
774 ));
775 }
776 #[cfg(unix)]
777 {
778 use std::os::unix::fs::PermissionsExt;
779 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
780 }
781 Ok(file)
782}
783
784fn validate_audit_path(path: &Path) -> std::io::Result<()> {
785 match std::fs::symlink_metadata(path) {
786 Ok(metadata) if metadata.file_type().is_file() => Ok(()),
787 Ok(_) => Err(std::io::Error::new(
788 std::io::ErrorKind::InvalidInput,
789 format!(
790 "audit path must be a regular file, not a symlink or special file: {}",
791 path.display()
792 ),
793 )),
794 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
795 Err(error) => Err(error),
796 }
797}
798
799fn write_line_durably(file: &mut File, line: &[u8]) -> std::io::Result<()> {
804 let original_len = file.metadata()?.len();
805 if let Err(write_error) = file.write_all(line).and_then(|()| file.sync_all()) {
806 return match file.set_len(original_len).and_then(|()| file.sync_all()) {
807 Ok(()) => Err(write_error),
808 Err(rollback_error) => Err(std::io::Error::new(
809 rollback_error.kind(),
810 format!("audit append failed ({write_error}); rollback failed ({rollback_error})"),
811 )),
812 };
813 }
814 Ok(())
815}
816
817fn is_rotation_filename(suffix: &str) -> bool {
818 let timestamp = suffix
819 .split_once('-')
820 .map_or(suffix, |(timestamp, _)| timestamp);
821 let bytes = timestamp.as_bytes();
822 if bytes.len() != 22 || bytes[8] != b'T' || bytes[21] != b'Z' {
823 return false;
824 }
825 if !bytes
826 .iter()
827 .enumerate()
828 .all(|(index, byte)| index == 8 || index == 21 || byte.is_ascii_digit())
829 {
830 return false;
831 }
832 match suffix.split_once('-') {
833 None => true,
834 Some((_, collision_suffix)) => collision_suffix
835 .parse::<u32>()
836 .is_ok_and(|number| number > 0),
837 }
838}
839
840fn read_chain_id_sidecar(path: &Path) -> Option<ChainId> {
844 let text = std::fs::read_to_string(path).ok()?;
845 let trimmed = text.trim();
846 let uuid = uuid::Uuid::parse_str(trimmed).ok()?;
847 Some(ChainId(uuid))
848}
849
850fn write_chain_id_sidecar(path: &Path, id: ChainId) -> std::io::Result<()> {
854 let parent = path.parent().unwrap_or_else(|| Path::new("."));
855 let tmp = parent.join(format!(
856 ".{}.{}.new",
857 path.file_name()
858 .and_then(|s| s.to_str())
859 .unwrap_or("chainid"),
860 uuid::Uuid::now_v7(),
861 ));
862 let body = format!("{}\n", id.0);
863 {
864 let mut options = OpenOptions::new();
865 options.create_new(true).write(true);
866 #[cfg(unix)]
867 {
868 use std::os::unix::fs::OpenOptionsExt;
869 options.mode(0o600);
870 }
871 let f = options.open(&tmp)?;
872 let mut w = BufWriter::new(f);
873 std::io::Write::write_all(&mut w, body.as_bytes())?;
874 w.flush()?;
875 let inner = w
876 .into_inner()
877 .map_err(|e| std::io::Error::other(e.to_string()))?;
878 inner.sync_all()?;
879 }
880 if let Err(error) = std::fs::rename(&tmp, path) {
881 let _ = std::fs::remove_file(&tmp);
882 return Err(error);
883 }
884 #[cfg(unix)]
885 File::open(parent)?.sync_all()?;
886 Ok(())
887}
888
889#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
892pub struct ChainedRecord {
893 pub prev_hash: String,
894 pub record_hash: String,
895 pub chain_id: ChainId,
896 #[serde(flatten)]
897 pub record: DecisionRecord,
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903
904 #[test]
905 fn plain_log_writes_no_chain_fields() {
906 let dir = tempfile::tempdir().unwrap();
907 let path = dir.path().join("plain.jsonl");
908 let log = DecisionLog::open(&path).unwrap();
909 assert!(log.chain_id().is_none());
910 }
911
912 #[test]
913 fn chained_log_assigns_chain_id() {
914 let dir = tempfile::tempdir().unwrap();
915 let path = dir.path().join("chained.jsonl");
916 let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
917 assert!(log.chain_id().is_some());
918 }
919
920 #[test]
921 fn chained_log_refuses_to_open_when_chain_id_cannot_be_persisted() {
922 let dir = tempfile::tempdir().unwrap();
923 let path = dir.path().join("audit.jsonl");
924 std::fs::create_dir(chain_id_sidecar_path(&path)).unwrap();
925
926 let result = DecisionLog::open_with_chain(&path, b"root");
927
928 assert!(
929 result.is_err(),
930 "a chained log must not open without durable chain identity"
931 );
932 }
933
934 #[test]
935 fn rotation_sidecar_copy_fails_closed_and_cleans_temporary_file() {
936 let dir = tempfile::tempdir().unwrap();
937 let active = dir.path().join(".audit.jsonl.chainid");
938 let rotated = dir.path().join(".audit-rotated.jsonl.chainid");
939 let id = ChainId::new();
940 write_chain_id_sidecar(&active, id).unwrap();
941 std::fs::create_dir(&rotated).unwrap();
942
943 assert!(copy_chain_id_sidecar(&active, &rotated, true).is_err());
944 assert_eq!(read_chain_id_sidecar(&active), Some(id));
945 assert_eq!(
946 std::fs::read_dir(dir.path())
947 .unwrap()
948 .filter_map(|entry| entry.ok())
949 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".new"))
950 .count(),
951 0,
952 "failed sidecar persistence should remove its temporary file"
953 );
954 }
955
956 #[test]
957 fn rotation_sidecar_copy_allows_missing_metadata_only_for_plain_logs() {
958 let dir = tempfile::tempdir().unwrap();
959 let active = dir.path().join("missing.chainid");
960 let rotated = dir.path().join("rotated.chainid");
961
962 assert!(copy_chain_id_sidecar(&active, &rotated, false).is_ok());
963 assert!(copy_chain_id_sidecar(&active, &rotated, true).is_err());
964 assert!(!rotated.exists());
965 }
966
967 #[test]
968 fn chained_append_with_unwritable_chain_id_sidecar_fails_closed() {
969 let dir = tempfile::tempdir().unwrap();
970 let path = dir.path().join("audit.jsonl");
971 let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
972 let sidecar = chain_id_sidecar_path(&path);
973 std::fs::remove_file(&sidecar).unwrap();
974 std::fs::create_dir(sidecar).unwrap();
975 let rec = DecisionRecord {
976 id: "a".into(),
977 timestamp: chrono::Utc::now(),
978 effect: "allow".into(),
979 policies: vec![],
980 request_id: None,
981 principal: "alice".into(),
982 action: "send".into(),
983 resource: "doc".into(),
984 reasons: vec![],
985 session_id: None,
986 agent_chain: None,
987 trace_id: None,
988 span_id: None,
989 tenant_id: None,
990 subject_id: None,
991 authenticated_actor: None,
992 };
993
994 assert!(log.append(&rec).is_err());
995 assert!(
996 !log.is_healthy(),
997 "failed metadata persistence withdraws readiness"
998 );
999 assert!(log.append(&rec).is_err());
1000 assert_eq!(std::fs::metadata(path).unwrap().len(), 0);
1001 }
1002
1003 #[test]
1004 fn chained_log_refuses_corrupt_existing_tail() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let path = dir.path().join("corrupt.jsonl");
1007 std::fs::write(&path, "{\"not\":\"a chained record\"}\n").unwrap();
1008
1009 let err = match DecisionLog::open_with_chain(&path, b"root") {
1010 Ok(_) => panic!("corrupt chained audit log must be rejected"),
1011 Err(err) => err,
1012 };
1013 assert!(err
1014 .to_string()
1015 .contains("refusing to open invalid chained audit log"));
1016 }
1017
1018 #[test]
1019 fn chained_log_refuses_valid_json_tampering_at_startup() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let path = dir.path().join("tampered.jsonl");
1022 let rec = DecisionRecord {
1023 id: "a".into(),
1024 timestamp: chrono::Utc::now(),
1025 effect: "allow".into(),
1026 policies: vec![],
1027 request_id: None,
1028 principal: "alice".into(),
1029 action: "send".into(),
1030 resource: "doc".into(),
1031 reasons: vec![],
1032 session_id: None,
1033 agent_chain: None,
1034 trace_id: None,
1035 span_id: None,
1036 tenant_id: None,
1037 subject_id: None,
1038 authenticated_actor: None,
1039 };
1040 {
1041 let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1042 log.append(&rec).unwrap();
1043 }
1044
1045 let mut line: serde_json::Value =
1046 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1047 line["principal"] = serde_json::Value::String("mallory".into());
1048 std::fs::write(&path, format!("{line}\n")).unwrap();
1049
1050 let err = match DecisionLog::open_with_chain(&path, b"root") {
1051 Ok(_) => panic!("tampered chained audit log must be rejected"),
1052 Err(err) => err,
1053 };
1054 assert!(err
1055 .to_string()
1056 .contains("refusing to open invalid chained audit log"));
1057 }
1058
1059 #[test]
1060 fn chain_id_persists_across_restart() {
1061 let dir = tempfile::tempdir().unwrap();
1064 let path = dir.path().join("chained.jsonl");
1065 let log1 = DecisionLog::open_with_chain(&path, b"root").unwrap();
1066 let id1 = log1.chain_id().unwrap();
1067 drop(log1);
1068 let log2 = DecisionLog::open_with_chain(&path, b"root").unwrap();
1069 let id2 = log2.chain_id().unwrap();
1070 assert_eq!(id1, id2, "chain_id must persist across restarts");
1071 }
1072
1073 #[test]
1074 fn chained_log_recovers_identity_when_sidecar_is_missing() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let path = dir.path().join("recover-sidecar.jsonl");
1077 let sidecar = chain_id_sidecar_path(&path);
1078 let record = |id: &str| DecisionRecord {
1079 id: id.into(),
1080 timestamp: chrono::Utc::now(),
1081 effect: "allow".into(),
1082 policies: vec![],
1083 request_id: None,
1084 principal: "alice".into(),
1085 action: "read".into(),
1086 resource: "document".into(),
1087 reasons: vec![],
1088 session_id: None,
1089 agent_chain: None,
1090 trace_id: None,
1091 span_id: None,
1092 tenant_id: None,
1093 subject_id: None,
1094 authenticated_actor: None,
1095 };
1096
1097 let original_id = {
1098 let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1099 log.append(&record("before-recovery")).unwrap();
1100 log.chain_id().unwrap()
1101 };
1102 std::fs::remove_file(&sidecar).unwrap();
1103
1104 let recovered = DecisionLog::open_with_chain(&path, b"root").unwrap();
1105 assert_eq!(recovered.chain_id(), Some(original_id));
1106 assert!(
1107 sidecar.exists(),
1108 "recovery must persist the restored identity"
1109 );
1110 recovered.append(&record("after-recovery")).unwrap();
1111 drop(recovered);
1112
1113 assert_eq!(
1114 DecisionLog::verify_chain(&path, b"root").unwrap(),
1115 original_id
1116 );
1117 assert_eq!(DecisionLog::read_all(&path).unwrap().len(), 2);
1118 }
1119
1120 #[test]
1121 fn chained_log_recovers_identity_from_rotated_segment_without_sidecar() {
1122 let dir = tempfile::tempdir().unwrap();
1123 let path = dir.path().join("recover-rotated-sidecar.jsonl");
1124 let record = |id: &str| DecisionRecord {
1125 id: id.into(),
1126 timestamp: chrono::Utc::now(),
1127 effect: "allow".into(),
1128 policies: vec![],
1129 request_id: None,
1130 principal: "alice".into(),
1131 action: "read".into(),
1132 resource: "document".into(),
1133 reasons: vec![],
1134 session_id: None,
1135 agent_chain: None,
1136 trace_id: None,
1137 span_id: None,
1138 tenant_id: None,
1139 subject_id: None,
1140 authenticated_actor: None,
1141 };
1142
1143 let original_id = {
1144 let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1145 log.append(&record("before-rotation")).unwrap();
1146 let id = log.chain_id().unwrap();
1147 log.rotate().unwrap();
1148 id
1149 };
1150 let rotated = latest_rotated_log(&path).unwrap().unwrap();
1151 std::fs::remove_file(chain_id_sidecar_path(&rotated)).unwrap();
1152
1153 let recovered = DecisionLog::open_with_chain(&path, b"root").unwrap();
1154 assert_eq!(recovered.chain_id(), Some(original_id));
1155 assert!(
1156 chain_id_sidecar_path(&path).exists(),
1157 "recovery must persist the restored identity for the active segment"
1158 );
1159 recovered.append(&record("after-rotation")).unwrap();
1160 drop(recovered);
1161
1162 assert_eq!(
1163 DecisionLog::verify_chain(&path, b"root").unwrap(),
1164 original_id
1165 );
1166 assert_eq!(DecisionLog::read_all(&path).unwrap().len(), 2);
1167 }
1168
1169 #[test]
1170 fn chained_append_advances_head() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let path = dir.path().join("chained.jsonl");
1173 let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1174 let rec = DecisionRecord {
1175 id: uuid::Uuid::new_v4().to_string(),
1176 timestamp: chrono::Utc::now(),
1177 effect: "allow".into(),
1178 policies: vec![],
1179 request_id: None,
1180 principal: "alice".into(),
1181 action: "send_email".into(),
1182 resource: "doc-1".into(),
1183 reasons: vec![],
1184 session_id: None,
1185 agent_chain: None,
1186 trace_id: None,
1187 span_id: None,
1188 tenant_id: None,
1189 subject_id: None,
1190 authenticated_actor: None,
1191 };
1192 log.append(&rec).unwrap();
1193 log.append(&rec).unwrap();
1194 let id = DecisionLog::verify_chain(&path, b"root").unwrap();
1196 assert_eq!(id, log.chain_id().unwrap());
1197 }
1198
1199 #[test]
1200 fn streaming_chain_verification_handles_a_large_log_and_resumes() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let path = dir.path().join("large-audit.jsonl");
1203 let root_key = b"large-log-test-root";
1204 let chain = HashChain::new(root_key);
1205 let chain_id = chain.id();
1206 let mut writer = BufWriter::new(File::create(&path).unwrap());
1207 let timestamp = chrono::Utc::now();
1208
1209 for index in 0..5_000 {
1210 let record = DecisionRecord {
1211 id: index.to_string(),
1212 timestamp,
1213 effect: "deny".into(),
1214 policies: vec![],
1215 request_id: None,
1216 principal: "load-test-agent".into(),
1217 action: "ToolCall::read".into(),
1218 resource: format!("document-{index}"),
1219 reasons: vec![],
1220 session_id: None,
1221 agent_chain: None,
1222 trace_id: None,
1223 span_id: None,
1224 tenant_id: None,
1225 subject_id: None,
1226 authenticated_actor: None,
1227 };
1228 let canonical = canonical_json(&record).unwrap();
1229 let (prev, hash) = chain.append(&canonical);
1230 serde_json::to_writer(
1231 &mut writer,
1232 &ChainedRecord {
1233 prev_hash: hex::encode(prev),
1234 record_hash: hex::encode(hash),
1235 chain_id,
1236 record,
1237 },
1238 )
1239 .unwrap();
1240 writer.write_all(b"\n").unwrap();
1241 }
1242 writer.flush().unwrap();
1243 drop(writer);
1244
1245 assert_eq!(
1246 DecisionLog::verify_chain(&path, root_key).unwrap(),
1247 chain_id
1248 );
1249 let resumed = DecisionLog::open_with_chain(&path, root_key).unwrap();
1250 assert_eq!(resumed.chain_id(), Some(chain_id));
1251 }
1252
1253 #[cfg(unix)]
1254 #[test]
1255 fn failed_audit_write_poisoning_is_reported_not_silently_dropped() {
1256 let full = Path::new("/dev/full");
1257 if !full.exists() {
1258 return;
1259 }
1260 let dir = tempfile::tempdir().unwrap();
1261 let rec = DecisionRecord {
1262 id: "storage-failure".into(),
1263 timestamp: chrono::Utc::now(),
1264 effect: "allow".into(),
1265 policies: vec![],
1266 request_id: None,
1267 principal: "alice".into(),
1268 action: "send".into(),
1269 resource: "doc".into(),
1270 reasons: vec![],
1271 session_id: None,
1272 agent_chain: None,
1273 trace_id: None,
1274 span_id: None,
1275 tenant_id: None,
1276 subject_id: None,
1277 authenticated_actor: None,
1278 };
1279
1280 for chained in [false, true] {
1281 let path = dir.path().join(format!("write-failure-{chained}.jsonl"));
1282 let log = if chained {
1283 DecisionLog::open_with_chain(&path, b"test-root").unwrap()
1284 } else {
1285 DecisionLog::open(&path).unwrap()
1286 };
1287 let failing_writer = OpenOptions::new().write(true).open(full).unwrap();
1293 match &log.mode {
1294 LogMode::Plain(file) | LogMode::Chained { file, .. } => {
1295 *file.lock().unwrap() = Some(failing_writer);
1296 }
1297 }
1298
1299 assert!(
1300 log.append(&rec).is_err(),
1301 "first storage failure must surface"
1302 );
1303 assert!(!log.is_healthy(), "failed writer must become unhealthy");
1304 let retry = log.append(&rec).unwrap_err();
1305 assert!(retry
1306 .to_string()
1307 .contains("unavailable after a prior storage failure"));
1308 }
1309 }
1310
1311 #[cfg(unix)]
1312 #[test]
1313 fn audit_open_rejects_special_files_and_symlinks_before_reading_them() {
1314 let full = Path::new("/dev/full");
1315 if !full.exists() {
1316 return;
1317 }
1318 let plain_error = DecisionLog::open(full)
1319 .err()
1320 .expect("special files must not be accepted as plain audit logs");
1321 assert!(plain_error.to_string().contains("regular file"));
1322
1323 let chained_error = DecisionLog::open_with_chain(full, b"test-root")
1324 .err()
1325 .expect("special files must be rejected before verification");
1326 assert!(chained_error.to_string().contains("regular file"));
1327
1328 let dir = tempfile::tempdir().unwrap();
1329 let link = dir.path().join("audit.jsonl");
1330 std::os::unix::fs::symlink(full, &link).unwrap();
1331 let symlink_error = DecisionLog::open_with_chain(&link, b"test-root")
1332 .err()
1333 .expect("symlinks must be rejected before verification");
1334 assert!(symlink_error.to_string().contains("regular file"));
1335 }
1336
1337 #[cfg(unix)]
1338 #[test]
1339 fn audit_log_permissions_are_private_on_open() {
1340 use std::os::unix::fs::PermissionsExt;
1341
1342 let dir = tempfile::tempdir().unwrap();
1343 let path = dir.path().join("audit.jsonl");
1344 std::fs::write(&path, b"").unwrap();
1345 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1346
1347 let _log = DecisionLog::open(&path).unwrap();
1348
1349 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1350 assert_eq!(
1351 mode, 0o600,
1352 "audit records must not be group/world readable"
1353 );
1354 }
1355
1356 #[test]
1359 fn read_all_handles_mixed_plain_and_chained() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let path = dir.path().join("mixed.jsonl");
1362 let rec = DecisionRecord {
1364 id: "a".into(),
1365 timestamp: chrono::Utc::now(),
1366 effect: "allow".into(),
1367 policies: vec![],
1368 request_id: None,
1369 principal: "alice".into(),
1370 action: "send".into(),
1371 resource: "doc".into(),
1372 reasons: vec![],
1373 session_id: None,
1374 agent_chain: None,
1375 trace_id: None,
1376 span_id: None,
1377 tenant_id: None,
1378 subject_id: None,
1379 authenticated_actor: None,
1380 };
1381 {
1385 let log = DecisionLog::open(&path).unwrap();
1386 log.append(&rec).unwrap();
1387 }
1388 {
1389 let chained_path = dir.path().join("chained-only.jsonl");
1390 let log = DecisionLog::open_with_chain(&chained_path, b"root").unwrap();
1391 log.append(&rec).unwrap();
1392 let chained_line = std::fs::read_to_string(chained_path).unwrap();
1393 std::fs::OpenOptions::new()
1394 .append(true)
1395 .open(&path)
1396 .unwrap()
1397 .write_all(chained_line.as_bytes())
1398 .unwrap();
1399 }
1400 {
1401 let log = DecisionLog::open(&path).unwrap();
1402 log.append(&rec).unwrap();
1403 }
1404 let records = DecisionLog::read_all(&path).unwrap();
1405 assert_eq!(records.len(), 3, "all 3 records must be readable");
1406 for r in &records {
1407 assert_eq!(r.principal, "alice");
1408 }
1409 }
1410
1411 #[test]
1412 fn read_all_mixed_surfaces_both_errors() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let path = dir.path().join("corrupt.jsonl");
1415 let rec = DecisionRecord {
1418 id: "a".into(),
1419 timestamp: chrono::Utc::now(),
1420 effect: "allow".into(),
1421 policies: vec![],
1422 request_id: None,
1423 principal: "alice".into(),
1424 action: "send".into(),
1425 resource: "doc".into(),
1426 reasons: vec![],
1427 session_id: None,
1428 agent_chain: None,
1429 trace_id: None,
1430 span_id: None,
1431 tenant_id: None,
1432 subject_id: None,
1433 authenticated_actor: None,
1434 };
1435 let log = DecisionLog::open(&path).unwrap();
1436 log.append(&rec).unwrap();
1437 std::fs::OpenOptions::new()
1439 .append(true)
1440 .open(&path)
1441 .unwrap()
1442 .write_all(b"{\"this\":\"is broken")
1443 .unwrap();
1444 let err = DecisionLog::read_all(&path).unwrap_err();
1445 let msg = err.to_string();
1446 assert!(
1447 msg.contains("line 2"),
1448 "error must reference the offending line number: {msg}"
1449 );
1450 assert!(
1451 msg.contains("DecisionRecord"),
1452 "error must mention the DecisionRecord parse failure: {msg}"
1453 );
1454 assert!(
1455 msg.contains("ChainedRecord"),
1456 "error must mention the ChainedRecord parse failure: {msg}"
1457 );
1458 }
1459
1460 #[test]
1461 fn rotation_config_requires_a_positive_integer() {
1462 assert_eq!(
1463 RotationConfig::parse("1048576").unwrap().max_bytes,
1464 1_048_576
1465 );
1466 for value in ["0", "-1", "many", ""] {
1467 assert!(RotationConfig::parse(value).is_err(), "accepted {value:?}");
1468 }
1469 }
1470
1471 #[test]
1472 fn rotation_creates_new_file() {
1473 let dir = tempfile::tempdir().unwrap();
1474 let path = dir.path().join("rotating.jsonl");
1475 let log =
1476 DecisionLog::open_with_rotation(&path, None::<&[u8]>, RotationConfig { max_bytes: 64 })
1477 .unwrap();
1478 let rec = DecisionRecord {
1479 id: "a".into(),
1480 timestamp: chrono::Utc::now(),
1481 effect: "allow".into(),
1482 policies: vec![],
1483 request_id: None,
1484 principal: "alice".into(),
1485 action: "send".into(),
1486 resource: "doc".into(),
1487 reasons: vec![],
1488 session_id: None,
1489 agent_chain: None,
1490 trace_id: None,
1491 span_id: None,
1492 tenant_id: None,
1493 subject_id: None,
1494 authenticated_actor: None,
1495 };
1496 for _ in 0..5 {
1497 log.append(&rec).unwrap();
1498 }
1499 let rotated: Vec<_> = std::fs::read_dir(dir.path())
1502 .unwrap()
1503 .filter_map(|e| e.ok())
1504 .filter(|e| e.file_name().to_string_lossy().starts_with("rotating-"))
1505 .collect();
1506 assert!(
1507 !rotated.is_empty(),
1508 "expected at least one rotated file in {}",
1509 dir.path().display()
1510 );
1511 assert!(path.exists(), "active file must still be open");
1512 }
1513
1514 #[test]
1515 fn rotated_log_discovery_ignores_lookalike_files() {
1516 let dir = tempfile::tempdir().unwrap();
1517 let active = dir.path().join("decisions.jsonl");
1518 let real = dir.path().join("decisions-20260923T123456000000Z.jsonl");
1519 std::fs::write(dir.path().join("decisions-backup.jsonl"), b"not a rotation").unwrap();
1520 std::fs::write(
1521 dir.path().join("decisions-20260923T123456Z.jsonl"),
1522 b"bad timestamp",
1523 )
1524 .unwrap();
1525 std::fs::write(&real, b"rotation").unwrap();
1526
1527 assert_eq!(rotated_logs(&active).unwrap(), vec![real]);
1528 }
1529
1530 #[test]
1531 fn rotated_log_discovery_orders_collision_suffixes_numerically() {
1532 let dir = tempfile::tempdir().unwrap();
1533 let active = dir.path().join("decisions.jsonl");
1534 let expected = [
1535 "decisions-20260923T123456000000Z.jsonl",
1536 "decisions-20260923T123456000000Z-2.jsonl",
1537 "decisions-20260923T123456000000Z-10.jsonl",
1538 ]
1539 .map(|name| dir.path().join(name));
1540 for path in expected.iter().rev() {
1541 std::fs::write(path, b"segment").unwrap();
1542 }
1543
1544 assert_eq!(rotated_logs(&active).unwrap(), expected);
1545 }
1546
1547 #[test]
1548 fn chained_rotation_recovers_head_after_restart_before_next_append() {
1549 let dir = tempfile::tempdir().unwrap();
1550 let path = dir.path().join("chained.jsonl");
1551 let rec = DecisionRecord {
1552 id: "a".into(),
1553 timestamp: chrono::Utc::now(),
1554 effect: "allow".into(),
1555 policies: vec![],
1556 request_id: None,
1557 principal: "alice".into(),
1558 action: "send".into(),
1559 resource: "doc".into(),
1560 reasons: vec![],
1561 session_id: None,
1562 agent_chain: None,
1563 trace_id: None,
1564 span_id: None,
1565 tenant_id: None,
1566 subject_id: None,
1567 authenticated_actor: None,
1568 };
1569
1570 let first_id = {
1571 let log = DecisionLog::open_with_rotation(
1572 &path,
1573 Some(b"root"),
1574 RotationConfig {
1575 max_bytes: u64::MAX,
1576 },
1577 )
1578 .unwrap();
1579 log.append(&rec).unwrap();
1580 let id = log.chain_id().unwrap();
1581 log.rotate().unwrap();
1582 id
1583 };
1584
1585 let log = DecisionLog::open_with_rotation(
1587 &path,
1588 Some(b"root"),
1589 RotationConfig {
1590 max_bytes: u64::MAX,
1591 },
1592 )
1593 .unwrap();
1594 assert_eq!(log.chain_id().unwrap(), first_id);
1595 log.append(&rec).unwrap();
1596
1597 let rotated = latest_rotated_log(&path).unwrap().unwrap();
1598 assert_eq!(
1599 read_chain_id_sidecar(&chain_id_sidecar_path(&rotated)),
1600 Some(first_id)
1601 );
1602 assert_eq!(
1603 read_chain_id_sidecar(&chain_id_sidecar_path(&path)),
1604 Some(first_id)
1605 );
1606 let before = DecisionLog::read_all_chained(&rotated).unwrap();
1607 let after = DecisionLog::read_all_chained(&path).unwrap();
1608 assert_eq!(before.len(), 1);
1609 assert_eq!(after.len(), 1);
1610 assert_eq!(before[0].chain_id, after[0].chain_id);
1611 assert_eq!(before[0].record_hash, after[0].prev_hash);
1612 assert_eq!(DecisionLog::verify_chain(&path, b"root").unwrap(), first_id);
1613 assert_eq!(DecisionLog::read_all(&path).unwrap().len(), 2);
1614 }
1615
1616 #[test]
1617 fn concurrent_rotation_does_not_lose_appends() {
1618 let dir = tempfile::tempdir().unwrap();
1619 let path = dir.path().join("concurrent.jsonl");
1620 let log = std::sync::Arc::new(
1621 DecisionLog::open_with_rotation(&path, None::<&[u8]>, RotationConfig { max_bytes: 64 })
1622 .unwrap(),
1623 );
1624 let rec = DecisionRecord {
1625 id: "concurrent".into(),
1626 timestamp: chrono::Utc::now(),
1627 effect: "allow".into(),
1628 policies: vec![],
1629 request_id: None,
1630 principal: "alice".into(),
1631 action: "send".into(),
1632 resource: "doc".into(),
1633 reasons: vec![],
1634 session_id: None,
1635 agent_chain: None,
1636 trace_id: None,
1637 span_id: None,
1638 tenant_id: None,
1639 subject_id: None,
1640 authenticated_actor: None,
1641 };
1642 let mut workers = Vec::new();
1643 for _ in 0..4 {
1644 let log = std::sync::Arc::clone(&log);
1645 let rec = rec.clone();
1646 workers.push(std::thread::spawn(move || {
1647 for _ in 0..25 {
1648 log.append(&rec).unwrap();
1649 }
1650 }));
1651 }
1652 for worker in workers {
1653 worker.join().unwrap();
1654 }
1655
1656 let total_records: usize = std::fs::read_dir(dir.path())
1657 .unwrap()
1658 .filter_map(|entry| entry.ok())
1659 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl"))
1660 .map(|entry| {
1661 std::fs::read_to_string(entry.path())
1662 .unwrap()
1663 .lines()
1664 .filter(|line| !line.trim().is_empty())
1665 .count()
1666 })
1667 .sum();
1668 assert_eq!(total_records, 100);
1669 }
1670}