1use std::path::Path;
2
3use crate::{
4 attestation::{sign, ArtifactId, Envelope, SignError, Signer, Verifier, VerifyError},
5 statements::{payload_type, ArtifactRef, BundleStatement},
6 storage::{Record, StorageError, Store},
7};
8
9#[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 UnverifiedEnvelope {
23 index: usize,
24 source: VerifyError,
25 },
26 NoTrustRoot,
30}
31
32impl std::fmt::Display for BundleError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::Storage(e) => write!(f, "bundle storage: {e}"),
36 Self::Sign(e) => write!(f, "bundle sign: {e}"),
37 Self::Io(e) => write!(f, "bundle io: {e}"),
38 Self::Json(e) => write!(f, "bundle json: {e}"),
39 Self::ArtifactNotFound(id) => write!(f, "artifact not found: {id}"),
40 Self::InvalidBundle(msg) => write!(f, "invalid bundle: {msg}"),
41 Self::UnverifiedEnvelope { index, source } => write!(
42 f,
43 "envelope {index} failed signature verification: {source}",
44 ),
45 Self::NoTrustRoot => write!(
46 f,
47 "bundle import requires a configured trust root: \
48 generate or import a signer key (treeship init / treeship keys add) \
49 before importing a .treeship bundle",
50 ),
51 }
52 }
53}
54
55impl std::error::Error for BundleError {}
56impl From<StorageError> for BundleError {
57 fn from(e: StorageError) -> Self {
58 Self::Storage(e)
59 }
60}
61impl From<SignError> for BundleError {
62 fn from(e: SignError) -> Self {
63 Self::Sign(e)
64 }
65}
66impl From<std::io::Error> for BundleError {
67 fn from(e: std::io::Error) -> Self {
68 Self::Io(e)
69 }
70}
71impl From<serde_json::Error> for BundleError {
72 fn from(e: serde_json::Error) -> Self {
73 Self::Json(e)
74 }
75}
76
77#[derive(Debug)]
79pub struct CreateResult {
80 pub artifact_id: ArtifactId,
81 pub digest: String,
82 pub record: Record,
83 pub statement: BundleStatement,
84}
85
86#[derive(Debug, serde::Serialize, serde::Deserialize)]
88pub struct ExportFile {
89 pub version: String,
91
92 pub bundle: Envelope,
94
95 pub artifacts: Vec<Envelope>,
97}
98
99const EXPORT_VERSION: &str = "treeship-export/v1";
100
101pub fn create(
106 artifact_ids: &[&str],
107 tag: Option<&str>,
108 description: Option<&str>,
109 storage: &Store,
110 signer: &dyn Signer,
111) -> Result<CreateResult, BundleError> {
112 if artifact_ids.is_empty() {
113 return Err(BundleError::InvalidBundle(
114 "no artifact IDs provided".into(),
115 ));
116 }
117
118 let mut refs = Vec::with_capacity(artifact_ids.len());
120 let mut records = Vec::with_capacity(artifact_ids.len());
121
122 for &id in artifact_ids {
123 let rec = storage
124 .read(id)
125 .map_err(|_| BundleError::ArtifactNotFound(id.to_string()))?;
126 refs.push(ArtifactRef {
127 id: rec.artifact_id.clone(),
128 digest: rec.digest.clone(),
129 type_: rec.payload_type.clone(),
130 });
131 records.push(rec);
132 }
133
134 let stmt = BundleStatement {
135 type_: crate::statements::TYPE_BUNDLE.into(),
136 timestamp: crate::statements::unix_to_rfc3339(now_secs()),
137 tag: tag.map(|s| s.to_string()),
138 description: description.map(|s| s.to_string()),
139 artifacts: refs,
140 policy_ref: None,
141 meta: None,
142 };
143
144 let pt = payload_type("bundle");
145 let result = sign(&pt, &stmt, signer)?;
146
147 let record = Record {
148 artifact_id: result.artifact_id.clone(),
149 digest: result.digest.clone(),
150 payload_type: pt,
151 key_id: signer.key_id().to_string(),
152 signed_at: stmt.timestamp.clone(),
153 parent_id: None,
154 envelope: result.envelope,
155 hub_url: None,
156 anchors: Vec::new(),
158 };
159
160 storage.write(&record)?;
161
162 Ok(CreateResult {
163 artifact_id: result.artifact_id,
164 digest: result.digest,
165 record,
166 statement: stmt,
167 })
168}
169
170pub fn export(bundle_id: &str, out_path: &Path, storage: &Store) -> Result<(), BundleError> {
175 let bundle_rec = storage.read(bundle_id)?;
176
177 let expected_pt = payload_type("bundle");
179 if bundle_rec.payload_type != expected_pt {
180 return Err(BundleError::InvalidBundle(format!(
181 "artifact {} is {}, not a bundle",
182 bundle_id, bundle_rec.payload_type
183 )));
184 }
185
186 let stmt: BundleStatement = bundle_rec
188 .envelope
189 .unmarshal_statement()
190 .map_err(|e| BundleError::InvalidBundle(format!("cannot decode bundle: {e}")))?;
191
192 let mut artifact_envelopes = Vec::with_capacity(stmt.artifacts.len());
194 for art_ref in &stmt.artifacts {
195 let rec = storage
196 .read(&art_ref.id)
197 .map_err(|_| BundleError::ArtifactNotFound(art_ref.id.clone()))?;
198 artifact_envelopes.push(rec.envelope);
199 }
200
201 let export = ExportFile {
202 version: EXPORT_VERSION.into(),
203 bundle: bundle_rec.envelope,
204 artifacts: artifact_envelopes,
205 };
206
207 let json = serde_json::to_vec_pretty(&export)?;
208 std::fs::write(out_path, &json)?;
209
210 Ok(())
211}
212
213pub fn import(
226 path: &Path,
227 storage: &Store,
228 verifier: &Verifier,
229) -> Result<ArtifactId, BundleError> {
230 let bytes = std::fs::read(path)?;
231 let export: ExportFile = serde_json::from_slice(&bytes)?;
232
233 if export.version != EXPORT_VERSION {
234 return Err(BundleError::InvalidBundle(format!(
235 "unsupported export version: {} (expected {})",
236 export.version, EXPORT_VERSION
237 )));
238 }
239
240 let bundle_vr = verifier
246 .verify_any(&export.bundle)
247 .map_err(|source| BundleError::UnverifiedEnvelope { index: 0, source })?;
248 let mut artifact_verified_keys: Vec<Option<String>> =
249 Vec::with_capacity(export.artifacts.len());
250 for (i, env) in export.artifacts.iter().enumerate() {
251 let vr = verifier
252 .verify_any(env)
253 .map_err(|source| BundleError::UnverifiedEnvelope {
254 index: i + 1,
255 source,
256 })?;
257 artifact_verified_keys.push(vr.verified_key_ids.first().cloned());
258 }
259
260 for (env, vk) in export.artifacts.iter().zip(artifact_verified_keys.iter()) {
263 let record = record_from_envelope(env, vk.as_deref())?;
264 storage.write(&record)?;
265 }
266
267 let bundle_record = record_from_envelope(
268 &export.bundle,
269 bundle_vr.verified_key_ids.first().map(|s| s.as_str()),
270 )?;
271 let bundle_id = bundle_record.artifact_id.clone();
272 storage.write(&bundle_record)?;
273
274 Ok(bundle_id)
275}
276
277fn record_from_envelope(
285 envelope: &Envelope,
286 verified_key_id: Option<&str>,
287) -> Result<Record, BundleError> {
288 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
289
290 let payload_bytes = URL_SAFE_NO_PAD
291 .decode(&envelope.payload)
292 .map_err(|e| BundleError::InvalidBundle(format!("bad payload base64: {e}")))?;
293
294 let pae_bytes = crate::attestation::pae(&envelope.payload_type, &payload_bytes);
295 let artifact_id = crate::attestation::artifact_id_from_pae(&pae_bytes);
296 let digest = crate::attestation::digest_from_pae(&pae_bytes);
297
298 let signed_at = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
300 .ok()
301 .and_then(|v| {
302 v.get("timestamp")
303 .and_then(|t| t.as_str().map(|s| s.to_string()))
304 })
305 .unwrap_or_default();
306
307 let parent_id = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
309 .ok()
310 .and_then(|v| {
311 v.get("parentId")
312 .and_then(|t| t.as_str().map(|s| s.to_string()))
313 });
314
315 let key_id = verified_key_id
318 .map(|s| s.to_string())
319 .or_else(|| envelope.signatures.first().map(|s| s.keyid.clone()))
320 .unwrap_or_default();
321
322 Ok(Record {
323 artifact_id,
324 digest,
325 payload_type: envelope.payload_type.clone(),
326 key_id,
327 signed_at,
328 parent_id,
329 envelope: envelope.clone(),
330 hub_url: None,
331 anchors: Vec::new(),
334 })
335}
336
337fn now_secs() -> u64 {
338 use std::time::{SystemTime, UNIX_EPOCH};
339 SystemTime::now()
340 .duration_since(UNIX_EPOCH)
341 .unwrap_or_default()
342 .as_secs()
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::attestation::Ed25519Signer;
349 use crate::statements::{ActionStatement, ApprovalStatement};
350
351 fn tmp_store() -> (Store, std::path::PathBuf) {
352 let mut p = std::env::temp_dir();
353 p.push(format!("treeship-bundle-test-{}", {
354 use rand::RngCore;
355 let mut b = [0u8; 4];
356 rand::thread_rng().fill_bytes(&mut b);
357 b.iter().fold(String::new(), |mut s, byte| {
358 s.push_str(&format!("{:02x}", byte));
359 s
360 })
361 }));
362 let store = Store::open(&p).unwrap();
363 (store, p)
364 }
365
366 fn rm(p: std::path::PathBuf) {
367 let _ = std::fs::remove_dir_all(p);
368 }
369
370 fn sign_and_store(
371 store: &Store,
372 signer: &dyn Signer,
373 pt: &str,
374 stmt: &impl serde::Serialize,
375 ) -> String {
376 let result = sign(pt, stmt, signer).unwrap();
377 store
378 .write(&Record {
379 artifact_id: result.artifact_id.clone(),
380 digest: result.digest.clone(),
381 payload_type: pt.to_string(),
382 key_id: signer.key_id().to_string(),
383 signed_at: String::new(),
384 parent_id: None,
385 envelope: result.envelope,
386 hub_url: None,
387 anchors: Vec::new(),
388 })
389 .unwrap();
390 result.artifact_id
391 }
392
393 #[test]
394 fn create_bundle() {
395 let (store, dir) = tmp_store();
396 let signer = Ed25519Signer::generate("key_test").unwrap();
397
398 let a1 = sign_and_store(
399 &store,
400 &signer,
401 &payload_type("action"),
402 &ActionStatement::new("agent://a", "tool.call"),
403 );
404 let a2 = sign_and_store(
405 &store,
406 &signer,
407 &payload_type("approval"),
408 &ApprovalStatement::new("human://b", "nonce_1"),
409 );
410
411 let result = create(&[&a1, &a2], Some("test-bundle"), None, &store, &signer).unwrap();
412
413 assert!(result.artifact_id.starts_with("art_"));
414 assert_eq!(result.statement.artifacts.len(), 2);
415 assert_eq!(result.statement.tag.as_deref(), Some("test-bundle"));
416
417 assert!(store.exists(&result.artifact_id));
419 rm(dir);
420 }
421
422 #[test]
423 fn create_empty_fails() {
424 let (store, dir) = tmp_store();
425 let signer = Ed25519Signer::generate("key_test").unwrap();
426 let err = create(&[], None, None, &store, &signer).unwrap_err();
427 assert!(err.to_string().contains("no artifact IDs"));
428 rm(dir);
429 }
430
431 #[test]
432 fn create_missing_artifact_fails() {
433 let (store, dir) = tmp_store();
434 let signer = Ed25519Signer::generate("key_test").unwrap();
435 let err = create(
436 &["art_doesnotexist1234567890123456"],
437 None,
438 None,
439 &store,
440 &signer,
441 )
442 .unwrap_err();
443 assert!(err.to_string().contains("not found"));
444 rm(dir);
445 }
446
447 #[test]
448 fn export_and_import_roundtrip() {
449 let (store, dir) = tmp_store();
450 let signer = Ed25519Signer::generate("key_test").unwrap();
451 let verifier = crate::attestation::Verifier::from_signer(&signer);
452
453 let a1 = sign_and_store(
454 &store,
455 &signer,
456 &payload_type("action"),
457 &ActionStatement::new("agent://a", "tool.call"),
458 );
459 let a2 = sign_and_store(
460 &store,
461 &signer,
462 &payload_type("action"),
463 &ActionStatement::new("agent://b", "web.fetch"),
464 );
465
466 let bundle = create(&[&a1, &a2], Some("roundtrip"), None, &store, &signer).unwrap();
467
468 let export_path = dir.join("test.treeship");
470 export(&bundle.artifact_id, &export_path, &store).unwrap();
471 assert!(export_path.exists());
472
473 let bytes = std::fs::read(&export_path).unwrap();
475 let ef: ExportFile = serde_json::from_slice(&bytes).unwrap();
476 assert_eq!(ef.version, EXPORT_VERSION);
477 assert_eq!(ef.artifacts.len(), 2);
478
479 let (store2, dir2) = tmp_store();
481 let imported_id = import(&export_path, &store2, &verifier).unwrap();
482 assert_eq!(imported_id, bundle.artifact_id);
483
484 assert!(store2.exists(&a1));
486 assert!(store2.exists(&a2));
487 assert!(store2.exists(&bundle.artifact_id));
488
489 rm(dir);
490 rm(dir2);
491 }
492
493 #[test]
494 fn export_non_bundle_fails() {
495 let (store, dir) = tmp_store();
496 let signer = Ed25519Signer::generate("key_test").unwrap();
497 let a1 = sign_and_store(
498 &store,
499 &signer,
500 &payload_type("action"),
501 &ActionStatement::new("agent://a", "tool.call"),
502 );
503
504 let export_path = dir.join("bad.treeship");
505 let err = export(&a1, &export_path, &store).unwrap_err();
506 assert!(err.to_string().contains("not a bundle"));
507 rm(dir);
508 }
509
510 #[test]
511 fn import_bad_version_fails() {
512 let (store, dir) = tmp_store();
513 let signer = Ed25519Signer::generate("key_test").unwrap();
514 let verifier = crate::attestation::Verifier::from_signer(&signer);
515 let bad = ExportFile {
516 version: "bad/v99".into(),
517 bundle: Envelope {
518 payload: String::new(),
519 payload_type: String::new(),
520 signatures: vec![],
521 },
522 artifacts: vec![],
523 };
524 let path = dir.join("bad.treeship");
525 std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
526
527 let err = import(&path, &store, &verifier).unwrap_err();
528 assert!(err.to_string().contains("unsupported export version"));
529 rm(dir);
530 }
531
532 #[test]
533 fn import_rejects_envelope_with_invalid_signature() {
534 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
540
541 let (store, dir) = tmp_store();
542 let signer = Ed25519Signer::generate("key_test").unwrap();
543 let verifier = crate::attestation::Verifier::from_signer(&signer);
544
545 let a1 = sign_and_store(
547 &store,
548 &signer,
549 &payload_type("action"),
550 &ActionStatement::new("agent://a", "tool.call"),
551 );
552 let bundle = create(&[&a1], Some("tampered"), None, &store, &signer).unwrap();
553 let export_path = dir.join("tampered.treeship");
554 export(&bundle.artifact_id, &export_path, &store).unwrap();
555
556 let raw = std::fs::read(&export_path).unwrap();
560 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
561 ef.artifacts[0].signatures[0].sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
564 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
565
566 let (store2, dir2) = tmp_store();
570 let err = import(&export_path, &store2, &verifier).unwrap_err();
571 assert!(
572 matches!(err, BundleError::UnverifiedEnvelope { index: 1, .. }),
573 "expected UnverifiedEnvelope{{index:1, ..}}, got: {err}"
574 );
575 assert!(!store2.exists(&a1));
578 assert!(!store2.exists(&bundle.artifact_id));
579
580 rm(dir);
581 rm(dir2);
582 }
583
584 #[test]
585 fn import_attributes_record_to_verified_key_not_decoy() {
586 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
591
592 let (store, dir) = tmp_store();
593 let signer = Ed25519Signer::generate("key_real").unwrap();
594 let verifier = crate::attestation::Verifier::from_signer(&signer);
595
596 let a1 = sign_and_store(
597 &store,
598 &signer,
599 &payload_type("action"),
600 &ActionStatement::new("agent://a", "tool.call"),
601 );
602 let bundle = create(&[&a1], Some("b"), None, &store, &signer).unwrap();
603 let export_path = dir.join("b.treeship");
604 export(&bundle.artifact_id, &export_path, &store).unwrap();
605
606 let raw = std::fs::read(&export_path).unwrap();
609 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
610 let real_sig = ef.artifacts[0].signatures[0].clone();
611 let decoy = crate::attestation::Signature {
612 keyid: "key_ceo".into(),
613 sig: URL_SAFE_NO_PAD.encode([0u8; 64]),
614 };
615 ef.artifacts[0].signatures = vec![decoy, real_sig];
616 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
617
618 let (store2, dir2) = tmp_store();
620 import(&export_path, &store2, &verifier).unwrap();
621
622 let rec = store2.read(&a1).unwrap();
623 assert_eq!(
624 rec.key_id, "key_real",
625 "record must be attributed to the VERIFIED key, not the prepended decoy"
626 );
627
628 rm(dir);
629 rm(dir2);
630 }
631
632 #[test]
633 fn import_rejects_unsigned_envelope() {
634 let (store, dir) = tmp_store();
639 let signer = Ed25519Signer::generate("key_test").unwrap();
640 let verifier = crate::attestation::Verifier::from_signer(&signer);
641
642 let a1 = sign_and_store(
643 &store,
644 &signer,
645 &payload_type("action"),
646 &ActionStatement::new("agent://a", "tool.call"),
647 );
648 let bundle = create(&[&a1], Some("unsigned"), None, &store, &signer).unwrap();
649 let export_path = dir.join("unsigned.treeship");
650 export(&bundle.artifact_id, &export_path, &store).unwrap();
651
652 let raw = std::fs::read(&export_path).unwrap();
654 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
655 ef.bundle.signatures.clear();
656 for env in &mut ef.artifacts {
657 env.signatures.clear();
658 }
659 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
660
661 let (store2, dir2) = tmp_store();
662 let err = import(&export_path, &store2, &verifier).unwrap_err();
663 assert!(
664 matches!(err, BundleError::UnverifiedEnvelope { index: 0, .. }),
665 "expected UnverifiedEnvelope{{index:0, ..}} (bundle envelope first), got: {err}"
666 );
667
668 rm(dir);
669 rm(dir2);
670 }
671}