Skip to main content

canic_host/evidence_envelope/
mod.rs

1//! Stable evidence envelopes for CI/GitOps automation.
2
3use canic_core::cdk::utils::hash::sha256_hex;
4use serde::{Deserialize, Serialize};
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 environment: 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 label(self) -> &'static str {
214        match self {
215            Self::Success => "success",
216            Self::SuccessWithWarnings => "success_with_warnings",
217            Self::BlockedByPolicy => "blocked_by_policy",
218            Self::EvidenceConflict => "evidence_conflict",
219            Self::MissingRequiredEvidence => "missing_required_evidence",
220            Self::InvalidInput => "invalid_input",
221            Self::ExecutionFailed => "execution_failed",
222            Self::InternalError => "internal_error",
223        }
224    }
225
226    #[must_use]
227    pub fn from_label(label: &str) -> Option<Self> {
228        match label {
229            "success" => Some(Self::Success),
230            "success_with_warnings" => Some(Self::SuccessWithWarnings),
231            "blocked_by_policy" => Some(Self::BlockedByPolicy),
232            "evidence_conflict" => Some(Self::EvidenceConflict),
233            "missing_required_evidence" => Some(Self::MissingRequiredEvidence),
234            "invalid_input" => Some(Self::InvalidInput),
235            "execution_failed" => Some(Self::ExecutionFailed),
236            "internal_error" => Some(Self::InternalError),
237            _ => None,
238        }
239    }
240
241    #[must_use]
242    pub const fn precedence(self) -> u8 {
243        match self {
244            Self::Success => 0,
245            Self::SuccessWithWarnings => 1,
246            Self::BlockedByPolicy => 2,
247            Self::MissingRequiredEvidence => 3,
248            Self::EvidenceConflict => 4,
249            Self::InvalidInput => 5,
250            Self::ExecutionFailed => 6,
251            Self::InternalError => 7,
252        }
253    }
254
255    #[must_use]
256    pub const fn dominates(self, other: Self) -> bool {
257        self.precedence() >= other.precedence()
258    }
259}
260
261#[must_use]
262pub fn combine_exit_classes(classes: impl IntoIterator<Item = ExitClassV1>) -> ExitClassV1 {
263    classes
264        .into_iter()
265        .max_by_key(|class| class.precedence())
266        .unwrap_or(ExitClassV1::Success)
267}
268
269#[must_use]
270pub const fn evidence_summary_exit_class(
271    summary: &EvidenceSummaryV1,
272    missing_required_evidence: bool,
273) -> ExitClassV1 {
274    if !summary.evidence_conflicts.is_empty() {
275        return ExitClassV1::EvidenceConflict;
276    }
277    if missing_required_evidence {
278        return ExitClassV1::MissingRequiredEvidence;
279    }
280    if !summary.blocked_actions.is_empty() {
281        return ExitClassV1::BlockedByPolicy;
282    }
283    if !summary.warnings.is_empty() || !summary.missing_or_stale_evidence.is_empty() {
284        return ExitClassV1::SuccessWithWarnings;
285    }
286
287    ExitClassV1::Success
288}
289
290pub const EVIDENCE_ENVELOPE_SCHEMA_ID: &str = "canic.evidence_envelope.v1";
291pub const ADOPTION_REPORT_SCHEMA_ID: &str = "canic.adoption_report.v1";
292pub const DEPLOYMENT_CHECK_SCHEMA_ID: &str = "canic.deployment_check.v1";
293pub const POLICY_GATE_REPORT_SCHEMA_ID: &str = "canic.policy_gate_report.v1";
294pub const PROJECT_EVIDENCE_MANIFEST_SCHEMA_ID: &str = "canic.project_evidence_manifest.v1";
295pub const PROJECT_EVIDENCE_GATE_REPORT_SCHEMA_ID: &str = "canic.project_evidence_gate_report.v1";
296
297#[must_use]
298pub fn evidence_envelope_schema() -> PayloadSchemaRefV1 {
299    PayloadSchemaRefV1::stable(EVIDENCE_ENVELOPE_SCHEMA_ID, "1")
300}
301
302#[must_use]
303pub fn adoption_report_schema() -> PayloadSchemaRefV1 {
304    PayloadSchemaRefV1::experimental(ADOPTION_REPORT_SCHEMA_ID, "1")
305}
306
307#[must_use]
308pub fn deployment_check_schema() -> PayloadSchemaRefV1 {
309    PayloadSchemaRefV1::internal(DEPLOYMENT_CHECK_SCHEMA_ID, "1")
310}
311
312#[must_use]
313pub fn policy_gate_report_schema() -> PayloadSchemaRefV1 {
314    PayloadSchemaRefV1::stable(POLICY_GATE_REPORT_SCHEMA_ID, "1")
315}
316
317#[must_use]
318pub fn project_evidence_manifest_schema() -> PayloadSchemaRefV1 {
319    PayloadSchemaRefV1::stable(PROJECT_EVIDENCE_MANIFEST_SCHEMA_ID, "1")
320}
321
322#[must_use]
323pub fn project_evidence_gate_report_schema() -> PayloadSchemaRefV1 {
324    PayloadSchemaRefV1::stable(PROJECT_EVIDENCE_GATE_REPORT_SCHEMA_ID, "1")
325}
326
327pub fn json_payload_sha256<T>(payload: &T) -> Result<String, serde_json::Error>
328where
329    T: Serialize,
330{
331    Ok(sha256_hex(&serde_json::to_vec(payload)?))
332}
333
334pub fn file_input_fingerprint(
335    kind: &str,
336    path: &Path,
337    root: &Path,
338    schema: Option<PayloadSchemaRefV1>,
339    note: Option<String>,
340) -> io::Result<InputFingerprintV1> {
341    let bytes = fs::read(path)?;
342    let metadata = fs::metadata(path)?;
343    let modified_unix_secs = metadata
344        .modified()
345        .ok()
346        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
347        .map(|duration| duration.as_secs());
348    let path_summary = input_path_summary(path, root);
349
350    Ok(InputFingerprintV1 {
351        kind: kind.to_string(),
352        path: path_summary.path,
353        path_display: path_summary.display,
354        sha256: Some(sha256_hex(&bytes)),
355        size_bytes: Some(metadata.len()),
356        modified_unix_secs,
357        schema,
358        note,
359    })
360}
361
362#[must_use]
363pub fn command_path_for_root(path: &Path, root: &Path) -> String {
364    input_path_summary(path, root)
365        .path
366        .unwrap_or_else(|| "<redacted:absolute-outside-root>".to_string())
367}
368
369///
370/// InputPathSummary
371///
372#[derive(Clone, Debug, Eq, PartialEq)]
373struct InputPathSummary {
374    path: Option<String>,
375    display: InputPathDisplayV1,
376}
377
378fn input_path_summary(path: &Path, root: &Path) -> InputPathSummary {
379    let canonical_path = fs::canonicalize(path).ok();
380    let canonical_root = fs::canonicalize(root).ok();
381
382    if let (Some(canonical_path), Some(canonical_root)) = (canonical_path, canonical_root) {
383        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
384            return InputPathSummary {
385                path: Some(path_to_display(relative)),
386                display: InputPathDisplayV1::Relative,
387            };
388        }
389        return InputPathSummary {
390            path: None,
391            display: InputPathDisplayV1::AbsoluteRedacted,
392        };
393    }
394
395    if path.is_absolute() {
396        return InputPathSummary {
397            path: None,
398            display: InputPathDisplayV1::AbsoluteRedacted,
399        };
400    }
401
402    InputPathSummary {
403        path: Some(path_to_display(path)),
404        display: InputPathDisplayV1::Relative,
405    }
406}
407
408fn path_to_display(path: &Path) -> String {
409    let mut components = Vec::new();
410
411    for component in path.components() {
412        match component {
413            Component::Prefix(prefix) => {
414                components.push(prefix.as_os_str().to_string_lossy().to_string());
415            }
416            Component::RootDir | Component::CurDir => {}
417            Component::ParentDir => components.push("..".to_string()),
418            Component::Normal(segment) => components.push(segment.to_string_lossy().to_string()),
419        }
420    }
421
422    if components.is_empty() {
423        ".".to_string()
424    } else {
425        components.join("/")
426    }
427}
428
429#[cfg(test)]
430mod tests;