Skip to main content

treeship_core/bundle/
mod.rs

1use std::path::Path;
2
3use crate::{
4    attestation::{sign, ArtifactId, Envelope, Signer, SignError, Verifier, VerifyError},
5    statements::{payload_type, ArtifactRef, BundleStatement},
6    storage::{Record, Store, StorageError},
7};
8
9/// Error from bundle operations.
10#[derive(Debug)]
11pub enum BundleError {
12    Storage(StorageError),
13    Sign(SignError),
14    Io(std::io::Error),
15    Json(serde_json::Error),
16    ArtifactNotFound(String),
17    InvalidBundle(String),
18    /// A signature on an imported envelope did not verify against the
19    /// configured trust root. Carries the offending envelope's index in
20    /// the export (0 = bundle envelope, 1..=N = artifact envelopes) and
21    /// the underlying verification error.
22    UnverifiedEnvelope { index: usize, source: VerifyError },
23    /// `import` was called with an empty trust root. Without trusted keys
24    /// there is nothing to verify signatures against, so import would
25    /// degenerate to "trust whatever the file says" — refused loudly.
26    NoTrustRoot,
27}
28
29impl std::fmt::Display for BundleError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::Storage(e)          => write!(f, "bundle storage: {e}"),
33            Self::Sign(e)             => write!(f, "bundle sign: {e}"),
34            Self::Io(e)               => write!(f, "bundle io: {e}"),
35            Self::Json(e)             => write!(f, "bundle json: {e}"),
36            Self::ArtifactNotFound(id)=> write!(f, "artifact not found: {id}"),
37            Self::InvalidBundle(msg)  => write!(f, "invalid bundle: {msg}"),
38            Self::UnverifiedEnvelope { index, source } => write!(
39                f,
40                "envelope {index} failed signature verification: {source}",
41            ),
42            Self::NoTrustRoot => write!(
43                f,
44                "bundle import requires a configured trust root: \
45                 generate or import a signer key (treeship init / treeship keys add) \
46                 before importing a .treeship bundle",
47            ),
48        }
49    }
50}
51
52impl std::error::Error for BundleError {}
53impl From<StorageError>       for BundleError { fn from(e: StorageError)       -> Self { Self::Storage(e) } }
54impl From<SignError>          for BundleError { fn from(e: SignError)          -> Self { Self::Sign(e) } }
55impl From<std::io::Error>    for BundleError { fn from(e: std::io::Error)    -> Self { Self::Io(e) } }
56impl From<serde_json::Error> for BundleError { fn from(e: serde_json::Error) -> Self { Self::Json(e) } }
57
58/// The result of creating a bundle.
59#[derive(Debug)]
60pub struct CreateResult {
61    pub artifact_id: ArtifactId,
62    pub digest:      String,
63    pub record:      Record,
64    pub statement:   BundleStatement,
65}
66
67/// A .treeship export file: the bundle envelope plus all referenced artifact envelopes.
68#[derive(Debug, serde::Serialize, serde::Deserialize)]
69pub struct ExportFile {
70    /// Format version for forward compatibility.
71    pub version: String,
72
73    /// The signed bundle envelope.
74    pub bundle: Envelope,
75
76    /// All artifact envelopes referenced by the bundle, in chain order.
77    pub artifacts: Vec<Envelope>,
78}
79
80const EXPORT_VERSION: &str = "treeship-export/v1";
81
82/// Create a bundle from a list of artifact IDs.
83///
84/// Reads each artifact from storage, builds a `BundleStatement` referencing
85/// them, signs it, and stores the bundle as a regular artifact.
86pub fn create(
87    artifact_ids: &[&str],
88    tag:          Option<&str>,
89    description:  Option<&str>,
90    storage:      &Store,
91    signer:       &dyn Signer,
92) -> Result<CreateResult, BundleError> {
93    if artifact_ids.is_empty() {
94        return Err(BundleError::InvalidBundle("no artifact IDs provided".into()));
95    }
96
97    // Read each artifact and build the reference list.
98    let mut refs = Vec::with_capacity(artifact_ids.len());
99    let mut records = Vec::with_capacity(artifact_ids.len());
100
101    for &id in artifact_ids {
102        let rec = storage.read(id)
103            .map_err(|_| BundleError::ArtifactNotFound(id.to_string()))?;
104        refs.push(ArtifactRef {
105            id:    rec.artifact_id.clone(),
106            digest: rec.digest.clone(),
107            type_: rec.payload_type.clone(),
108        });
109        records.push(rec);
110    }
111
112    let stmt = BundleStatement {
113        type_:      crate::statements::TYPE_BUNDLE.into(),
114        timestamp:  crate::statements::unix_to_rfc3339(now_secs()),
115        tag:        tag.map(|s| s.to_string()),
116        description: description.map(|s| s.to_string()),
117        artifacts:  refs,
118        policy_ref: None,
119        meta:       None,
120    };
121
122    let pt     = payload_type("bundle");
123    let result = sign(&pt, &stmt, signer)?;
124
125    let record = Record {
126        artifact_id:  result.artifact_id.clone(),
127        digest:       result.digest.clone(),
128        payload_type: pt,
129        key_id:       signer.key_id().to_string(),
130        signed_at:    stmt.timestamp.clone(),
131        parent_id:    None,
132        envelope:     result.envelope,
133        hub_url:      None,
134    };
135
136    storage.write(&record)?;
137
138    Ok(CreateResult {
139        artifact_id: result.artifact_id,
140        digest:      result.digest,
141        record,
142        statement:   stmt,
143    })
144}
145
146/// Export a bundle to a .treeship file.
147///
148/// The export file contains the bundle envelope and all referenced artifact
149/// envelopes. This is the portable format for sharing proof chains.
150pub fn export(
151    bundle_id: &str,
152    out_path:  &Path,
153    storage:   &Store,
154) -> Result<(), BundleError> {
155    let bundle_rec = storage.read(bundle_id)?;
156
157    // Verify this is actually a bundle.
158    let expected_pt = payload_type("bundle");
159    if bundle_rec.payload_type != expected_pt {
160        return Err(BundleError::InvalidBundle(format!(
161            "artifact {} is {}, not a bundle",
162            bundle_id, bundle_rec.payload_type
163        )));
164    }
165
166    // Decode the bundle statement to get artifact references.
167    let stmt: BundleStatement = bundle_rec.envelope.unmarshal_statement()
168        .map_err(|e| BundleError::InvalidBundle(format!("cannot decode bundle: {e}")))?;
169
170    // Collect all referenced artifact envelopes.
171    let mut artifact_envelopes = Vec::with_capacity(stmt.artifacts.len());
172    for art_ref in &stmt.artifacts {
173        let rec = storage.read(&art_ref.id)
174            .map_err(|_| BundleError::ArtifactNotFound(art_ref.id.clone()))?;
175        artifact_envelopes.push(rec.envelope);
176    }
177
178    let export = ExportFile {
179        version:   EXPORT_VERSION.into(),
180        bundle:    bundle_rec.envelope,
181        artifacts: artifact_envelopes,
182    };
183
184    let json = serde_json::to_vec_pretty(&export)?;
185    std::fs::write(out_path, &json)?;
186
187    Ok(())
188}
189
190/// Import a .treeship file into local storage.
191///
192/// Reads the export file, verifies every envelope's signatures against the
193/// provided `verifier`, re-derives content-addressed IDs, and stores everything
194/// locally. Returns the bundle's artifact ID.
195///
196/// P0 #5 (audit): import previously called `record_from_envelope` with no
197/// signature check, which made imported bundles forged-record vectors. The
198/// `verifier` argument carries the caller's trust root — typically built from
199/// the local keystore's public keys. If verification fails for any envelope
200/// the entire import is rejected; partial writes are avoided by verifying all
201/// envelopes before writing any record.
202pub fn import(
203    path:     &Path,
204    storage:  &Store,
205    verifier: &Verifier,
206) -> Result<ArtifactId, BundleError> {
207    let bytes = std::fs::read(path)?;
208    let export: ExportFile = serde_json::from_slice(&bytes)?;
209
210    if export.version != EXPORT_VERSION {
211        return Err(BundleError::InvalidBundle(format!(
212            "unsupported export version: {} (expected {})",
213            export.version, EXPORT_VERSION
214        )));
215    }
216
217    // Verify every envelope before writing any record. `verify_any` (not
218    // `verify`) is the right primitive here: an envelope may carry multiple
219    // signatures from a rotation/co-sign setup and the local trust root only
220    // needs one to match. Index 0 = bundle envelope, 1..=N = artifact
221    // envelopes (matches the order shown in error messages and CLI output).
222    verifier.verify_any(&export.bundle)
223        .map_err(|source| BundleError::UnverifiedEnvelope { index: 0, source })?;
224    for (i, env) in export.artifacts.iter().enumerate() {
225        verifier.verify_any(env)
226            .map_err(|source| BundleError::UnverifiedEnvelope { index: i + 1, source })?;
227    }
228
229    // All signatures check out: now write.
230    for env in &export.artifacts {
231        let record = record_from_envelope(env)?;
232        storage.write(&record)?;
233    }
234
235    let bundle_record = record_from_envelope(&export.bundle)?;
236    let bundle_id = bundle_record.artifact_id.clone();
237    storage.write(&bundle_record)?;
238
239    Ok(bundle_id)
240}
241
242/// Reconstruct a Record from a DSSE envelope by re-deriving the artifact ID.
243fn record_from_envelope(envelope: &Envelope) -> Result<Record, BundleError> {
244    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
245
246    let payload_bytes = URL_SAFE_NO_PAD.decode(&envelope.payload)
247        .map_err(|e| BundleError::InvalidBundle(format!("bad payload base64: {e}")))?;
248
249    let pae_bytes = crate::attestation::pae(&envelope.payload_type, &payload_bytes);
250    let artifact_id = crate::attestation::artifact_id_from_pae(&pae_bytes);
251    let digest      = crate::attestation::digest_from_pae(&pae_bytes);
252
253    // Extract timestamp from the payload if possible.
254    let signed_at = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
255        .ok()
256        .and_then(|v| v.get("timestamp").and_then(|t| t.as_str().map(|s| s.to_string())))
257        .unwrap_or_default();
258
259    // Extract parent_id from the payload if present.
260    let parent_id = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
261        .ok()
262        .and_then(|v| v.get("parentId").and_then(|t| t.as_str().map(|s| s.to_string())));
263
264    let key_id = envelope.signatures.first()
265        .map(|s| s.keyid.clone())
266        .unwrap_or_default();
267
268    Ok(Record {
269        artifact_id,
270        digest,
271        payload_type: envelope.payload_type.clone(),
272        key_id,
273        signed_at,
274        parent_id,
275        envelope: envelope.clone(),
276        hub_url: None,
277    })
278}
279
280fn now_secs() -> u64 {
281    use std::time::{SystemTime, UNIX_EPOCH};
282    SystemTime::now()
283        .duration_since(UNIX_EPOCH)
284        .unwrap_or_default()
285        .as_secs()
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::attestation::Ed25519Signer;
292    use crate::statements::{ActionStatement, ApprovalStatement};
293
294    fn tmp_store() -> (Store, std::path::PathBuf) {
295        let mut p = std::env::temp_dir();
296        p.push(format!("treeship-bundle-test-{}", {
297            use rand::RngCore;
298            let mut b = [0u8; 4];
299            rand::thread_rng().fill_bytes(&mut b);
300            b.iter().fold(String::new(), |mut s, byte| {
301                s.push_str(&format!("{:02x}", byte));
302                s
303            })
304        }));
305        let store = Store::open(&p).unwrap();
306        (store, p)
307    }
308
309    fn rm(p: std::path::PathBuf) { let _ = std::fs::remove_dir_all(p); }
310
311    fn sign_and_store(store: &Store, signer: &dyn Signer, pt: &str, stmt: &impl serde::Serialize) -> String {
312        let result = sign(pt, stmt, signer).unwrap();
313        store.write(&Record {
314            artifact_id:  result.artifact_id.clone(),
315            digest:       result.digest.clone(),
316            payload_type: pt.to_string(),
317            key_id:       signer.key_id().to_string(),
318            signed_at:    String::new(),
319            parent_id:    None,
320            envelope:     result.envelope,
321            hub_url:      None,
322        }).unwrap();
323        result.artifact_id
324    }
325
326    #[test]
327    fn create_bundle() {
328        let (store, dir) = tmp_store();
329        let signer = Ed25519Signer::generate("key_test").unwrap();
330
331        let a1 = sign_and_store(&store, &signer, &payload_type("action"),
332            &ActionStatement::new("agent://a", "tool.call"));
333        let a2 = sign_and_store(&store, &signer, &payload_type("approval"),
334            &ApprovalStatement::new("human://b", "nonce_1"));
335
336        let result = create(
337            &[&a1, &a2],
338            Some("test-bundle"),
339            None,
340            &store,
341            &signer,
342        ).unwrap();
343
344        assert!(result.artifact_id.starts_with("art_"));
345        assert_eq!(result.statement.artifacts.len(), 2);
346        assert_eq!(result.statement.tag.as_deref(), Some("test-bundle"));
347
348        // Bundle is stored
349        assert!(store.exists(&result.artifact_id));
350        rm(dir);
351    }
352
353    #[test]
354    fn create_empty_fails() {
355        let (store, dir) = tmp_store();
356        let signer = Ed25519Signer::generate("key_test").unwrap();
357        let err = create(&[], None, None, &store, &signer).unwrap_err();
358        assert!(err.to_string().contains("no artifact IDs"));
359        rm(dir);
360    }
361
362    #[test]
363    fn create_missing_artifact_fails() {
364        let (store, dir) = tmp_store();
365        let signer = Ed25519Signer::generate("key_test").unwrap();
366        let err = create(&["art_doesnotexist1234567890123456"], None, None, &store, &signer).unwrap_err();
367        assert!(err.to_string().contains("not found"));
368        rm(dir);
369    }
370
371    #[test]
372    fn export_and_import_roundtrip() {
373        let (store, dir) = tmp_store();
374        let signer = Ed25519Signer::generate("key_test").unwrap();
375        let verifier = crate::attestation::Verifier::from_signer(&signer);
376
377        let a1 = sign_and_store(&store, &signer, &payload_type("action"),
378            &ActionStatement::new("agent://a", "tool.call"));
379        let a2 = sign_and_store(&store, &signer, &payload_type("action"),
380            &ActionStatement::new("agent://b", "web.fetch"));
381
382        let bundle = create(&[&a1, &a2], Some("roundtrip"), None, &store, &signer).unwrap();
383
384        // Export
385        let export_path = dir.join("test.treeship");
386        export(&bundle.artifact_id, &export_path, &store).unwrap();
387        assert!(export_path.exists());
388
389        // Read and check the export file structure
390        let bytes = std::fs::read(&export_path).unwrap();
391        let ef: ExportFile = serde_json::from_slice(&bytes).unwrap();
392        assert_eq!(ef.version, EXPORT_VERSION);
393        assert_eq!(ef.artifacts.len(), 2);
394
395        // Import into a fresh store
396        let (store2, dir2) = tmp_store();
397        let imported_id = import(&export_path, &store2, &verifier).unwrap();
398        assert_eq!(imported_id, bundle.artifact_id);
399
400        // All artifacts are now in the new store
401        assert!(store2.exists(&a1));
402        assert!(store2.exists(&a2));
403        assert!(store2.exists(&bundle.artifact_id));
404
405        rm(dir);
406        rm(dir2);
407    }
408
409    #[test]
410    fn export_non_bundle_fails() {
411        let (store, dir) = tmp_store();
412        let signer = Ed25519Signer::generate("key_test").unwrap();
413        let a1 = sign_and_store(&store, &signer, &payload_type("action"),
414            &ActionStatement::new("agent://a", "tool.call"));
415
416        let export_path = dir.join("bad.treeship");
417        let err = export(&a1, &export_path, &store).unwrap_err();
418        assert!(err.to_string().contains("not a bundle"));
419        rm(dir);
420    }
421
422    #[test]
423    fn import_bad_version_fails() {
424        let (store, dir) = tmp_store();
425        let signer   = Ed25519Signer::generate("key_test").unwrap();
426        let verifier = crate::attestation::Verifier::from_signer(&signer);
427        let bad = ExportFile {
428            version:   "bad/v99".into(),
429            bundle:    Envelope {
430                payload: String::new(),
431                payload_type: String::new(),
432                signatures: vec![],
433            },
434            artifacts: vec![],
435        };
436        let path = dir.join("bad.treeship");
437        std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
438
439        let err = import(&path, &store, &verifier).unwrap_err();
440        assert!(err.to_string().contains("unsupported export version"));
441        rm(dir);
442    }
443
444    #[test]
445    fn import_rejects_envelope_with_invalid_signature() {
446        // P0 #5: before this fix, `import` called `record_from_envelope`
447        // straight from the file with no verification — an attacker could
448        // hand-craft a `.treeship` whose envelopes pointed at any payload
449        // and the import would happily land it in storage. Now every
450        // envelope must verify against the caller's trust root.
451        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
452
453        let (store, dir) = tmp_store();
454        let signer       = Ed25519Signer::generate("key_test").unwrap();
455        let verifier     = crate::attestation::Verifier::from_signer(&signer);
456
457        // Build a normal bundle so we have a valid export to start from.
458        let a1 = sign_and_store(&store, &signer, &payload_type("action"),
459            &ActionStatement::new("agent://a", "tool.call"));
460        let bundle = create(&[&a1], Some("tampered"), None, &store, &signer).unwrap();
461        let export_path = dir.join("tampered.treeship");
462        export(&bundle.artifact_id, &export_path, &store).unwrap();
463
464        // Tamper the *first* artifact envelope's signature bytes. The keyid
465        // remains correct (still our trusted key) but the cipher-bytes no
466        // longer verify against the PAE.
467        let raw = std::fs::read(&export_path).unwrap();
468        let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
469        // Replace the 64-byte signature with all zeros — well-formed length,
470        // mathematically invalid.
471        ef.artifacts[0].signatures[0].sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
472        std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
473
474        // Import into a fresh store. The tamper must be detected and the
475        // import must fail; the fresh store must remain empty of the
476        // artifact and bundle.
477        let (store2, dir2) = tmp_store();
478        let err = import(&export_path, &store2, &verifier).unwrap_err();
479        assert!(
480            matches!(err, BundleError::UnverifiedEnvelope { index: 1, .. }),
481            "expected UnverifiedEnvelope{{index:1, ..}}, got: {err}"
482        );
483        // Verify nothing was written: signatures are checked before any
484        // record is persisted, so the destination store stays clean.
485        assert!(!store2.exists(&a1));
486        assert!(!store2.exists(&bundle.artifact_id));
487
488        rm(dir);
489        rm(dir2);
490    }
491
492    #[test]
493    fn import_rejects_unsigned_envelope() {
494        // Companion to the P0 #4 fix: a bundle file whose envelopes carry
495        // zero signatures must not import. The `Verifier` returns
496        // `NoValidSignature` and `import` propagates it as
497        // `UnverifiedEnvelope`.
498        let (store, dir) = tmp_store();
499        let signer       = Ed25519Signer::generate("key_test").unwrap();
500        let verifier     = crate::attestation::Verifier::from_signer(&signer);
501
502        let a1 = sign_and_store(&store, &signer, &payload_type("action"),
503            &ActionStatement::new("agent://a", "tool.call"));
504        let bundle = create(&[&a1], Some("unsigned"), None, &store, &signer).unwrap();
505        let export_path = dir.join("unsigned.treeship");
506        export(&bundle.artifact_id, &export_path, &store).unwrap();
507
508        // Strip every signature off every envelope in the export.
509        let raw = std::fs::read(&export_path).unwrap();
510        let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
511        ef.bundle.signatures.clear();
512        for env in &mut ef.artifacts { env.signatures.clear(); }
513        std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
514
515        let (store2, dir2) = tmp_store();
516        let err = import(&export_path, &store2, &verifier).unwrap_err();
517        assert!(
518            matches!(err, BundleError::UnverifiedEnvelope { index: 0, .. }),
519            "expected UnverifiedEnvelope{{index:0, ..}} (bundle envelope first), got: {err}"
520        );
521
522        rm(dir);
523        rm(dir2);
524    }
525}