use clap::Args;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use invariant_robotics::proof_package::{
assemble, verify_manifest, CampaignSummary, PackageInputs, ProofPackageManifest,
};
#[derive(Serialize, Deserialize, Debug, Clone)]
struct AssembleState {
version: u32,
output_dir: String,
consumed: Vec<ConsumedShard>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ConsumedShard {
path: String,
audit_sha256: String,
audit_line_count: u64,
summary_total: u64,
summary_approved: u64,
summary_rejected: u64,
summary_escapes: u64,
summary_adv: u64,
summary_adv_esc: u64,
control_hz: Option<f64>,
}
const ASSEMBLE_STATE_VERSION: u32 = 1;
#[cfg(test)]
thread_local! {
static TEST_PANIC_AFTER_SHARD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn sidecar_path(output: &Path) -> PathBuf {
let mut s = output.as_os_str().to_owned();
s.push(".assemble-state.json");
PathBuf::from(s)
}
#[derive(Args)]
pub struct AssembleArgs {
#[arg(long, value_name = "DIR")]
pub shards: PathBuf,
#[arg(long, value_name = "DIR")]
pub output: PathBuf,
#[arg(long, value_name = "PATH")]
pub key: Option<PathBuf>,
#[arg(long = "public-key", value_name = "PATH")]
pub public_key: Option<PathBuf>,
#[arg(long = "metadata", value_name = "KEY=VALUE")]
pub metadata: Vec<String>,
#[arg(long, value_name = "NAME")]
pub campaign_name: Option<String>,
#[arg(long, value_name = "NAME")]
pub profile_name: Option<String>,
#[arg(long, value_name = "HEX")]
pub binary_hash: Option<String>,
#[arg(long = "public-keys", value_name = "PATH")]
pub public_keys: Option<PathBuf>,
#[arg(long = "adversarial", value_name = "PATH")]
pub adversarial: Vec<PathBuf>,
#[arg(long)]
pub resume: bool,
}
pub fn run(args: &AssembleArgs) -> i32 {
if !args.shards.is_dir() {
eprintln!(
"error: --shards directory does not exist: {}",
args.shards.display()
);
return 2;
}
let sidecar = sidecar_path(&args.output);
let mut prior_state: Option<AssembleState> = None;
if sidecar.exists() {
if !args.resume {
eprintln!(
"error: existing assembly state at {} — pass --resume or remove the sidecar",
sidecar.display()
);
return 2;
}
match load_state(&sidecar, &args.output) {
Ok(s) => prior_state = Some(s),
Err(e) => {
eprintln!("error: failed to load assembly state: {e}");
return 2;
}
}
} else if args.resume {
eprintln!(
"note: --resume passed but no sidecar at {}; starting fresh.",
sidecar.display()
);
}
if args.output.exists() && prior_state.is_none() {
match std::fs::read_dir(&args.output) {
Ok(mut entries) => {
if entries.next().is_some() {
eprintln!(
"error: --output {} already exists and is not empty",
args.output.display()
);
return 2;
}
}
Err(e) => {
eprintln!("error: cannot read --output {}: {e}", args.output.display());
return 2;
}
}
}
let metadata = match parse_metadata(&args.metadata) {
Ok(m) => m,
Err(e) => {
eprintln!("error: --metadata: {e}");
return 2;
}
};
let shards = match enumerate_shards(&args.shards) {
Ok(s) if s.is_empty() => {
eprintln!(
"error: --shards {} contains no shard subdirectories",
args.shards.display()
);
return 2;
}
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to enumerate shards: {e}");
return 2;
}
};
let merged = match merge_shards_with_state(&shards, &args.output, prior_state.as_ref()) {
Ok(m) => m,
Err(e) => {
eprintln!("error: failed to merge shards: {e}");
return 2;
}
};
let merkle_root_hex = match merkle_root_hex(&merged.audit_jsonl) {
Ok(hex) => hex,
Err(e) => {
eprintln!("error: failed to compute merkle root: {e}");
return 2;
}
};
let signing_key = match args.key.as_deref() {
Some(path) => match load_signing_key(path) {
Ok(pair) => Some(pair),
Err(e) => {
eprintln!("error: --key: {e}");
return 2;
}
},
None => None,
};
if signing_key.is_none() {
eprintln!("warning: --key not supplied; manifest will be unsigned.");
}
let temp_dir = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
eprintln!("error: failed to create temp dir: {e}");
return 2;
}
};
let merged_audit_path = temp_dir.path().join("audit.jsonl");
if let Err(e) = std::fs::write(&merged_audit_path, &merged.audit_jsonl) {
eprintln!("error: failed to write merged audit log: {e}");
return 2;
}
let binary_hash = args.binary_hash.clone().unwrap_or_else(default_binary_hash);
let campaign_name = args
.campaign_name
.clone()
.unwrap_or_else(|| basename(&args.shards).unwrap_or_else(|| "campaign".into()));
let profile_name = args
.profile_name
.clone()
.unwrap_or_else(|| "unknown".into());
let mut adversarial_reports: HashMap<String, PathBuf> = HashMap::new();
for path in &args.adversarial {
let name = match path.file_name().and_then(|s| s.to_str()) {
Some(n) if !n.is_empty() => n.to_string(),
_ => {
eprintln!(
"error: --adversarial {} has no usable filename",
path.display()
);
return 2;
}
};
if adversarial_reports.contains_key(&name) {
eprintln!(
"error: duplicate adversarial report filename {name:?} (rename one of the inputs)"
);
return 2;
}
adversarial_reports.insert(name, path.clone());
}
let inputs = PackageInputs {
campaign_config: None,
profile: None,
audit_log: Some(merged_audit_path.clone()),
adversarial_reports,
compliance_mappings: HashMap::new(),
public_keys: args.public_keys.clone(),
campaign_name,
profile_name,
binary_hash,
summary: merged.summary,
merkle_root_hex: Some(merkle_root_hex.clone()),
signing_key,
};
let manifest = match assemble(&inputs, &args.output) {
Ok(m) => m,
Err(e) => {
eprintln!("error: assemble failed: {e}");
return 2;
}
};
if !metadata.is_empty() {
let path = args.output.join("integrity").join("metadata.json");
match serde_json::to_string_pretty(&metadata) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json.as_bytes()) {
eprintln!("error: failed to write metadata sidecar: {e}");
return 2;
}
}
Err(e) => {
eprintln!("error: failed to serialize metadata: {e}");
return 2;
}
}
}
if let Some(pub_path) = &args.public_key {
if manifest.manifest_signature.is_none() {
eprintln!(
"warning: --public-key supplied but manifest is unsigned (no --key); skipping self-verify."
);
} else {
match self_verify(&args.output, pub_path) {
Ok(()) => {
eprintln!("Self-verify: manifest signature OK.");
}
Err(e) => {
eprintln!("error: self-verify failed: {e}");
return 1;
}
}
}
}
if sidecar.exists() {
if let Err(e) = std::fs::remove_file(&sidecar) {
eprintln!(
"warning: failed to remove sidecar {}: {e}",
sidecar.display()
);
}
}
println!(
"Assembled proof package at {} ({} shards, {} audit entries, merkle_root={}).",
args.output.display(),
shards.len(),
merged.entry_count,
&merkle_root_hex[..16.min(merkle_root_hex.len())],
);
0
}
fn parse_metadata(items: &[String]) -> Result<BTreeMap<String, String>, String> {
let mut out = BTreeMap::new();
for item in items {
let (k, v) = item
.split_once('=')
.ok_or_else(|| format!("expected KEY=VALUE, got {item:?}"))?;
if k.is_empty() {
return Err(format!("empty key in {item:?}"));
}
if !k
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!(
"key {k:?} contains characters outside [A-Za-z0-9_-]"
));
}
if out.insert(k.to_string(), v.to_string()).is_some() {
return Err(format!("duplicate key {k:?}"));
}
}
Ok(out)
}
fn enumerate_shards(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut subdirs: Vec<PathBuf> = std::fs::read_dir(root)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
subdirs.sort();
Ok(subdirs)
}
struct MergedShards {
audit_jsonl: String,
summary: CampaignSummary,
entry_count: u64,
}
#[cfg(test)]
fn merge_shards(shards: &[PathBuf]) -> Result<MergedShards, String> {
let mut audit = String::new();
let mut entry_count: u64 = 0;
let mut total: u64 = 0;
let mut approved: u64 = 0;
let mut rejected: u64 = 0;
let mut escapes: u64 = 0;
let mut adv: u64 = 0;
let mut adv_esc: u64 = 0;
let mut control_hz: Option<f64> = None;
for shard in shards {
let shard_name = shard
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("<unnamed>")
.to_string();
let audit_path = shard.join("audit.jsonl");
if audit_path.exists() {
let content = std::fs::read_to_string(&audit_path)
.map_err(|e| format!("shard {shard_name}: read audit.jsonl: {e}"))?;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
audit.push_str(line);
audit.push('\n');
entry_count += 1;
}
}
let summary_path = shard.join("summary.json");
if summary_path.exists() {
let s: CampaignSummary = serde_json::from_str(
&std::fs::read_to_string(&summary_path)
.map_err(|e| format!("shard {shard_name}: read summary.json: {e}"))?,
)
.map_err(|e| format!("shard {shard_name}: parse summary.json: {e}"))?;
total += s.total_commands;
approved += s.commands_approved;
rejected += s.commands_rejected;
escapes += s.violation_escapes;
adv += s.adversarial_commands;
adv_esc += s.adversarial_escapes;
match control_hz {
None => control_hz = Some(s.control_frequency_hz),
Some(existing) if (existing - s.control_frequency_hz).abs() > f64::EPSILON => {
eprintln!(
"warning: shard {shard_name} reports control_frequency_hz={}, expected {existing}; using {existing}",
s.control_frequency_hz
);
}
Some(_) => {}
}
}
}
let summary = CampaignSummary::compute(
total,
approved,
rejected,
escapes,
adv,
adv_esc,
control_hz.unwrap_or(0.0),
);
Ok(MergedShards {
audit_jsonl: audit,
summary,
entry_count,
})
}
fn sha256_file_hex(path: &Path) -> Result<String, String> {
use sha2::{Digest, Sha256};
if !path.exists() {
return Ok(String::new());
}
let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let mut h = Sha256::new();
h.update(&bytes);
Ok(format!("{:x}", h.finalize()))
}
fn load_state(sidecar: &Path, output: &Path) -> Result<AssembleState, String> {
let bytes = std::fs::read(sidecar).map_err(|e| format!("read {}: {e}", sidecar.display()))?;
let state: AssembleState =
serde_json::from_slice(&bytes).map_err(|e| format!("parse {}: {e}", sidecar.display()))?;
if state.version != ASSEMBLE_STATE_VERSION {
return Err(format!(
"sidecar version {} does not match expected {}",
state.version, ASSEMBLE_STATE_VERSION
));
}
let want = output.to_string_lossy().to_string();
if state.output_dir != want {
return Err(format!(
"sidecar output_dir {:?} does not match --output {:?}",
state.output_dir, want
));
}
Ok(state)
}
fn write_state_durable(sidecar: &Path, state: &AssembleState) -> Result<(), String> {
let parent = sidecar.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
let tmp = sidecar.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(state).map_err(|e| format!("serialize state: {e}"))?;
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)
.map_err(|e| format!("open {}: {e}", tmp.display()))?;
std::io::Write::write_all(&mut f, &json)
.map_err(|e| format!("write {}: {e}", tmp.display()))?;
f.sync_all()
.map_err(|e| format!("fsync {}: {e}", tmp.display()))?;
}
std::fs::rename(&tmp, sidecar)
.map_err(|e| format!("rename {} → {}: {e}", tmp.display(), sidecar.display()))?;
Ok(())
}
fn merge_shards_with_state(
shards: &[PathBuf],
output: &Path,
prior: Option<&AssembleState>,
) -> Result<MergedShards, String> {
let sidecar = sidecar_path(output);
let prior_map: HashMap<&str, &ConsumedShard> = prior
.map(|s| s.consumed.iter().map(|c| (c.path.as_str(), c)).collect())
.unwrap_or_default();
let mut state = AssembleState {
version: ASSEMBLE_STATE_VERSION,
output_dir: output.to_string_lossy().to_string(),
consumed: Vec::with_capacity(shards.len()),
};
let mut audit = String::new();
let mut entry_count: u64 = 0;
let mut total: u64 = 0;
let mut approved: u64 = 0;
let mut rejected: u64 = 0;
let mut escapes: u64 = 0;
let mut adv: u64 = 0;
let mut adv_esc: u64 = 0;
let mut control_hz: Option<f64> = None;
#[allow(unused_variables)]
for (idx, shard) in shards.iter().enumerate() {
let shard_key = shard.to_string_lossy().to_string();
let shard_name = shard
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("<unnamed>")
.to_string();
let audit_path = shard.join("audit.jsonl");
let current_digest =
sha256_file_hex(&audit_path).map_err(|e| format!("shard {shard_name}: {e}"))?;
if let Some(prev) = prior_map.get(shard_key.as_str()) {
if prev.audit_sha256 != current_digest {
return Err(format!(
"shard {shard_name}: audit.jsonl digest changed since last run \
(cached {}, current {}); source tampering or out-of-band edit",
prev.audit_sha256, current_digest
));
}
if audit_path.exists() {
let content = std::fs::read_to_string(&audit_path)
.map_err(|e| format!("shard {shard_name}: read audit.jsonl: {e}"))?;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
audit.push_str(line);
audit.push('\n');
entry_count += 1;
}
}
total += prev.summary_total;
approved += prev.summary_approved;
rejected += prev.summary_rejected;
escapes += prev.summary_escapes;
adv += prev.summary_adv;
adv_esc += prev.summary_adv_esc;
if let Some(hz) = prev.control_hz {
control_hz.get_or_insert(hz);
}
state.consumed.push((*prev).clone());
} else {
let mut shard_line_count: u64 = 0;
if audit_path.exists() {
let content = std::fs::read_to_string(&audit_path)
.map_err(|e| format!("shard {shard_name}: read audit.jsonl: {e}"))?;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
audit.push_str(line);
audit.push('\n');
entry_count += 1;
shard_line_count += 1;
}
}
let mut shard_total = 0u64;
let mut shard_approved = 0u64;
let mut shard_rejected = 0u64;
let mut shard_escapes = 0u64;
let mut shard_adv = 0u64;
let mut shard_adv_esc = 0u64;
let mut shard_hz: Option<f64> = None;
let summary_path = shard.join("summary.json");
if summary_path.exists() {
let s: CampaignSummary = serde_json::from_str(
&std::fs::read_to_string(&summary_path)
.map_err(|e| format!("shard {shard_name}: read summary.json: {e}"))?,
)
.map_err(|e| format!("shard {shard_name}: parse summary.json: {e}"))?;
shard_total = s.total_commands;
shard_approved = s.commands_approved;
shard_rejected = s.commands_rejected;
shard_escapes = s.violation_escapes;
shard_adv = s.adversarial_commands;
shard_adv_esc = s.adversarial_escapes;
shard_hz = Some(s.control_frequency_hz);
total += shard_total;
approved += shard_approved;
rejected += shard_rejected;
escapes += shard_escapes;
adv += shard_adv;
adv_esc += shard_adv_esc;
match control_hz {
None => control_hz = Some(s.control_frequency_hz),
Some(existing) if (existing - s.control_frequency_hz).abs() > f64::EPSILON => {
eprintln!(
"warning: shard {shard_name} reports control_frequency_hz={}, expected {existing}; using {existing}",
s.control_frequency_hz
);
}
Some(_) => {}
}
}
state.consumed.push(ConsumedShard {
path: shard_key,
audit_sha256: current_digest,
audit_line_count: shard_line_count,
summary_total: shard_total,
summary_approved: shard_approved,
summary_rejected: shard_rejected,
summary_escapes: shard_escapes,
summary_adv: shard_adv,
summary_adv_esc: shard_adv_esc,
control_hz: shard_hz,
});
write_state_durable(&sidecar, &state)?;
#[cfg(test)]
{
let after = TEST_PANIC_AFTER_SHARD.with(|c| c.get());
if after > 0 && (idx + 1) >= after {
panic!("TEST_PANIC_AFTER_SHARD={after}: simulated abort after shard {idx}");
}
}
}
}
let summary = CampaignSummary::compute(
total,
approved,
rejected,
escapes,
adv,
adv_esc,
control_hz.unwrap_or(0.0),
);
Ok(MergedShards {
audit_jsonl: audit,
summary,
entry_count,
})
}
fn merkle_root_hex(jsonl: &str) -> Result<String, String> {
use invariant_core::merkle::{leaf_hash, MerkleAccumulator};
let mut acc = MerkleAccumulator::new();
for (idx, line) in jsonl.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(line)
.map_err(|e| format!("audit line {}: parse: {e}", idx + 1))?;
let entry_hash = value
.get("entry_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("audit line {}: missing entry_hash", idx + 1))?;
acc.push_leaf_hash(leaf_hash(entry_hash.as_bytes()));
}
let root = acc.root();
let mut hex = String::with_capacity(64);
for b in root {
hex.push_str(&format!("{b:02x}"));
}
Ok(hex)
}
fn load_signing_key(path: &Path) -> Result<(ed25519_dalek::SigningKey, String), String> {
let kf = crate::key_file::load_key_file(path)?;
let (sk, _vk, kid) = crate::key_file::load_signing_key(&kf)?;
Ok((sk, kid))
}
fn self_verify(package_dir: &Path, pubkey_path: &Path) -> Result<(), String> {
let manifest_json = std::fs::read_to_string(package_dir.join("manifest.json"))
.map_err(|e| format!("read manifest.json: {e}"))?;
let manifest: ProofPackageManifest =
serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest.json: {e}"))?;
let kf = crate::key_file::load_key_file(pubkey_path)?;
let (vk, _kid) = crate::key_file::load_verifying_key(&kf)?;
verify_manifest(&manifest, &vk).map_err(|e| format!("verify_manifest: {e}"))?;
Ok(())
}
fn basename(path: &Path) -> Option<String> {
path.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
}
fn default_binary_hash() -> String {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return "sha256:unknown".into(),
};
let bytes = match std::fs::read(&exe) {
Ok(b) => b,
Err(_) => return "sha256:unknown".into(),
};
format!("sha256:{}", invariant_robotics::util::sha256_hex(&bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::Signer;
use rand::rngs::OsRng;
use std::io::Write;
use tempfile::TempDir;
fn make_signed_audit_line(
sk: &ed25519_dalek::SigningKey,
kid: &str,
seq: u64,
prev: &str,
) -> String {
use sha2::{Digest, Sha256};
let body = format!(r#"{{"seq":{seq},"prev":"{prev}"}}"#);
let mut h = Sha256::new();
h.update(body.as_bytes());
let entry_hash = format!("{:x}", h.finalize());
let sig_payload = format!("{kid}:{entry_hash}");
let sig = sk.sign(sig_payload.as_bytes());
let sig_b64 = STANDARD.encode(sig.to_bytes());
format!(
r#"{{"sequence":{seq},"entry_hash":"{entry_hash}","previous_hash":"{prev}","signature":"{sig_b64}","kid":"{kid}"}}"#
)
}
fn write_shard(dir: &Path, name: &str, lines: &[String], summary: &CampaignSummary) {
let shard = dir.join(name);
std::fs::create_dir_all(&shard).unwrap();
let mut audit = std::fs::File::create(shard.join("audit.jsonl")).unwrap();
for l in lines {
writeln!(audit, "{l}").unwrap();
}
let summary_json = serde_json::to_string_pretty(summary).unwrap();
std::fs::write(shard.join("summary.json"), summary_json.as_bytes()).unwrap();
}
fn keypair() -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) {
let sk = invariant_robotics::authority::crypto::generate_keypair(&mut OsRng);
let vk = sk.verifying_key();
(sk, vk)
}
fn write_key_file(path: &Path, kid: &str, sk: &ed25519_dalek::SigningKey) {
let kf = crate::key_file::KeyFile {
kid: kid.into(),
public_key: STANDARD.encode(sk.verifying_key().as_bytes()),
secret_key: Some(STANDARD.encode(sk.to_bytes())),
};
crate::key_file::write_key_file(path, &kf).unwrap();
}
fn write_pub_key_file(path: &Path, kid: &str, vk: &ed25519_dalek::VerifyingKey) {
let kf = crate::key_file::KeyFile {
kid: kid.into(),
public_key: STANDARD.encode(vk.as_bytes()),
secret_key: None,
};
crate::key_file::write_key_file(path, &kf).unwrap();
}
fn setup_shards(temp: &TempDir, n: usize) -> PathBuf {
let shards = temp.path().join("shards");
std::fs::create_dir_all(&shards).unwrap();
let (sk, _vk) = keypair();
let mut prev = "0".repeat(64);
for s in 0..n {
let mut lines = Vec::new();
for i in 0..3 {
let seq = (s * 3 + i) as u64;
let line = make_signed_audit_line(&sk, "test-kid", seq, &prev);
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
prev = v["entry_hash"].as_str().unwrap().to_string();
lines.push(line);
}
let summary = CampaignSummary::compute(100, 95, 5, 0, 10, 0, 100.0);
write_shard(&shards, &format!("shard-{s:03}"), &lines, &summary);
}
shards
}
#[test]
fn missing_shards_dir_returns_2() {
let args = AssembleArgs {
shards: PathBuf::from("/nonexistent/shards"),
output: PathBuf::from("/tmp/out"),
key: None,
public_key: None,
metadata: vec![],
campaign_name: None,
profile_name: None,
binary_hash: None,
public_keys: None,
adversarial: vec![],
resume: false,
};
assert_eq!(run(&args), 2);
}
#[test]
fn empty_shards_dir_returns_2() {
let temp = tempfile::tempdir().unwrap();
let shards = temp.path().join("shards");
std::fs::create_dir_all(&shards).unwrap();
let args = AssembleArgs {
shards,
output: temp.path().join("out"),
key: None,
public_key: None,
metadata: vec![],
campaign_name: None,
profile_name: None,
binary_hash: None,
public_keys: None,
adversarial: vec![],
resume: false,
};
assert_eq!(run(&args), 2);
}
#[test]
fn nonempty_output_dir_returns_2() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let output = temp.path().join("out");
std::fs::create_dir_all(&output).unwrap();
std::fs::write(output.join("preexisting"), b"x").unwrap();
let args = AssembleArgs {
shards,
output,
key: None,
public_key: None,
metadata: vec![],
campaign_name: None,
profile_name: None,
binary_hash: None,
public_keys: None,
adversarial: vec![],
resume: false,
};
assert_eq!(run(&args), 2);
}
#[test]
fn assembles_unsigned_package_from_two_shards() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 2);
let output = temp.path().join("out");
let args = AssembleArgs {
shards,
output: output.clone(),
key: None,
public_key: None,
metadata: vec![],
campaign_name: Some("test_campaign".into()),
profile_name: Some("ur10".into()),
binary_hash: Some("sha256:test".into()),
public_keys: None,
adversarial: vec![],
resume: false,
};
assert_eq!(run(&args), 0);
assert!(output.join("manifest.json").exists());
assert!(output.join("results/audit.jsonl").exists());
assert!(output.join("integrity/merkle_root.txt").exists());
let merged = std::fs::read_to_string(output.join("results/audit.jsonl")).unwrap();
assert_eq!(merged.lines().filter(|l| !l.is_empty()).count(), 6);
assert!(!output.join("manifest.sig").exists());
}
#[test]
fn assembles_signed_package_and_self_verifies() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 2);
let output = temp.path().join("out");
let (sk, vk) = keypair();
let key_path = temp.path().join("signer.json");
let pub_path = temp.path().join("signer.pub.json");
write_key_file(&key_path, "signer-1", &sk);
write_pub_key_file(&pub_path, "signer-1", &vk);
let args = AssembleArgs {
shards,
output: output.clone(),
key: Some(key_path),
public_key: Some(pub_path),
metadata: vec!["run_id=abc-123".into(), "operator=ci".into()],
campaign_name: None,
profile_name: None,
binary_hash: Some("sha256:test".into()),
public_keys: None,
adversarial: vec![],
resume: false,
};
assert_eq!(run(&args), 0);
assert!(output.join("manifest.sig").exists());
assert!(output.join("integrity/metadata.json").exists());
let metadata: BTreeMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(output.join("integrity/metadata.json")).unwrap(),
)
.unwrap();
assert_eq!(metadata.get("run_id").map(String::as_str), Some("abc-123"));
assert_eq!(metadata.get("operator").map(String::as_str), Some("ci"));
}
#[test]
fn self_verify_fails_with_wrong_public_key() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let output = temp.path().join("out");
let (sk, _vk) = keypair();
let (_, vk_other) = keypair();
let key_path = temp.path().join("signer.json");
let pub_path = temp.path().join("wrong.pub.json");
write_key_file(&key_path, "signer-1", &sk);
write_pub_key_file(&pub_path, "signer-1", &vk_other);
let args = AssembleArgs {
shards,
output,
key: Some(key_path),
public_key: Some(pub_path),
metadata: vec![],
campaign_name: None,
profile_name: None,
binary_hash: Some("sha256:test".into()),
public_keys: None,
adversarial: vec![],
resume: false,
};
assert_eq!(run(&args), 1);
}
#[test]
fn merge_shards_is_deterministic_in_sorted_order() {
let temp = tempfile::tempdir().unwrap();
let shards = temp.path().join("shards");
std::fs::create_dir_all(&shards).unwrap();
let (sk, _vk) = keypair();
let mut prev = "0".repeat(64);
let l1 = make_signed_audit_line(&sk, "kid", 0, &prev);
prev = serde_json::from_str::<serde_json::Value>(&l1).unwrap()["entry_hash"]
.as_str()
.unwrap()
.to_string();
write_shard(
&shards,
"shard-001",
std::slice::from_ref(&l1),
&CampaignSummary::compute(1, 1, 0, 0, 0, 0, 100.0),
);
let l0 = make_signed_audit_line(&sk, "kid", 1, &prev);
write_shard(
&shards,
"shard-000",
std::slice::from_ref(&l0),
&CampaignSummary::compute(1, 1, 0, 0, 0, 0, 100.0),
);
let listed = enumerate_shards(&shards).unwrap();
assert!(listed[0].ends_with("shard-000"));
assert!(listed[1].ends_with("shard-001"));
let merged = merge_shards(&listed).unwrap();
let lines: Vec<&str> = merged.audit_jsonl.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], l0);
assert_eq!(lines[1], l1);
assert_eq!(merged.summary.total_commands, 2);
}
#[test]
fn parse_metadata_rejects_bad_keys() {
assert!(parse_metadata(&["bad key=v".into()]).is_err());
assert!(parse_metadata(&["=v".into()]).is_err());
assert!(parse_metadata(&["nokvp".into()]).is_err());
assert!(parse_metadata(&["dup=1".into(), "dup=2".into()]).is_err());
let ok = parse_metadata(&["good_key-1=hello world".into()]).unwrap();
assert_eq!(
ok.get("good_key-1").map(String::as_str),
Some("hello world")
);
}
#[test]
fn merkle_root_hex_matches_verify_helper() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let merged = merge_shards(&enumerate_shards(&shards).unwrap()).unwrap();
let hex = merkle_root_hex(&merged.audit_jsonl).unwrap();
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
struct PanicAfter;
impl PanicAfter {
fn arm(n: usize) -> Self {
TEST_PANIC_AFTER_SHARD.with(|c| c.set(n));
Self
}
}
impl Drop for PanicAfter {
fn drop(&mut self) {
TEST_PANIC_AFTER_SHARD.with(|c| c.set(0));
}
}
fn args_for(shards: PathBuf, output: PathBuf, resume: bool) -> AssembleArgs {
AssembleArgs {
shards,
output,
key: None,
public_key: None,
metadata: vec![],
campaign_name: Some("test_campaign".into()),
profile_name: Some("ur10".into()),
binary_hash: Some("sha256:test".into()),
public_keys: None,
adversarial: vec![],
resume,
}
}
#[test]
fn pre_existing_sidecar_without_resume_returns_2() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let output = temp.path().join("out");
let state = AssembleState {
version: ASSEMBLE_STATE_VERSION,
output_dir: output.to_string_lossy().to_string(),
consumed: vec![],
};
let sc = sidecar_path(&output);
std::fs::write(&sc, serde_json::to_vec_pretty(&state).unwrap()).unwrap();
assert_eq!(run(&args_for(shards, output, false)), 2);
}
#[test]
fn resume_without_prior_sidecar_is_a_normal_run() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let output = temp.path().join("out");
assert_eq!(run(&args_for(shards, output.clone(), true)), 0);
assert!(!sidecar_path(&output).exists());
assert!(output.join("manifest.json").exists());
}
fn assemble_oneshot(shards: PathBuf, output: PathBuf) -> String {
assert_eq!(run(&args_for(shards, output.clone(), false)), 0);
std::fs::read_to_string(output.join("integrity/merkle_root.txt")).unwrap()
}
#[test]
fn resume_after_simulated_abort_produces_identical_merkle_root() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 4);
let out_a = temp.path().join("out-a");
let root_oneshot = assemble_oneshot(shards.clone(), out_a);
let out_b = temp.path().join("out-b");
let shards_b = shards.clone();
let out_b_clone = out_b.clone();
let guard = PanicAfter::arm(2);
let aborted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run(&args_for(shards_b, out_b_clone, false))
}));
drop(guard);
assert!(
aborted.is_err(),
"expected panic from INVARIANT_ASSEMBLE_PANIC_AFTER_SHARD"
);
assert!(sidecar_path(&out_b).exists());
let saved: AssembleState =
serde_json::from_slice(&std::fs::read(sidecar_path(&out_b)).unwrap()).unwrap();
assert_eq!(saved.consumed.len(), 2);
assert_eq!(run(&args_for(shards, out_b.clone(), true)), 0);
let root_resumed =
std::fs::read_to_string(out_b.join("integrity/merkle_root.txt")).unwrap();
assert_eq!(root_resumed, root_oneshot);
assert!(!sidecar_path(&out_b).exists(), "sidecar removed on success");
}
#[test]
fn resume_detects_source_shard_tampering() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 3);
let output = temp.path().join("out");
let shards_clone = shards.clone();
let out_clone = output.clone();
let guard = PanicAfter::arm(1);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run(&args_for(shards_clone, out_clone, false))
}));
drop(guard);
assert!(sidecar_path(&output).exists());
let consumed_path = {
let st: AssembleState =
serde_json::from_slice(&std::fs::read(sidecar_path(&output)).unwrap()).unwrap();
PathBuf::from(&st.consumed[0].path)
};
let audit_path = consumed_path.join("audit.jsonl");
let mut content = std::fs::read_to_string(&audit_path).unwrap();
content.push_str(r#"{"entry_hash":"deadbeef","sequence":999}"#);
content.push('\n');
std::fs::write(&audit_path, content).unwrap();
assert_eq!(run(&args_for(shards, output, true)), 2);
}
#[test]
fn sidecar_with_mismatched_output_dir_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let output = temp.path().join("out");
let state = AssembleState {
version: ASSEMBLE_STATE_VERSION,
output_dir: temp
.path()
.join("out-elsewhere")
.to_string_lossy()
.to_string(),
consumed: vec![],
};
let sc = sidecar_path(&output);
std::fs::write(&sc, serde_json::to_vec_pretty(&state).unwrap()).unwrap();
assert_eq!(run(&args_for(shards, output, true)), 2);
}
#[test]
fn sidecar_with_unknown_version_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let shards = setup_shards(&temp, 1);
let output = temp.path().join("out");
let state = serde_json::json!({
"version": 9999,
"output_dir": output.to_string_lossy(),
"consumed": []
});
let sc = sidecar_path(&output);
std::fs::write(&sc, serde_json::to_vec_pretty(&state).unwrap()).unwrap();
assert_eq!(run(&args_for(shards, output, true)), 2);
}
}