Skip to main content

car_bundle/
lib.rs

1//! Manifest format, canonicalization, and ed25519 signing for
2//! CAR contributed-agent bundles (Parslee-ai/car#182).
3//!
4//! ## Scope
5//!
6//! This crate owns the on-disk shape and crypto primitives shared
7//! between the supervisor (loads + verifies installed agents),
8//! the CLI (publishes + signs new agents), and the registry
9//! (serves signed manifests). It is `no-runtime` — pure data,
10//! pure functions, no async, no I/O outside `read_to_string` for
11//! tests. The supervisor and CLI hold the I/O.
12//!
13//! ## Phase status
14//!
15//! - **Phase 1** (`car-registry`): the manifest format landed
16//!   inline at `car_registry::manifest`. The supervisor dual-reads
17//!   legacy `agents.json` and the new `~/.car/agents/<id>/manifest.toml`
18//!   layout. Signature verification was stubbed out.
19//! - **Phase 2** (this crate): types extracted here; ed25519
20//!   sign/verify added; manifest-level canonicalization landed.
21//!   The supervisor wires verification with warn-but-not-reject
22//!   semantics so existing setups keep working while operators
23//!   sign their agents.
24//! - **Phase 3+**: full-bundle canonicalization (multi-file:
25//!   `identity.md`, `skills.jsonl`, `policies.json`, …) per
26//!   `docs/agent-bundle-spec.md §canonicalization`. Today's
27//!   `canonical_manifest_bytes` covers only the single
28//!   `manifest.toml` file — sufficient for `external_process`
29//!   bundles which carry no auxiliary data files.
30
31use 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/// `manifest.toml` top-level structure. One file per installed
57/// agent at `~/.car/agents/<id>/manifest.toml`.
58#[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/// `[agent]` block.
73#[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/// `[publisher]` block. The `signature` field is the base64-
90/// encoded ed25519 signature over the canonicalized manifest
91/// (i.e., the manifest with `publisher.signature` cleared).
92/// `key_id` is the ed25519 public key, base64-encoded (32 bytes
93/// raw → 44 char base64).
94#[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    /// Optional bare interpreter name (`"node"`, `"python3"`,
137    /// `"deno"`, …) resolved against the consumer's `$PATH` at
138    /// install time (Parslee-ai/car#182 phase 5). A registry
139    /// publisher cannot know the consumer's absolute interpreter
140    /// path — it moves across nvm / fnm / Homebrew / Volta — so a
141    /// portable manifest sets `interpreter` instead of `command`.
142    /// At install, `to_agent_spec` resolves the name to an absolute
143    /// path via the supervisor's `resolve_interpreter` (which runs
144    /// the same `validate_command` gate as a bare `command`, so a
145    /// PATH-injection or `/tmp`-parked interpreter is still
146    /// rejected). Mutually exclusive with `command`: set exactly one.
147    /// This is the only opt-in to PATH resolution — a bare `command`
148    /// still requires an absolute path.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub interpreter: Option<String>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub sha256: Option<String>,
153    /// Optional `https://` URL the publisher hosts the binary at.
154    /// When present, `car install` fetches the binary from this
155    /// URL, verifies the digest against `sha256`, and writes the
156    /// resulting file at the local `command` path before adoption
157    /// (Parslee-ai/car#182 phase 5). Mutually exclusive with
158    /// `health_url`. Locally-developed manifests can leave this
159    /// unset and ship `command` pointing at a binary the
160    /// developer placed there manually.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub binary_url: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub health_url: Option<String>,
165    #[serde(default)]
166    pub args: Vec<String>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub cwd: Option<PathBuf>,
169    #[serde(default)]
170    pub env: BTreeMap<String, String>,
171    #[serde(default)]
172    pub restart: RestartPolicy,
173    #[serde(default = "default_max_restarts")]
174    pub max_restarts: u32,
175    #[serde(default = "default_backoff")]
176    pub backoff_secs: u64,
177    #[serde(default)]
178    pub auto_start: bool,
179    #[serde(default)]
180    pub token: String,
181}
182
183fn default_max_restarts() -> u32 {
184    10
185}
186
187fn default_backoff() -> u64 {
188    5
189}
190
191/// Restart policy mirrors the supervisor's surface. Re-declared
192/// here (rather than re-exported from `car-registry`) so this
193/// crate stays standalone — `car-registry` depends on
194/// `car-bundle`, not the other way around.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum RestartPolicy {
198    Never,
199    #[default]
200    OnFailure,
201    Always,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
205pub struct CapabilityDeclarations {
206    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207    pub required: BTreeMap<String, Vec<String>>,
208    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
209    pub optional: BTreeMap<String, Vec<String>>,
210    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211    pub denied: BTreeMap<String, Vec<String>>,
212}
213
214impl AgentManifest {
215    pub fn is_pure_data(&self) -> bool {
216        matches!(self.transport, TransportSpec::PureData)
217    }
218
219    pub fn is_remote_service(&self) -> bool {
220        matches!(
221            &self.transport,
222            TransportSpec::ExternalProcess(t) if t.health_url.is_some() && t.command.is_none()
223        )
224    }
225
226    /// Parse a `manifest.toml` text. Does NOT verify the signature
227    /// — pair with [`verify_signature`] when verification is
228    /// required.
229    pub fn from_toml_str(text: &str) -> Result<Self, BundleError> {
230        toml::from_str(text).map_err(|e| BundleError::InvalidToml(e.to_string()))
231    }
232
233    /// Serialize back to canonical TOML text. Round-trips through
234    /// `to_string_pretty` and back via `from_toml_str`.
235    pub fn to_toml_string(&self) -> Result<String, BundleError> {
236        toml::to_string_pretty(self).map_err(|e| BundleError::InvalidToml(e.to_string()))
237    }
238}
239
240// ---------------------------------------------------------------------
241// Canonicalization
242// ---------------------------------------------------------------------
243
244/// Produce canonical bytes of a manifest for signing / verification.
245///
246/// Canonicalization rules for the phase-2 single-file form:
247///
248/// 1. The `publisher.signature` field is cleared (a signature
249///    cannot sign itself).
250/// 2. The manifest is serialized to JSON (not TOML — TOML lacks a
251///    spec-mandated canonical form; serde_json with sorted keys
252///    via `BTreeMap` is well-defined).
253/// 3. Whitespace is stripped (no pretty-printing).
254/// 4. Output is UTF-8 bytes, LF line endings.
255///
256/// Multi-file bundle canonicalization (per
257/// `docs/agent-bundle-spec.md §canonicalization`) lands in a later
258/// phase when pure-data bundles need it.
259pub fn canonical_manifest_bytes(manifest: &AgentManifest) -> Result<Vec<u8>, BundleError> {
260    let mut cleared = manifest.clone();
261    if let Some(pub_info) = cleared.publisher.as_mut() {
262        pub_info.signature = None;
263    }
264    // JSON because it has a deterministic canonical form when
265    // keys are sorted, and the spec is unambiguous. TOML's
266    // round-trip whitespace handling is loose enough that two
267    // serializers can disagree on the bytes.
268    serde_json::to_vec(&cleared).map_err(|e| BundleError::InvalidJson(e.to_string()))
269}
270
271/// SHA-256 hex digest of the canonical manifest bytes. Useful for
272/// content-addressed lookups (registry caching, etc.) without
273/// requiring signature verification.
274pub fn manifest_digest_hex(manifest: &AgentManifest) -> Result<String, BundleError> {
275    let bytes = canonical_manifest_bytes(manifest)?;
276    let mut hasher = Sha256::new();
277    hasher.update(&bytes);
278    Ok(hex(&hasher.finalize()))
279}
280
281/// SHA-256 hex digest of an arbitrary byte slice. Used by
282/// `car install` to verify binaries fetched via
283/// `transport.binary_url` against the manifest's
284/// `transport.sha256` (Parslee-ai/car#182 phase 5).
285pub fn sha256_hex(bytes: &[u8]) -> String {
286    let mut hasher = Sha256::new();
287    hasher.update(bytes);
288    hex(&hasher.finalize())
289}
290
291/// Verify a byte slice matches a hex-encoded SHA-256 digest.
292/// Comparison is constant-time-ish (case-insensitive hex
293/// equality), and returns an error rather than a bool so the
294/// failure message can name the expected + actual digests.
295pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), BundleError> {
296    let actual = sha256_hex(bytes);
297    if actual.eq_ignore_ascii_case(expected_hex) {
298        Ok(())
299    } else {
300        Err(BundleError::SignatureInvalid(format!(
301            "sha256 mismatch: expected `{expected_hex}`, got `{actual}`"
302        )))
303    }
304}
305
306fn hex(bytes: &[u8]) -> String {
307    let mut out = String::with_capacity(bytes.len() * 2);
308    for b in bytes {
309        out.push_str(&format!("{:02x}", b));
310    }
311    out
312}
313
314// ---------------------------------------------------------------------
315// Signing + verification
316// ---------------------------------------------------------------------
317
318/// Sign a manifest in place: writes the public key id into
319/// `publisher.key_id`, then serializes canonical bytes (which
320/// clear the signature but include the key_id), signs them with
321/// `key`, and writes the base64 signature into
322/// `publisher.signature`. Replaces any existing signature.
323///
324/// Ordering matters: key_id MUST be set BEFORE computing canonical
325/// bytes for signing, so verification sees the same input. A
326/// previous draft set key_id after computing the bytes and the
327/// signature failed to verify — caught by
328/// `sign_then_verify_round_trip`.
329pub fn sign_manifest(manifest: &mut AgentManifest, key: &SigningKey) -> Result<(), BundleError> {
330    let pub_info = manifest
331        .publisher
332        .get_or_insert_with(PublisherInfo::default);
333    pub_info.key_id = Some(encode_base64(key.verifying_key().as_bytes()));
334    pub_info.signature = None;
335    let bytes = canonical_manifest_bytes(manifest)?;
336    let signature = key.sign(&bytes);
337    // Re-borrow — the canonical-bytes call took an immutable view.
338    manifest.publisher.as_mut().unwrap().signature = Some(encode_base64(&signature.to_bytes()));
339    Ok(())
340}
341
342/// Verify a manifest's signature against the embedded
343/// `publisher.key_id`. Returns `Ok(())` on success, `Err(...)` on
344/// any failure (missing publisher, malformed key, mismatched
345/// signature). A manifest with no `publisher` block is treated as
346/// unsigned and rejected — callers that want to accept unsigned
347/// manifests should not call this function.
348pub fn verify_signature(manifest: &AgentManifest) -> Result<(), BundleError> {
349    let pub_info = manifest
350        .publisher
351        .as_ref()
352        .ok_or(BundleError::PublisherMissing)?;
353    let key_b64 = pub_info
354        .key_id
355        .as_deref()
356        .ok_or_else(|| BundleError::KeyMalformed("missing key_id".into()))?;
357    let sig_b64 = pub_info
358        .signature
359        .as_deref()
360        .ok_or_else(|| BundleError::SignatureInvalid("missing signature".into()))?;
361    let key_bytes = decode_base64(key_b64)
362        .map_err(|e| BundleError::KeyMalformed(format!("key_id base64: {e}")))?;
363    let key_arr: [u8; 32] = key_bytes
364        .as_slice()
365        .try_into()
366        .map_err(|_| BundleError::KeyMalformed("key_id must be 32 bytes".into()))?;
367    let verifying =
368        VerifyingKey::from_bytes(&key_arr).map_err(|e| BundleError::KeyMalformed(e.to_string()))?;
369    let sig_bytes = decode_base64(sig_b64)
370        .map_err(|e| BundleError::SignatureInvalid(format!("signature base64: {e}")))?;
371    let sig_arr: [u8; 64] = sig_bytes
372        .as_slice()
373        .try_into()
374        .map_err(|_| BundleError::SignatureInvalid("signature must be 64 bytes".into()))?;
375    let signature = Signature::from_bytes(&sig_arr);
376    let bytes = canonical_manifest_bytes(manifest)?;
377    verifying
378        .verify(&bytes, &signature)
379        .map_err(|e| BundleError::SignatureInvalid(e.to_string()))
380}
381
382/// Verify a **detached** ed25519 signature over raw `bytes` against a
383/// base64-encoded 32-byte public key and base64-encoded 64-byte
384/// signature. Used to authenticate documents that aren't manifests (e.g.
385/// the refreshable model catalog in `car-inference`): the source signs
386/// the exact file bytes, so no canonicalization is needed.
387pub fn verify_detached(
388    bytes: &[u8],
389    signature_b64: &str,
390    public_key_b64: &str,
391) -> Result<(), BundleError> {
392    let key_bytes = decode_base64(public_key_b64)
393        .map_err(|e| BundleError::KeyMalformed(format!("public key base64: {e}")))?;
394    let key_arr: [u8; 32] = key_bytes
395        .as_slice()
396        .try_into()
397        .map_err(|_| BundleError::KeyMalformed("public key must be 32 bytes".into()))?;
398    let verifying =
399        VerifyingKey::from_bytes(&key_arr).map_err(|e| BundleError::KeyMalformed(e.to_string()))?;
400    let sig_bytes = decode_base64(signature_b64)
401        .map_err(|e| BundleError::SignatureInvalid(format!("signature base64: {e}")))?;
402    let sig_arr: [u8; 64] = sig_bytes
403        .as_slice()
404        .try_into()
405        .map_err(|_| BundleError::SignatureInvalid("signature must be 64 bytes".into()))?;
406    let signature = Signature::from_bytes(&sig_arr);
407    verifying
408        .verify(bytes, &signature)
409        .map_err(|e| BundleError::SignatureInvalid(e.to_string()))
410}
411
412fn encode_base64(bytes: &[u8]) -> String {
413    use base64::Engine;
414    base64::engine::general_purpose::STANDARD.encode(bytes)
415}
416
417fn decode_base64(s: &str) -> Result<Vec<u8>, String> {
418    use base64::Engine;
419    base64::engine::general_purpose::STANDARD
420        .decode(s)
421        .map_err(|e| e.to_string())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use ed25519_dalek::SigningKey;
428    use rand_core::OsRng;
429
430    fn sample_manifest() -> AgentManifest {
431        AgentManifest {
432            agent: AgentIdentity {
433                id: "ui-improver".into(),
434                name: "UI Improvement".into(),
435                namespace: Some("parslee".into()),
436                version: Some("0.1.0".into()),
437                description: Some("Issues A2UI patches".into()),
438                license: Some("Apache-2.0".into()),
439                homepage: None,
440            },
441            publisher: None,
442            runtime: Some(RuntimeRequirements {
443                car_min_version: Some("0.8.0".into()),
444                bundle_format_version: 1,
445            }),
446            lifecycle: Some(LifecyclePolicy {
447                stateful: true,
448                persistence: Some("host".into()),
449                default_inference_complexity: Some("low".into()),
450            }),
451            transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
452                command: Some("/usr/local/bin/ui-improver".into()),
453                interpreter: None,
454                binary_url: None,
455                sha256: Some("abc123".into()),
456                health_url: None,
457                args: vec!["--mode".into(), "a2ui".into()],
458                cwd: None,
459                env: BTreeMap::new(),
460                restart: RestartPolicy::OnFailure,
461                max_restarts: 10,
462                backoff_secs: 5,
463                auto_start: false,
464                token: String::new(),
465            }),
466            capabilities: None,
467        }
468    }
469
470    #[test]
471    fn round_trip_through_toml() {
472        let m = sample_manifest();
473        let text = m.to_toml_string().unwrap();
474        let round = AgentManifest::from_toml_str(&text).unwrap();
475        assert_eq!(round.agent.id, m.agent.id);
476        assert_eq!(round.agent.namespace, m.agent.namespace);
477        match (&round.transport, &m.transport) {
478            (TransportSpec::ExternalProcess(a), TransportSpec::ExternalProcess(b)) => {
479                assert_eq!(a.command, b.command);
480                assert_eq!(a.sha256, b.sha256);
481                assert_eq!(a.args, b.args);
482            }
483            _ => panic!("transport kind drift after round-trip"),
484        }
485    }
486
487    #[test]
488    fn contrib_template_manifest_parses_external_process_command() {
489        let text = include_str!("../../../examples/contrib-template/manifest.toml");
490        let manifest = AgentManifest::from_toml_str(text).unwrap();
491
492        match manifest.transport {
493            TransportSpec::ExternalProcess(transport) => {
494                assert_eq!(
495                    transport.command.as_deref(),
496                    Some("/absolute/path/to/contrib-template/agent.sh")
497                );
498            }
499            TransportSpec::PureData => panic!("contrib template must use external_process"),
500        }
501    }
502
503    #[test]
504    fn canonical_bytes_clear_signature_for_signing() {
505        let mut m = sample_manifest();
506        m.publisher = Some(PublisherInfo {
507            key_id: Some("abc".into()),
508            signature: Some("REAL_SIG".into()),
509        });
510        let bytes = canonical_manifest_bytes(&m).unwrap();
511        let s = std::str::from_utf8(&bytes).unwrap();
512        // The signature is cleared in the canonical form — otherwise
513        // a signature couldn't sign itself.
514        assert!(!s.contains("REAL_SIG"));
515        // key_id stays in (it's a claim, not the signature).
516        assert!(s.contains("abc"));
517    }
518
519    #[test]
520    fn manifest_digest_is_stable() {
521        let m = sample_manifest();
522        let a = manifest_digest_hex(&m).unwrap();
523        let b = manifest_digest_hex(&m).unwrap();
524        assert_eq!(a, b);
525        assert_eq!(a.len(), 64); // SHA-256 = 32 bytes hex = 64 chars
526    }
527
528    #[test]
529    fn sign_then_verify_round_trip() {
530        let mut m = sample_manifest();
531        let key = SigningKey::generate(&mut OsRng);
532        sign_manifest(&mut m, &key).unwrap();
533        verify_signature(&m).expect("freshly signed manifest must verify");
534    }
535
536    #[test]
537    fn verify_fails_on_tampered_manifest() {
538        let mut m = sample_manifest();
539        let key = SigningKey::generate(&mut OsRng);
540        sign_manifest(&mut m, &key).unwrap();
541        // Tamper with a field after signing.
542        if let TransportSpec::ExternalProcess(ref mut t) = m.transport {
543            t.command = Some("/tmp/malicious".into());
544        }
545        let err = verify_signature(&m).expect_err("tampered manifest must fail verify");
546        assert!(matches!(err, BundleError::SignatureInvalid(_)));
547    }
548
549    #[test]
550    fn verify_fails_on_missing_publisher() {
551        let m = sample_manifest();
552        let err = verify_signature(&m).expect_err("unsigned manifest must error");
553        assert!(matches!(err, BundleError::PublisherMissing));
554    }
555
556    #[test]
557    fn verify_fails_on_wrong_key() {
558        let mut m = sample_manifest();
559        let key = SigningKey::generate(&mut OsRng);
560        sign_manifest(&mut m, &key).unwrap();
561        // Substitute a different key_id.
562        let other = SigningKey::generate(&mut OsRng);
563        m.publisher.as_mut().unwrap().key_id =
564            Some(encode_base64(other.verifying_key().as_bytes()));
565        let err = verify_signature(&m).expect_err("wrong key_id must fail verify");
566        assert!(matches!(err, BundleError::SignatureInvalid(_)));
567    }
568
569    #[test]
570    fn sha256_hex_matches_known_value() {
571        // Empty input → SHA-256 of zero bytes.
572        let empty = sha256_hex(b"");
573        assert_eq!(
574            empty,
575            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
576        );
577        // Known vector for "abc".
578        let abc = sha256_hex(b"abc");
579        assert_eq!(
580            abc,
581            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
582        );
583    }
584
585    #[test]
586    fn verify_sha256_accepts_match_rejects_mismatch_and_is_case_insensitive() {
587        let bytes = b"agent-binary-payload";
588        let digest = sha256_hex(bytes);
589        verify_sha256(bytes, &digest).expect("matching digest must verify");
590        verify_sha256(bytes, &digest.to_uppercase())
591            .expect("verify is case-insensitive on the hex digest");
592        let err = verify_sha256(bytes, "00".repeat(32).as_str())
593            .expect_err("non-matching digest must fail");
594        assert!(matches!(err, BundleError::SignatureInvalid(_)));
595    }
596
597    #[test]
598    fn signing_is_idempotent_with_same_key() {
599        // Signing the same manifest twice with the same key
600        // produces identical bytes — ed25519 is deterministic.
601        let mut a = sample_manifest();
602        let mut b = sample_manifest();
603        let key = SigningKey::generate(&mut OsRng);
604        sign_manifest(&mut a, &key).unwrap();
605        sign_manifest(&mut b, &key).unwrap();
606        assert_eq!(
607            a.publisher.as_ref().unwrap().signature,
608            b.publisher.as_ref().unwrap().signature
609        );
610    }
611}