Skip to main content

canic_host/
evidence_envelope.rs

1//! Stable evidence envelopes for CI/GitOps automation.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::{
6    fs, io,
7    path::{Component, Path},
8    time::UNIX_EPOCH,
9};
10
11///
12/// EvidenceEnvelopeV1
13///
14#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
15pub struct EvidenceEnvelopeV1 {
16    pub envelope_schema: PayloadSchemaRefV1,
17    pub canic_version: String,
18    pub command: CommandProvenanceV1,
19    pub target: EvidenceTargetV1,
20    pub generated_at: String,
21    pub source_config: Option<InputFingerprintV1>,
22    pub inputs: Vec<InputFingerprintV1>,
23    pub payload_schema: PayloadSchemaRefV1,
24    pub payload_sha256: Option<String>,
25    pub payload: serde_json::Value,
26    pub summary: EvidenceSummaryV1,
27    pub exit_class: ExitClassV1,
28}
29
30///
31/// CommandProvenanceV1
32///
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct CommandProvenanceV1 {
35    pub name: String,
36    pub argv_normalized: Vec<String>,
37    pub argv_redactions: Vec<String>,
38    pub format: String,
39}
40
41///
42/// EvidenceTargetV1
43///
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45pub struct EvidenceTargetV1 {
46    pub kind: EvidenceTargetKindV1,
47    pub deployment: Option<String>,
48    pub fleet: Option<String>,
49    pub role: Option<String>,
50    pub profile: Option<String>,
51    pub network: Option<String>,
52}
53
54///
55/// EvidenceTargetKindV1
56///
57#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum EvidenceTargetKindV1 {
60    Deployment,
61    Fleet,
62    FleetAdoption,
63    Artifact,
64    PolicyGate,
65    Unknown,
66}
67
68///
69/// PayloadSchemaRefV1
70///
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
72pub struct PayloadSchemaRefV1 {
73    pub id: String,
74    pub version: String,
75    pub stability: PayloadSchemaStabilityV1,
76}
77
78impl PayloadSchemaRefV1 {
79    #[must_use]
80    pub fn stable(id: &str, version: &str) -> Self {
81        Self {
82            id: id.to_string(),
83            version: version.to_string(),
84            stability: PayloadSchemaStabilityV1::Stable,
85        }
86    }
87
88    #[must_use]
89    pub fn experimental(id: &str, version: &str) -> Self {
90        Self {
91            id: id.to_string(),
92            version: version.to_string(),
93            stability: PayloadSchemaStabilityV1::Experimental,
94        }
95    }
96
97    #[must_use]
98    pub fn internal(id: &str, version: &str) -> Self {
99        Self {
100            id: id.to_string(),
101            version: version.to_string(),
102            stability: PayloadSchemaStabilityV1::Internal,
103        }
104    }
105}
106
107///
108/// PayloadSchemaStabilityV1
109///
110#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
111#[serde(rename_all = "snake_case")]
112pub enum PayloadSchemaStabilityV1 {
113    Stable,
114    Experimental,
115    Internal,
116}
117
118///
119/// InputFingerprintV1
120///
121#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
122pub struct InputFingerprintV1 {
123    pub kind: String,
124    pub path: Option<String>,
125    pub path_display: InputPathDisplayV1,
126    pub sha256: Option<String>,
127    pub size_bytes: Option<u64>,
128    pub modified_unix_secs: Option<u64>,
129    pub schema: Option<PayloadSchemaRefV1>,
130    pub note: Option<String>,
131}
132
133///
134/// InputPathDisplayV1
135///
136#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(rename_all = "snake_case")]
138pub enum InputPathDisplayV1 {
139    Relative,
140    AbsoluteRedacted,
141    Omitted,
142}
143
144///
145/// EvidenceSummaryV1
146///
147#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
148pub struct EvidenceSummaryV1 {
149    pub warnings: Vec<EvidenceMessageV1>,
150    pub blocked_actions: Vec<EvidenceMessageV1>,
151    pub missing_or_stale_evidence: Vec<EvidenceMessageV1>,
152    pub evidence_conflicts: Vec<EvidenceMessageV1>,
153}
154
155///
156/// EvidenceMessageV1
157///
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159pub struct EvidenceMessageV1 {
160    pub code: String,
161    pub message: String,
162    pub severity: EvidenceMessageSeverityV1,
163    pub source: Option<String>,
164    pub related_input: Option<String>,
165}
166
167impl EvidenceMessageV1 {
168    #[must_use]
169    pub fn new(
170        code: &str,
171        message: impl Into<String>,
172        severity: EvidenceMessageSeverityV1,
173    ) -> Self {
174        Self {
175            code: code.to_string(),
176            message: message.into(),
177            severity,
178            source: None,
179            related_input: None,
180        }
181    }
182}
183
184///
185/// EvidenceMessageSeverityV1
186///
187#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(rename_all = "snake_case")]
189pub enum EvidenceMessageSeverityV1 {
190    Info,
191    Warning,
192    Error,
193}
194
195///
196/// ExitClassV1
197///
198#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum ExitClassV1 {
201    Success,
202    SuccessWithWarnings,
203    BlockedByPolicy,
204    EvidenceConflict,
205    MissingRequiredEvidence,
206    InvalidInput,
207    ExecutionFailed,
208    InternalError,
209}
210
211impl ExitClassV1 {
212    #[must_use]
213    pub const fn precedence(self) -> u8 {
214        match self {
215            Self::Success => 0,
216            Self::SuccessWithWarnings => 1,
217            Self::BlockedByPolicy => 2,
218            Self::MissingRequiredEvidence => 3,
219            Self::EvidenceConflict => 4,
220            Self::InvalidInput => 5,
221            Self::ExecutionFailed => 6,
222            Self::InternalError => 7,
223        }
224    }
225
226    #[must_use]
227    pub const fn dominates(self, other: Self) -> bool {
228        self.precedence() >= other.precedence()
229    }
230}
231
232#[must_use]
233pub fn combine_exit_classes(classes: impl IntoIterator<Item = ExitClassV1>) -> ExitClassV1 {
234    classes
235        .into_iter()
236        .max_by_key(|class| class.precedence())
237        .unwrap_or(ExitClassV1::Success)
238}
239
240#[must_use]
241pub const fn evidence_summary_exit_class(
242    summary: &EvidenceSummaryV1,
243    missing_required_evidence: bool,
244) -> ExitClassV1 {
245    if !summary.evidence_conflicts.is_empty() {
246        return ExitClassV1::EvidenceConflict;
247    }
248    if missing_required_evidence {
249        return ExitClassV1::MissingRequiredEvidence;
250    }
251    if !summary.blocked_actions.is_empty() {
252        return ExitClassV1::BlockedByPolicy;
253    }
254    if !summary.warnings.is_empty() || !summary.missing_or_stale_evidence.is_empty() {
255        return ExitClassV1::SuccessWithWarnings;
256    }
257
258    ExitClassV1::Success
259}
260
261pub const EVIDENCE_ENVELOPE_SCHEMA_ID: &str = "canic.evidence_envelope.v1";
262pub const ADOPTION_REPORT_SCHEMA_ID: &str = "canic.adoption_report.v1";
263pub const DEPLOYMENT_CHECK_SCHEMA_ID: &str = "canic.deployment_check.v1";
264pub const POLICY_GATE_REPORT_SCHEMA_ID: &str = "canic.policy_gate_report.v1";
265pub const PROJECT_EVIDENCE_MANIFEST_SCHEMA_ID: &str = "canic.project_evidence_manifest.v1";
266pub const PROJECT_EVIDENCE_GATE_REPORT_SCHEMA_ID: &str = "canic.project_evidence_gate_report.v1";
267
268#[must_use]
269pub fn evidence_envelope_schema() -> PayloadSchemaRefV1 {
270    PayloadSchemaRefV1::stable(EVIDENCE_ENVELOPE_SCHEMA_ID, "1")
271}
272
273#[must_use]
274pub fn adoption_report_schema() -> PayloadSchemaRefV1 {
275    PayloadSchemaRefV1::experimental(ADOPTION_REPORT_SCHEMA_ID, "1")
276}
277
278#[must_use]
279pub fn deployment_check_schema() -> PayloadSchemaRefV1 {
280    PayloadSchemaRefV1::internal(DEPLOYMENT_CHECK_SCHEMA_ID, "1")
281}
282
283#[must_use]
284pub fn policy_gate_report_schema() -> PayloadSchemaRefV1 {
285    PayloadSchemaRefV1::stable(POLICY_GATE_REPORT_SCHEMA_ID, "1")
286}
287
288#[must_use]
289pub fn project_evidence_manifest_schema() -> PayloadSchemaRefV1 {
290    PayloadSchemaRefV1::stable(PROJECT_EVIDENCE_MANIFEST_SCHEMA_ID, "1")
291}
292
293#[must_use]
294pub fn project_evidence_gate_report_schema() -> PayloadSchemaRefV1 {
295    PayloadSchemaRefV1::stable(PROJECT_EVIDENCE_GATE_REPORT_SCHEMA_ID, "1")
296}
297
298#[must_use]
299pub fn sha256_hex(bytes: &[u8]) -> String {
300    hex_bytes(Sha256::digest(bytes))
301}
302
303pub fn json_payload_sha256<T>(payload: &T) -> Result<String, serde_json::Error>
304where
305    T: Serialize,
306{
307    Ok(sha256_hex(&serde_json::to_vec(payload)?))
308}
309
310pub fn file_input_fingerprint(
311    kind: &str,
312    path: &Path,
313    root: &Path,
314    schema: Option<PayloadSchemaRefV1>,
315    note: Option<String>,
316) -> io::Result<InputFingerprintV1> {
317    let bytes = fs::read(path)?;
318    let metadata = fs::metadata(path)?;
319    let modified_unix_secs = metadata
320        .modified()
321        .ok()
322        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
323        .map(|duration| duration.as_secs());
324    let path_summary = input_path_summary(path, root);
325
326    Ok(InputFingerprintV1 {
327        kind: kind.to_string(),
328        path: path_summary.path,
329        path_display: path_summary.display,
330        sha256: Some(sha256_hex(&bytes)),
331        size_bytes: Some(metadata.len()),
332        modified_unix_secs,
333        schema,
334        note,
335    })
336}
337
338#[must_use]
339pub fn command_path_for_root(path: &Path, root: &Path) -> String {
340    input_path_summary(path, root)
341        .path
342        .unwrap_or_else(|| "<redacted:absolute-outside-root>".to_string())
343}
344
345///
346/// InputPathSummaryV1
347///
348#[derive(Clone, Debug, Eq, PartialEq)]
349struct InputPathSummaryV1 {
350    path: Option<String>,
351    display: InputPathDisplayV1,
352}
353
354fn input_path_summary(path: &Path, root: &Path) -> InputPathSummaryV1 {
355    let canonical_path = fs::canonicalize(path).ok();
356    let canonical_root = fs::canonicalize(root).ok();
357
358    if let (Some(canonical_path), Some(canonical_root)) = (canonical_path, canonical_root) {
359        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
360            return InputPathSummaryV1 {
361                path: Some(path_to_display(relative)),
362                display: InputPathDisplayV1::Relative,
363            };
364        }
365        return InputPathSummaryV1 {
366            path: None,
367            display: InputPathDisplayV1::AbsoluteRedacted,
368        };
369    }
370
371    if path.is_absolute() {
372        return InputPathSummaryV1 {
373            path: None,
374            display: InputPathDisplayV1::AbsoluteRedacted,
375        };
376    }
377
378    InputPathSummaryV1 {
379        path: Some(path_to_display(path)),
380        display: InputPathDisplayV1::Relative,
381    }
382}
383
384fn path_to_display(path: &Path) -> String {
385    let mut components = Vec::new();
386
387    for component in path.components() {
388        match component {
389            Component::Prefix(prefix) => {
390                components.push(prefix.as_os_str().to_string_lossy().to_string());
391            }
392            Component::RootDir | Component::CurDir => {}
393            Component::ParentDir => components.push("..".to_string()),
394            Component::Normal(segment) => components.push(segment.to_string_lossy().to_string()),
395        }
396    }
397
398    if components.is_empty() {
399        ".".to_string()
400    } else {
401        components.join("/")
402    }
403}
404
405fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
406    const HEX: &[u8; 16] = b"0123456789abcdef";
407    let bytes = bytes.as_ref();
408    let mut output = String::with_capacity(bytes.len() * 2);
409    for byte in bytes {
410        output.push(HEX[(byte >> 4) as usize] as char);
411        output.push(HEX[(byte & 0x0f) as usize] as char);
412    }
413    output
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use std::path::PathBuf;
420
421    #[test]
422    fn exit_class_serializes_to_snake_case() {
423        let encoded = serde_json::to_string(&ExitClassV1::SuccessWithWarnings).expect("serialize");
424
425        assert_eq!(encoded, "\"success_with_warnings\"");
426    }
427
428    #[test]
429    fn exit_class_precedence_prefers_policy_relevant_failures() {
430        assert_eq!(
431            combine_exit_classes([
432                ExitClassV1::SuccessWithWarnings,
433                ExitClassV1::BlockedByPolicy,
434                ExitClassV1::EvidenceConflict,
435            ]),
436            ExitClassV1::EvidenceConflict
437        );
438        assert!(ExitClassV1::InvalidInput.dominates(ExitClassV1::EvidenceConflict));
439        assert!(ExitClassV1::InternalError.dominates(ExitClassV1::ExecutionFailed));
440    }
441
442    #[test]
443    fn evidence_summary_exit_class_uses_stable_precedence() {
444        let mut summary = EvidenceSummaryV1 {
445            warnings: vec![EvidenceMessageV1::new(
446                "test.warning",
447                "warning",
448                EvidenceMessageSeverityV1::Warning,
449            )],
450            blocked_actions: Vec::new(),
451            missing_or_stale_evidence: Vec::new(),
452            evidence_conflicts: Vec::new(),
453        };
454
455        assert_eq!(
456            evidence_summary_exit_class(&summary, false),
457            ExitClassV1::SuccessWithWarnings
458        );
459
460        summary.blocked_actions.push(EvidenceMessageV1::new(
461            "test.blocked",
462            "blocked",
463            EvidenceMessageSeverityV1::Error,
464        ));
465        assert_eq!(
466            evidence_summary_exit_class(&summary, false),
467            ExitClassV1::BlockedByPolicy
468        );
469        assert_eq!(
470            evidence_summary_exit_class(&summary, true),
471            ExitClassV1::MissingRequiredEvidence
472        );
473
474        summary.evidence_conflicts.push(EvidenceMessageV1::new(
475            "test.conflict",
476            "conflict",
477            EvidenceMessageSeverityV1::Error,
478        ));
479        assert_eq!(
480            evidence_summary_exit_class(&summary, true),
481            ExitClassV1::EvidenceConflict
482        );
483    }
484
485    #[test]
486    fn schema_refs_record_stability() {
487        assert_eq!(
488            evidence_envelope_schema(),
489            PayloadSchemaRefV1 {
490                id: "canic.evidence_envelope.v1".to_string(),
491                version: "1".to_string(),
492                stability: PayloadSchemaStabilityV1::Stable,
493            }
494        );
495        assert_eq!(
496            adoption_report_schema().stability,
497            PayloadSchemaStabilityV1::Experimental
498        );
499        assert_eq!(
500            deployment_check_schema().stability,
501            PayloadSchemaStabilityV1::Internal
502        );
503    }
504
505    #[test]
506    fn file_input_fingerprint_uses_relative_path_under_root() {
507        let root = temp_dir("canic-envelope-relative");
508        let input = root.join("evidence").join("input.json");
509        fs::create_dir_all(input.parent().expect("input parent")).expect("create parent");
510        fs::write(&input, b"{\"ok\":true}").expect("write input");
511
512        let fingerprint =
513            file_input_fingerprint("input", &input, &root, None, None).expect("fingerprint");
514
515        fs::remove_dir_all(&root).expect("clean temp dir");
516        assert_eq!(fingerprint.path.as_deref(), Some("evidence/input.json"));
517        assert_eq!(fingerprint.path_display, InputPathDisplayV1::Relative);
518        assert_eq!(fingerprint.size_bytes, Some(11));
519        assert!(
520            fingerprint
521                .sha256
522                .as_deref()
523                .is_some_and(|hash| hash.len() == 64)
524        );
525    }
526
527    #[test]
528    fn file_input_fingerprint_redacts_absolute_path_outside_root() {
529        let root = temp_dir("canic-envelope-root");
530        let outside = temp_dir("canic-envelope-outside");
531        fs::create_dir_all(&root).expect("create root");
532        fs::create_dir_all(&outside).expect("create outside");
533        let input = outside.join("secret.json");
534        fs::write(&input, b"secret").expect("write input");
535
536        let fingerprint =
537            file_input_fingerprint("input", &input, &root, None, None).expect("fingerprint");
538        let command_path = command_path_for_root(&input, &root);
539
540        fs::remove_dir_all(&root).expect("clean root");
541        fs::remove_dir_all(&outside).expect("clean outside");
542        assert_eq!(fingerprint.path, None);
543        assert_eq!(
544            fingerprint.path_display,
545            InputPathDisplayV1::AbsoluteRedacted
546        );
547        assert_eq!(command_path, "<redacted:absolute-outside-root>");
548    }
549
550    fn temp_dir(name: &str) -> PathBuf {
551        let suffix = std::time::SystemTime::now()
552            .duration_since(UNIX_EPOCH)
553            .expect("system clock before unix epoch")
554            .as_nanos();
555        std::env::temp_dir().join(format!("{name}-{suffix}"))
556    }
557}