1use chrono::{DateTime, Utc};
59use serde::{Deserialize, Serialize};
60
61use crate::sign::SignaturePayload;
62
63pub const RUN_SCHEMA: &str = "tsafe.run.v1";
69
70pub const LEGACY_RUN_SCHEMA: &str = "algol.run.v1";
76
77pub const RUN_EVIDENCE_VERSION: &str = env!("CARGO_PKG_VERSION");
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct ContractRef {
92 pub path: String,
93 pub hash: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct InjectedSecretEvidence {
99 pub name: String,
100 pub source: String,
101 pub hash: String,
102 pub redacted_value: String,
103 pub required: bool,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub struct DeniedSensitiveEnvEvidence {
109 pub name: String,
110 pub hash: String,
111 pub reason: String,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct EnvironmentEvidence {
123 pub parent_env_count: usize,
124 pub child_env_count: usize,
125 pub removed_env_count: usize,
126 pub safe_baseline_injected: Vec<String>,
127 pub secrets_injected: Vec<InjectedSecretEvidence>,
128 pub sensitive_env_denied: Vec<DeniedSensitiveEnvEvidence>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct ProcessEvidence {
134 pub pid: u32,
135 pub exit_code: i32,
136 pub duration_ms: u128,
137 pub cwd: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147pub struct MachineEvidence {
148 pub hostname_hash: String,
149 pub username_hash: String,
150 pub os: String,
151 pub arch: String,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156pub struct RiskDelta {
157 pub before_score: u32,
158 pub after_score: u32,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct EnforcementResult {
164 pub contract_enforced: bool,
165 pub violations: Vec<String>,
166 pub risk_delta: RiskDelta,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct RunEvidence {
186 pub schema: String,
187 #[serde(alias = "algol_version", rename = "tsafe_attest_version")]
193 pub tsafe_attest_version: String,
194 pub started_at: DateTime<Utc>,
195 pub finished_at: DateTime<Utc>,
196 pub repo_path: String,
197 pub repo_commit: Option<String>,
198 pub command: Vec<String>,
199 pub contract: ContractRef,
200 pub environment: EnvironmentEvidence,
201 pub process: ProcessEvidence,
202 pub machine: MachineEvidence,
203 pub result: EnforcementResult,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub signature: Option<SignaturePayload>,
213}
214
215impl RunEvidence {
216 pub fn validation_errors(&self) -> Vec<String> {
226 let mut errors = Vec::new();
227
228 if !is_supported_run_schema(&self.schema) {
229 errors.push(format!("unsupported schema {}", self.schema));
230 }
231 if self.tsafe_attest_version.trim().is_empty() {
232 errors.push("tsafe_attest_version must not be empty".to_string());
233 }
234 if self.repo_path.trim().is_empty() {
235 errors.push("repo_path must not be empty".to_string());
236 }
237 if self.command.is_empty() || self.command.iter().any(|part| part.trim().is_empty()) {
238 errors.push("command must contain non-empty argv entries".to_string());
239 }
240 if self.contract.path.trim().is_empty() {
241 errors.push("contract.path must not be empty".to_string());
242 }
243 if !is_supported_hash(&self.contract.hash) {
244 errors.push(
245 "contract.hash must be a blake3 hash (or sha256 during compat window)".to_string(),
246 );
247 }
248 for item in &self.environment.secrets_injected {
249 if !is_supported_hash(&item.hash) {
250 errors.push(format!(
251 "secrets_injected {} hash must be a blake3 hash (or sha256 during compat window)",
252 item.name
253 ));
254 }
255 }
256 for item in &self.environment.sensitive_env_denied {
257 if !is_supported_hash(&item.hash) {
258 errors.push(format!(
259 "sensitive_env_denied {} hash must be a blake3 hash (or sha256 during compat window)",
260 item.name
261 ));
262 }
263 }
264 if !is_supported_hash(&self.machine.hostname_hash) {
265 errors.push(
266 "machine.hostname_hash must be a blake3 hash (or sha256 during compat window)"
267 .to_string(),
268 );
269 }
270 if !is_supported_hash(&self.machine.username_hash) {
271 errors.push(
272 "machine.username_hash must be a blake3 hash (or sha256 during compat window)"
273 .to_string(),
274 );
275 }
276 if self.environment.child_env_count > self.environment.parent_env_count {
277 errors.push("child_env_count must not exceed parent_env_count".to_string());
278 }
279 let expected_removed = self
280 .environment
281 .parent_env_count
282 .saturating_sub(self.environment.child_env_count);
283 if self.environment.removed_env_count != expected_removed {
284 errors.push(format!(
285 "removed_env_count must equal parent_env_count - child_env_count ({expected_removed})"
286 ));
287 }
288
289 errors
290 }
291
292 pub fn ensure_valid(&self) -> Result<(), String> {
294 let errors = self.validation_errors();
295 if errors.is_empty() {
296 Ok(())
297 } else {
298 Err(errors.join("; "))
299 }
300 }
301}
302
303pub fn is_supported_run_schema(schema: &str) -> bool {
309 schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
310}
311
312pub fn is_blake3_hash(value: &str) -> bool {
315 let Some(hex) = value.strip_prefix("blake3:") else {
316 return false;
317 };
318 hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
319}
320
321pub fn is_sha256_hash(value: &str) -> bool {
324 let Some(hex) = value.strip_prefix("sha256:") else {
325 return false;
326 };
327 hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
328}
329
330pub fn is_supported_hash(value: &str) -> bool {
336 is_blake3_hash(value) || is_sha256_hash(value)
337}
338
339pub fn blake3_hash(value: impl AsRef<[u8]>) -> String {
343 let digest = blake3::hash(value.as_ref());
344 format!("blake3:{}", digest.to_hex())
345}
346
347#[deprecated(
354 since = "1.2.0",
355 note = "Use `blake3_hash` per ec ADR-0003. SHA-256 retained only for \
356 compat-window reads of legacy `algol.run.v1` artifacts."
357)]
358pub fn sha256_hash(value: impl AsRef<[u8]>) -> String {
359 use sha2::{Digest, Sha256};
360 let digest = Sha256::digest(value.as_ref());
361 format!("sha256:{}", hex_encode(&digest))
362}
363
364fn hex_encode(bytes: &[u8]) -> String {
367 const HEX: &[u8; 16] = b"0123456789abcdef";
368 let mut out = String::with_capacity(bytes.len() * 2);
369 for byte in bytes {
370 out.push(HEX[(byte >> 4) as usize] as char);
371 out.push(HEX[(byte & 0x0f) as usize] as char);
372 }
373 out
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn valid_run() -> RunEvidence {
381 RunEvidence {
382 schema: RUN_SCHEMA.to_string(),
383 tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
384 started_at: Utc::now(),
385 finished_at: Utc::now(),
386 repo_path: ".".to_string(),
387 repo_commit: None,
388 command: vec!["true".to_string()],
389 contract: ContractRef {
390 path: "tsafe.contract.json".to_string(),
391 hash: blake3_hash("contract"),
392 },
393 environment: EnvironmentEvidence {
394 parent_env_count: 3,
395 child_env_count: 1,
396 removed_env_count: 2,
397 safe_baseline_injected: vec!["PATH".to_string()],
398 secrets_injected: vec![InjectedSecretEvidence {
399 name: "DATABASE_URL".to_string(),
400 source: "literal://demo/DATABASE_URL".to_string(),
401 hash: blake3_hash("database"),
402 redacted_value: "po***se".to_string(),
403 required: true,
404 }],
405 sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
406 name: "AWS_SECRET_ACCESS_KEY".to_string(),
407 hash: blake3_hash("secret"),
408 reason: "test".to_string(),
409 }],
410 },
411 process: ProcessEvidence {
412 pid: 1,
413 exit_code: 0,
414 duration_ms: 1,
415 cwd: ".".to_string(),
416 },
417 machine: MachineEvidence {
418 hostname_hash: blake3_hash("host"),
419 username_hash: blake3_hash("user"),
420 os: "linux".to_string(),
421 arch: "x86_64".to_string(),
422 },
423 result: EnforcementResult {
424 contract_enforced: true,
425 violations: Vec::new(),
426 risk_delta: RiskDelta {
427 before_score: 10,
428 after_score: 0,
429 },
430 },
431 signature: None,
432 }
433 }
434
435 #[test]
436 fn run_rejects_empty_and_blank_command_entries() {
437 let mut empty = valid_run();
438 empty.command.clear();
439 assert!(empty
440 .validation_errors()
441 .iter()
442 .any(|error| error.contains("command must contain")));
443
444 let mut blank = valid_run();
445 blank.command = vec!["true".to_string(), "".to_string()];
446 assert!(blank
447 .validation_errors()
448 .iter()
449 .any(|error| error.contains("command must contain")));
450 }
451
452 #[test]
453 fn run_rejects_env_count_edges() {
454 let mut child_exceeds_parent = valid_run();
455 child_exceeds_parent.environment.parent_env_count = 2;
456 child_exceeds_parent.environment.child_env_count = 3;
457 child_exceeds_parent.environment.removed_env_count = 0;
458 let errors = child_exceeds_parent.validation_errors().join("; ");
459 assert!(errors.contains("child_env_count must not exceed parent_env_count"));
460
461 let mut equal_counts = valid_run();
462 equal_counts.environment.parent_env_count = 2;
463 equal_counts.environment.child_env_count = 2;
464 equal_counts.environment.removed_env_count = 1;
465 let errors = equal_counts.validation_errors().join("; ");
466 assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
467 assert!(errors.contains("removed_env_count must equal"));
468
469 let mut removed_mismatch = valid_run();
470 removed_mismatch.environment.parent_env_count = 5;
471 removed_mismatch.environment.child_env_count = 3;
472 removed_mismatch.environment.removed_env_count = 1;
473 let errors = removed_mismatch.validation_errors().join("; ");
474 assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
475 assert!(errors.contains("removed_env_count must equal"));
476 }
477
478 #[test]
479 fn run_rejects_short_or_non_hex_hashes() {
480 let mut short_hash = valid_run();
481 short_hash.contract.hash = "blake3:abc123".to_string();
482 assert!(short_hash
483 .validation_errors()
484 .iter()
485 .any(|error| error.contains("contract.hash must be a blake3 hash")));
486
487 let mut non_hex_hash = valid_run();
488 non_hex_hash.machine.hostname_hash = format!("blake3:{}", "g".repeat(64));
489 assert!(non_hex_hash
490 .validation_errors()
491 .iter()
492 .any(|error| error.contains("machine.hostname_hash must be a blake3 hash")));
493 }
494
495 #[test]
496 fn run_accepts_a_well_formed_artifact() {
497 let run = valid_run();
498 assert!(
499 run.ensure_valid().is_ok(),
500 "valid_run() should pass validation: {:?}",
501 run.validation_errors()
502 );
503 }
504
505 #[test]
506 fn blake3_hash_produces_prefixed_64_hex_string() {
507 let hash = blake3_hash("any payload");
508 assert!(is_blake3_hash(&hash), "rejected own output: {hash}");
509 assert_eq!(hash.len(), "blake3:".len() + 64);
510 assert!(hash.starts_with("blake3:"));
511 }
512
513 #[test]
514 fn blake3_hash_is_deterministic_and_distinct() {
515 assert_eq!(blake3_hash("same input"), blake3_hash("same input"));
516 assert_ne!(blake3_hash("input one"), blake3_hash("input two"));
517 }
518
519 #[test]
520 fn is_blake3_hash_rejects_wrong_prefix_and_length() {
521 assert!(!is_blake3_hash(
522 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
523 ));
524 assert!(!is_blake3_hash("blake3:short"));
525 assert!(!is_blake3_hash("blake3:"));
526 assert!(!is_blake3_hash(""));
527 assert!(!is_blake3_hash(&format!("blake3:{}", "z".repeat(64))));
528 }
529
530 #[test]
531 fn compat_legacy_schema_and_sha256_hashes_accepted() {
532 let mut legacy = valid_run();
533 legacy.schema = LEGACY_RUN_SCHEMA.to_string();
534 #[allow(deprecated)]
536 let legacy_hash = sha256_hash("contract");
537 legacy.contract.hash = legacy_hash;
538 #[allow(deprecated)]
540 {
541 for item in &mut legacy.environment.secrets_injected {
542 item.hash = sha256_hash(&item.name);
543 }
544 for item in &mut legacy.environment.sensitive_env_denied {
545 item.hash = sha256_hash(&item.name);
546 }
547 legacy.machine.hostname_hash = sha256_hash("host");
548 legacy.machine.username_hash = sha256_hash("user");
549 }
550 assert!(
551 legacy.ensure_valid().is_ok(),
552 "legacy artifact must remain valid during compat: {:?}",
553 legacy.validation_errors()
554 );
555 }
556
557 #[test]
558 fn compat_legacy_algol_version_field_name_deserializes() {
559 let blob = serde_json::json!({
561 "schema": LEGACY_RUN_SCHEMA,
562 "algol_version": "0.1.0",
563 "started_at": "2026-05-21T00:00:00Z",
564 "finished_at": "2026-05-21T00:00:01Z",
565 "repo_path": ".",
566 "repo_commit": null,
567 "command": ["true"],
568 "contract": {
569 "path": "algol.contract.json",
570 "hash": format!("sha256:{}", "a".repeat(64)),
571 },
572 "environment": {
573 "parent_env_count": 1,
574 "child_env_count": 1,
575 "removed_env_count": 0,
576 "safe_baseline_injected": ["PATH"],
577 "secrets_injected": [],
578 "sensitive_env_denied": [],
579 },
580 "process": {
581 "pid": 1,
582 "exit_code": 0,
583 "duration_ms": 1u64,
584 "cwd": ".",
585 },
586 "machine": {
587 "hostname_hash": format!("sha256:{}", "b".repeat(64)),
588 "username_hash": format!("sha256:{}", "c".repeat(64)),
589 "os": "linux",
590 "arch": "x86_64",
591 },
592 "result": {
593 "contract_enforced": true,
594 "violations": [],
595 "risk_delta": {"before_score": 10, "after_score": 0},
596 },
597 });
598 let parsed: RunEvidence = serde_json::from_value(blob).expect("legacy json parses");
599 assert_eq!(parsed.tsafe_attest_version, "0.1.0");
600 assert!(parsed.ensure_valid().is_ok());
601 }
602}