1use crate::finding::{compute_fingerprint, Finding, Severity};
29use crate::graph::AuthorityGraph;
30use chrono::{DateTime, Duration, Utc};
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use std::path::{Path, PathBuf};
34
35pub const MAX_CRITICAL_WAIVER_DAYS: i64 = 90;
40
41pub const MIN_REASON_LENGTH: usize = 10;
45
46pub const BASELINE_SCHEMA_VERSION: &str = "1.0.0";
49
50#[derive(Debug, thiserror::Error)]
52pub enum BaselineError {
53 #[error("failed to read baseline {path}: {source}")]
54 Read {
55 path: PathBuf,
56 #[source]
57 source: std::io::Error,
58 },
59 #[error("failed to write baseline {path}: {source}")]
60 Write {
61 path: PathBuf,
62 #[source]
63 source: std::io::Error,
64 },
65 #[error("failed to parse baseline {path}: {source}")]
66 Parse {
67 path: PathBuf,
68 #[source]
69 source: serde_json::Error,
70 },
71 #[error("failed to serialize baseline: {0}")]
72 Serialize(#[from] serde_json::Error),
73 #[error("baseline schema version {found:?} not supported (expected major 1.x.y)")]
74 UnsupportedVersion { found: String },
75 #[error("waiver reason must be at least {min} characters (got {got})")]
76 ReasonTooShort { min: usize, got: usize },
77 #[error("critical-severity override requires expires_at <= {days}d from accepted_at")]
78 CriticalWaiverTooLong { days: i64 },
79 #[error("critical-severity override requires expires_at to be set")]
80 CriticalWaiverNoExpiry,
81 #[error("critical-severity override requires a reason")]
82 CriticalWaiverNoReason,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct BaselineFinding {
100 pub fingerprint: String,
102 pub rule_id: String,
105 pub severity: Severity,
107 pub first_seen_at: DateTime<Utc>,
109 #[serde(skip_serializing_if = "Option::is_none", default)]
112 pub reason_waived: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none", default)]
117 pub severity_override: Option<Severity>,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
122 pub expires_at: Option<DateTime<Utc>>,
123}
124
125impl BaselineFinding {
126 pub fn is_valid_critical_waiver(&self, now: DateTime<Utc>) -> bool {
129 if self.severity_override != Some(Severity::Critical) {
130 return false;
131 }
132 let Some(expires_at) = self.expires_at else {
133 return false;
134 };
135 if expires_at <= now {
136 return false;
137 }
138 if (expires_at - self.first_seen_at) > Duration::days(MAX_CRITICAL_WAIVER_DAYS) {
139 return false;
140 }
141 matches!(self.reason_waived.as_deref(), Some(r) if r.chars().count() >= MIN_REASON_LENGTH)
142 }
143
144 pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
146 match self.expires_at {
147 Some(t) => t <= now,
148 None => false,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct CapturedWith {
156 pub taudit_version: String,
157 pub rules_version: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct Baseline {
166 pub schema_version: String,
167 pub pipeline_path: String,
168 pub pipeline_content_hash: String,
170 pub captured_at: DateTime<Utc>,
171 pub captured_by: String,
172 pub captured_with: CapturedWith,
173 pub baseline_findings: Vec<BaselineFinding>,
175}
176
177impl Baseline {
178 pub fn load(path: &Path) -> Result<Option<Self>, BaselineError> {
181 if !path.exists() {
182 return Ok(None);
183 }
184 let bytes = std::fs::read(path).map_err(|source| BaselineError::Read {
185 path: path.to_path_buf(),
186 source,
187 })?;
188 let baseline: Baseline =
189 serde_json::from_slice(&bytes).map_err(|source| BaselineError::Parse {
190 path: path.to_path_buf(),
191 source,
192 })?;
193 if !baseline.schema_version.starts_with("1.") {
194 return Err(BaselineError::UnsupportedVersion {
195 found: baseline.schema_version,
196 });
197 }
198 Ok(Some(baseline))
199 }
200
201 pub fn save(&self, path: &Path) -> Result<(), BaselineError> {
204 if let Some(parent) = path.parent() {
205 std::fs::create_dir_all(parent).map_err(|source| BaselineError::Write {
206 path: path.to_path_buf(),
207 source,
208 })?;
209 }
210 let mut sorted = self.clone();
211 sorted
212 .baseline_findings
213 .sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
214 let mut bytes = serde_json::to_vec_pretty(&sorted)?;
215 bytes.push(b'\n');
216 std::fs::write(path, bytes).map_err(|source| BaselineError::Write {
217 path: path.to_path_buf(),
218 source,
219 })?;
220 Ok(())
221 }
222
223 #[allow(clippy::too_many_arguments)]
228 pub fn from_findings(
229 pipeline_path: &str,
230 content: &str,
231 graph: &AuthorityGraph,
232 findings: &[Finding],
233 captured_by: &str,
234 taudit_version: &str,
235 rules_version: &str,
236 now: DateTime<Utc>,
237 ) -> Self {
238 let mut baseline_findings: Vec<BaselineFinding> = findings
239 .iter()
240 .map(|f| BaselineFinding {
241 fingerprint: compute_fingerprint(f, graph),
242 rule_id: rule_id_for(f),
243 severity: f.severity,
244 first_seen_at: now,
245 reason_waived: None,
246 severity_override: None,
247 expires_at: None,
248 })
249 .collect();
250 baseline_findings.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
252 baseline_findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
253
254 Baseline {
255 schema_version: BASELINE_SCHEMA_VERSION.to_string(),
256 pipeline_path: pipeline_path.to_string(),
257 pipeline_content_hash: compute_pipeline_hash(content),
258 captured_at: now,
259 captured_by: captured_by.to_string(),
260 captured_with: CapturedWith {
261 taudit_version: taudit_version.to_string(),
262 rules_version: rules_version.to_string(),
263 },
264 baseline_findings,
265 }
266 }
267
268 #[allow(clippy::too_many_arguments)]
273 pub fn accept(
274 &mut self,
275 fingerprint: &str,
276 rule_id: &str,
277 severity: Severity,
278 reason: &str,
279 severity_override: Option<Severity>,
280 expires_at: Option<DateTime<Utc>>,
281 now: DateTime<Utc>,
282 ) -> Result<&BaselineFinding, BaselineError> {
283 let reason_chars = reason.chars().count();
284 if reason_chars < MIN_REASON_LENGTH {
285 return Err(BaselineError::ReasonTooShort {
286 min: MIN_REASON_LENGTH,
287 got: reason_chars,
288 });
289 }
290 if severity_override == Some(Severity::Critical) {
291 let Some(exp) = expires_at else {
292 return Err(BaselineError::CriticalWaiverNoExpiry);
293 };
294 if (exp - now) > Duration::days(MAX_CRITICAL_WAIVER_DAYS) {
295 return Err(BaselineError::CriticalWaiverTooLong {
296 days: MAX_CRITICAL_WAIVER_DAYS,
297 });
298 }
299 }
300 let entry = BaselineFinding {
301 fingerprint: fingerprint.to_string(),
302 rule_id: rule_id.to_string(),
303 severity,
304 first_seen_at: now,
305 reason_waived: Some(reason.to_string()),
306 severity_override,
307 expires_at,
308 };
309 if let Some(slot) = self
311 .baseline_findings
312 .iter_mut()
313 .find(|e| e.fingerprint == entry.fingerprint)
314 {
315 *slot = entry;
316 } else {
317 self.baseline_findings.push(entry);
318 }
319 self.baseline_findings
320 .sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
321 Ok(self
322 .baseline_findings
323 .iter()
324 .find(|e| e.fingerprint == fingerprint)
325 .expect("just inserted"))
326 }
327}
328
329#[derive(Debug, Clone)]
332pub struct BaselineDiff {
333 pub new: Vec<Finding>,
336 pub fixed: Vec<BaselineFinding>,
340 pub preexisting: Vec<Finding>,
344 pub waived_count: usize,
347}
348
349impl BaselineDiff {
350 pub fn critical_without_valid_waiver(
355 &self,
356 baseline: &Baseline,
357 graph: &AuthorityGraph,
358 now: DateTime<Utc>,
359 ) -> Vec<Finding> {
360 self.preexisting
361 .iter()
362 .filter(|f| f.severity == Severity::Critical)
363 .filter(|f| {
364 let fp = compute_fingerprint(f, graph);
365 match baseline
366 .baseline_findings
367 .iter()
368 .find(|e| e.fingerprint == fp)
369 {
370 Some(entry) => !entry.is_valid_critical_waiver(now),
371 None => true, }
373 })
374 .cloned()
375 .collect()
376 }
377}
378
379pub fn diff(
383 current_findings: &[Finding],
384 baseline: &Baseline,
385 graph: &AuthorityGraph,
386) -> BaselineDiff {
387 use std::collections::{HashMap, HashSet};
388
389 let baseline_index: HashMap<&str, &BaselineFinding> = baseline
390 .baseline_findings
391 .iter()
392 .map(|e| (e.fingerprint.as_str(), e))
393 .collect();
394
395 let mut new = Vec::new();
396 let mut preexisting = Vec::new();
397 let mut seen_fingerprints: HashSet<String> = HashSet::new();
398 let mut waived_count = 0usize;
399
400 for finding in current_findings {
401 let fp = compute_fingerprint(finding, graph);
402 seen_fingerprints.insert(fp.clone());
403 match baseline_index.get(fp.as_str()) {
404 Some(entry) => {
405 if entry.reason_waived.is_some() {
406 waived_count += 1;
407 }
408 preexisting.push(finding.clone());
409 }
410 None => new.push(finding.clone()),
411 }
412 }
413
414 let fixed: Vec<BaselineFinding> = baseline
415 .baseline_findings
416 .iter()
417 .filter(|e| !seen_fingerprints.contains(&e.fingerprint))
418 .cloned()
419 .collect();
420
421 BaselineDiff {
422 new,
423 fixed,
424 preexisting,
425 waived_count,
426 }
427}
428
429pub fn compute_pipeline_hash(content: &str) -> String {
433 let digest = Sha256::digest(content.as_bytes());
434 let mut hex = String::with_capacity(64);
435 for byte in digest.iter() {
436 use std::fmt::Write;
437 let _ = write!(&mut hex, "{byte:02x}");
438 }
439 format!("sha256:{hex}")
440}
441
442pub fn baselines_dir(root: &Path) -> PathBuf {
445 root.join(".taudit").join("baselines")
446}
447
448pub fn baseline_filename_for(pipeline_content_hash: &str) -> String {
451 let hex = pipeline_content_hash
452 .strip_prefix("sha256:")
453 .unwrap_or(pipeline_content_hash);
454 format!("{hex}.json")
455}
456
457pub fn baseline_path_for(root: &Path, pipeline_content_hash: &str) -> PathBuf {
460 baselines_dir(root).join(baseline_filename_for(pipeline_content_hash))
461}
462
463pub fn compute_finding_fingerprint(finding: &Finding, graph: &AuthorityGraph) -> String {
469 compute_fingerprint(finding, graph)
470}
471
472fn rule_id_for(f: &Finding) -> String {
475 if let Some(id) = f.message.strip_prefix('[') {
476 if let Some(end) = id.find(']') {
477 let candidate = &id[..end];
478 if !candidate.is_empty() {
479 return candidate.to_string();
480 }
481 }
482 }
483 serde_json::to_value(f.category)
484 .ok()
485 .and_then(|v| v.as_str().map(str::to_string))
486 .unwrap_or_else(|| "unknown".to_string())
487}
488
489#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::finding::{FindingCategory, FindingExtras, FindingSource, Recommendation};
495 use crate::graph::{AuthorityGraph, NodeKind, PipelineSource, TrustZone};
496
497 fn source(file: &str) -> PipelineSource {
498 PipelineSource {
499 file: file.to_string(),
500 repo: None,
501 git_ref: None,
502 commit_sha: None,
503 }
504 }
505
506 fn make_graph(file: &str) -> (AuthorityGraph, crate::graph::NodeId) {
507 let mut g = AuthorityGraph::new(source(file));
508 let s = g.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
509 (g, s)
510 }
511
512 fn make_finding(
513 category: FindingCategory,
514 severity: Severity,
515 msg: &str,
516 nodes: Vec<crate::graph::NodeId>,
517 ) -> Finding {
518 Finding {
519 severity,
520 category,
521 path: None,
522 nodes_involved: nodes,
523 message: msg.to_string(),
524 recommendation: Recommendation::Manual {
525 action: "fix".to_string(),
526 },
527 source: FindingSource::BuiltIn,
528 extras: FindingExtras::default(),
529 }
530 }
531
532 fn now() -> DateTime<Utc> {
533 DateTime::parse_from_rfc3339("2026-04-26T12:00:00Z")
534 .unwrap()
535 .with_timezone(&Utc)
536 }
537
538 #[test]
543 fn baseline_fingerprint_matches_sarif_fingerprint() {
544 let (graph, s) = make_graph(".github/workflows/release.yml");
545 let f = make_finding(
546 FindingCategory::AuthorityPropagation,
547 Severity::High,
548 "AWS_KEY reaches third party",
549 vec![s],
550 );
551 let baseline_fp = compute_finding_fingerprint(&f, &graph);
552 let sarif_fp = compute_fingerprint(&f, &graph);
553 assert_eq!(
554 baseline_fp, sarif_fp,
555 "baseline and SARIF fingerprints MUST be byte-equal — do not introduce a second fingerprint scheme"
556 );
557 }
558
559 #[test]
560 fn pipeline_hash_is_deterministic_and_prefixed() {
561 let h = compute_pipeline_hash("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n");
562 assert!(h.starts_with("sha256:"));
563 assert_eq!(h.len(), 7 + 64);
564 let h2 = compute_pipeline_hash("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n");
565 assert_eq!(h, h2, "same content -> same hash");
566 let h3 = compute_pipeline_hash("on: push\n");
567 assert_ne!(h, h3);
568 }
569
570 #[test]
571 fn init_captures_current_findings() {
572 let (graph, s) = make_graph("ci.yml");
573 let f1 = make_finding(
574 FindingCategory::UnpinnedAction,
575 Severity::High,
576 "actions/checkout@v4 unpinned",
577 vec![s],
578 );
579 let f2 = make_finding(
580 FindingCategory::AuthorityPropagation,
581 Severity::Critical,
582 "AWS_KEY reaches untrusted",
583 vec![s],
584 );
585 let baseline = Baseline::from_findings(
586 "ci.yml",
587 "on: push\n",
588 &graph,
589 &[f1, f2],
590 "ryan@example.com",
591 "0.10.0",
592 "32-builtin",
593 now(),
594 );
595 assert_eq!(baseline.baseline_findings.len(), 2);
596 assert_eq!(baseline.captured_by, "ryan@example.com");
597 assert_eq!(baseline.captured_with.taudit_version, "0.10.0");
598 let fps: Vec<&str> = baseline
600 .baseline_findings
601 .iter()
602 .map(|e| e.fingerprint.as_str())
603 .collect();
604 let mut sorted = fps.clone();
605 sorted.sort();
606 assert_eq!(fps, sorted, "entries must be fingerprint-sorted");
607 for entry in &baseline.baseline_findings {
609 assert!(entry.reason_waived.is_none());
610 assert!(entry.severity_override.is_none());
611 assert!(entry.expires_at.is_none());
612 }
613 }
614
615 #[test]
616 fn save_then_load_round_trips() {
617 let dir = tempdir();
618 let (graph, s) = make_graph("ci.yml");
619 let f = make_finding(
620 FindingCategory::UnpinnedAction,
621 Severity::High,
622 "actions/checkout@v4 unpinned",
623 vec![s],
624 );
625 let baseline = Baseline::from_findings(
626 "ci.yml",
627 "x",
628 &graph,
629 &[f],
630 "ryan",
631 "0.10.0",
632 "32-builtin",
633 now(),
634 );
635 let path = dir.join("b.json");
636 baseline.save(&path).expect("save");
637 let loaded = Baseline::load(&path).expect("load").expect("present");
638 assert_eq!(baseline, loaded);
639 }
640
641 #[test]
642 fn load_returns_none_when_absent() {
643 let dir = tempdir();
644 let path = dir.join("does-not-exist.json");
645 assert!(Baseline::load(&path).expect("ok").is_none());
646 }
647
648 #[test]
649 fn accept_rejects_short_reason() {
650 let mut baseline = empty_baseline();
651 let err = baseline
652 .accept(
653 "abcd1234abcd1234",
654 "unpinned_action",
655 Severity::High,
656 "wip",
657 None,
658 None,
659 now(),
660 )
661 .unwrap_err();
662 assert!(matches!(err, BaselineError::ReasonTooShort { .. }));
663 }
664
665 #[test]
666 fn accept_critical_without_expires_is_rejected() {
667 let mut baseline = empty_baseline();
668 let err = baseline
669 .accept(
670 "deadbeefdeadbeef",
671 "trigger_context_mismatch",
672 Severity::Critical,
673 "Threat-modeled exception per ABC-123",
674 Some(Severity::Critical),
675 None, now(),
677 )
678 .unwrap_err();
679 assert!(matches!(err, BaselineError::CriticalWaiverNoExpiry));
680 }
681
682 #[test]
683 fn accept_critical_with_expiry_beyond_90d_is_rejected() {
684 let mut baseline = empty_baseline();
685 let too_long = now() + Duration::days(100);
686 let err = baseline
687 .accept(
688 "deadbeefdeadbeef",
689 "trigger_context_mismatch",
690 Severity::Critical,
691 "Threat-modeled exception per ABC-123",
692 Some(Severity::Critical),
693 Some(too_long),
694 now(),
695 )
696 .unwrap_err();
697 assert!(matches!(
698 err,
699 BaselineError::CriticalWaiverTooLong { days: 90 }
700 ));
701 }
702
703 #[test]
704 fn accept_critical_with_valid_expiry_succeeds() {
705 let mut baseline = empty_baseline();
706 let exp = now() + Duration::days(60);
707 baseline
708 .accept(
709 "deadbeefdeadbeef",
710 "trigger_context_mismatch",
711 Severity::Critical,
712 "Threat-modeled exception per ABC-123",
713 Some(Severity::Critical),
714 Some(exp),
715 now(),
716 )
717 .expect("valid critical waiver");
718 let entry = &baseline.baseline_findings[0];
719 assert!(entry.is_valid_critical_waiver(now()));
720 assert!(!entry.is_valid_critical_waiver(exp + Duration::seconds(1)));
722 }
723
724 #[test]
725 fn diff_classifies_new_fixed_preexisting() {
726 let (graph, s) = make_graph("ci.yml");
727 let f_old = make_finding(
728 FindingCategory::UnpinnedAction,
729 Severity::High,
730 "actions/checkout@v4 unpinned",
731 vec![s],
732 );
733 let f_unchanged = make_finding(
734 FindingCategory::AuthorityPropagation,
735 Severity::High,
736 "AWS_KEY reaches untrusted",
737 vec![s],
738 );
739 let baseline = Baseline::from_findings(
740 "ci.yml",
741 "x",
742 &graph,
743 &[f_old.clone(), f_unchanged.clone()],
744 "ryan",
745 "0.10.0",
746 "32-builtin",
747 now(),
748 );
749 let f_new = make_finding(
751 FindingCategory::OverPrivilegedIdentity,
752 Severity::Medium,
753 "GITHUB_TOKEN over-privileged",
754 vec![s],
755 );
756 let current = vec![f_unchanged.clone(), f_new.clone()];
757 let diff = diff(¤t, &baseline, &graph);
758 assert_eq!(diff.new.len(), 1, "f_new is new");
759 assert_eq!(diff.fixed.len(), 1, "f_old was fixed");
760 assert_eq!(diff.preexisting.len(), 1, "f_unchanged preexisting");
761 assert_eq!(diff.waived_count, 0, "no waivers yet");
762 }
763
764 #[test]
765 fn critical_preexisting_without_waiver_blocks_exit_zero() {
766 let (graph, s) = make_graph("ci.yml");
767 let crit = make_finding(
768 FindingCategory::AuthorityPropagation,
769 Severity::Critical,
770 "AWS_KEY reaches untrusted",
771 vec![s],
772 );
773 let baseline = Baseline::from_findings(
774 "ci.yml",
775 "x",
776 &graph,
777 std::slice::from_ref(&crit),
778 "ryan",
779 "0.10.0",
780 "32-builtin",
781 now(),
782 );
783 let diff = diff(&[crit], &baseline, &graph);
784 assert_eq!(diff.preexisting.len(), 1);
785 let blockers = diff.critical_without_valid_waiver(&baseline, &graph, now());
788 assert_eq!(
789 blockers.len(),
790 1,
791 "critical without explicit waiver must always block"
792 );
793 }
794
795 #[test]
796 fn critical_with_explicit_waiver_does_not_block() {
797 let (graph, s) = make_graph("ci.yml");
798 let crit = make_finding(
799 FindingCategory::AuthorityPropagation,
800 Severity::Critical,
801 "AWS_KEY reaches untrusted",
802 vec![s],
803 );
804 let mut baseline = Baseline::from_findings(
805 "ci.yml",
806 "x",
807 &graph,
808 std::slice::from_ref(&crit),
809 "ryan",
810 "0.10.0",
811 "32-builtin",
812 now(),
813 );
814 let fp = compute_fingerprint(&crit, &graph);
816 baseline
817 .accept(
818 &fp,
819 "authority_propagation",
820 Severity::Critical,
821 "Threat-modeled; documented exception ABC-123",
822 Some(Severity::Critical),
823 Some(now() + Duration::days(60)),
824 now(),
825 )
826 .expect("valid waiver");
827 let diff = diff(&[crit], &baseline, &graph);
828 let blockers = diff.critical_without_valid_waiver(&baseline, &graph, now());
829 assert_eq!(blockers.len(), 0, "valid waiver bypasses exit 1");
830 }
831
832 #[test]
833 fn expired_critical_waiver_no_longer_protects() {
834 let (graph, s) = make_graph("ci.yml");
835 let crit = make_finding(
836 FindingCategory::AuthorityPropagation,
837 Severity::Critical,
838 "AWS_KEY reaches untrusted",
839 vec![s],
840 );
841 let mut baseline = Baseline::from_findings(
842 "ci.yml",
843 "x",
844 &graph,
845 std::slice::from_ref(&crit),
846 "ryan",
847 "0.10.0",
848 "32-builtin",
849 now(),
850 );
851 let fp = compute_fingerprint(&crit, &graph);
852 let exp = now() + Duration::days(30);
853 baseline
854 .accept(
855 &fp,
856 "authority_propagation",
857 Severity::Critical,
858 "Threat-modeled; documented exception ABC-123",
859 Some(Severity::Critical),
860 Some(exp),
861 now(),
862 )
863 .expect("valid waiver");
864 let later = exp + Duration::days(1);
866 let diff = diff(&[crit], &baseline, &graph);
867 let blockers = diff.critical_without_valid_waiver(&baseline, &graph, later);
868 assert_eq!(blockers.len(), 1, "expired waiver must not protect");
869 }
870
871 #[test]
872 fn baselines_dir_and_filename_layout() {
873 let root = std::path::Path::new("/tmp/repo");
874 let dir = baselines_dir(root);
875 assert_eq!(dir, std::path::PathBuf::from("/tmp/repo/.taudit/baselines"));
876 let f = baseline_filename_for("sha256:abcdef0123");
877 assert_eq!(f, "abcdef0123.json");
878 let p = baseline_path_for(root, "sha256:abcdef0123");
879 assert_eq!(
880 p,
881 std::path::PathBuf::from("/tmp/repo/.taudit/baselines/abcdef0123.json")
882 );
883 }
884
885 #[test]
886 fn unsupported_schema_version_rejected() {
887 let dir = tempdir();
888 let path = dir.join("b.json");
889 let body = r#"{"schema_version":"2.0.0","pipeline_path":"x","pipeline_content_hash":"sha256:x","captured_at":"2026-04-26T12:00:00Z","captured_by":"r","captured_with":{"taudit_version":"0.10.0","rules_version":"32-builtin"},"baseline_findings":[]}"#;
890 std::fs::write(&path, body).unwrap();
891 let err = Baseline::load(&path).unwrap_err();
892 assert!(matches!(err, BaselineError::UnsupportedVersion { .. }));
893 }
894
895 fn empty_baseline() -> Baseline {
898 Baseline {
899 schema_version: BASELINE_SCHEMA_VERSION.to_string(),
900 pipeline_path: "ci.yml".to_string(),
901 pipeline_content_hash: compute_pipeline_hash("x"),
902 captured_at: now(),
903 captured_by: "ryan".to_string(),
904 captured_with: CapturedWith {
905 taudit_version: "0.10.0".to_string(),
906 rules_version: "32-builtin".to_string(),
907 },
908 baseline_findings: Vec::new(),
909 }
910 }
911
912 fn tempdir() -> std::path::PathBuf {
915 let pid = std::process::id();
916 let nanos = std::time::SystemTime::now()
917 .duration_since(std::time::UNIX_EPOCH)
918 .unwrap()
919 .as_nanos();
920 let p = std::env::temp_dir().join(format!("taudit-baselines-test-{pid}-{nanos}"));
921 std::fs::create_dir_all(&p).unwrap();
922 p
923 }
924}