1use chrono::{DateTime, Utc};
64use serde::{Deserialize, Serialize};
65
66use crate::sign::SignaturePayload;
67
68pub const RECEIPT_SCHEMA: &str = "axiom.receipt.v1";
78
79pub const RUN_SCHEMA: &str = "tsafe.run.v1";
87
88pub const LEGACY_RUN_SCHEMA: &str = "algol.run.v1";
93
94pub const RUN_EVIDENCE_VERSION: &str = env!("CARGO_PKG_VERSION");
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct ContractRef {
109 pub path: String,
110 pub hash: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub struct InjectedSecretEvidence {
116 pub name: String,
117 pub source: String,
118 pub hash: String,
119 pub redacted_value: String,
120 pub required: bool,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct DeniedSensitiveEnvEvidence {
126 pub name: String,
127 pub hash: String,
128 pub reason: String,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct EnvironmentEvidence {
140 pub parent_env_count: usize,
141 pub child_env_count: usize,
142 pub removed_env_count: usize,
143 pub safe_baseline_injected: Vec<String>,
144 pub secrets_injected: Vec<InjectedSecretEvidence>,
145 pub sensitive_env_denied: Vec<DeniedSensitiveEnvEvidence>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150pub struct ProcessEvidence {
151 pub pid: u32,
152 pub exit_code: i32,
153 pub duration_ms: u128,
154 pub cwd: String,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164pub struct MachineEvidence {
165 pub hostname_hash: String,
166 pub username_hash: String,
167 pub os: String,
168 pub arch: String,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173pub struct RiskDelta {
174 pub before_score: u32,
175 pub after_score: u32,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180pub struct EnforcementResult {
181 pub contract_enforced: bool,
182 pub violations: Vec<String>,
183 pub risk_delta: RiskDelta,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202pub struct RunEvidence {
203 pub schema: String,
204 #[serde(alias = "algol_version", rename = "tsafe_attest_version")]
210 pub tsafe_attest_version: String,
211 pub started_at: DateTime<Utc>,
212 pub finished_at: DateTime<Utc>,
213 pub repo_path: String,
214 pub repo_commit: Option<String>,
215 pub command: Vec<String>,
216 pub contract: ContractRef,
217 pub environment: EnvironmentEvidence,
218 pub process: ProcessEvidence,
219 pub machine: MachineEvidence,
220 pub result: EnforcementResult,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub signature: Option<SignaturePayload>,
230}
231
232impl RunEvidence {
233 pub fn validation_errors(&self) -> Vec<String> {
244 let mut errors = Vec::new();
245
246 if !is_supported_run_schema(&self.schema) {
247 errors.push(format!("unsupported schema {}", self.schema));
248 }
249 if self.tsafe_attest_version.trim().is_empty() {
250 errors.push("tsafe_attest_version must not be empty".to_string());
251 }
252 if self.repo_path.trim().is_empty() {
253 errors.push("repo_path must not be empty".to_string());
254 }
255 if self.command.is_empty() || self.command.iter().any(|part| part.trim().is_empty()) {
256 errors.push("command must contain non-empty argv entries".to_string());
257 }
258 if self.contract.path.trim().is_empty() {
259 errors.push("contract.path must not be empty".to_string());
260 }
261 if !is_supported_hash(&self.contract.hash) {
262 errors.push(
263 "contract.hash must be a blake3 hash (or sha256 during compat window)".to_string(),
264 );
265 }
266 for item in &self.environment.secrets_injected {
267 if !is_supported_hash(&item.hash) {
268 errors.push(format!(
269 "secrets_injected {} hash must be a blake3 hash (or sha256 during compat window)",
270 item.name
271 ));
272 }
273 }
274 for item in &self.environment.sensitive_env_denied {
275 if !is_supported_hash(&item.hash) {
276 errors.push(format!(
277 "sensitive_env_denied {} hash must be a blake3 hash (or sha256 during compat window)",
278 item.name
279 ));
280 }
281 }
282 if !is_supported_hash(&self.machine.hostname_hash) {
283 errors.push(
284 "machine.hostname_hash must be a blake3 hash (or sha256 during compat window)"
285 .to_string(),
286 );
287 }
288 if !is_supported_hash(&self.machine.username_hash) {
289 errors.push(
290 "machine.username_hash must be a blake3 hash (or sha256 during compat window)"
291 .to_string(),
292 );
293 }
294 if self.environment.child_env_count > self.environment.parent_env_count {
295 errors.push("child_env_count must not exceed parent_env_count".to_string());
296 }
297 let expected_removed = self
298 .environment
299 .parent_env_count
300 .saturating_sub(self.environment.child_env_count);
301 if self.environment.removed_env_count != expected_removed {
302 errors.push(format!(
303 "removed_env_count must equal parent_env_count - child_env_count ({expected_removed})"
304 ));
305 }
306
307 errors
308 }
309
310 pub fn ensure_valid(&self) -> Result<(), String> {
312 let errors = self.validation_errors();
313 if errors.is_empty() {
314 Ok(())
315 } else {
316 Err(errors.join("; "))
317 }
318 }
319}
320
321pub fn is_supported_run_schema(schema: &str) -> bool {
328 schema == RECEIPT_SCHEMA || schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
329}
330
331pub fn is_legacy_run_schema(schema: &str) -> bool {
340 schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
341}
342
343pub fn is_blake3_hash(value: &str) -> bool {
346 let Some(hex) = value.strip_prefix("blake3:") else {
347 return false;
348 };
349 hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
350}
351
352pub fn is_sha256_hash(value: &str) -> bool {
355 let Some(hex) = value.strip_prefix("sha256:") else {
356 return false;
357 };
358 hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
359}
360
361pub fn is_supported_hash(value: &str) -> bool {
367 is_blake3_hash(value) || is_sha256_hash(value)
368}
369
370pub fn blake3_hash(value: impl AsRef<[u8]>) -> String {
374 let digest = blake3::hash(value.as_ref());
375 format!("blake3:{}", digest.to_hex())
376}
377
378#[deprecated(
385 since = "1.2.0",
386 note = "Use `blake3_hash` per ec ADR-0003. SHA-256 retained only for \
387 compat-window reads of legacy `algol.run.v1` artifacts."
388)]
389pub fn sha256_hash(value: impl AsRef<[u8]>) -> String {
390 use sha2::{Digest, Sha256};
391 let digest = Sha256::digest(value.as_ref());
392 format!("sha256:{}", hex_encode(&digest))
393}
394
395fn hex_encode(bytes: &[u8]) -> String {
398 const HEX: &[u8; 16] = b"0123456789abcdef";
399 let mut out = String::with_capacity(bytes.len() * 2);
400 for byte in bytes {
401 out.push(HEX[(byte >> 4) as usize] as char);
402 out.push(HEX[(byte & 0x0f) as usize] as char);
403 }
404 out
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn valid_run() -> RunEvidence {
412 RunEvidence {
413 schema: RECEIPT_SCHEMA.to_string(),
414 tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
415 started_at: Utc::now(),
416 finished_at: Utc::now(),
417 repo_path: ".".to_string(),
418 repo_commit: None,
419 command: vec!["true".to_string()],
420 contract: ContractRef {
421 path: "tsafe.contract.json".to_string(),
422 hash: blake3_hash("contract"),
423 },
424 environment: EnvironmentEvidence {
425 parent_env_count: 3,
426 child_env_count: 1,
427 removed_env_count: 2,
428 safe_baseline_injected: vec!["PATH".to_string()],
429 secrets_injected: vec![InjectedSecretEvidence {
430 name: "DATABASE_URL".to_string(),
431 source: "literal://demo/DATABASE_URL".to_string(),
432 hash: blake3_hash("database"),
433 redacted_value: "po***se".to_string(),
434 required: true,
435 }],
436 sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
437 name: "AWS_SECRET_ACCESS_KEY".to_string(),
438 hash: blake3_hash("secret"),
439 reason: "test".to_string(),
440 }],
441 },
442 process: ProcessEvidence {
443 pid: 1,
444 exit_code: 0,
445 duration_ms: 1,
446 cwd: ".".to_string(),
447 },
448 machine: MachineEvidence {
449 hostname_hash: blake3_hash("host"),
450 username_hash: blake3_hash("user"),
451 os: "linux".to_string(),
452 arch: "x86_64".to_string(),
453 },
454 result: EnforcementResult {
455 contract_enforced: true,
456 violations: Vec::new(),
457 risk_delta: RiskDelta {
458 before_score: 10,
459 after_score: 0,
460 },
461 },
462 signature: None,
463 }
464 }
465
466 #[test]
467 fn run_rejects_empty_and_blank_command_entries() {
468 let mut empty = valid_run();
469 empty.command.clear();
470 assert!(empty
471 .validation_errors()
472 .iter()
473 .any(|error| error.contains("command must contain")));
474
475 let mut blank = valid_run();
476 blank.command = vec!["true".to_string(), "".to_string()];
477 assert!(blank
478 .validation_errors()
479 .iter()
480 .any(|error| error.contains("command must contain")));
481 }
482
483 #[test]
484 fn run_rejects_env_count_edges() {
485 let mut child_exceeds_parent = valid_run();
486 child_exceeds_parent.environment.parent_env_count = 2;
487 child_exceeds_parent.environment.child_env_count = 3;
488 child_exceeds_parent.environment.removed_env_count = 0;
489 let errors = child_exceeds_parent.validation_errors().join("; ");
490 assert!(errors.contains("child_env_count must not exceed parent_env_count"));
491
492 let mut equal_counts = valid_run();
493 equal_counts.environment.parent_env_count = 2;
494 equal_counts.environment.child_env_count = 2;
495 equal_counts.environment.removed_env_count = 1;
496 let errors = equal_counts.validation_errors().join("; ");
497 assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
498 assert!(errors.contains("removed_env_count must equal"));
499
500 let mut removed_mismatch = valid_run();
501 removed_mismatch.environment.parent_env_count = 5;
502 removed_mismatch.environment.child_env_count = 3;
503 removed_mismatch.environment.removed_env_count = 1;
504 let errors = removed_mismatch.validation_errors().join("; ");
505 assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
506 assert!(errors.contains("removed_env_count must equal"));
507 }
508
509 #[test]
510 fn run_rejects_short_or_non_hex_hashes() {
511 let mut short_hash = valid_run();
512 short_hash.contract.hash = "blake3:abc123".to_string();
513 assert!(short_hash
514 .validation_errors()
515 .iter()
516 .any(|error| error.contains("contract.hash must be a blake3 hash")));
517
518 let mut non_hex_hash = valid_run();
519 non_hex_hash.machine.hostname_hash = format!("blake3:{}", "g".repeat(64));
520 assert!(non_hex_hash
521 .validation_errors()
522 .iter()
523 .any(|error| error.contains("machine.hostname_hash must be a blake3 hash")));
524 }
525
526 #[test]
527 fn run_accepts_a_well_formed_artifact() {
528 let run = valid_run();
529 assert!(
530 run.ensure_valid().is_ok(),
531 "valid_run() should pass validation: {:?}",
532 run.validation_errors()
533 );
534 }
535
536 #[test]
537 fn blake3_hash_produces_prefixed_64_hex_string() {
538 let hash = blake3_hash("any payload");
539 assert!(is_blake3_hash(&hash), "rejected own output: {hash}");
540 assert_eq!(hash.len(), "blake3:".len() + 64);
541 assert!(hash.starts_with("blake3:"));
542 }
543
544 #[test]
545 fn blake3_hash_is_deterministic_and_distinct() {
546 assert_eq!(blake3_hash("same input"), blake3_hash("same input"));
547 assert_ne!(blake3_hash("input one"), blake3_hash("input two"));
548 }
549
550 #[test]
551 fn is_blake3_hash_rejects_wrong_prefix_and_length() {
552 assert!(!is_blake3_hash(
553 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
554 ));
555 assert!(!is_blake3_hash("blake3:short"));
556 assert!(!is_blake3_hash("blake3:"));
557 assert!(!is_blake3_hash(""));
558 assert!(!is_blake3_hash(&format!("blake3:{}", "z".repeat(64))));
559 }
560
561 #[test]
562 fn compat_legacy_schema_and_sha256_hashes_accepted() {
563 let mut legacy = valid_run();
564 legacy.schema = LEGACY_RUN_SCHEMA.to_string();
565 #[allow(deprecated)]
567 let legacy_hash = sha256_hash("contract");
568 legacy.contract.hash = legacy_hash;
569 #[allow(deprecated)]
571 {
572 for item in &mut legacy.environment.secrets_injected {
573 item.hash = sha256_hash(&item.name);
574 }
575 for item in &mut legacy.environment.sensitive_env_denied {
576 item.hash = sha256_hash(&item.name);
577 }
578 legacy.machine.hostname_hash = sha256_hash("host");
579 legacy.machine.username_hash = sha256_hash("user");
580 }
581 assert!(
582 legacy.ensure_valid().is_ok(),
583 "legacy artifact must remain valid during compat: {:?}",
584 legacy.validation_errors()
585 );
586 }
587
588 #[test]
589 fn compat_legacy_algol_version_field_name_deserializes() {
590 let blob = serde_json::json!({
592 "schema": LEGACY_RUN_SCHEMA,
593 "algol_version": "0.1.0",
594 "started_at": "2026-05-21T00:00:00Z",
595 "finished_at": "2026-05-21T00:00:01Z",
596 "repo_path": ".",
597 "repo_commit": null,
598 "command": ["true"],
599 "contract": {
600 "path": "algol.contract.json",
601 "hash": format!("sha256:{}", "a".repeat(64)),
602 },
603 "environment": {
604 "parent_env_count": 1,
605 "child_env_count": 1,
606 "removed_env_count": 0,
607 "safe_baseline_injected": ["PATH"],
608 "secrets_injected": [],
609 "sensitive_env_denied": [],
610 },
611 "process": {
612 "pid": 1,
613 "exit_code": 0,
614 "duration_ms": 1u64,
615 "cwd": ".",
616 },
617 "machine": {
618 "hostname_hash": format!("sha256:{}", "b".repeat(64)),
619 "username_hash": format!("sha256:{}", "c".repeat(64)),
620 "os": "linux",
621 "arch": "x86_64",
622 },
623 "result": {
624 "contract_enforced": true,
625 "violations": [],
626 "risk_delta": {"before_score": 10, "after_score": 0},
627 },
628 });
629 let parsed: RunEvidence = serde_json::from_value(blob).expect("legacy json parses");
630 assert_eq!(parsed.tsafe_attest_version, "0.1.0");
631 assert!(parsed.ensure_valid().is_ok());
632 }
633}