use std::path::Path;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecipeSignature {
pub recipe_path: String,
pub blake3_hash: String,
pub algorithm: String,
pub signer: String,
pub timestamp: String,
pub signature: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct VerifyResult {
pub recipe_path: String,
pub valid: bool,
pub signer: String,
pub algorithm: String,
pub reason: String,
}
pub fn sign_recipe(recipe_path: &Path, signer: &str) -> Result<RecipeSignature, String> {
let content = std::fs::read(recipe_path).map_err(|e| format!("read recipe: {e}"))?;
let blake3_hash = blake3::hash(&content).to_hex().to_string();
let sig_input = format!("{blake3_hash}:{signer}");
let signature = blake3::hash(sig_input.as_bytes()).to_hex().to_string();
let sig = RecipeSignature {
recipe_path: recipe_path.display().to_string(),
blake3_hash,
algorithm: "blake3-hmac".to_string(),
signer: signer.to_string(),
timestamp: format!("{:?}", std::time::SystemTime::now()),
signature,
};
let sig_path = recipe_path.with_extension("sig.json");
let data = serde_json::to_string_pretty(&sig).map_err(|e| format!("serialize sig: {e}"))?;
std::fs::write(&sig_path, data).map_err(|e| format!("write sig: {e}"))?;
Ok(sig)
}
pub fn verify_recipe(recipe_path: &Path) -> Result<VerifyResult, String> {
let sig_path = recipe_path.with_extension("sig.json");
if !sig_path.exists() {
return Ok(VerifyResult {
recipe_path: recipe_path.display().to_string(),
valid: false,
signer: String::new(),
algorithm: String::new(),
reason: "no signature file found".to_string(),
});
}
let sig_data = std::fs::read_to_string(&sig_path).map_err(|e| format!("read sig: {e}"))?;
let sig: RecipeSignature =
serde_json::from_str(&sig_data).map_err(|e| format!("parse sig: {e}"))?;
let content = std::fs::read(recipe_path).map_err(|e| format!("read recipe: {e}"))?;
let current_hash = blake3::hash(&content).to_hex().to_string();
let valid = current_hash == sig.blake3_hash;
Ok(VerifyResult {
recipe_path: recipe_path.display().to_string(),
valid,
signer: sig.signer,
algorithm: sig.algorithm,
reason: if valid {
"hash matches".to_string()
} else {
"hash mismatch — recipe modified after signing".to_string()
},
})
}
pub fn cmd_recipe_sign(
recipe_path: &Path,
verify_only: bool,
signer: Option<&str>,
json: bool,
) -> Result<(), String> {
if verify_only {
let result = verify_recipe(recipe_path)?;
if json {
let out =
serde_json::to_string_pretty(&result).map_err(|e| format!("JSON error: {e}"))?;
println!("{out}");
} else {
print_verify_result(&result);
}
if !result.valid {
return Err("signature verification failed".to_string());
}
} else {
let who = signer.unwrap_or("local");
let sig = sign_recipe(recipe_path, who)?;
if json {
let out = serde_json::to_string_pretty(&sig).map_err(|e| format!("JSON error: {e}"))?;
println!("{out}");
} else {
println!("Signed: {}", sig.recipe_path);
println!("Hash: {}", sig.blake3_hash);
println!("Signer: {}", sig.signer);
}
}
Ok(())
}
fn print_verify_result(result: &VerifyResult) {
let icon = if result.valid { "OK" } else { "FAIL" };
println!("[{icon}] {}: {}", result.recipe_path, result.reason);
if !result.signer.is_empty() {
println!(" Signer: {}", result.signer);
}
}