use super::error::CapabilityError;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ToolEvidence {
pub canonical_path: Option<String>,
pub byte_count: Option<u64>,
pub sha256: Option<String>,
pub modified_at: Option<String>,
pub session_id: Option<String>,
pub request_id: String,
}
impl ToolEvidence {
pub fn without_file(session_id: Option<String>) -> Self {
Self {
canonical_path: None,
byte_count: None,
sha256: None,
modified_at: None,
session_id,
request_id: new_request_id(),
}
}
pub fn for_file(path: &Path, session_id: Option<String>) -> Result<Self, CapabilityError> {
let canonical = path.canonicalize().map_err(|error| CapabilityError::Io {
detail: format!("evidence: canonicalize {}: {error}", path.display()),
})?;
let bytes = fs::read(&canonical).map_err(|error| CapabilityError::Io {
detail: format!("evidence: read {}: {error}", canonical.display()),
})?;
let meta = fs::metadata(&canonical).map_err(|error| CapabilityError::Io {
detail: format!("evidence: metadata {}: {error}", canonical.display()),
})?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let sha256 = format!("{:x}", hasher.finalize());
let modified_at = meta.modified().ok().and_then(|t| {
t.duration_since(UNIX_EPOCH)
.ok()
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0))
.map(|dt| dt.to_rfc3339())
});
Ok(Self {
canonical_path: Some(canonical.to_string_lossy().into_owned()),
byte_count: Some(bytes.len() as u64),
sha256: Some(sha256),
modified_at,
session_id,
request_id: new_request_id(),
})
}
}
fn new_request_id() -> String {
uuid::Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("yana-evidence-{tag}-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn for_file_computes_real_hash_and_size() {
let dir = tmp_dir("hash");
let file = dir.join("a.txt");
fs::write(&file, "hello").unwrap();
let evidence = ToolEvidence::for_file(&file, Some("sess-1".into())).unwrap();
assert_eq!(evidence.byte_count, Some(5));
assert_eq!(
evidence.sha256.as_deref(),
Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
);
assert_eq!(evidence.session_id.as_deref(), Some("sess-1"));
assert!(evidence.canonical_path.unwrap().ends_with("a.txt"));
assert!(!evidence.request_id.is_empty());
fs::remove_dir_all(dir).ok();
}
#[test]
fn without_file_leaves_file_fields_none() {
let evidence = ToolEvidence::without_file(None);
assert!(evidence.canonical_path.is_none());
assert!(evidence.byte_count.is_none());
assert!(evidence.sha256.is_none());
assert!(evidence.modified_at.is_none());
assert!(!evidence.request_id.is_empty());
}
#[test]
fn for_file_errors_on_missing_file() {
let dir = tmp_dir("missing");
let missing = dir.join("nope.txt");
assert!(ToolEvidence::for_file(&missing, None).is_err());
fs::remove_dir_all(dir).ok();
}
#[test]
fn two_calls_get_distinct_request_ids() {
let a = ToolEvidence::without_file(None);
let b = ToolEvidence::without_file(None);
assert_ne!(a.request_id, b.request_id);
}
}