use super::bundle::{
create_bundle, fingerprint, public_key_hex, sha256_hex, slice_for_window, EvidenceInput, Files,
Window,
};
use crate::receiver_trust::ingest::read_log;
use crate::receiver_trust::scenario::{self, ReceiverTrustScenario};
use crate::telemetry::time::parse_rfc3339_utc;
use serde::Serialize;
use serde_json::{json, Value};
pub struct PackRequest<'a> {
pub scenario: &'a ReceiverTrustScenario,
pub log_bytes: &'a [u8],
pub nav_bytes: Option<&'a [u8]>,
pub log_file_name: &'a str,
pub from: &'a str,
pub to: &'a str,
pub title: &'a str,
pub created_utc: Option<&'a str>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackSummary {
#[serde(skip)]
pub files: Files,
pub epochs_in_window: usize,
pub window: Window,
pub slice: Option<(usize, usize)>,
pub manifest_sha256: String,
pub signer_fingerprint: String,
}
fn parse_time(arg: &str, start_label: Option<&str>) -> Result<f64, String> {
if let Ok(x) = arg.parse::<f64>() {
return if x.is_finite() {
Ok(x)
} else {
Err(format!("`{arg}` is not finite"))
};
}
let t = parse_rfc3339_utc(arg)
.ok_or_else(|| format!("`{arg}` is neither seconds nor an ISO-8601 UTC time"))?;
let start = start_label.and_then(parse_rfc3339_utc).ok_or(
"the log does not state a parseable start time; give seconds since the first epoch",
)?;
Ok(t - start)
}
fn sanitise_source(v: &mut Value) {
if let Some(o) = v.as_object_mut() {
if let Some(p) = o.get("path").and_then(Value::as_str).map(str::to_string) {
o.insert(
"path".into(),
json!(p.rsplit(['/', '\\']).next().unwrap_or_default()),
);
}
for k in ["text", "base64"] {
if let Some(s) = o.get(k).and_then(Value::as_str).map(str::len) {
o.insert(k.into(), json!(format!("<inline, {s} bytes>")));
}
}
}
}
pub fn build_receiver_trust_pack(
req: &PackRequest<'_>,
seed: &[u8; 32],
timestamp_token: Option<&[u8]>,
) -> Result<PackSummary, String> {
let scn = req.scenario;
let result = scenario::run_receiver_trust_bytes(scn, req.log_bytes, req.nav_bytes)?;
let t0 = parse_time(req.from, result.log.start_label.as_deref())?;
let t1 = parse_time(req.to, result.log.start_label.as_deref())?;
let epochs: Vec<Value> = result
.epochs
.iter()
.filter(|e| e.t_s >= t0 && e.t_s <= t1)
.map(|e| serde_json::to_value(e).map_err(|e| e.to_string()))
.collect::<Result<_, _>>()?;
if epochs.is_empty() {
return Err(format!(
"no epoch of the log falls between {t0} s and {t1} s (the log spans 0 to {} s)",
result.log.duration_s
));
}
let mut scn_v = serde_json::to_value(scn).map_err(|e| e.to_string())?;
if let Some(log) = scn_v.get_mut("log") {
sanitise_source(log);
if let Some(nav) = log.get_mut("nav") {
sanitise_source(nav);
}
}
let config = json!({
"scenario": scn_v,
"scenario_hash": result.scenario_hash,
"monitors_run": result.monitors_run,
"baseline": result.baseline,
"honesty_label": scenario::LABEL,
"advisory": result.advisory,
});
let slice = read_log(scn.log.format, req.log_bytes)
.ok()
.and_then(|tl| slice_for_window(tl.epochs.iter().map(|e| (e.t_s, e.source_span)), t0, t1));
let log_format = serde_json::to_value(result.log.format)
.map_err(|e| e.to_string())?
.as_str()
.unwrap_or("unknown")
.to_string();
let window = Window {
from_s: t0,
to_s: t1,
};
let input = EvidenceInput {
title: req.title,
engine_version: env!("CARGO_PKG_VERSION"),
log_format: &log_format,
log_file_name: req.log_file_name,
log_bytes: req.log_bytes,
start_label: result.log.start_label.as_deref(),
slice,
window,
config,
epochs,
created_utc: req.created_utc,
};
let files = create_bundle(&input, seed, timestamp_token).map_err(|e| e.to_string())?;
let manifest_sha256 = files
.get("manifest.json")
.map(|b| sha256_hex(b))
.unwrap_or_default();
Ok(PackSummary {
epochs_in_window: input.epochs.len(),
window,
slice,
manifest_sha256,
signer_fingerprint: fingerprint(&hex::decode(public_key_hex(seed)).unwrap_or_default()),
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn time_arguments() {
assert_eq!(parse_time("120", None), Ok(120.0));
assert_eq!(
parse_time("2026-01-01T00:02:00Z", Some("2026-01-01T00:00:00Z")),
Ok(120.0)
);
assert!(parse_time("2026-01-01T00:02:00Z", None).is_err());
assert!(parse_time("soon", None).is_err());
}
}