use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::sign::SignaturePayload;
pub const RUN_SCHEMA: &str = "tsafe.run.v1";
pub const LEGACY_RUN_SCHEMA: &str = "algol.run.v1";
pub const RUN_EVIDENCE_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContractRef {
pub path: String,
pub hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InjectedSecretEvidence {
pub name: String,
pub source: String,
pub hash: String,
pub redacted_value: String,
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeniedSensitiveEnvEvidence {
pub name: String,
pub hash: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnvironmentEvidence {
pub parent_env_count: usize,
pub child_env_count: usize,
pub removed_env_count: usize,
pub safe_baseline_injected: Vec<String>,
pub secrets_injected: Vec<InjectedSecretEvidence>,
pub sensitive_env_denied: Vec<DeniedSensitiveEnvEvidence>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProcessEvidence {
pub pid: u32,
pub exit_code: i32,
pub duration_ms: u128,
pub cwd: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MachineEvidence {
pub hostname_hash: String,
pub username_hash: String,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RiskDelta {
pub before_score: u32,
pub after_score: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnforcementResult {
pub contract_enforced: bool,
pub violations: Vec<String>,
pub risk_delta: RiskDelta,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunEvidence {
pub schema: String,
#[serde(alias = "algol_version", rename = "tsafe_attest_version")]
pub tsafe_attest_version: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub repo_path: String,
pub repo_commit: Option<String>,
pub command: Vec<String>,
pub contract: ContractRef,
pub environment: EnvironmentEvidence,
pub process: ProcessEvidence,
pub machine: MachineEvidence,
pub result: EnforcementResult,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<SignaturePayload>,
}
impl RunEvidence {
pub fn validation_errors(&self) -> Vec<String> {
let mut errors = Vec::new();
if !is_supported_run_schema(&self.schema) {
errors.push(format!("unsupported schema {}", self.schema));
}
if self.tsafe_attest_version.trim().is_empty() {
errors.push("tsafe_attest_version must not be empty".to_string());
}
if self.repo_path.trim().is_empty() {
errors.push("repo_path must not be empty".to_string());
}
if self.command.is_empty() || self.command.iter().any(|part| part.trim().is_empty()) {
errors.push("command must contain non-empty argv entries".to_string());
}
if self.contract.path.trim().is_empty() {
errors.push("contract.path must not be empty".to_string());
}
if !is_supported_hash(&self.contract.hash) {
errors.push(
"contract.hash must be a blake3 hash (or sha256 during compat window)".to_string(),
);
}
for item in &self.environment.secrets_injected {
if !is_supported_hash(&item.hash) {
errors.push(format!(
"secrets_injected {} hash must be a blake3 hash (or sha256 during compat window)",
item.name
));
}
}
for item in &self.environment.sensitive_env_denied {
if !is_supported_hash(&item.hash) {
errors.push(format!(
"sensitive_env_denied {} hash must be a blake3 hash (or sha256 during compat window)",
item.name
));
}
}
if !is_supported_hash(&self.machine.hostname_hash) {
errors.push(
"machine.hostname_hash must be a blake3 hash (or sha256 during compat window)"
.to_string(),
);
}
if !is_supported_hash(&self.machine.username_hash) {
errors.push(
"machine.username_hash must be a blake3 hash (or sha256 during compat window)"
.to_string(),
);
}
if self.environment.child_env_count > self.environment.parent_env_count {
errors.push("child_env_count must not exceed parent_env_count".to_string());
}
let expected_removed = self
.environment
.parent_env_count
.saturating_sub(self.environment.child_env_count);
if self.environment.removed_env_count != expected_removed {
errors.push(format!(
"removed_env_count must equal parent_env_count - child_env_count ({expected_removed})"
));
}
errors
}
pub fn ensure_valid(&self) -> Result<(), String> {
let errors = self.validation_errors();
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
}
pub fn is_supported_run_schema(schema: &str) -> bool {
schema == RUN_SCHEMA || schema == LEGACY_RUN_SCHEMA
}
pub fn is_blake3_hash(value: &str) -> bool {
let Some(hex) = value.strip_prefix("blake3:") else {
return false;
};
hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
}
pub fn is_sha256_hash(value: &str) -> bool {
let Some(hex) = value.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64 && hex.chars().all(|char| char.is_ascii_hexdigit())
}
pub fn is_supported_hash(value: &str) -> bool {
is_blake3_hash(value) || is_sha256_hash(value)
}
pub fn blake3_hash(value: impl AsRef<[u8]>) -> String {
let digest = blake3::hash(value.as_ref());
format!("blake3:{}", digest.to_hex())
}
#[deprecated(
since = "1.2.0",
note = "Use `blake3_hash` per ec ADR-0003. SHA-256 retained only for \
compat-window reads of legacy `algol.run.v1` artifacts."
)]
pub fn sha256_hash(value: impl AsRef<[u8]>) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(value.as_ref());
format!("sha256:{}", hex_encode(&digest))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_run() -> RunEvidence {
RunEvidence {
schema: RUN_SCHEMA.to_string(),
tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
repo_path: ".".to_string(),
repo_commit: None,
command: vec!["true".to_string()],
contract: ContractRef {
path: "tsafe.contract.json".to_string(),
hash: blake3_hash("contract"),
},
environment: EnvironmentEvidence {
parent_env_count: 3,
child_env_count: 1,
removed_env_count: 2,
safe_baseline_injected: vec!["PATH".to_string()],
secrets_injected: vec![InjectedSecretEvidence {
name: "DATABASE_URL".to_string(),
source: "literal://demo/DATABASE_URL".to_string(),
hash: blake3_hash("database"),
redacted_value: "po***se".to_string(),
required: true,
}],
sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
name: "AWS_SECRET_ACCESS_KEY".to_string(),
hash: blake3_hash("secret"),
reason: "test".to_string(),
}],
},
process: ProcessEvidence {
pid: 1,
exit_code: 0,
duration_ms: 1,
cwd: ".".to_string(),
},
machine: MachineEvidence {
hostname_hash: blake3_hash("host"),
username_hash: blake3_hash("user"),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
result: EnforcementResult {
contract_enforced: true,
violations: Vec::new(),
risk_delta: RiskDelta {
before_score: 10,
after_score: 0,
},
},
signature: None,
}
}
#[test]
fn run_rejects_empty_and_blank_command_entries() {
let mut empty = valid_run();
empty.command.clear();
assert!(empty
.validation_errors()
.iter()
.any(|error| error.contains("command must contain")));
let mut blank = valid_run();
blank.command = vec!["true".to_string(), "".to_string()];
assert!(blank
.validation_errors()
.iter()
.any(|error| error.contains("command must contain")));
}
#[test]
fn run_rejects_env_count_edges() {
let mut child_exceeds_parent = valid_run();
child_exceeds_parent.environment.parent_env_count = 2;
child_exceeds_parent.environment.child_env_count = 3;
child_exceeds_parent.environment.removed_env_count = 0;
let errors = child_exceeds_parent.validation_errors().join("; ");
assert!(errors.contains("child_env_count must not exceed parent_env_count"));
let mut equal_counts = valid_run();
equal_counts.environment.parent_env_count = 2;
equal_counts.environment.child_env_count = 2;
equal_counts.environment.removed_env_count = 1;
let errors = equal_counts.validation_errors().join("; ");
assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
assert!(errors.contains("removed_env_count must equal"));
let mut removed_mismatch = valid_run();
removed_mismatch.environment.parent_env_count = 5;
removed_mismatch.environment.child_env_count = 3;
removed_mismatch.environment.removed_env_count = 1;
let errors = removed_mismatch.validation_errors().join("; ");
assert!(!errors.contains("child_env_count must not exceed parent_env_count"));
assert!(errors.contains("removed_env_count must equal"));
}
#[test]
fn run_rejects_short_or_non_hex_hashes() {
let mut short_hash = valid_run();
short_hash.contract.hash = "blake3:abc123".to_string();
assert!(short_hash
.validation_errors()
.iter()
.any(|error| error.contains("contract.hash must be a blake3 hash")));
let mut non_hex_hash = valid_run();
non_hex_hash.machine.hostname_hash = format!("blake3:{}", "g".repeat(64));
assert!(non_hex_hash
.validation_errors()
.iter()
.any(|error| error.contains("machine.hostname_hash must be a blake3 hash")));
}
#[test]
fn run_accepts_a_well_formed_artifact() {
let run = valid_run();
assert!(
run.ensure_valid().is_ok(),
"valid_run() should pass validation: {:?}",
run.validation_errors()
);
}
#[test]
fn blake3_hash_produces_prefixed_64_hex_string() {
let hash = blake3_hash("any payload");
assert!(is_blake3_hash(&hash), "rejected own output: {hash}");
assert_eq!(hash.len(), "blake3:".len() + 64);
assert!(hash.starts_with("blake3:"));
}
#[test]
fn blake3_hash_is_deterministic_and_distinct() {
assert_eq!(blake3_hash("same input"), blake3_hash("same input"));
assert_ne!(blake3_hash("input one"), blake3_hash("input two"));
}
#[test]
fn is_blake3_hash_rejects_wrong_prefix_and_length() {
assert!(!is_blake3_hash(
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
));
assert!(!is_blake3_hash("blake3:short"));
assert!(!is_blake3_hash("blake3:"));
assert!(!is_blake3_hash(""));
assert!(!is_blake3_hash(&format!("blake3:{}", "z".repeat(64))));
}
#[test]
fn compat_legacy_schema_and_sha256_hashes_accepted() {
let mut legacy = valid_run();
legacy.schema = LEGACY_RUN_SCHEMA.to_string();
#[allow(deprecated)]
let legacy_hash = sha256_hash("contract");
legacy.contract.hash = legacy_hash;
#[allow(deprecated)]
{
for item in &mut legacy.environment.secrets_injected {
item.hash = sha256_hash(&item.name);
}
for item in &mut legacy.environment.sensitive_env_denied {
item.hash = sha256_hash(&item.name);
}
legacy.machine.hostname_hash = sha256_hash("host");
legacy.machine.username_hash = sha256_hash("user");
}
assert!(
legacy.ensure_valid().is_ok(),
"legacy artifact must remain valid during compat: {:?}",
legacy.validation_errors()
);
}
#[test]
fn compat_legacy_algol_version_field_name_deserializes() {
let blob = serde_json::json!({
"schema": LEGACY_RUN_SCHEMA,
"algol_version": "0.1.0",
"started_at": "2026-05-21T00:00:00Z",
"finished_at": "2026-05-21T00:00:01Z",
"repo_path": ".",
"repo_commit": null,
"command": ["true"],
"contract": {
"path": "algol.contract.json",
"hash": format!("sha256:{}", "a".repeat(64)),
},
"environment": {
"parent_env_count": 1,
"child_env_count": 1,
"removed_env_count": 0,
"safe_baseline_injected": ["PATH"],
"secrets_injected": [],
"sensitive_env_denied": [],
},
"process": {
"pid": 1,
"exit_code": 0,
"duration_ms": 1u64,
"cwd": ".",
},
"machine": {
"hostname_hash": format!("sha256:{}", "b".repeat(64)),
"username_hash": format!("sha256:{}", "c".repeat(64)),
"os": "linux",
"arch": "x86_64",
},
"result": {
"contract_enforced": true,
"violations": [],
"risk_delta": {"before_score": 10, "after_score": 0},
},
});
let parsed: RunEvidence = serde_json::from_value(blob).expect("legacy json parses");
assert_eq!(parsed.tsafe_attest_version, "0.1.0");
assert!(parsed.ensure_valid().is_ok());
}
}