use crate::cli::commands::profile_types::{Profile, ProfileEntry};
use anyhow::Result;
use assay_evidence::types::{EvidenceEvent, PayloadSandboxDegraded, SandboxDegradationComponent};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum DetailLevel {
Summary,
#[default]
Observed,
Full,
}
pub struct EvidenceMapper {
run_id: String,
producer_version: String,
seq: u64,
}
impl EvidenceMapper {
pub fn new(run_id_input: Option<String>, profile_name: &str) -> Self {
let run_id = if let Some(id) = run_id_input {
id
} else {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(profile_name.as_bytes());
let hash = hex::encode(hasher.finalize());
assert!(hash.len() >= 16, "SHA256 hex hash too short");
format!("run_{}", &hash[..16])
};
Self {
run_id,
producer_version: env!("CARGO_PKG_VERSION").to_string(),
seq: 0,
}
}
pub fn run_id(&self) -> &str {
&self.run_id
}
pub fn map_profile(
&mut self,
profile: &Profile,
detail: DetailLevel,
) -> Result<Vec<EvidenceEvent>> {
let mut events = Vec::new();
let export_time = DateTime::parse_from_rfc3339(&profile.updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
events.push(self.create_event(
"assay.profile.started",
"urn:assay:phase:profile",
serde_json::json!({
"profile_name": profile.name,
"profile_version": profile.version,
"total_runs_aggregated": profile.total_runs,
}),
export_time,
));
self.map_sandbox_degradations(&mut events, &profile.sandbox_degradations, export_time)?;
if detail != DetailLevel::Summary {
self.map_entries(
&mut events,
&profile.entries.files,
"assay.fs.access",
"file",
detail,
export_time,
);
self.map_entries(
&mut events,
&profile.entries.network,
"assay.net.connect",
"host",
detail,
export_time,
);
self.map_entries(
&mut events,
&profile.entries.processes,
"assay.process.exec",
"cmd",
detail,
export_time,
);
}
events.push(self.create_event(
"assay.profile.finished",
"urn:assay:phase:profile",
serde_json::json!({
"files_count": profile.entries.files.len(),
"network_count": profile.entries.network.len(),
"processes_count": profile.entries.processes.len(),
"sandbox_degradation_count": profile.sandbox_degradations.len(),
"integrity_scope": profile.scope,
}),
export_time,
));
Ok(events)
}
fn map_sandbox_degradations(
&mut self,
events: &mut Vec<EvidenceEvent>,
degradations: &[PayloadSandboxDegraded],
export_time: DateTime<Utc>,
) -> Result<()> {
for degradation in degradations {
let subject = match degradation.component {
SandboxDegradationComponent::Landlock => "landlock",
};
let payload = serde_json::to_value(degradation)?;
events.push(self.create_event("assay.sandbox.degraded", subject, payload, export_time));
}
Ok(())
}
fn map_entries(
&mut self,
events: &mut Vec<EvidenceEvent>,
entries: &std::collections::BTreeMap<String, ProfileEntry>,
event_type: &str,
subject_key: &str,
detail: DetailLevel,
export_time: DateTime<Utc>,
) {
for (key, entry) in entries {
let subject: String = if detail == DetailLevel::Full {
key.clone()
} else {
self.scrub_subject(key)
};
let payload = if detail == DetailLevel::Full {
serde_json::json!({
subject_key: subject,
"first_seen": entry.first_seen,
"last_seen": entry.last_seen,
"hits": entry.hits_total,
"runs_seen": entry.runs_seen,
})
} else {
serde_json::json!({
"hits": entry.hits_total,
})
};
let ev = self.create_event(event_type, &subject, payload, export_time);
events.push(ev);
}
}
fn scrub_subject(&self, input: &str) -> String {
let mut scrubbed = input.to_string();
let normalized = scrubbed.replace('\\', "/");
if normalized.starts_with("/Users/")
|| normalized.starts_with("/home/")
|| normalized.contains("/Users/")
{
let parts: Vec<&str> = normalized.split('/').collect();
for (i, part) in parts.iter().enumerate() {
if (*part == "Users" || *part == "home") && i + 1 < parts.len() {
scrubbed = format!("~/**/{}", parts[i + 2..].join("/"));
break;
}
}
}
let sensitive = [
"authorization: bearer ",
"authorization: ",
"token=",
"key=",
"bearer ",
"api_key=",
"apikey=",
"secret=",
"password=",
"session=",
"cookie=",
"x-api-key:",
"x-token:",
];
let lower_scrubbed = scrubbed.to_lowercase();
for pattern in sensitive {
if let Some(idx) = lower_scrubbed.find(pattern) {
scrubbed.truncate(idx + pattern.len());
scrubbed.push_str("***");
break;
}
}
scrubbed
}
fn create_event(
&mut self,
type_: &str,
subject: &str,
data: serde_json::Value,
time: DateTime<Utc>,
) -> EvidenceEvent {
let id = format!("{}:{}", self.run_id, self.seq);
let source = format!("urn:assay:cli:{}", self.producer_version);
use sha2::Digest;
let mut t_hasher = sha2::Sha256::new();
t_hasher.update(self.run_id.as_bytes());
let t_hash = hex::encode(t_hasher.finalize());
assert!(t_hash.len() >= 32, "SHA256 hex hash too short");
let trace_id = &t_hash[..32];
let mut s_hasher = sha2::Sha256::new();
s_hasher.update(id.as_bytes());
let s_hash = hex::encode(s_hasher.finalize());
assert!(s_hash.len() >= 16, "SHA256 hex hash too short");
let span_id = &s_hash[..16];
let mut ev = EvidenceEvent::new(type_, &source, &self.run_id, self.seq, data);
ev.id = id;
ev.time = time;
if !subject.is_empty() {
ev.subject = Some(subject.to_string());
}
ev.trace_parent = Some(format!("00-{}-{}-01", trace_id, span_id));
self.seq += 1;
ev
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
#[test]
fn test_scrub_subject_paths() {
let mapper = EvidenceMapper::new(None, "test");
assert_eq!(
mapper.scrub_subject("/Users/alice/repo/file.txt"),
"~/**/repo/file.txt"
);
assert_eq!(
mapper.scrub_subject("/home/bob/scripts/run.sh"),
"~/**/scripts/run.sh"
);
assert_eq!(
mapper.scrub_subject("C:\\Users\\charlie\\docs\\sec.txt"),
"~/**/docs/sec.txt"
);
assert_eq!(mapper.scrub_subject("/etc/passwd"), "/etc/passwd");
}
#[test]
fn test_scrub_subject_secrets() {
let mapper = EvidenceMapper::new(None, "test");
assert_eq!(
mapper.scrub_subject("curl -H 'Authorization: Bearer MOCKED_TOKEN'"),
"curl -H 'Authorization: Bearer ***"
);
assert_eq!(
mapper.scrub_subject("curl -H 'authorization: bearer MOCKED_TOKEN'"),
"curl -H 'authorization: bearer ***"
);
assert_eq!(
mapper.scrub_subject("mysql --password=secret123"),
"mysql --password=***"
);
assert_eq!(
mapper.scrub_subject("https://api.com?api_key=12345&other=val"),
"https://api.com?api_key=***"
);
assert_eq!(mapper.scrub_subject("X-API-Key: 12345"), "X-API-Key:***");
}
#[test]
fn test_determinism_stable_time() {
let mut mapper = EvidenceMapper::new(None, "test");
let profile = Profile {
version: "1.0".into(),
name: "test".into(),
created_at: "2026-01-26T22:00:00Z".into(),
updated_at: "2026-01-26T23:00:00Z".into(),
total_runs: 1,
run_ids: VecDeque::from(vec!["run1".into()]),
run_id_digests: VecDeque::new(),
scope: None,
entries: Default::default(),
sandbox_degradations: Vec::new(),
};
let events = mapper.map_profile(&profile, DetailLevel::Summary).unwrap();
for ev in events {
assert_eq!(ev.time.to_rfc3339(), "2026-01-26T23:00:00+00:00");
}
}
#[test]
fn sandbox_degradation_observation_maps_to_evidence_event() {
let mut mapper = EvidenceMapper::new(Some("run_degraded".into()), "degraded");
let profile = Profile {
version: "1.0".into(),
name: "degraded".into(),
created_at: "2026-01-26T23:00:00Z".into(),
updated_at: "2026-01-26T23:00:00Z".into(),
total_runs: 1,
run_ids: VecDeque::from(vec!["run_degraded".into()]),
run_id_digests: VecDeque::new(),
scope: None,
entries: Default::default(),
sandbox_degradations: vec![PayloadSandboxDegraded {
reason_code: assay_evidence::types::SandboxDegradationReasonCode::PolicyConflict,
degradation_mode: assay_evidence::types::SandboxDegradationMode::AuditFallback,
component: SandboxDegradationComponent::Landlock,
detail: None,
}],
};
let events = mapper.map_profile(&profile, DetailLevel::Summary).unwrap();
let degraded = events
.iter()
.find(|event| event.type_ == "assay.sandbox.degraded")
.expect("expected sandbox degradation event");
assert_eq!(degraded.subject.as_deref(), Some("landlock"));
assert_eq!(degraded.payload["reason_code"], "policy_conflict");
assert_eq!(degraded.payload["degradation_mode"], "audit_fallback");
assert_eq!(degraded.payload["component"], "landlock");
assert!(degraded.payload.get("detail").is_none());
}
}