use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use crate::error::{Error, Result};
use crate::etree::{self, ParseOps};
use crate::ledger;
use crate::{capability, cappolicy, output};
use super::{CommonArgs, VerifyChainSubcmd, apply_common, resolve_policy};
pub(super) fn run(common: CommonArgs, a: VerifyChainSubcmd) -> Result<()> {
let mut trust: HashMap<String, String> = HashMap::new();
for pem_path in &a.trust_roots {
let pem = fs::read_to_string(pem_path)?;
let fp = capability::KeyFp::from_pem(&pem)?;
trust.insert(fp.to_hex(), pem);
}
let cap_policy = common
.policy_file
.as_ref()
.map(|p| cappolicy::CapPolicy::load_file(p))
.transpose()?;
let policy = resolve_policy(&common)?;
let mut paops = ParseOps::new(policy)?;
apply_common(&common, &mut paops);
let mut json_reports: Vec<output::VerifyChainFileReport> = Vec::new();
let mut any_failure = false;
for path_in in &a.files {
let result = verify_chain_one_file(path_in, &mut paops, &trust, cap_policy.as_ref());
match common.format {
output::OutputFormat::Text => match &result {
Ok(()) => println!("OK {}", path_in),
Err(e) => {
any_failure = true;
eprintln!("FAIL {}: {}", path_in, e);
}
},
output::OutputFormat::Json => {
let report = verify_chain_report_from_result(path_in, &result, &mut paops);
if !report.ok {
any_failure = true;
}
json_reports.push(report);
}
}
}
if matches!(common.format, output::OutputFormat::Json) {
let payload = output::VerifyChainOutput {
ok: !any_failure,
files: json_reports,
};
println!("{}", output::to_json(&payload)?);
}
if any_failure {
Err(Error::SignatureVerify {
key_id: "one or more files failed chain verification".to_string(),
})
} else {
Ok(())
}
}
fn verify_chain_report_from_result(
path_in: &str,
result: &Result<()>,
paops: &mut ParseOps,
) -> output::VerifyChainFileReport {
let (ok, message) = match result {
Ok(()) => (true, None),
Err(e) => (false, Some(e.to_string())),
};
let reader: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
match File::open(path_in) {
Ok(f) => Box::new(BufReader::new(f)),
Err(_) => {
return output::VerifyChainFileReport {
path: path_in.to_string(),
ok: false,
anchors_total: 0,
signers: Vec::new(),
forks: Vec::new(),
errors: vec![output::VerifyError {
anchor: None,
message: message.unwrap_or_else(|| "unknown error".into()),
}],
};
}
}
};
paops.runtime.fname = path_in.into();
let tree = match etree::parse(reader, paops) {
Ok(t) => t,
Err(e) => {
return output::VerifyChainFileReport {
path: path_in.to_string(),
ok: false,
anchors_total: 0,
signers: Vec::new(),
forks: Vec::new(),
errors: vec![output::VerifyError {
anchor: None,
message: e.to_string(),
}],
};
}
};
let mut dag = ledger::AnchorDag::new();
if let Err(e) = collect_chain_anchors(&tree, &mut dag) {
return output::VerifyChainFileReport {
path: path_in.to_string(),
ok: false,
anchors_total: 0,
signers: Vec::new(),
forks: Vec::new(),
errors: vec![output::VerifyError {
anchor: None,
message: e.to_string(),
}],
};
}
let tips = dag.tips();
let signers: Vec<String> = dag
.iter()
.map(|(_, s)| s.anchor.signer.to_string())
.collect();
let forks: Vec<output::ForkPoint> = tips
.into_iter()
.map(|id| {
let parents = dag
.get(&id)
.map(|s| s.anchor.parents.iter().map(|p| p.to_string()).collect())
.unwrap_or_default();
output::ForkPoint {
anchor: id.to_string(),
parents,
}
})
.collect();
let errors = match message {
Some(m) => vec![output::VerifyError {
anchor: None,
message: m,
}],
None => Vec::new(),
};
output::VerifyChainFileReport {
path: path_in.to_string(),
ok,
anchors_total: dag.len(),
signers,
forks,
errors,
}
}
fn verify_chain_one_file(
path_in: &str,
paops: &mut ParseOps,
trust: &HashMap<String, String>,
cap_policy: Option<&cappolicy::CapPolicy>,
) -> Result<()> {
paops.runtime.fname = path_in.into();
let reader: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
Box::new(BufReader::new(File::open(path_in).map_err(|e| {
Error::Io(std::io::Error::other(format!(
"Failed to open {path_in}: {e}"
)))
})?))
};
let tree = etree::parse(reader, paops)?;
let mut dag = ledger::AnchorDag::new();
collect_chain_anchors(&tree, &mut dag)?;
let report = dag.verify_signatures(|fp_hex| trust.get(fp_hex).cloned());
let mut errors: Vec<String> = Vec::new();
for r in &report.reports {
if !r.ok
&& let Some(ref e) = r.error
{
errors.push(format!("{}: {}", r.id, e));
}
if let Some(p) = cap_policy
&& let Some(signed) = dag.get(&r.id)
&& !p.trust_root_allows(&signed.anchor.signer)
{
errors.push(format!(
"{}: signer {} not in policy trust_roots",
r.id, signed.anchor.signer
));
}
}
if let Some(p) = cap_policy
&& p.chain.require_monotonic_timestamps
{
errors.extend(check_monotonic_timestamps(&dag));
}
let payload_errors = verify_payload_hashes(&tree, paops)?;
errors.extend(payload_errors);
if !errors.is_empty() {
return Err(Error::SignatureVerify {
key_id: format!(
"{} anchor(s) failed verification: {}",
errors.len(),
errors.join("; ")
),
});
}
Ok(())
}
fn verify_payload_hashes(tree: &etree::TextTree, paops: &mut ParseOps) -> Result<Vec<String>> {
let policy = crate::crypto::CryptoPolicyDefault {};
let mut errors = Vec::new();
let mut prefix: etree::TextTree = Vec::new();
for node in tree {
if let etree::TextNode::Chain { extfields } = node {
let blob = etree::tree_to_blob(&prefix, paops)?;
let recomputed = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let recorded = extfields.get("payload").map(|s| s.as_str()).unwrap_or("");
if recomputed != recorded {
errors.push(format!(
"payload mismatch: anchor payload={} but file content hashes to {}",
recorded, recomputed
));
}
}
prefix.push(node.clone());
}
Ok(errors)
}
fn check_monotonic_timestamps(dag: &ledger::AnchorDag) -> Vec<String> {
let mut errors = Vec::new();
for (id, signed) in dag.iter() {
let Some(child_ts) = signed.anchor.timestamp.as_ref() else {
continue;
};
for parent_id in &signed.anchor.parents {
let Some(parent) = dag.get(parent_id) else {
continue;
};
let Some(parent_ts) = parent.anchor.timestamp.as_ref() else {
continue;
};
if child_ts < parent_ts {
errors.push(format!(
"{}: timestamp {} older than parent {} ({})",
id, child_ts, parent_id, parent_ts
));
}
}
}
errors
}
pub(super) fn collect_chain_anchors(
tree: &etree::TextTree,
dag: &mut ledger::AnchorDag,
) -> Result<()> {
for node in tree {
match node {
etree::TextNode::Chain { extfields } => {
let signed = ledger::SignedAnchor::from_extfields(extfields)?;
dag.push(signed)
.map_err(|e| Error::from(e).with_context("DAG construction failed"))?;
}
etree::TextNode::BeginEnd { txt, .. } | etree::TextNode::Encrypted { txt, .. } => {
collect_chain_anchors(txt, dag)?;
}
_ => {}
}
}
Ok(())
}