1use std::collections::BTreeMap;
32use std::path::PathBuf;
33
34use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
35use serde::{Deserialize, Serialize};
36use sha2::{Digest, Sha256};
37
38#[derive(Debug, thiserror::Error)]
39pub enum BundleError {
40 #[error("manifest is not valid TOML: {0}")]
41 InvalidToml(String),
42 #[error("manifest is not valid JSON: {0}")]
43 InvalidJson(String),
44 #[error("manifest validation failed: {0}")]
45 Validation(String),
46 #[error("signature verification failed: {0}")]
47 SignatureInvalid(String),
48 #[error("publisher key is malformed: {0}")]
49 KeyMalformed(String),
50 #[error("missing publisher info on signed manifest")]
51 PublisherMissing,
52 #[error("bundle I/O error: {0}")]
53 Io(#[from] std::io::Error),
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AgentManifest {
60 pub agent: AgentIdentity,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub publisher: Option<PublisherInfo>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub runtime: Option<RuntimeRequirements>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub lifecycle: Option<LifecyclePolicy>,
67 pub transport: TransportSpec,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub capabilities: Option<CapabilityDeclarations>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AgentIdentity {
75 pub id: String,
76 pub name: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub namespace: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub version: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub description: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub license: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub homepage: Option<String>,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95pub struct PublisherInfo {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub key_id: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub signature: Option<String>,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub struct RuntimeRequirements {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub car_min_version: Option<String>,
107 #[serde(default = "default_bundle_format_version")]
108 pub bundle_format_version: u32,
109}
110
111fn default_bundle_format_version() -> u32 {
112 1
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct LifecyclePolicy {
117 #[serde(default)]
118 pub stateful: bool,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub persistence: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub default_inference_complexity: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(tag = "kind", rename_all = "snake_case")]
127pub enum TransportSpec {
128 PureData,
129 ExternalProcess(ExternalProcessTransport),
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct ExternalProcessTransport {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub command: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub sha256: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub binary_url: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub health_url: Option<String>,
150 #[serde(default)]
151 pub args: Vec<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub cwd: Option<PathBuf>,
154 #[serde(default)]
155 pub env: BTreeMap<String, String>,
156 #[serde(default)]
157 pub restart: RestartPolicy,
158 #[serde(default = "default_max_restarts")]
159 pub max_restarts: u32,
160 #[serde(default = "default_backoff")]
161 pub backoff_secs: u64,
162 #[serde(default)]
163 pub auto_start: bool,
164 #[serde(default)]
165 pub token: String,
166}
167
168fn default_max_restarts() -> u32 {
169 10
170}
171
172fn default_backoff() -> u64 {
173 5
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
181#[serde(rename_all = "snake_case")]
182pub enum RestartPolicy {
183 Never,
184 #[default]
185 OnFailure,
186 Always,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190pub struct CapabilityDeclarations {
191 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
192 pub required: BTreeMap<String, Vec<String>>,
193 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
194 pub optional: BTreeMap<String, Vec<String>>,
195 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
196 pub denied: BTreeMap<String, Vec<String>>,
197}
198
199impl AgentManifest {
200 pub fn is_pure_data(&self) -> bool {
201 matches!(self.transport, TransportSpec::PureData)
202 }
203
204 pub fn is_remote_service(&self) -> bool {
205 matches!(
206 &self.transport,
207 TransportSpec::ExternalProcess(t) if t.health_url.is_some() && t.command.is_none()
208 )
209 }
210
211 pub fn from_toml_str(text: &str) -> Result<Self, BundleError> {
215 toml::from_str(text).map_err(|e| BundleError::InvalidToml(e.to_string()))
216 }
217
218 pub fn to_toml_string(&self) -> Result<String, BundleError> {
221 toml::to_string_pretty(self).map_err(|e| BundleError::InvalidToml(e.to_string()))
222 }
223}
224
225pub fn canonical_manifest_bytes(manifest: &AgentManifest) -> Result<Vec<u8>, BundleError> {
245 let mut cleared = manifest.clone();
246 if let Some(pub_info) = cleared.publisher.as_mut() {
247 pub_info.signature = None;
248 }
249 serde_json::to_vec(&cleared).map_err(|e| BundleError::InvalidJson(e.to_string()))
254}
255
256pub fn manifest_digest_hex(manifest: &AgentManifest) -> Result<String, BundleError> {
260 let bytes = canonical_manifest_bytes(manifest)?;
261 let mut hasher = Sha256::new();
262 hasher.update(&bytes);
263 Ok(hex(&hasher.finalize()))
264}
265
266pub fn sha256_hex(bytes: &[u8]) -> String {
271 let mut hasher = Sha256::new();
272 hasher.update(bytes);
273 hex(&hasher.finalize())
274}
275
276pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), BundleError> {
281 let actual = sha256_hex(bytes);
282 if actual.eq_ignore_ascii_case(expected_hex) {
283 Ok(())
284 } else {
285 Err(BundleError::SignatureInvalid(format!(
286 "sha256 mismatch: expected `{expected_hex}`, got `{actual}`"
287 )))
288 }
289}
290
291fn hex(bytes: &[u8]) -> String {
292 let mut out = String::with_capacity(bytes.len() * 2);
293 for b in bytes {
294 out.push_str(&format!("{:02x}", b));
295 }
296 out
297}
298
299pub fn sign_manifest(
315 manifest: &mut AgentManifest,
316 key: &SigningKey,
317) -> Result<(), BundleError> {
318 let pub_info = manifest.publisher.get_or_insert_with(PublisherInfo::default);
319 pub_info.key_id = Some(encode_base64(key.verifying_key().as_bytes()));
320 pub_info.signature = None;
321 let bytes = canonical_manifest_bytes(manifest)?;
322 let signature = key.sign(&bytes);
323 manifest.publisher.as_mut().unwrap().signature =
325 Some(encode_base64(&signature.to_bytes()));
326 Ok(())
327}
328
329pub fn verify_signature(manifest: &AgentManifest) -> Result<(), BundleError> {
336 let pub_info = manifest
337 .publisher
338 .as_ref()
339 .ok_or(BundleError::PublisherMissing)?;
340 let key_b64 = pub_info
341 .key_id
342 .as_deref()
343 .ok_or_else(|| BundleError::KeyMalformed("missing key_id".into()))?;
344 let sig_b64 = pub_info
345 .signature
346 .as_deref()
347 .ok_or_else(|| BundleError::SignatureInvalid("missing signature".into()))?;
348 let key_bytes = decode_base64(key_b64)
349 .map_err(|e| BundleError::KeyMalformed(format!("key_id base64: {e}")))?;
350 let key_arr: [u8; 32] = key_bytes
351 .as_slice()
352 .try_into()
353 .map_err(|_| BundleError::KeyMalformed("key_id must be 32 bytes".into()))?;
354 let verifying =
355 VerifyingKey::from_bytes(&key_arr).map_err(|e| BundleError::KeyMalformed(e.to_string()))?;
356 let sig_bytes = decode_base64(sig_b64)
357 .map_err(|e| BundleError::SignatureInvalid(format!("signature base64: {e}")))?;
358 let sig_arr: [u8; 64] = sig_bytes
359 .as_slice()
360 .try_into()
361 .map_err(|_| BundleError::SignatureInvalid("signature must be 64 bytes".into()))?;
362 let signature = Signature::from_bytes(&sig_arr);
363 let bytes = canonical_manifest_bytes(manifest)?;
364 verifying
365 .verify(&bytes, &signature)
366 .map_err(|e| BundleError::SignatureInvalid(e.to_string()))
367}
368
369fn encode_base64(bytes: &[u8]) -> String {
370 use base64::Engine;
371 base64::engine::general_purpose::STANDARD.encode(bytes)
372}
373
374fn decode_base64(s: &str) -> Result<Vec<u8>, String> {
375 use base64::Engine;
376 base64::engine::general_purpose::STANDARD
377 .decode(s)
378 .map_err(|e| e.to_string())
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use ed25519_dalek::SigningKey;
385 use rand_core::OsRng;
386
387 fn sample_manifest() -> AgentManifest {
388 AgentManifest {
389 agent: AgentIdentity {
390 id: "ui-improver".into(),
391 name: "UI Improvement".into(),
392 namespace: Some("parslee".into()),
393 version: Some("0.1.0".into()),
394 description: Some("Issues A2UI patches".into()),
395 license: Some("Apache-2.0".into()),
396 homepage: None,
397 },
398 publisher: None,
399 runtime: Some(RuntimeRequirements {
400 car_min_version: Some("0.8.0".into()),
401 bundle_format_version: 1,
402 }),
403 lifecycle: Some(LifecyclePolicy {
404 stateful: true,
405 persistence: Some("host".into()),
406 default_inference_complexity: Some("low".into()),
407 }),
408 transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
409 command: Some("/usr/local/bin/ui-improver".into()),
410 binary_url: None,
411 sha256: Some("abc123".into()),
412 health_url: None,
413 args: vec!["--mode".into(), "a2ui".into()],
414 cwd: None,
415 env: BTreeMap::new(),
416 restart: RestartPolicy::OnFailure,
417 max_restarts: 10,
418 backoff_secs: 5,
419 auto_start: false,
420 token: String::new(),
421 }),
422 capabilities: None,
423 }
424 }
425
426 #[test]
427 fn round_trip_through_toml() {
428 let m = sample_manifest();
429 let text = m.to_toml_string().unwrap();
430 let round = AgentManifest::from_toml_str(&text).unwrap();
431 assert_eq!(round.agent.id, m.agent.id);
432 assert_eq!(round.agent.namespace, m.agent.namespace);
433 match (&round.transport, &m.transport) {
434 (TransportSpec::ExternalProcess(a), TransportSpec::ExternalProcess(b)) => {
435 assert_eq!(a.command, b.command);
436 assert_eq!(a.sha256, b.sha256);
437 assert_eq!(a.args, b.args);
438 }
439 _ => panic!("transport kind drift after round-trip"),
440 }
441 }
442
443 #[test]
444 fn canonical_bytes_clear_signature_for_signing() {
445 let mut m = sample_manifest();
446 m.publisher = Some(PublisherInfo {
447 key_id: Some("abc".into()),
448 signature: Some("REAL_SIG".into()),
449 });
450 let bytes = canonical_manifest_bytes(&m).unwrap();
451 let s = std::str::from_utf8(&bytes).unwrap();
452 assert!(!s.contains("REAL_SIG"));
455 assert!(s.contains("abc"));
457 }
458
459 #[test]
460 fn manifest_digest_is_stable() {
461 let m = sample_manifest();
462 let a = manifest_digest_hex(&m).unwrap();
463 let b = manifest_digest_hex(&m).unwrap();
464 assert_eq!(a, b);
465 assert_eq!(a.len(), 64); }
467
468 #[test]
469 fn sign_then_verify_round_trip() {
470 let mut m = sample_manifest();
471 let key = SigningKey::generate(&mut OsRng);
472 sign_manifest(&mut m, &key).unwrap();
473 verify_signature(&m).expect("freshly signed manifest must verify");
474 }
475
476 #[test]
477 fn verify_fails_on_tampered_manifest() {
478 let mut m = sample_manifest();
479 let key = SigningKey::generate(&mut OsRng);
480 sign_manifest(&mut m, &key).unwrap();
481 if let TransportSpec::ExternalProcess(ref mut t) = m.transport {
483 t.command = Some("/tmp/malicious".into());
484 }
485 let err = verify_signature(&m).expect_err("tampered manifest must fail verify");
486 assert!(matches!(err, BundleError::SignatureInvalid(_)));
487 }
488
489 #[test]
490 fn verify_fails_on_missing_publisher() {
491 let m = sample_manifest();
492 let err = verify_signature(&m).expect_err("unsigned manifest must error");
493 assert!(matches!(err, BundleError::PublisherMissing));
494 }
495
496 #[test]
497 fn verify_fails_on_wrong_key() {
498 let mut m = sample_manifest();
499 let key = SigningKey::generate(&mut OsRng);
500 sign_manifest(&mut m, &key).unwrap();
501 let other = SigningKey::generate(&mut OsRng);
503 m.publisher.as_mut().unwrap().key_id =
504 Some(encode_base64(other.verifying_key().as_bytes()));
505 let err = verify_signature(&m).expect_err("wrong key_id must fail verify");
506 assert!(matches!(err, BundleError::SignatureInvalid(_)));
507 }
508
509 #[test]
510 fn sha256_hex_matches_known_value() {
511 let empty = sha256_hex(b"");
513 assert_eq!(
514 empty,
515 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
516 );
517 let abc = sha256_hex(b"abc");
519 assert_eq!(
520 abc,
521 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
522 );
523 }
524
525 #[test]
526 fn verify_sha256_accepts_match_rejects_mismatch_and_is_case_insensitive() {
527 let bytes = b"agent-binary-payload";
528 let digest = sha256_hex(bytes);
529 verify_sha256(bytes, &digest).expect("matching digest must verify");
530 verify_sha256(bytes, &digest.to_uppercase())
531 .expect("verify is case-insensitive on the hex digest");
532 let err = verify_sha256(bytes, "00".repeat(32).as_str())
533 .expect_err("non-matching digest must fail");
534 assert!(matches!(err, BundleError::SignatureInvalid(_)));
535 }
536
537 #[test]
538 fn signing_is_idempotent_with_same_key() {
539 let mut a = sample_manifest();
542 let mut b = sample_manifest();
543 let key = SigningKey::generate(&mut OsRng);
544 sign_manifest(&mut a, &key).unwrap();
545 sign_manifest(&mut b, &key).unwrap();
546 assert_eq!(
547 a.publisher.as_ref().unwrap().signature,
548 b.publisher.as_ref().unwrap().signature
549 );
550 }
551}