use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const FLEET_BUNDLE_FORMAT: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleEntry {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleManifest {
pub format_version: u32,
pub fleet_name: String,
pub created_at: String,
pub signer_pubkey: String,
pub signer_fingerprint: String,
pub includes_members: bool,
pub members: Vec<String>,
pub entries: Vec<BundleEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
pub fn content_hash(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
hex::encode(h.finalize())
}
pub fn manifest_sign_input(m: &BundleManifest) -> Vec<u8> {
let mut unsigned = m.clone();
unsigned.sig = None;
serde_json::to_vec(&unsigned).expect("manifest serializes")
}
pub fn signer_fingerprint(signer_pubkey_multibase: &str) -> String {
let h = content_hash(signer_pubkey_multibase.as_bytes());
format!("{}-{}", &h[0..4], &h[4..8])
}
pub fn verify_manifest_sig(m: &BundleManifest, pubkey: &[u8; 32]) -> bool {
let Some(sig) = m.sig.as_deref() else {
return false;
};
crate::identity::verify_bytes(pubkey, &manifest_sign_input(m), sig)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::AgentIdentity;
fn sample_manifest() -> BundleManifest {
BundleManifest {
format_version: FLEET_BUNDLE_FORMAT,
fleet_name: "devteam".into(),
created_at: "2026-06-20T00:00:00Z".into(),
signer_pubkey: String::new(),
signer_fingerprint: String::new(),
includes_members: false,
members: vec!["pm".into(), "qa".into()],
entries: vec![BundleEntry {
path: "fleet.yaml".into(),
sha256: content_hash(b"name: devteam\n"),
}],
sig: None,
}
}
#[test]
fn content_hash_is_deterministic_hex() {
assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
assert_eq!(content_hash(b"").len(), 64); }
#[test]
fn manifest_roundtrips_yaml() {
let m = sample_manifest();
let y = serde_yaml::to_string(&m).unwrap();
let back: BundleManifest = serde_yaml::from_str(&y).unwrap();
assert_eq!(m, back);
}
#[test]
fn sign_then_verify_roundtrip_and_tamper_fails() {
let id = AgentIdentity::generate();
let mut m = sample_manifest();
m.signer_pubkey = id.public_key_multibase();
m.signer_fingerprint = signer_fingerprint(&m.signer_pubkey);
let input = manifest_sign_input(&m);
m.sig = Some(multibase::encode(
multibase::Base::Base58Btc,
id.sign_bytes(&input),
));
let pubkey = id.verifying_key_bytes();
assert!(verify_manifest_sig(&m, &pubkey));
let mut tampered = m.clone();
tampered.entries[0].sha256 = content_hash(b"evil");
assert!(!verify_manifest_sig(&tampered, &pubkey));
let mut badsig = m.clone();
badsig.sig = Some(multibase::encode(multibase::Base::Base58Btc, [0u8; 64]));
assert!(!verify_manifest_sig(&badsig, &pubkey));
let mut unsigned = m.clone();
unsigned.sig = None;
assert!(!verify_manifest_sig(&unsigned, &pubkey));
}
#[test]
fn fingerprint_is_short_and_stable() {
let fp = signer_fingerprint("zSomePubKey");
assert_eq!(fp, signer_fingerprint("zSomePubKey"));
assert!(fp.len() <= 12 && !fp.is_empty());
}
}