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 app: Option<String>,
49    pub fleet: Option<String>,
50    pub role: Option<String>,
51    pub profile: Option<String>,
52    pub environment: Option<String>,
53}
54
55///
56/// EvidenceTargetKindV1
57///
58#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(rename_all = "snake_case")]
60pub enum EvidenceTargetKindV1 {
61    Deployment,
62    App,
63    Fleet,
64    AppAdoption,
65    Artifact,
66    PolicyGate,
67    Unknown,
68}
69
70///
71/// PayloadSchemaRefV1
72///
73#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct PayloadSchemaRefV1 {
75    pub id: String,
76    pub version: String,
77    pub stability: PayloadSchemaStabilityV1,
78}
79
80impl PayloadSchemaRefV1 {
81    #[must_use]
82    pub fn stable(id: &str, version: &str) -> Self {
83        Self {
84            id: id.to_string(),
85            version: version.to_string(),
86            stability: PayloadSchemaStabilityV1::Stable,
87        }
88    }
89
90    #[must_use]
91    pub fn experimental(id: &str, version: &str) -> Self {
92        Self {
93            id: id.to_string(),
94            version: version.to_string(),
95            stability: PayloadSchemaStabilityV1::Experimental,
96        }
97    }
98
99    #[must_use]
100    pub fn internal(id: &str, version: &str) -> Self {
101        Self {
102            id: id.to_string(),
103            version: version.to_string(),
104            stability: PayloadSchemaStabilityV1::Internal,
105        }
106    }
107}
108
109///
110/// PayloadSchemaStabilityV1
111///
112#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
113#[serde(rename_all = "snake_case")]
114pub enum PayloadSchemaStabilityV1 {
115    Stable,
116    Experimental,
117    Internal,
118}
119
120///
121/// InputFingerprintV1
122///
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct InputFingerprintV1 {
125    pub kind: String,
126    pub path: Option<String>,
127    pub path_display: InputPathDisplayV1,
128    pub sha256: Option<String>,
129    pub size_bytes: Option<u64>,
130    pub modified_unix_secs: Option<u64>,
131    pub schema: Option<PayloadSchemaRefV1>,
132    pub note: Option<String>,
133}
134
135///
136/// InputPathDisplayV1
137///
138#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum InputPathDisplayV1 {
141    Relative,
142    AbsoluteRedacted,
143    Omitted,
144}
145
146///
147/// EvidenceSummaryV1
148///
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150pub struct EvidenceSummaryV1 {
151    pub warnings: Vec<EvidenceMessageV1>,
152    pub blocked_actions: Vec<EvidenceMessageV1>,
153    pub missing_or_stale_evidence: Vec<EvidenceMessageV1>,
154    pub evidence_conflicts: Vec<EvidenceMessageV1>,
155}
156
157///
158/// EvidenceMessageV1
159///
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
161pub struct EvidenceMessageV1 {
162    pub code: String,
163    pub message: String,
164    pub severity: EvidenceMessageSeverityV1,
165    pub source: Option<String>,
166    pub related_input: Option<String>,
167}
168
169impl EvidenceMessageV1 {
170    #[must_use]
171    pub fn new(
172        code: &str,
173        message: impl Into<String>,
174        severity: EvidenceMessageSeverityV1,
175    ) -> Self {
176        Self {
177            code: code.to_string(),
178            message: message.into(),
179            severity,
180            source: None,
181            related_input: None,
182        }
183    }
184}
185
186///
187/// EvidenceMessageSeverityV1
188///
189#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
190#[serde(rename_all = "snake_case")]
191pub enum EvidenceMessageSeverityV1 {
192    Info,
193    Warning,
194    Error,
195}
196
197///
198/// ExitClassV1
199///
200#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
201#[serde(rename_all = "snake_case")]
202pub enum ExitClassV1 {
203    Success,
204    SuccessWithWarnings,
205    BlockedByPolicy,
206    EvidenceConflict,
207    MissingRequiredEvidence,
208    InvalidInput,
209    ExecutionFailed,
210    InternalError,
211}
212
213impl ExitClassV1 {
214    #[must_use]
215    pub const fn label(self) -> &'static str {
216        match self {
217            Self::Success => "success",
218            Self::SuccessWithWarnings => "success_with_warnings",
219            Self::BlockedByPolicy => "blocked_by_policy",
220            Self::EvidenceConflict => "evidence_conflict",
221            Self::MissingRequiredEvidence => "missing_required_evidence",
222            Self::InvalidInput => "invalid_input",
223            Self::ExecutionFailed => "execution_failed",
224            Self::InternalError => "internal_error",
225        }
226    }
227
228    #[must_use]
229    pub fn from_label(label: &str) -> Option<Self> {
230        match label {
231            "success" => Some(Self::Success),
232            "success_with_warnings" => Some(Self::SuccessWithWarnings),
233            "blocked_by_policy" => Some(Self::BlockedByPolicy),
234            "evidence_conflict" => Some(Self::EvidenceConflict),
235            "missing_required_evidence" => Some(Self::MissingRequiredEvidence),
236            "invalid_input" => Some(Self::InvalidInput),
237            "execution_failed" => Some(Self::ExecutionFailed),
238            "internal_error" => Some(Self::InternalError),
239            _ => None,
240        }
241    }
242
243    #[must_use]
244    pub const fn precedence(self) -> u8 {
245        match self {
246            Self::Success => 0,
247            Self::SuccessWithWarnings => 1,
248            Self::BlockedByPolicy => 2,
249            Self::MissingRequiredEvidence => 3,
250            Self::EvidenceConflict => 4,
251            Self::InvalidInput => 5,
252            Self::ExecutionFailed => 6,
253            Self::InternalError => 7,
254        }
255    }
256
257    #[must_use]
258    pub const fn dominates(self, other: Self) -> bool {
259        self.precedence() >= other.precedence()
260    }
261}
262
263#[must_use]
264pub fn combine_exit_classes(classes: impl IntoIterator<Item = ExitClassV1>) -> ExitClassV1 {
265    classes
266        .into_iter()
267        .max_by_key(|class| class.precedence())
268        .unwrap_or(ExitClassV1::Success)
269}
270
271#[must_use]
272pub const fn evidence_summary_exit_class(
273    summary: &EvidenceSummaryV1,
274    missing_required_evidence: bool,
275) -> ExitClassV1 {
276    if !summary.evidence_conflicts.is_empty() {
277        return ExitClassV1::EvidenceConflict;
278    }
279    if missing_required_evidence {
280        return ExitClassV1::MissingRequiredEvidence;
281    }
282    if !summary.blocked_actions.is_empty() {
283        return ExitClassV1::BlockedByPolicy;
284    }
285    if !summary.warnings.is_empty() || !summary.missing_or_stale_evidence.is_empty() {
286        return ExitClassV1::SuccessWithWarnings;
287    }
288
289    ExitClassV1::Success
290}
291
292pub const EVIDENCE_ENVELOPE_SCHEMA_ID: &str = "canic.evidence_envelope.v1";
293pub const ADOPTION_REPORT_SCHEMA_ID: &str = "canic.adoption_report.v1";
294pub const DEPLOYMENT_CHECK_SCHEMA_ID: &str = "canic.deployment_check.v1";
295pub const POLICY_GATE_REPORT_SCHEMA_ID: &str = "canic.policy_gate_report.v1";
296pub const PROJECT_EVIDENCE_MANIFEST_SCHEMA_ID: &str = "canic.project_evidence_manifest.v1";
297pub const PROJECT_EVIDENCE_GATE_REPORT_SCHEMA_ID: &str = "canic.project_evidence_gate_report.v1";
298
299#[must_use]
300pub fn evidence_envelope_schema() -> PayloadSchemaRefV1 {
301    PayloadSchemaRefV1::stable(EVIDENCE_ENVELOPE_SCHEMA_ID, "1")
302}
303
304#[must_use]
305pub fn adoption_report_schema() -> PayloadSchemaRefV1 {
306    PayloadSchemaRefV1::experimental(ADOPTION_REPORT_SCHEMA_ID, "1")
307}
308
309#[must_use]
310pub fn deployment_check_schema() -> PayloadSchemaRefV1 {
311    PayloadSchemaRefV1::internal(DEPLOYMENT_CHECK_SCHEMA_ID, "1")
312}
313
314#[must_use]
315pub fn policy_gate_report_schema() -> PayloadSchemaRefV1 {
316    PayloadSchemaRefV1::stable(POLICY_GATE_REPORT_SCHEMA_ID, "1")
317}
318
319#[must_use]
320pub fn project_evidence_manifest_schema() -> PayloadSchemaRefV1 {
321    PayloadSchemaRefV1::stable(PROJECT_EVIDENCE_MANIFEST_SCHEMA_ID, "1")
322}
323
324#[must_use]
325pub fn project_evidence_gate_report_schema() -> PayloadSchemaRefV1 {
326    PayloadSchemaRefV1::stable(PROJECT_EVIDENCE_GATE_REPORT_SCHEMA_ID, "1")
327}
328
329pub fn json_payload_sha256<T>(payload: &T) -> Result<String, serde_json::Error>
330where
331    T: Serialize,
332{
333    Ok(sha256_hex(&serde_json::to_vec(payload)?))
334}
335
336pub fn file_input_fingerprint(
337    kind: &str,
338    path: &Path,
339    root: &Path,
340    schema: Option<PayloadSchemaRefV1>,
341    note: Option<String>,
342) -> io::Result<InputFingerprintV1> {
343    let bytes = fs::read(path)?;
344    let metadata = fs::metadata(path)?;
345    let modified_unix_secs = metadata
346        .modified()
347        .ok()
348        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
349        .map(|duration| duration.as_secs());
350    let path_summary = input_path_summary(path, root);
351
352    Ok(InputFingerprintV1 {
353        kind: kind.to_string(),
354        path: path_summary.path,
355        path_display: path_summary.display,
356        sha256: Some(sha256_hex(&bytes)),
357        size_bytes: Some(metadata.len()),
358        modified_unix_secs,
359        schema,
360        note,
361    })
362}
363
364#[must_use]
365pub fn command_path_for_root(path: &Path, root: &Path) -> String {
366    input_path_summary(path, root)
367        .path
368        .unwrap_or_else(|| "<redacted:absolute-outside-root>".to_string())
369}
370
371///
372/// InputPathSummary
373///
374#[derive(Clone, Debug, Eq, PartialEq)]
375struct InputPathSummary {
376    path: Option<String>,
377    display: InputPathDisplayV1,
378}
379
380fn input_path_summary(path: &Path, root: &Path) -> InputPathSummary {
381    let canonical_path = fs::canonicalize(path).ok();
382    let canonical_root = fs::canonicalize(root).ok();
383
384    if let (Some(canonical_path), Some(canonical_root)) = (canonical_path, canonical_root) {
385        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
386            return InputPathSummary {
387                path: Some(path_to_display(relative)),
388                display: InputPathDisplayV1::Relative,
389            };
390        }
391        return InputPathSummary {
392            path: None,
393            display: InputPathDisplayV1::AbsoluteRedacted,
394        };
395    }
396
397    if path.is_absolute() {
398        return InputPathSummary {
399            path: None,
400            display: InputPathDisplayV1::AbsoluteRedacted,
401        };
402    }
403
404    InputPathSummary {
405        path: Some(path_to_display(path)),
406        display: InputPathDisplayV1::Relative,
407    }
408}
409
410fn path_to_display(path: &Path) -> String {
411    let mut components = Vec::new();
412
413    for component in path.components() {
414        match component {
415            Component::Prefix(prefix) => {
416                components.push(prefix.as_os_str().to_string_lossy().to_string());
417            }
418            Component::RootDir | Component::CurDir => {}
419            Component::ParentDir => components.push("..".to_string()),
420            Component::Normal(segment) => components.push(segment.to_string_lossy().to_string()),
421        }
422    }
423
424    if components.is_empty() {
425        ".".to_string()
426    } else {
427        components.join("/")
428    }
429}
430
431#[cfg(test)]
432mod tests;