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