1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
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 fleet: Option<String>,
49 pub role: Option<String>,
50 pub profile: Option<String>,
51 pub network: Option<String>,
52}
53
54#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}