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)]
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#[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 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#[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;