use std::collections::BTreeMap;
use std::path::PathBuf;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("manifest is not valid TOML: {0}")]
InvalidToml(String),
#[error("manifest is not valid JSON: {0}")]
InvalidJson(String),
#[error("manifest validation failed: {0}")]
Validation(String),
#[error("signature verification failed: {0}")]
SignatureInvalid(String),
#[error("publisher key is malformed: {0}")]
KeyMalformed(String),
#[error("missing publisher info on signed manifest")]
PublisherMissing,
#[error("bundle I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentManifest {
pub agent: AgentIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<PublisherInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<RuntimeRequirements>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<LifecyclePolicy>,
pub transport: TransportSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<CapabilityDeclarations>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentIdentity {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PublisherInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct RuntimeRequirements {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub car_min_version: Option<String>,
#[serde(default = "default_bundle_format_version")]
pub bundle_format_version: u32,
}
fn default_bundle_format_version() -> u32 {
1
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LifecyclePolicy {
#[serde(default)]
pub stateful: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persistence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_inference_complexity: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TransportSpec {
PureData,
ExternalProcess(ExternalProcessTransport),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExternalProcessTransport {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_url: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub restart: RestartPolicy,
#[serde(default = "default_max_restarts")]
pub max_restarts: u32,
#[serde(default = "default_backoff")]
pub backoff_secs: u64,
#[serde(default)]
pub auto_start: bool,
#[serde(default)]
pub token: String,
}
fn default_max_restarts() -> u32 {
10
}
fn default_backoff() -> u64 {
5
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RestartPolicy {
Never,
#[default]
OnFailure,
Always,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapabilityDeclarations {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub required: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub optional: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub denied: BTreeMap<String, Vec<String>>,
}
impl AgentManifest {
pub fn is_pure_data(&self) -> bool {
matches!(self.transport, TransportSpec::PureData)
}
pub fn is_remote_service(&self) -> bool {
matches!(
&self.transport,
TransportSpec::ExternalProcess(t) if t.health_url.is_some() && t.command.is_none()
)
}
pub fn from_toml_str(text: &str) -> Result<Self, BundleError> {
toml::from_str(text).map_err(|e| BundleError::InvalidToml(e.to_string()))
}
pub fn to_toml_string(&self) -> Result<String, BundleError> {
toml::to_string_pretty(self).map_err(|e| BundleError::InvalidToml(e.to_string()))
}
}
pub fn canonical_manifest_bytes(manifest: &AgentManifest) -> Result<Vec<u8>, BundleError> {
let mut cleared = manifest.clone();
if let Some(pub_info) = cleared.publisher.as_mut() {
pub_info.signature = None;
}
serde_json::to_vec(&cleared).map_err(|e| BundleError::InvalidJson(e.to_string()))
}
pub fn manifest_digest_hex(manifest: &AgentManifest) -> Result<String, BundleError> {
let bytes = canonical_manifest_bytes(manifest)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hex(&hasher.finalize()))
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex(&hasher.finalize())
}
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), BundleError> {
let actual = sha256_hex(bytes);
if actual.eq_ignore_ascii_case(expected_hex) {
Ok(())
} else {
Err(BundleError::SignatureInvalid(format!(
"sha256 mismatch: expected `{expected_hex}`, got `{actual}`"
)))
}
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{:02x}", b));
}
out
}
pub fn sign_manifest(manifest: &mut AgentManifest, key: &SigningKey) -> Result<(), BundleError> {
let pub_info = manifest
.publisher
.get_or_insert_with(PublisherInfo::default);
pub_info.key_id = Some(encode_base64(key.verifying_key().as_bytes()));
pub_info.signature = None;
let bytes = canonical_manifest_bytes(manifest)?;
let signature = key.sign(&bytes);
manifest.publisher.as_mut().unwrap().signature = Some(encode_base64(&signature.to_bytes()));
Ok(())
}
pub fn verify_signature(manifest: &AgentManifest) -> Result<(), BundleError> {
let pub_info = manifest
.publisher
.as_ref()
.ok_or(BundleError::PublisherMissing)?;
let key_b64 = pub_info
.key_id
.as_deref()
.ok_or_else(|| BundleError::KeyMalformed("missing key_id".into()))?;
let sig_b64 = pub_info
.signature
.as_deref()
.ok_or_else(|| BundleError::SignatureInvalid("missing signature".into()))?;
let key_bytes = decode_base64(key_b64)
.map_err(|e| BundleError::KeyMalformed(format!("key_id base64: {e}")))?;
let key_arr: [u8; 32] = key_bytes
.as_slice()
.try_into()
.map_err(|_| BundleError::KeyMalformed("key_id must be 32 bytes".into()))?;
let verifying =
VerifyingKey::from_bytes(&key_arr).map_err(|e| BundleError::KeyMalformed(e.to_string()))?;
let sig_bytes = decode_base64(sig_b64)
.map_err(|e| BundleError::SignatureInvalid(format!("signature base64: {e}")))?;
let sig_arr: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| BundleError::SignatureInvalid("signature must be 64 bytes".into()))?;
let signature = Signature::from_bytes(&sig_arr);
let bytes = canonical_manifest_bytes(manifest)?;
verifying
.verify(&bytes, &signature)
.map_err(|e| BundleError::SignatureInvalid(e.to_string()))
}
fn encode_base64(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn decode_base64(s: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(s)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand_core::OsRng;
fn sample_manifest() -> AgentManifest {
AgentManifest {
agent: AgentIdentity {
id: "ui-improver".into(),
name: "UI Improvement".into(),
namespace: Some("parslee".into()),
version: Some("0.1.0".into()),
description: Some("Issues A2UI patches".into()),
license: Some("Apache-2.0".into()),
homepage: None,
},
publisher: None,
runtime: Some(RuntimeRequirements {
car_min_version: Some("0.8.0".into()),
bundle_format_version: 1,
}),
lifecycle: Some(LifecyclePolicy {
stateful: true,
persistence: Some("host".into()),
default_inference_complexity: Some("low".into()),
}),
transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
command: Some("/usr/local/bin/ui-improver".into()),
binary_url: None,
sha256: Some("abc123".into()),
health_url: None,
args: vec!["--mode".into(), "a2ui".into()],
cwd: None,
env: BTreeMap::new(),
restart: RestartPolicy::OnFailure,
max_restarts: 10,
backoff_secs: 5,
auto_start: false,
token: String::new(),
}),
capabilities: None,
}
}
#[test]
fn round_trip_through_toml() {
let m = sample_manifest();
let text = m.to_toml_string().unwrap();
let round = AgentManifest::from_toml_str(&text).unwrap();
assert_eq!(round.agent.id, m.agent.id);
assert_eq!(round.agent.namespace, m.agent.namespace);
match (&round.transport, &m.transport) {
(TransportSpec::ExternalProcess(a), TransportSpec::ExternalProcess(b)) => {
assert_eq!(a.command, b.command);
assert_eq!(a.sha256, b.sha256);
assert_eq!(a.args, b.args);
}
_ => panic!("transport kind drift after round-trip"),
}
}
#[test]
fn canonical_bytes_clear_signature_for_signing() {
let mut m = sample_manifest();
m.publisher = Some(PublisherInfo {
key_id: Some("abc".into()),
signature: Some("REAL_SIG".into()),
});
let bytes = canonical_manifest_bytes(&m).unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("REAL_SIG"));
assert!(s.contains("abc"));
}
#[test]
fn manifest_digest_is_stable() {
let m = sample_manifest();
let a = manifest_digest_hex(&m).unwrap();
let b = manifest_digest_hex(&m).unwrap();
assert_eq!(a, b);
assert_eq!(a.len(), 64); }
#[test]
fn sign_then_verify_round_trip() {
let mut m = sample_manifest();
let key = SigningKey::generate(&mut OsRng);
sign_manifest(&mut m, &key).unwrap();
verify_signature(&m).expect("freshly signed manifest must verify");
}
#[test]
fn verify_fails_on_tampered_manifest() {
let mut m = sample_manifest();
let key = SigningKey::generate(&mut OsRng);
sign_manifest(&mut m, &key).unwrap();
if let TransportSpec::ExternalProcess(ref mut t) = m.transport {
t.command = Some("/tmp/malicious".into());
}
let err = verify_signature(&m).expect_err("tampered manifest must fail verify");
assert!(matches!(err, BundleError::SignatureInvalid(_)));
}
#[test]
fn verify_fails_on_missing_publisher() {
let m = sample_manifest();
let err = verify_signature(&m).expect_err("unsigned manifest must error");
assert!(matches!(err, BundleError::PublisherMissing));
}
#[test]
fn verify_fails_on_wrong_key() {
let mut m = sample_manifest();
let key = SigningKey::generate(&mut OsRng);
sign_manifest(&mut m, &key).unwrap();
let other = SigningKey::generate(&mut OsRng);
m.publisher.as_mut().unwrap().key_id =
Some(encode_base64(other.verifying_key().as_bytes()));
let err = verify_signature(&m).expect_err("wrong key_id must fail verify");
assert!(matches!(err, BundleError::SignatureInvalid(_)));
}
#[test]
fn sha256_hex_matches_known_value() {
let empty = sha256_hex(b"");
assert_eq!(
empty,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
let abc = sha256_hex(b"abc");
assert_eq!(
abc,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn verify_sha256_accepts_match_rejects_mismatch_and_is_case_insensitive() {
let bytes = b"agent-binary-payload";
let digest = sha256_hex(bytes);
verify_sha256(bytes, &digest).expect("matching digest must verify");
verify_sha256(bytes, &digest.to_uppercase())
.expect("verify is case-insensitive on the hex digest");
let err = verify_sha256(bytes, "00".repeat(32).as_str())
.expect_err("non-matching digest must fail");
assert!(matches!(err, BundleError::SignatureInvalid(_)));
}
#[test]
fn signing_is_idempotent_with_same_key() {
let mut a = sample_manifest();
let mut b = sample_manifest();
let key = SigningKey::generate(&mut OsRng);
sign_manifest(&mut a, &key).unwrap();
sign_manifest(&mut b, &key).unwrap();
assert_eq!(
a.publisher.as_ref().unwrap().signature,
b.publisher.as_ref().unwrap().signature
);
}
}