use super::helpers::*;
use std::path::Path;
struct ChainVerdict {
machine: String,
valid: bool,
detail: String,
}
impl ChainVerdict {
fn new(machine: &str, valid: bool, detail: impl Into<String>) -> Self {
Self {
machine: machine.to_string(),
valid,
detail: detail.into(),
}
}
fn json(&self) -> serde_json::Value {
serde_json::json!({
"machine": self.machine,
"valid": self.valid,
"detail": self.detail,
})
}
}
fn chain_candidates(state_dir: &Path) -> Vec<String> {
let mut machines = Vec::new();
if let Ok(entries) = std::fs::read_dir(state_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.join("state.lock.yaml").exists() || path.join("lock.sig").exists() {
machines.push(entry.file_name().to_string_lossy().to_string());
}
}
}
machines.sort();
machines
}
fn sig_preview(sig: &str) -> String {
sig.chars().take(20).collect()
}
fn bare_digest(hash: &str) -> &str {
hash.strip_prefix("blake3:").unwrap_or(hash)
}
fn well_formed_sig(sig: &str) -> Option<&str> {
let hash = bare_digest(sig);
let ok = hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit());
ok.then_some(hash)
}
fn chain_verdict(state_dir: &Path, m: &str, key: Option<&str>) -> ChainVerdict {
use crate::tripwire::hasher;
let lock_path = state_dir.join(m).join("state.lock.yaml");
let sig_path = state_dir.join(m).join("lock.sig");
if !lock_path.exists() {
return ChainVerdict::new(m, false, "lock file missing — a signature signs nothing");
}
if !sig_path.exists() {
return ChainVerdict::new(m, false, "signature file missing — lock was never signed");
}
let sig_raw = std::fs::read_to_string(&sig_path)
.unwrap_or_default()
.trim()
.to_string();
let Some(sig) = well_formed_sig(&sig_raw) else {
return ChainVerdict::new(
m,
false,
format!("malformed signature: {}", sig_preview(&sig_raw)),
);
};
let Some(key) = key else {
return ChainVerdict::new(
m,
true,
"signature present and well-formed (presence only — chain NOT verified)",
);
};
let content = match std::fs::read_to_string(&lock_path) {
Ok(c) => c,
Err(e) => return ChainVerdict::new(m, false, format!("lock file unreadable: {e}")),
};
let expected = hasher::hash_string(&format!("{content}{key}"));
if sig == bare_digest(&expected) {
ChainVerdict::new(m, true, "signature verified against lock content")
} else {
ChainVerdict::new(
m,
false,
"signature does not match the lock — wrong key, or the lock changed after signing",
)
}
}
fn chain_mode(key: Option<&str>, presence_only: bool) -> Result<Option<String>, String> {
match (key, presence_only) {
(Some(_), true) => Err(
"--presence-only cannot be combined with --key: presence-only does not verify \
the signature against the lock, which is the whole point of passing a key"
.to_string(),
),
(Some(k), false) => crate::core::key_source::resolve(k, "--key").map(Some),
(None, true) => Ok(None),
(None, false) => Err(
"chain of custody cannot be verified without the signing key: pass --key <KEY> \
(the key lock-sign used), or --presence-only to check only that every lock \
carries a well-formed signature"
.to_string(),
),
}
}
fn chain_json_failure(mode: &str, error: &str) {
let out = serde_json::json!({
"mode": mode,
"machines": [],
"all_valid": false,
"error": error,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
}
fn print_chain_json(mode: &str, verdicts: &[ChainVerdict], all_valid: bool) {
let out = serde_json::json!({
"mode": mode,
"machines": verdicts.iter().map(ChainVerdict::json).collect::<Vec<_>>(),
"all_valid": all_valid,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
}
fn print_chain_text(mode: &str, verdicts: &[ChainVerdict]) {
println!("Lock chain verification ({mode}):\n");
for v in verdicts {
let icon = if v.valid { green("✓") } else { red("✗") };
println!(" {icon} {} — {}", v.machine, v.detail);
}
}
pub(crate) fn cmd_lock_verify_chain(
state_dir: &Path,
key: Option<&str>,
presence_only: bool,
json: bool,
) -> Result<(), String> {
let resolved_key = chain_mode(key, presence_only)?;
let key = resolved_key.as_deref();
let mode = if key.is_some() {
"verified"
} else {
"presence-only"
};
if let Err(e) = require_state_dir(state_dir) {
if json {
chain_json_failure(mode, &e);
}
return Err(e);
}
let machines = chain_candidates(state_dir);
if machines.is_empty() {
let e = format!(
"no lock files found in {} — a chain over zero locks verifies nothing",
state_dir.display()
);
if json {
chain_json_failure(mode, &e);
}
return Err(e);
}
let verdicts: Vec<ChainVerdict> = machines
.iter()
.map(|m| chain_verdict(state_dir, m, key))
.collect();
let failed = verdicts.iter().filter(|v| !v.valid).count();
if json {
print_chain_json(mode, &verdicts, failed == 0);
} else {
print_chain_text(mode, &verdicts);
}
if failed == 0 {
Ok(())
} else {
Err(format!(
"chain of custody broken for {failed} of {} machine(s)",
verdicts.len()
))
}
}