use super::*;
use std::collections::BTreeMap;
pub(super) const SCHEMA: &str = "rsleigh.evidence-bundle/v1";
const VERIFICATION_SCHEMA: &str = "rsleigh.bundle-verification/v1";
const IMPORT_SCHEMA: &str = "rsleigh.bundle-import/v1";
fn record_file(id: &str) -> Result<String, AgentError> {
Ok(format!("record-{}.json", evidence::parse_id(id)?))
}
fn summary(records: &BTreeMap<String, Value>) -> Value {
let count = |status: &str| records.values().filter(|r| r["status"] == status).count();
json!({"records":records.len(),"ok":count("ok"),"partial":count("partial"),"failed":count("failed"),
"complete":records.values().filter(|r| r["coverage"]["complete"] == true).count()})
}
fn entry(record: &Value) -> Result<Value, AgentError> {
let id = record["id"].as_str().unwrap_or_default();
Ok(
json!({"id":id,"kind":record["kind"],"status":record["status"],
"file":record_file(id)?,"references":record["references"]}),
)
}
pub(super) fn closure(
store: &Path,
roots: Vec<String>,
sha256: &str,
) -> Result<BTreeMap<String, Value>, AgentError> {
let mut records = BTreeMap::new();
let mut pending = roots;
while let Some(id) = pending.pop() {
if records.contains_key(&id) {
continue;
}
let record = evidence::load(store, &id)?;
if record["subject"]["binary_sha256"] != sha256 {
return Err(AgentError::new(
"invalid_combination",
format!("{id} describes a different binary; bundles hold one target"),
));
}
for reference in record["references"].as_array().into_iter().flatten() {
pending.push(reference.as_str().unwrap_or_default().to_string());
}
records.insert(id, record);
}
Ok(records)
}
fn common_subject(records: &BTreeMap<String, Value>) -> Result<Value, String> {
let mut subjects = records.values().map(|r| &r["subject"]);
let first = subjects.next().ok_or("bundle declares no records")?.clone();
if subjects.any(|s| *s != first) {
return Err("records describe different targets".into());
}
Ok(first)
}
pub(super) struct Attachment {
pub manifest_key: &'static str,
pub manifest_value: Value,
pub artifacts: Vec<(String, Vec<u8>)>,
}
pub(super) fn publish(
out: &Path,
records: &BTreeMap<String, Value>,
attachment: Option<Attachment>,
) -> Result<Value, AgentError> {
let subject = common_subject(records).map_err(|e| AgentError::new("missing_evidence", e))?;
let mut artifacts = Vec::new();
let mut entries = Vec::new();
for (id, record) in records {
artifacts.push((
record_file(id)?,
serde_json::to_vec_pretty(record).map_err(|e| e.to_string())?,
));
entries.push(entry(record)?);
}
let mut manifest = json!({
"schema":SCHEMA, "status":"ok", "canonicalization":evidence::CANONICALIZATION,
"subject":subject, "exported_by":capabilities::tool_json(),
"records":entries, "coverage":summary(records),
"scope":"checksums establish consistency, not the truth or authorship of claims",
});
if let Some(attachment) = attachment {
manifest[attachment.manifest_key] = attachment.manifest_value;
artifacts.extend(attachment.artifacts);
}
let artifacts: Vec<(&str, Vec<u8>)> = artifacts
.iter()
.map(|(name, bytes)| (name.as_str(), bytes.clone()))
.collect();
index::publish(out, manifest, &artifacts)
.map_err(|e| AgentError::new("write_failed", format!("cannot publish bundle: {e}")))
}
pub(super) fn export(data: &[u8], args: &[String]) -> Result<u8, AgentError> {
let out = value_arg(args, "--export-bundle")?.expect("validated mode value");
let investigation = value_arg(args, "--investigation")?;
let store = value_arg(args, "--evidence-store")?
.or(investigation)
.ok_or_else(|| {
AgentError::new(
"invalid_combination",
"--export-bundle requires --evidence-store or --investigation",
)
})?;
let sha256 = compute_sha256(data);
let mut roots: Vec<String> = values_arg(args, "--record")?
.into_iter()
.map(str::to_string)
.collect();
for id in &roots {
evidence::parse_id(id)?;
}
let selected = !roots.is_empty();
let mut attachment = None;
if let Some(directory) = investigation {
let (doc, attached) = investigation::attachment(Path::new(directory), &sha256)?;
roots.extend(investigation::referenced(&doc));
attachment = Some(attached);
}
if !selected {
roots = evidence::list(Path::new(store))?
.into_iter()
.filter(|r| r["subject"]["binary_sha256"] == sha256)
.filter_map(|r| r["id"].as_str().map(str::to_string))
.collect();
}
if roots.is_empty() {
return Err(AgentError::new(
"missing_evidence",
"the store holds no evidence for this binary",
));
}
roots.sort();
roots.dedup();
let records = closure(Path::new(store), roots, &sha256)?;
emit(&publish(Path::new(out), &records, attachment)?);
Ok(0)
}
pub(super) struct Bundle {
pub manifest: Value,
pub records: BTreeMap<String, Value>,
pub attachments: BTreeMap<String, Vec<u8>>,
}
pub(super) fn read(root: &Path) -> Result<Bundle, String> {
let (bytes, manifest) = index::read_manifest(root)?;
if manifest["schema"] != SCHEMA {
return Err(format!("expected {SCHEMA}"));
}
if manifest["canonicalization"] != evidence::CANONICALIZATION {
return Err("unsupported canonicalization".into());
}
let mut artifacts: BTreeMap<String, Vec<u8>> = index::read_artifacts(root, &bytes, &manifest)?
.into_iter()
.collect();
let entries = manifest["records"]
.as_array()
.ok_or("bundle has no record list")?;
let mut records = BTreeMap::new();
for declared in entries {
let id = declared["id"].as_str().ok_or("record entry has no ID")?;
let file = record_file(id).map_err(String::from)?;
if declared["file"] != file {
return Err(format!("{id}: unexpected record file"));
}
let bytes = artifacts
.remove(&file)
.ok_or_else(|| format!("{id}: record file missing from generation"))?;
let record: Value =
serde_json::from_slice(&bytes).map_err(|e| format!("{id}: invalid record: {e}"))?;
evidence::validate_record(&record)?;
if record["id"] != id {
return Err(format!("{id}: file holds another record"));
}
if *declared != entry(&record).map_err(String::from)? {
return Err(format!("{id}: manifest entry differs from record"));
}
if records.insert(id.to_string(), record).is_some() {
return Err(format!("{id}: declared twice"));
}
}
let subject = common_subject(&records)?;
if manifest["subject"] != subject {
return Err("manifest subject differs from records".into());
}
for (id, record) in &records {
for reference in record["references"].as_array().into_iter().flatten() {
let reference = reference.as_str().unwrap_or_default();
if !records.contains_key(reference) {
return Err(format!(
"{id}: references {reference}, which the bundle lacks"
));
}
}
}
if manifest["coverage"] != summary(&records) {
return Err("declared coverage differs from records".into());
}
Ok(Bundle {
manifest,
records,
attachments: artifacts,
})
}
fn source_binding(bundle: &Bundle, source: Option<&[u8]>) -> Result<&'static str, AgentError> {
match source {
None => Ok("not_checked"),
Some(data) if bundle.manifest["subject"]["binary_sha256"] == compute_sha256(data) => {
Ok("verified")
}
Some(_) => Err(AgentError::new(
"verification_failed",
"bundle subject SHA-256 does not match the supplied binary",
)),
}
}
fn read_verified(
dir: &str,
extra: impl Fn(&Bundle) -> Result<(), String>,
) -> Result<Bundle, AgentError> {
let bundle = read(Path::new(dir)).map_err(|e| AgentError::new("verification_failed", e))?;
extra(&bundle).map_err(|e| AgentError::new("verification_failed", e))?;
Ok(bundle)
}
fn producers(records: &BTreeMap<String, Value>) -> Vec<Value> {
let mut seen = std::collections::BTreeMap::new();
for record in records.values() {
let producer = &record["producer"];
let identity = json!({"version":producer["version"],"build":producer["build"]});
seen.entry(evidence::canonical_bytes(&identity))
.or_insert(identity);
}
seen.into_values().collect()
}
fn checks(bundle: &Bundle, binding: &str) -> Value {
let attachments = !bundle.attachments.is_empty();
json!({"integrity":"verified","record_ids":"verified","references":"verified",
"coverage":"verified","source_binding":binding,
"attachments":if attachments { "verified" } else { "none" }})
}
pub(super) fn verify(
source: Option<&[u8]>,
args: &[String],
extra: impl Fn(&Bundle) -> Result<(), String>,
) -> Result<u8, AgentError> {
let dir = value_arg(args, "--verify-bundle")?.expect("validated mode value");
let bundle = read_verified(dir, extra)?;
let binding = source_binding(&bundle, source)?;
let status = if binding == "verified" {
"ok"
} else {
"partial"
};
emit(&json!({
"schema":VERIFICATION_SCHEMA, "status":status, "valid":true,
"checks":checks(&bundle, binding),
"verified_scope":if binding == "verified" {
"bundle integrity and source binary identity"
} else {
"bundle integrity only; supply FILE to check source binding"
},
"subject":bundle.manifest["subject"], "generation":bundle.manifest["generation"],
"records":summary(&bundle.records),
"producers":producers(&bundle.records),
}));
Ok(status_exit(status))
}
pub(super) fn import(
source: Option<&[u8]>,
args: &[String],
extra: impl Fn(&Bundle) -> Result<(), String>,
attach: impl FnOnce(&Bundle) -> Result<Value, AgentError>,
) -> Result<u8, AgentError> {
if let Some(directory) = value_arg(args, "--investigation")? {
if Path::new(directory).join("revisions").exists() {
return Err(AgentError::new(
"stale_revision",
format!("an investigation already exists at {directory}"),
));
}
}
let dir = value_arg(args, "--import-bundle")?.expect("validated mode value");
let store = value_arg(args, "--evidence-store")?
.or(value_arg(args, "--investigation")?)
.ok_or_else(|| {
AgentError::new(
"invalid_combination",
"--import-bundle requires --evidence-store or --investigation",
)
})?;
let bundle = read_verified(dir, extra)?;
let binding = source_binding(&bundle, source)?;
let (mut created, mut existing) = (0, 0);
for record in bundle.records.values() {
match evidence::store(Path::new(store), record)? {
"created" => created += 1,
_ => existing += 1,
}
}
let attached = attach(&bundle)?;
emit(&json!({
"schema":IMPORT_SCHEMA, "status":"ok", "store":store,
"checks":checks(&bundle, binding), "subject":bundle.manifest["subject"],
"records":{"imported":created,"existing":existing,"total":bundle.records.len()},
"investigation":attached,
"scope":"records were copied as observations; no saved command was executed",
}));
Ok(0)
}