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