Skip to main content

cortex_core/
source_attestation.rs

1//! Source-attestation compatibility shape for the schema v2 cutover.
2//!
3//! ADR 0018 requires legacy v1 rows without cryptographic proof to be
4//! represented explicitly, never as a fake zero-signature [`crate::Attestation`].
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::Attestation;
10
11/// Attestation state attached to an event source across the v1 -> v2 cutover.
12///
13/// `Verified` is the normal v2 path. `LegacyUnattested` records honest absence
14/// of cryptographic source proof for v1-era rows or imports. `Missing` is a
15/// partial-read state and must not satisfy authority-required paths.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "state", content = "value", rename_all = "snake_case")]
18pub enum SourceAttestation {
19    /// Normal v2 path: verified Ed25519 per ADR 0010/0014.
20    Verified(Attestation),
21    /// v1-era row or import without cryptographic proof.
22    LegacyUnattested {
23        /// When Cortex imported or migrated the legacy record.
24        imported_at: DateTime<Utc>,
25        /// Original timestamp recorded on the legacy source record.
26        original_recorded_at: DateTime<Utc>,
27    },
28    /// Missing source attestation on a partial read.
29    Missing,
30}
31
32impl SourceAttestation {
33    /// Returns true only for cryptographically verified source proof.
34    #[must_use]
35    pub const fn is_verified(&self) -> bool {
36        matches!(self, Self::Verified(_))
37    }
38
39    /// Returns true for legacy or partial states that cannot satisfy
40    /// authority-required paths.
41    #[must_use]
42    pub const fn is_unattested(&self) -> bool {
43        matches!(self, Self::LegacyUnattested { .. } | Self::Missing)
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use std::fs;
51    use std::path::{Path, PathBuf};
52
53    use crate::{Attestation, Attestor, InMemoryAttestor};
54
55    fn attestation_fixture() -> Attestation {
56        let attestor = InMemoryAttestor::from_seed(&[7; 32]);
57        Attestation {
58            key_id: attestor.key_id().to_string(),
59            signature: attestor.sign(b"source-attestation-test").to_bytes(),
60            signed_at: "2026-05-04T12:00:00Z".parse().unwrap(),
61        }
62    }
63
64    #[test]
65    fn source_attestation_variants_round_trip() {
66        let imported_at = "2026-05-04T13:00:00Z".parse().unwrap();
67        let original_recorded_at = "2026-05-04T11:59:00Z".parse().unwrap();
68        let variants = [
69            SourceAttestation::Verified(attestation_fixture()),
70            SourceAttestation::LegacyUnattested {
71                imported_at,
72                original_recorded_at,
73            },
74            SourceAttestation::Missing,
75        ];
76
77        for variant in variants {
78            let json = serde_json::to_string(&variant).unwrap();
79            let decoded: SourceAttestation = serde_json::from_str(&json).unwrap();
80            assert_eq!(decoded, variant);
81        }
82    }
83
84    #[test]
85    fn source_attestation_wire_strings_are_stable() {
86        let verified_json =
87            serde_json::to_value(SourceAttestation::Verified(attestation_fixture()))
88                .expect("verified variant serializes");
89        assert_eq!(verified_json["state"], "verified");
90
91        let legacy_json = serde_json::to_value(SourceAttestation::LegacyUnattested {
92            imported_at: "2026-05-04T13:00:00Z".parse().unwrap(),
93            original_recorded_at: "2026-05-04T11:59:00Z".parse().unwrap(),
94        })
95        .expect("legacy variant serializes");
96        assert_eq!(legacy_json["state"], "legacy_unattested");
97
98        let missing_json =
99            serde_json::to_value(SourceAttestation::Missing).expect("missing variant serializes");
100        assert_eq!(missing_json["state"], "missing");
101    }
102
103    #[test]
104    fn source_attestation_authority_helpers_separate_unattested_rows() {
105        assert!(SourceAttestation::Verified(attestation_fixture()).is_verified());
106        assert!(!SourceAttestation::Verified(attestation_fixture()).is_unattested());
107        assert!(SourceAttestation::LegacyUnattested {
108            imported_at: "2026-05-04T13:00:00Z".parse().unwrap(),
109            original_recorded_at: "2026-05-04T11:59:00Z".parse().unwrap(),
110        }
111        .is_unattested());
112        assert!(SourceAttestation::Missing.is_unattested());
113    }
114
115    #[test]
116    fn no_zero_signature_sentinel_in_codebase() {
117        let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
118            .parent()
119            .and_then(Path::parent)
120            .expect("cortex-core sits under crates/");
121        let mut files = Vec::new();
122        collect_rs_files(workspace, &mut files);
123        let compact_array = ["[0;", "64]"].join(" ");
124        let attestation_field = ["signature:", compact_array.as_str()].join(" ");
125
126        for file in files {
127            let text = fs::read_to_string(&file).expect("source file is readable");
128            assert!(
129                !text.contains(&attestation_field) && !text.contains(&compact_array),
130                "zero-signature attestation sentinel is forbidden by ADR 0018: {}",
131                file.display()
132            );
133        }
134    }
135
136    fn collect_rs_files(dir: &Path, files: &mut Vec<PathBuf>) {
137        let Ok(entries) = fs::read_dir(dir) else {
138            return;
139        };
140        for entry in entries {
141            let entry = entry.expect("directory entry is readable");
142            let path = entry.path();
143            if should_skip(&path) {
144                continue;
145            }
146            if path.is_dir() {
147                collect_rs_files(&path, files);
148            } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
149                files.push(path);
150            }
151        }
152    }
153
154    fn should_skip(path: &Path) -> bool {
155        path.file_name()
156            .and_then(|name| name.to_str())
157            .is_some_and(|name| {
158                matches!(
159                    name,
160                    "target" | ".git" | ".cargo" | "generated" | "fixtures"
161                )
162            })
163    }
164}