use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use ed25519_dalek::Signer;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::cli::CliOutput;
use crate::identity::keypair as kp_mod;
use crate::identity::sign::SignableLink;
const MANIFEST_FILE_NAME: &str = "manifest.json";
#[derive(clap::Args, Debug)]
pub struct ExportForensicBundleArgs {
#[arg(long, value_name = "ID")]
pub memory_id: String,
#[arg(long, default_value_t = false)]
pub include_reflections: bool,
#[arg(long, default_value_t = false)]
pub include_transcripts: bool,
#[arg(long, value_name = "PATH")]
pub output: Option<PathBuf>,
#[arg(long, default_value_t = true)]
pub include_atomisation_chain: bool,
}
#[derive(clap::Args, Debug)]
pub struct VerifyForensicBundleArgs {
pub bundle_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManifestFile {
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u32,
pub memory_id: String,
pub generated_at: String,
pub include_reflections: bool,
pub include_transcripts: bool,
pub files: Vec<ManifestFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signer_agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
pub const BUNDLE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEnvelope {
pub id: String,
pub namespace: String,
pub title: String,
pub content: String,
pub tier: String,
pub memory_kind: String,
pub reflection_depth: i32,
pub created_at: String,
pub updated_at: String,
pub metadata: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub atomisation: Option<AtomisationEnvelope>,
#[serde(default)]
pub citations: Vec<crate::models::Citation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_span: Option<crate::models::SourceSpan>,
#[serde(default)]
pub confidence_source: crate::models::ConfidenceSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence_signals: Option<crate::models::ConfidenceSignals>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence_decayed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AtomisationEnvelope {
#[serde(skip_serializing_if = "Option::is_none")]
pub atomised_into: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub atom_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub atom_of: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeEnvelope {
pub source_id: String,
pub target_id: String,
pub relation: String,
pub created_at: String,
pub observed_by: Option<String>,
pub valid_from: Option<String>,
pub valid_until: Option<String>,
pub attest_level: String,
pub signature_hex: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedEventEnvelope {
pub id: String,
pub agent_id: String,
pub event_type: String,
pub payload_hash_hex: String,
pub signature_hex: Option<String>,
pub attest_level: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptEnvelope {
pub id: String,
pub namespace: String,
pub created_at: String,
pub expires_at: Option<String>,
pub compressed_size: i64,
pub original_size: i64,
pub linked_memory_ids: Vec<String>,
}
type BundleFiles = BTreeMap<String, Vec<u8>>;
pub fn build(
conn: &Connection,
args: &ExportForensicBundleArgs,
output_path: &Path,
generated_at: Option<&str>,
) -> Result<()> {
let files = build_files(conn, args, generated_at)?;
write_ustar(output_path, &files).context("write forensic bundle tar")
}
pub fn build_files(
conn: &Connection,
args: &ExportForensicBundleArgs,
generated_at: Option<&str>,
) -> Result<BundleFiles> {
let generated_at: String = generated_at
.map(ToString::to_string)
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let mut chain_ids = walk_reflection_chain(conn, &args.memory_id)?;
if args.include_atomisation_chain {
let mut expanded = chain_ids.clone();
for mid in &chain_ids {
for atom_id in atom_ids_of_source(conn, mid)? {
if !expanded.contains(&atom_id) {
expanded.push(atom_id);
}
}
if let Some(parent_id) = atom_of_for(conn, mid)? {
if !expanded.contains(&parent_id) {
expanded.push(parent_id.clone());
for atom_id in atom_ids_of_source(conn, &parent_id)? {
if !expanded.contains(&atom_id) {
expanded.push(atom_id);
}
}
}
}
}
expanded.sort();
chain_ids = expanded;
}
let mut files: BundleFiles = BTreeMap::new();
let memory_ids_to_emit: Vec<String> = if args.include_reflections {
chain_ids.clone()
} else if args.include_atomisation_chain {
let mut ids = vec![args.memory_id.clone()];
for atom_id in atom_ids_of_source(conn, &args.memory_id)? {
if !ids.contains(&atom_id) {
ids.push(atom_id);
}
}
if let Some(parent) = atom_of_for(conn, &args.memory_id)? {
if !ids.contains(&parent) {
ids.push(parent.clone());
}
for atom_id in atom_ids_of_source(conn, &parent)? {
if !ids.contains(&atom_id) {
ids.push(atom_id);
}
}
}
ids.sort();
ids
} else {
vec![args.memory_id.clone()]
};
for mid in &memory_ids_to_emit {
if let Some(mem) = crate::db::get(conn, mid).context("db::get for bundle")? {
let atomisation = if args.include_atomisation_chain {
build_atomisation_envelope(conn, &mem)?
} else {
None
};
let env = MemoryEnvelope {
id: mem.id.clone(),
namespace: mem.namespace.clone(),
title: mem.title.clone(),
content: mem.content.clone(),
tier: mem.tier.as_str().to_string(),
memory_kind: format!("{:?}", mem.memory_kind).to_ascii_lowercase(),
reflection_depth: mem.reflection_depth,
created_at: mem.created_at.clone(),
updated_at: mem.updated_at.clone(),
metadata: mem.metadata.clone(),
atomisation,
citations: mem.citations.clone(),
source_uri: mem.source_uri.clone(),
source_span: mem.source_span,
confidence_source: mem.confidence_source,
confidence_signals: mem.confidence_signals.clone(),
confidence_decayed_at: mem.confidence_decayed_at.clone(),
};
let bytes = serde_json::to_vec_pretty(&env).context("serialise MemoryEnvelope")?;
files.insert(format!("memories/{}.json", mem.id), bytes);
}
}
let edges_raw = fetch_edges_for(conn, &chain_ids)?;
let edges: Vec<_> = if args.include_atomisation_chain {
edges_raw
} else {
edges_raw
.into_iter()
.filter(|e| e.relation != crate::models::MemoryLinkRelation::DerivesFrom.as_str())
.collect()
};
for edge in &edges {
let bytes = serde_json::to_vec_pretty(edge).context("serialise EdgeEnvelope")?;
let path = format!(
"edges/{}__{}__{}.json",
edge.source_id, edge.relation, edge.target_id
);
files.insert(path, bytes);
}
let mut event_ids_emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
let events = fetch_signed_events_for(conn, &chain_ids)?;
for ev in &events {
let bytes = serde_json::to_vec_pretty(ev).context("serialise SignedEventEnvelope")?;
files.insert(format!("signed_events/{}.json", ev.id), bytes);
event_ids_emitted.insert(ev.id.clone());
}
if args.include_atomisation_chain {
let extra = fetch_atomisation_signed_events_for(conn, &chain_ids)?;
for ev in &extra {
if event_ids_emitted.contains(&ev.id) {
continue;
}
let bytes = serde_json::to_vec_pretty(ev).context("serialise SignedEventEnvelope")?;
files.insert(format!("signed_events/{}.json", ev.id), bytes);
event_ids_emitted.insert(ev.id.clone());
}
}
if args.include_transcripts {
let entries =
crate::transcripts::replay::replay_transcript_union(conn, &args.memory_id, None)
.context("replay_transcript_union for bundle")?;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for entry in &entries {
if !seen.insert(entry.meta.id.clone()) {
continue;
}
let mut linked: Vec<String> = entries
.iter()
.filter(|e| e.meta.id == entry.meta.id)
.map(|e| e.memory_id.clone())
.collect();
linked.sort();
linked.dedup();
let env = TranscriptEnvelope {
id: entry.meta.id.clone(),
namespace: entry.meta.namespace.clone(),
created_at: entry.meta.created_at.clone(),
expires_at: entry.meta.expires_at.clone(),
compressed_size: entry.meta.compressed_size,
original_size: entry.meta.original_size,
linked_memory_ids: linked,
};
let meta_bytes =
serde_json::to_vec_pretty(&env).context("serialise TranscriptEnvelope")?;
files.insert(format!("transcripts/{}.json", entry.meta.id), meta_bytes);
if let Some(content) = crate::transcripts::storage::fetch(conn, &entry.meta.id)
.context("fetch transcript content for bundle")?
{
files.insert(
format!("transcripts/{}.content", entry.meta.id),
content.into_bytes(),
);
}
}
}
let report =
crate::cli::verify::build_chain_report_at(conn, &args.memory_id, true, Some(&generated_at))
.context("build_chain_report for bundle")?;
let verification_bytes =
serde_json::to_vec_pretty(&report).context("serialise chain report")?;
files.insert("verification.json".to_string(), verification_bytes);
let mut manifest = Manifest {
schema_version: BUNDLE_SCHEMA_VERSION,
memory_id: args.memory_id.clone(),
generated_at,
include_reflections: args.include_reflections,
include_transcripts: args.include_transcripts,
files: files
.iter()
.map(|(p, body)| ManifestFile {
path: p.clone(),
size: body.len() as u64,
sha256: hex_sha256(body),
})
.collect(),
signer_agent_id: None,
signature: None,
};
if let Some((agent_id, sig_b64)) = sign_manifest_if_keyed(&manifest)? {
manifest.signer_agent_id = Some(agent_id);
manifest.signature = Some(sig_b64);
}
let manifest_bytes = serde_json::to_vec_pretty(&manifest).context("serialise Manifest")?;
files.insert(MANIFEST_FILE_NAME.to_string(), manifest_bytes);
Ok(files)
}
pub fn canonical_signed_bytes(m: &Manifest) -> Vec<u8> {
let mut out = String::new();
for f in &m.files {
out.push_str(&f.path);
out.push(':');
out.push_str(&f.size.to_string());
out.push(':');
out.push_str(&f.sha256);
out.push('\n');
}
out.push_str("schema_version:");
out.push_str(&m.schema_version.to_string());
out.push('\n');
out.push_str("memory_id:");
out.push_str(&m.memory_id);
out.push('\n');
out.into_bytes()
}
fn sign_manifest_if_keyed(manifest: &Manifest) -> Result<Option<(String, String)>> {
let key_dir = match kp_mod::default_key_dir() {
Ok(p) => p,
Err(_) => return Ok(None),
};
if !key_dir.exists() {
return Ok(None);
}
let entries = match kp_mod::list(&key_dir) {
Ok(v) => v,
Err(_) => return Ok(None),
};
let mut candidates: Vec<String> = entries.into_iter().map(|kp| kp.agent_id).collect();
candidates.sort();
for agent_id in candidates {
if let Ok(kp) = kp_mod::load(&agent_id, &key_dir) {
if let Some(signing) = kp.private.as_ref() {
let bytes = canonical_signed_bytes(manifest);
let sig = signing.sign(&bytes);
let sig_b64 = STANDARD_NO_PAD.encode(sig.to_bytes());
return Ok(Some((agent_id, sig_b64)));
}
}
}
Ok(None)
}
fn walk_reflection_chain(conn: &Connection, root: &str) -> Result<Vec<String>> {
use std::collections::{HashSet, VecDeque};
let mut visited: HashSet<String> = HashSet::new();
let mut order: Vec<String> = Vec::new();
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(root.to_string());
while let Some(cur) = queue.pop_front() {
if !visited.insert(cur.clone()) {
continue;
}
order.push(cur.clone());
let mut stmt = conn.prepare(
"SELECT target_id FROM memory_links \
WHERE source_id = ?1 AND relation = 'reflects_on' \
ORDER BY target_id",
)?;
let rows = stmt.query_map(params![cur], |r| r.get::<_, String>(0))?;
for r in rows {
let tgt = r?;
if !visited.contains(&tgt) {
queue.push_back(tgt);
}
}
}
order.sort();
Ok(order)
}
fn fetch_edges_for(conn: &Connection, chain_ids: &[String]) -> Result<Vec<EdgeEnvelope>> {
let mut out = Vec::new();
if chain_ids.is_empty() {
return Ok(out);
}
let placeholders: String = chain_ids
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT source_id, target_id, relation, created_at, observed_by, \
valid_from, valid_until, signature, attest_level \
FROM memory_links \
WHERE source_id IN ({placeholders}) \
AND relation IN ('reflects_on', 'supersedes', 'derived_from', 'derives_from') \
ORDER BY source_id, relation, target_id"
);
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = chain_ids
.iter()
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(param_refs.as_slice(), |r| {
Ok(EdgeEnvelope {
source_id: r.get::<_, String>(0)?,
target_id: r.get::<_, String>(1)?,
relation: r.get::<_, String>(2)?,
created_at: r.get::<_, String>(3)?,
observed_by: r.get::<_, Option<String>>(4)?,
valid_from: r.get::<_, Option<String>>(5)?,
valid_until: r.get::<_, Option<String>>(6)?,
signature_hex: r.get::<_, Option<Vec<u8>>>(7)?.map(|b| bytes_to_hex(&b)),
attest_level: r
.get::<_, Option<String>>(8)?
.unwrap_or_else(|| crate::models::AttestLevel::Unsigned.as_str().to_string()),
})
})?;
for r in rows {
out.push(r?);
}
Ok(out)
}
fn atom_ids_of_source(conn: &Connection, source_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT id FROM memories \
WHERE atom_of = ?1 \
ORDER BY created_at ASC, id ASC",
)?;
let rows = stmt.query_map(params![source_id], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
fn atom_of_for(conn: &Connection, id: &str) -> Result<Option<String>> {
let res: rusqlite::Result<Option<String>> = conn.query_row(
"SELECT atom_of FROM memories WHERE id = ?1",
params![id],
|r| r.get::<_, Option<String>>(0),
);
match res {
Ok(v) => Ok(v),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn build_atomisation_envelope(
conn: &Connection,
mem: &crate::models::Memory,
) -> Result<Option<AtomisationEnvelope>> {
let (atomised_into, atom_of_col): (Option<i64>, Option<String>) = conn
.query_row(
"SELECT atomised_into, atom_of FROM memories WHERE id = ?1",
params![mem.id],
|r| Ok((r.get::<_, Option<i64>>(0)?, r.get::<_, Option<String>>(1)?)),
)
.unwrap_or((None, None));
let archived_at = mem
.metadata
.get(crate::models::field_names::ATOMISATION_ARCHIVED_AT)
.and_then(|v| v.as_str())
.map(ToString::to_string);
let is_archived_source = atomised_into.unwrap_or(0) > 0 || archived_at.is_some();
let is_atom = atom_of_col.is_some();
if !is_archived_source && !is_atom {
return Ok(None);
}
let atom_ids = if is_archived_source {
atom_ids_of_source(conn, &mem.id)?
} else {
Vec::new()
};
Ok(Some(AtomisationEnvelope {
atomised_into: atomised_into.filter(|n| *n > 0),
archived_at,
atom_ids,
atom_of: atom_of_col,
}))
}
fn fetch_atomisation_signed_events_for(
conn: &Connection,
chain_ids: &[String],
) -> Result<Vec<SignedEventEnvelope>> {
if chain_ids.is_empty() {
return Ok(Vec::new());
}
let src_placeholders: String = (1..=chain_ids.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let tgt_placeholders: String = (chain_ids.len() + 1..=chain_ids.len() * 2)
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let agent_sql = format!(
"SELECT DISTINCT observed_by FROM memory_links \
WHERE relation = 'derives_from' \
AND (source_id IN ({src_placeholders}) OR target_id IN ({tgt_placeholders})) \
AND observed_by IS NOT NULL"
);
let mut agent_stmt = conn.prepare(&agent_sql)?;
let bind_pairs: Vec<&dyn rusqlite::ToSql> = chain_ids
.iter()
.chain(chain_ids.iter())
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let agent_rows = agent_stmt.query_map(bind_pairs.as_slice(), |r| r.get::<_, String>(0))?;
let mut writer_agents: Vec<String> = Vec::new();
for r in agent_rows {
let id = r?;
if !writer_agents.contains(&id) {
writer_agents.push(id);
}
}
let mut out: Vec<SignedEventEnvelope> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
if writer_agents.is_empty() {
let sql = "SELECT id, agent_id, event_type, payload_hash, signature, \
attest_level, timestamp \
FROM signed_events \
WHERE event_type IN ('atomisation_complete', 'memory_link.created') \
ORDER BY timestamp ASC, id ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], row_to_signed_event_envelope)?;
for r in rows {
let ev = r?;
if seen.insert(ev.id.clone()) {
out.push(ev);
}
}
return Ok(out);
}
let agent_placeholders: String = writer_agents
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT id, agent_id, event_type, payload_hash, signature, \
attest_level, timestamp \
FROM signed_events \
WHERE event_type IN ('atomisation_complete', 'memory_link.created') \
AND agent_id IN ({agent_placeholders}) \
ORDER BY timestamp ASC, id ASC"
);
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = writer_agents
.iter()
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(param_refs.as_slice(), row_to_signed_event_envelope)?;
for r in rows {
let ev = r?;
if seen.insert(ev.id.clone()) {
out.push(ev);
}
}
Ok(out)
}
fn row_to_signed_event_envelope(r: &rusqlite::Row<'_>) -> rusqlite::Result<SignedEventEnvelope> {
Ok(SignedEventEnvelope {
id: r.get::<_, String>(0)?,
agent_id: r.get::<_, String>(1)?,
event_type: r.get::<_, String>(2)?,
payload_hash_hex: bytes_to_hex(&r.get::<_, Vec<u8>>(3)?),
signature_hex: r.get::<_, Option<Vec<u8>>>(4)?.map(|b| bytes_to_hex(&b)),
attest_level: r.get::<_, String>(5)?,
timestamp: r.get::<_, String>(6)?,
})
}
fn fetch_signed_events_for(
conn: &Connection,
chain_ids: &[String],
) -> Result<Vec<SignedEventEnvelope>> {
if chain_ids.is_empty() {
return Ok(Vec::new());
}
let placeholders: String = chain_ids
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT id, agent_id, event_type, payload_hash, signature, \
attest_level, timestamp \
FROM signed_events \
WHERE agent_id IN ({placeholders}) \
ORDER BY timestamp ASC, id ASC"
);
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = chain_ids
.iter()
.map(|s| s as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(param_refs.as_slice(), |r| {
Ok(SignedEventEnvelope {
id: r.get::<_, String>(0)?,
agent_id: r.get::<_, String>(1)?,
event_type: r.get::<_, String>(2)?,
payload_hash_hex: bytes_to_hex(&r.get::<_, Vec<u8>>(3)?),
signature_hex: r.get::<_, Option<Vec<u8>>>(4)?.map(|b| bytes_to_hex(&b)),
attest_level: r.get::<_, String>(5)?,
timestamp: r.get::<_, String>(6)?,
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
#[derive(Debug, Clone, Serialize)]
pub struct VerificationReport {
pub ok: bool,
pub bundle_path: String,
pub manifest_present: bool,
pub schema_version: u32,
pub memory_id: String,
pub signer_agent_id: Option<String>,
pub signature_status: SignatureStatus,
pub tampered_files: Vec<String>,
pub missing_files: Vec<String>,
pub extra_files: Vec<String>,
pub chain_edges_failed: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SignatureStatus {
Verified,
Failed,
Absent,
UnknownSigner,
}
pub fn verify(bundle_path: &Path) -> Result<VerificationReport> {
let bytes = fs::read(bundle_path)
.with_context(|| format!("read bundle from {}", bundle_path.display()))?;
let files = read_ustar(&bytes).context("parse forensic bundle tar")?;
let manifest_bytes = files
.get(MANIFEST_FILE_NAME)
.ok_or_else(|| anyhow!("bundle is missing manifest.json"))?
.clone();
let manifest: Manifest =
serde_json::from_slice(&manifest_bytes).context("parse manifest.json")?;
let mut report = VerificationReport {
ok: true,
bundle_path: bundle_path.display().to_string(),
manifest_present: true,
schema_version: manifest.schema_version,
memory_id: manifest.memory_id.clone(),
signer_agent_id: manifest.signer_agent_id.clone(),
signature_status: SignatureStatus::Absent,
tampered_files: Vec::new(),
missing_files: Vec::new(),
extra_files: Vec::new(),
chain_edges_failed: Vec::new(),
};
let manifest_index: BTreeMap<&str, &ManifestFile> = manifest
.files
.iter()
.map(|m| (m.path.as_str(), m))
.collect();
for (path, body) in &files {
if path == MANIFEST_FILE_NAME {
continue;
}
match manifest_index.get(path.as_str()) {
Some(mf) => {
let actual = hex_sha256(body);
if actual != mf.sha256 || u64::try_from(body.len()).unwrap_or(0) != mf.size {
report.tampered_files.push(path.clone());
}
}
None => report.extra_files.push(path.clone()),
}
}
for (path, _) in manifest_index.iter() {
if !files.contains_key(*path) {
report.missing_files.push((*path).to_string());
}
}
if let (Some(signer), Some(sig_b64)) = (
manifest.signer_agent_id.as_ref(),
manifest.signature.as_ref(),
) {
let pubkey_opt = crate::identity::verify::lookup_peer_public_key(signer);
match pubkey_opt {
Some(pubkey) => {
let signed_bytes = canonical_signed_bytes(&Manifest {
signer_agent_id: None,
signature: None,
..manifest.clone()
});
let sig_bytes = STANDARD_NO_PAD
.decode(sig_b64)
.context("decode manifest signature")?;
let sig_arr: [u8; ed25519_dalek::SIGNATURE_LENGTH] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("manifest signature has wrong length"))?;
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
report.signature_status = match pubkey.verify_strict(&signed_bytes, &sig) {
Ok(()) => SignatureStatus::Verified,
Err(_) => SignatureStatus::Failed,
};
}
None => {
report.signature_status = SignatureStatus::UnknownSigner;
}
}
}
for (path, body) in &files {
if !path.starts_with("edges/") || !path.ends_with(".json") {
continue;
}
let edge: EdgeEnvelope = match serde_json::from_slice(body) {
Ok(e) => e,
Err(_) => {
report.chain_edges_failed.push(path.clone());
continue;
}
};
if !verify_edge_envelope(&edge) {
report.chain_edges_failed.push(path.clone());
}
}
report.ok = report.tampered_files.is_empty()
&& report.missing_files.is_empty()
&& report.chain_edges_failed.is_empty()
&& !matches!(report.signature_status, SignatureStatus::Failed);
Ok(report)
}
fn verify_edge_envelope(edge: &EdgeEnvelope) -> bool {
let Some(sig_hex) = edge.signature_hex.as_ref() else {
return true; };
let Some(observed_by) = edge.observed_by.as_ref() else {
return false; };
let Some(pubkey) = crate::identity::verify::lookup_peer_public_key(observed_by) else {
return false;
};
let Ok(sig_bytes) = hex_to_bytes(sig_hex) else {
return false;
};
let link = SignableLink {
src_id: &edge.source_id,
dst_id: &edge.target_id,
relation: &edge.relation,
observed_by: Some(observed_by),
valid_from: edge.valid_from.as_deref(),
valid_until: edge.valid_until.as_deref(),
};
crate::identity::verify::verify(&pubkey, &link, &sig_bytes).is_ok()
}
pub fn run_export(
db_path: &Path,
args: &ExportForensicBundleArgs,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let conn = crate::db::open(db_path).context("open db")?;
let output = match args.output.as_ref() {
Some(p) => p.clone(),
None => {
let short = args.memory_id.chars().take(8).collect::<String>();
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
PathBuf::from(format!("forensic-bundle-{short}-{ts}.tar"))
}
};
build(&conn, args, &output, None)?;
writeln!(out.stdout, "forensic bundle written: {}", output.display())?;
Ok(0)
}
pub fn run_verify(args: &VerifyForensicBundleArgs, out: &mut CliOutput<'_>) -> Result<i32> {
let report = verify(&args.bundle_path)?;
let payload = serde_json::to_string_pretty(&report).context("serialise VerificationReport")?;
writeln!(out.stdout, "{payload}")?;
if report.ok {
writeln!(out.stdout, "verification OK")?;
Ok(0)
} else {
writeln!(out.stdout, "verification FAILED")?;
Ok(2)
}
}
fn bytes_to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn hex_to_bytes(s: &str) -> Result<Vec<u8>> {
if s.len() % 2 != 0 {
bail!("hex string has odd length");
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
let pair = &s[i..i + 2];
let byte =
u8::from_str_radix(pair, 16).with_context(|| format!("invalid hex pair '{pair}'"))?;
out.push(byte);
}
Ok(out)
}
fn hex_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
bytes_to_hex(&hasher.finalize())
}
const USTAR_BLOCK_SIZE: usize = 512;
fn write_ustar(path: &Path, files: &BundleFiles) -> Result<()> {
let mut out: Vec<u8> = Vec::new();
for (name, body) in files {
write_ustar_entry(&mut out, name, body)?;
}
out.extend(std::iter::repeat(0u8).take(USTAR_BLOCK_SIZE * 2));
fs::write(path, &out).with_context(|| format!("write tarball to {}", path.display()))?;
Ok(())
}
pub fn pack_to_vec(files: &BundleFiles) -> Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
for (name, body) in files {
write_ustar_entry(&mut out, name, body)?;
}
out.extend(std::iter::repeat(0u8).take(USTAR_BLOCK_SIZE * 2));
Ok(out)
}
fn write_ustar_entry(out: &mut Vec<u8>, name: &str, body: &[u8]) -> Result<()> {
if name.len() > 100 {
bail!(
"bundle path '{name}' exceeds 100-byte ustar name limit; the bundle layout is \
documented to keep every path under 100 bytes"
);
}
let mut header = [0u8; USTAR_BLOCK_SIZE];
header[..name.len()].copy_from_slice(name.as_bytes());
write_octal(&mut header[100..108], 0o644, 7);
write_octal(&mut header[108..116], 0, 7);
write_octal(&mut header[116..124], 0, 7);
write_octal(&mut header[124..136], body.len() as u64, 11);
write_octal(&mut header[136..148], 0, 11);
for b in &mut header[148..156] {
*b = b' ';
}
header[156] = b'0';
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
write_octal(&mut header[329..337], 0, 7);
write_octal(&mut header[337..345], 0, 7);
let chksum: u32 = header.iter().map(|b| u32::from(*b)).sum();
let s = format!("{chksum:06o}\0 ");
header[148..156].copy_from_slice(s.as_bytes());
out.extend_from_slice(&header);
out.extend_from_slice(body);
let pad = (USTAR_BLOCK_SIZE - (body.len() % USTAR_BLOCK_SIZE)) % USTAR_BLOCK_SIZE;
out.extend(std::iter::repeat(0u8).take(pad));
Ok(())
}
fn write_octal(field: &mut [u8], value: u64, width: usize) {
let s = format!("{value:0width$o}", width = width);
for (i, b) in s.bytes().enumerate() {
field[i] = b;
}
field[width] = 0;
}
pub fn read_ustar(bytes: &[u8]) -> Result<BundleFiles> {
let mut files: BundleFiles = BTreeMap::new();
let mut pos = 0;
while pos + USTAR_BLOCK_SIZE <= bytes.len() {
let header = &bytes[pos..pos + USTAR_BLOCK_SIZE];
if header[0] == 0 {
break;
}
let name = read_cstr(&header[..100]);
let size = read_octal_size(&header[124..136])?;
if size > MAX_TAR_ENTRY_BYTES {
bail!(
"tar entry '{name}' size {size} exceeds the {MAX_TAR_ENTRY_BYTES}-byte \
hard cap (likely a malformed or crafted bundle)"
);
}
pos = pos
.checked_add(USTAR_BLOCK_SIZE)
.ok_or_else(|| anyhow!("tar parser: pos overflow advancing past header"))?;
let body_end = pos
.checked_add(size)
.ok_or_else(|| anyhow!("tar entry '{name}' size {size} overflows usize"))?;
if body_end > bytes.len() {
bail!("tar entry '{name}' size {size} extends beyond archive bytes");
}
let body = bytes[pos..body_end].to_vec();
files.insert(name, body);
let pad = (USTAR_BLOCK_SIZE - (size % USTAR_BLOCK_SIZE)) % USTAR_BLOCK_SIZE;
pos = body_end
.checked_add(pad)
.ok_or_else(|| anyhow!("tar parser: pos overflow advancing past padding"))?;
}
Ok(files)
}
pub const MAX_TAR_ENTRY_BYTES: usize = 1024 * 1024 * 1024;
fn read_cstr(bytes: &[u8]) -> String {
let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
fn read_octal_size(bytes: &[u8]) -> Result<usize> {
let s = read_cstr(bytes);
let trimmed = s.trim().trim_matches(|c: char| !c.is_ascii_digit());
if trimmed.is_empty() {
return Ok(0);
}
usize::from_str_radix(trimmed, 8).with_context(|| format!("invalid octal size field '{s}'"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::models::{Memory, MemoryKind, Tier};
use chrono::Utc;
use rusqlite::params;
use tempfile::TempDir;
fn open_tmp_db(tmp: &TempDir) -> (rusqlite::Connection, PathBuf) {
let p = tmp.path().join("ai-memory.db");
let conn = db::open(&p).expect("db::open");
(conn, p)
}
fn insert_mem(conn: &rusqlite::Connection, ns: &str, depth: i32, kind: MemoryKind) -> String {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: id.clone(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: format!("t-{depth}"),
content: format!("c-{depth}"),
reflection_depth: depth,
created_at: now.clone(),
updated_at: now,
memory_kind: kind,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
..Default::default()
};
db::insert(conn, &mem).expect("insert");
id
}
fn link_unsigned(conn: &rusqlite::Connection, src: &str, tgt: &str) {
conn.execute(
"INSERT OR IGNORE INTO memory_links \
(source_id, target_id, relation, created_at, attest_level) \
VALUES (?1, ?2, 'reflects_on', ?3, 'unsigned')",
params![src, tgt, Utc::now().to_rfc3339()],
)
.expect("link_unsigned");
}
#[test]
fn write_and_read_ustar_round_trips() {
let mut files = BTreeMap::new();
files.insert("a.json".to_string(), b"{\"a\":1}".to_vec());
files.insert("nested/b.txt".to_string(), b"hello world".to_vec());
let bytes = pack_to_vec(&files).expect("pack");
let parsed = read_ustar(&bytes).expect("parse");
assert_eq!(parsed, files);
}
#[test]
fn ustar_is_byte_deterministic() {
let mut files = BTreeMap::new();
files.insert("z.txt".to_string(), b"last".to_vec());
files.insert("a.txt".to_string(), b"first".to_vec());
let a = pack_to_vec(&files).expect("pack a");
let b = pack_to_vec(&files).expect("pack b");
assert_eq!(a, b, "same input must produce byte-identical output");
}
#[test]
fn build_files_emits_manifest_with_pinned_schema_version() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let id = insert_mem(&conn, "fb-ns", 0, MemoryKind::Observation);
let args = ExportForensicBundleArgs {
memory_id: id.clone(),
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let files = build_files(&conn, &args, Some("2026-01-01T00:00:00Z")).expect("build");
let manifest_bytes = files.get("manifest.json").expect("manifest present");
let manifest: Manifest = serde_json::from_slice(manifest_bytes).expect("parse manifest");
assert_eq!(manifest.schema_version, BUNDLE_SCHEMA_VERSION);
assert_eq!(manifest.memory_id, id);
assert_eq!(manifest.generated_at, "2026-01-01T00:00:00Z");
}
#[test]
fn build_files_reproducible_modulo_timestamp() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let d0 = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let d1 = insert_mem(&conn, "ns", 1, MemoryKind::Reflection);
link_unsigned(&conn, &d1, &d0);
let args = ExportForensicBundleArgs {
memory_id: d1.clone(),
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let files_a = build_files(&conn, &args, Some("2026-01-01T00:00:00Z")).expect("build a");
let files_b = build_files(&conn, &args, Some("2026-01-01T00:00:00Z")).expect("build b");
let bytes_a = pack_to_vec(&files_a).expect("pack a");
let bytes_b = pack_to_vec(&files_b).expect("pack b");
assert_eq!(
bytes_a, bytes_b,
"byte-identical mod timestamp is the L2-5 acceptance criterion"
);
}
#[test]
fn verify_clean_bundle_reports_ok() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let d0 = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let d1 = insert_mem(&conn, "ns", 1, MemoryKind::Reflection);
link_unsigned(&conn, &d1, &d0);
let args = ExportForensicBundleArgs {
memory_id: d1.clone(),
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let bundle_path = tmp.path().join("bundle.tar");
build(&conn, &args, &bundle_path, Some("2026-01-01T00:00:00Z")).expect("build");
let report = verify(&bundle_path).expect("verify");
assert!(report.ok, "clean bundle must verify: {report:#?}");
assert!(report.tampered_files.is_empty());
assert!(report.missing_files.is_empty());
}
#[test]
fn verify_detects_tampered_file_in_bundle() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let d0 = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let d1 = insert_mem(&conn, "ns", 1, MemoryKind::Reflection);
link_unsigned(&conn, &d1, &d0);
let args = ExportForensicBundleArgs {
memory_id: d1.clone(),
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let bundle_path = tmp.path().join("bundle.tar");
build(&conn, &args, &bundle_path, Some("2026-01-01T00:00:00Z")).expect("build");
let bytes = fs::read(&bundle_path).expect("read");
let mut files = read_ustar(&bytes).expect("parse");
let target_key = files
.keys()
.find(|k| k.starts_with("memories/"))
.expect("at least one memory entry")
.clone();
files.insert(target_key.clone(), b"tampered".to_vec());
let new_bytes = pack_to_vec(&files).expect("repack");
fs::write(&bundle_path, &new_bytes).expect("write");
let report = verify(&bundle_path).expect("verify");
assert!(!report.ok, "tampered bundle must fail verification");
assert!(
report.tampered_files.contains(&target_key),
"verifier must name the tampered file; got {:?}",
report.tampered_files
);
}
#[test]
fn canonical_signed_bytes_is_stable() {
let m = Manifest {
schema_version: 1,
memory_id: "abc".into(),
generated_at: "2026-01-01T00:00:00Z".into(),
include_reflections: true,
include_transcripts: false,
files: vec![
ManifestFile {
path: "a.json".into(),
size: 5,
sha256: "ff".into(),
},
ManifestFile {
path: "b.json".into(),
size: 10,
sha256: "ee".into(),
},
],
signer_agent_id: None,
signature: None,
};
let a = canonical_signed_bytes(&m);
let b = canonical_signed_bytes(&m);
assert_eq!(a, b);
let s = String::from_utf8(a).unwrap();
assert!(s.contains("a.json:5:ff"));
assert!(s.contains("b.json:10:ee"));
assert!(s.contains("memory_id:abc"));
}
#[test]
fn build_chain_includes_ancestors_when_reflections_requested() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let d0 = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let d1 = insert_mem(&conn, "ns", 1, MemoryKind::Reflection);
let d2 = insert_mem(&conn, "ns", 2, MemoryKind::Reflection);
link_unsigned(&conn, &d2, &d1);
link_unsigned(&conn, &d1, &d0);
let args = ExportForensicBundleArgs {
memory_id: d2.clone(),
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let files = build_files(&conn, &args, Some("2026-01-01T00:00:00Z")).expect("build");
for id in [&d0, &d1, &d2] {
let key = format!("memories/{id}.json");
assert!(
files.contains_key(&key),
"depth-2 chain must include all ancestors; missing {key}"
);
}
let edge_count = files.keys().filter(|k| k.starts_with("edges/")).count();
assert_eq!(edge_count, 2, "expected 2 reflects_on edges");
}
#[test]
fn build_chain_excludes_ancestors_without_reflections_flag() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let d0 = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let d1 = insert_mem(&conn, "ns", 1, MemoryKind::Reflection);
link_unsigned(&conn, &d1, &d0);
let args = ExportForensicBundleArgs {
memory_id: d1.clone(),
include_reflections: false,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let files = build_files(&conn, &args, Some("2026-01-01T00:00:00Z")).expect("build");
assert!(files.contains_key(&format!("memories/{d1}.json")));
assert!(
!files.contains_key(&format!("memories/{d0}.json")),
"ancestor must be excluded when --include-reflections is unset"
);
}
#[test]
fn verify_detects_missing_file_from_bundle() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let d0 = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let d1 = insert_mem(&conn, "ns", 1, MemoryKind::Reflection);
link_unsigned(&conn, &d1, &d0);
let args = ExportForensicBundleArgs {
memory_id: d1.clone(),
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let bundle_path = tmp.path().join("bundle.tar");
build(&conn, &args, &bundle_path, Some("2026-01-01T00:00:00Z")).expect("build");
let bytes = fs::read(&bundle_path).expect("read");
let mut files = read_ustar(&bytes).expect("parse");
let memory_key = files
.keys()
.find(|k| k.starts_with("memories/") && k.contains(&d0))
.expect("ancestor entry present")
.clone();
files.remove(&memory_key);
let new_bytes = pack_to_vec(&files).expect("repack");
fs::write(&bundle_path, &new_bytes).expect("write");
let report = verify(&bundle_path).expect("verify");
assert!(!report.ok, "missing file must fail verification");
assert!(report.missing_files.contains(&memory_key));
}
#[test]
fn hex_round_trip() {
let bytes = vec![0u8, 0x0f, 0xa1, 0xff];
let hex = bytes_to_hex(&bytes);
assert_eq!(hex, "000fa1ff");
assert_eq!(hex_to_bytes(&hex).unwrap(), bytes);
}
#[test]
fn hex_to_bytes_rejects_odd_length() {
assert!(hex_to_bytes("abc").is_err());
}
#[test]
fn ustar_rejects_long_paths() {
let mut files = BTreeMap::new();
files.insert("a".repeat(101), b"x".to_vec());
assert!(pack_to_vec(&files).is_err());
}
#[test]
fn hex_to_bytes_rejects_invalid_pair() {
let err = hex_to_bytes("zz").unwrap_err();
assert!(format!("{err:#}").contains("invalid hex pair"));
}
#[test]
fn hex_sha256_stable_for_same_input() {
let a = hex_sha256(b"hello world");
let b = hex_sha256(b"hello world");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn read_octal_size_parses_padded_field() {
let mut field = [0u8; 12];
write_octal(&mut field, 256, 11);
let parsed = read_octal_size(&field).unwrap();
assert_eq!(parsed, 256);
}
#[test]
fn read_octal_size_empty_returns_zero() {
let field = [0u8; 12];
let parsed = read_octal_size(&field).unwrap();
assert_eq!(parsed, 0);
}
#[test]
fn read_octal_size_garbage_returns_error_or_zero() {
let field = b" \0\0\0\0\0\0\0\0\0\0";
let parsed = read_octal_size(field).unwrap();
assert_eq!(parsed, 0);
}
#[test]
fn ustar_pack_unpack_empty_files_map() {
let files: BundleFiles = BTreeMap::new();
let bytes = pack_to_vec(&files).unwrap();
let parsed = read_ustar(&bytes).unwrap();
assert!(parsed.is_empty());
}
#[test]
fn ustar_pack_unpack_handles_block_aligned_body() {
let mut files = BundleFiles::new();
files.insert("aligned.bin".to_string(), vec![b'A'; 512]);
let bytes = pack_to_vec(&files).unwrap();
let parsed = read_ustar(&bytes).unwrap();
assert_eq!(parsed.get("aligned.bin").unwrap().len(), 512);
}
#[test]
fn read_ustar_stops_on_zero_block() {
let bytes = vec![0u8; 1024];
let parsed = read_ustar(&bytes).unwrap();
assert!(parsed.is_empty());
}
#[test]
fn canonical_signed_bytes_excludes_signature_fields() {
let mut m1 = Manifest {
schema_version: 1,
memory_id: "abc".into(),
generated_at: "2026-01-01T00:00:00Z".into(),
include_reflections: true,
include_transcripts: false,
files: vec![ManifestFile {
path: "a.json".into(),
size: 5,
sha256: "ff".into(),
}],
signer_agent_id: None,
signature: None,
};
let bytes_unsigned = canonical_signed_bytes(&m1);
m1.signer_agent_id = Some("alice".into());
m1.signature = Some("0xdead".into());
let bytes_signed = canonical_signed_bytes(&m1);
assert_eq!(
bytes_unsigned, bytes_signed,
"signer fields must not affect canonical signed bytes"
);
}
#[test]
fn bytes_to_hex_empty_returns_empty_string() {
assert_eq!(bytes_to_hex(&[]), "");
}
#[test]
fn hex_to_bytes_empty_returns_empty_vec() {
let v = hex_to_bytes("").unwrap();
assert!(v.is_empty());
}
#[test]
fn write_octal_zero_value_is_padded() {
let mut field = [0u8; 8];
write_octal(&mut field, 0, 7);
assert_eq!(&field[..7], b"0000000");
assert_eq!(field[7], 0);
}
#[test]
fn read_ustar_truncated_body_rejected() {
let mut files = BundleFiles::new();
files.insert("x.txt".to_string(), b"hello".to_vec());
let bytes = pack_to_vec(&files).unwrap();
let truncated = &bytes[..516];
let err = read_ustar(truncated).unwrap_err();
let s = format!("{err}");
assert!(s.contains("extends beyond"));
}
#[test]
fn verify_returns_error_for_missing_bundle_path() {
let p = std::path::Path::new("/this/does/not/exist/bundle.tar");
assert!(verify(p).is_err());
}
#[test]
fn read_ustar_rejects_oversize_entry_1250() {
let mut header = [0u8; USTAR_BLOCK_SIZE];
header[0] = b'x';
for b in &mut header[124..135] {
*b = b'7';
}
header[135] = b' ';
let err = read_ustar(&header).expect_err("oversize entry must be refused");
let s = format!("{err}");
assert!(
s.contains("exceeds the") || s.contains("hard cap"),
"expected MAX_TAR_ENTRY_BYTES rejection message, got: {s}"
);
}
#[test]
fn read_ustar_oversize_cap_invariants_1250() {
assert!(
MAX_TAR_ENTRY_BYTES < usize::MAX / 4,
"MAX_TAR_ENTRY_BYTES must be << usize::MAX so checked_add can never panic"
);
assert!(
MAX_TAR_ENTRY_BYTES >= 100 * 1024 * 1024,
"MAX_TAR_ENTRY_BYTES must accommodate the largest realistic forensic bundle"
);
}
#[test]
fn run_export_explicit_output_writes_bundle() {
let tmp = TempDir::new().unwrap();
let (conn, db_path) = open_tmp_db(&tmp);
let id = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
drop(conn); let output = tmp.path().join("explicit.tar");
let args = ExportForensicBundleArgs {
memory_id: id,
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: Some(output.clone()),
};
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let code = {
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
run_export(&db_path, &args, &mut out).expect("run_export")
};
assert_eq!(code, 0);
assert!(output.exists(), "bundle file must be written");
let printed = String::from_utf8(stdout).unwrap();
assert!(printed.contains("forensic bundle written"));
assert!(printed.contains("explicit.tar"));
}
#[test]
fn run_export_default_output_name_derived() {
let tmp = TempDir::new().unwrap();
let (conn, db_path) = open_tmp_db(&tmp);
let id = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
drop(conn);
let args = ExportForensicBundleArgs {
memory_id: id.clone(),
include_reflections: false,
include_transcripts: false,
include_atomisation_chain: false,
output: None,
};
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let code = {
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
run_export(&db_path, &args, &mut out).expect("run_export default name")
};
assert_eq!(code, 0);
let printed = String::from_utf8(stdout).unwrap();
let short: String = id.chars().take(8).collect();
let prefix = format!("forensic-bundle-{short}-");
assert!(
printed.contains(&prefix),
"default name must embed short id: {printed}"
);
if let Some(name) = printed
.lines()
.find_map(|l| l.trim().strip_prefix("forensic bundle written: "))
{
let _ = fs::remove_file(name);
}
}
#[test]
fn run_verify_clean_bundle_exit_zero() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let id = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let args = ExportForensicBundleArgs {
memory_id: id,
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let bundle_path = tmp.path().join("ok.tar");
build(&conn, &args, &bundle_path, Some("2026-01-01T00:00:00Z")).expect("build");
let vargs = VerifyForensicBundleArgs {
bundle_path: bundle_path.clone(),
};
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let code = {
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
run_verify(&vargs, &mut out).expect("run_verify")
};
assert_eq!(code, 0);
let printed = String::from_utf8(stdout).unwrap();
assert!(printed.contains("verification OK"));
}
#[test]
fn run_verify_tampered_bundle_exit_two() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let id = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let args = ExportForensicBundleArgs {
memory_id: id,
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let bundle_path = tmp.path().join("bad.tar");
build(&conn, &args, &bundle_path, Some("2026-01-01T00:00:00Z")).expect("build");
let bytes = fs::read(&bundle_path).unwrap();
let mut files = read_ustar(&bytes).unwrap();
let key = files
.keys()
.find(|k| k.starts_with("memories/"))
.unwrap()
.clone();
files.insert(key, b"tampered".to_vec());
fs::write(&bundle_path, pack_to_vec(&files).unwrap()).unwrap();
let vargs = VerifyForensicBundleArgs { bundle_path };
let mut stdout = Vec::<u8>::new();
let mut stderr = Vec::<u8>::new();
let code = {
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
run_verify(&vargs, &mut out).expect("run_verify")
};
assert_eq!(code, 2, "verification failure must exit 2 (#709)");
let printed = String::from_utf8(stdout).unwrap();
assert!(printed.contains("verification FAILED"));
}
#[test]
fn verify_detects_extra_file_in_bundle() {
let tmp = TempDir::new().unwrap();
let (conn, _) = open_tmp_db(&tmp);
let id = insert_mem(&conn, "ns", 0, MemoryKind::Observation);
let args = ExportForensicBundleArgs {
memory_id: id,
include_reflections: true,
include_transcripts: false,
include_atomisation_chain: true,
output: None,
};
let bundle_path = tmp.path().join("extra.tar");
build(&conn, &args, &bundle_path, Some("2026-01-01T00:00:00Z")).expect("build");
let bytes = fs::read(&bundle_path).unwrap();
let mut files = read_ustar(&bytes).unwrap();
files.insert("memories/intruder.json".to_string(), b"{}".to_vec());
fs::write(&bundle_path, pack_to_vec(&files).unwrap()).unwrap();
let report = verify(&bundle_path).expect("verify");
assert!(
report
.extra_files
.contains(&"memories/intruder.json".to_string()),
"extra file must be reported: {:?}",
report.extra_files
);
}
#[test]
fn verify_missing_manifest_errors() {
let tmp = TempDir::new().unwrap();
let mut files = BundleFiles::new();
files.insert("memories/x.json".to_string(), b"{}".to_vec());
let bundle_path = tmp.path().join("no-manifest.tar");
fs::write(&bundle_path, pack_to_vec(&files).unwrap()).unwrap();
let err = verify(&bundle_path).unwrap_err();
assert!(format!("{err:#}").contains("missing manifest"));
}
#[test]
fn verify_edge_envelope_unsigned_is_ok() {
let edge = EdgeEnvelope {
source_id: "a".into(),
target_id: "b".into(),
relation: "reflects_on".into(),
created_at: "2026-01-01T00:00:00Z".into(),
observed_by: None,
valid_from: None,
valid_until: None,
attest_level: "unsigned".into(),
signature_hex: None,
};
assert!(verify_edge_envelope(&edge));
}
#[test]
fn verify_edge_envelope_signed_without_agent_is_false() {
let edge = EdgeEnvelope {
source_id: "a".into(),
target_id: "b".into(),
relation: "reflects_on".into(),
created_at: "2026-01-01T00:00:00Z".into(),
observed_by: None,
valid_from: None,
valid_until: None,
attest_level: "signed".into(),
signature_hex: Some("deadbeef".into()),
};
assert!(!verify_edge_envelope(&edge));
}
#[test]
fn verify_edge_envelope_unknown_signer_is_false() {
let edge = EdgeEnvelope {
source_id: "a".into(),
target_id: "b".into(),
relation: "reflects_on".into(),
created_at: "2026-01-01T00:00:00Z".into(),
observed_by: Some("nobody:unenrolled".into()),
valid_from: None,
valid_until: None,
attest_level: "signed".into(),
signature_hex: Some("deadbeef".into()),
};
assert!(!verify_edge_envelope(&edge));
}
#[test]
fn verification_report_serializes() {
let report = VerificationReport {
ok: true,
bundle_path: "/x.tar".into(),
manifest_present: true,
schema_version: BUNDLE_SCHEMA_VERSION,
memory_id: "abc".into(),
signer_agent_id: None,
signature_status: SignatureStatus::Absent,
tampered_files: Vec::new(),
missing_files: Vec::new(),
extra_files: Vec::new(),
chain_edges_failed: Vec::new(),
};
let json = serde_json::to_string(&report).expect("serialize");
assert!(json.contains("\"ok\":true"));
assert!(json.contains("abc"));
}
}