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 };
157
158 storage.write(&record)?;
159
160 Ok(CreateResult {
161 artifact_id: result.artifact_id,
162 digest: result.digest,
163 record,
164 statement: stmt,
165 })
166}
167
168pub fn export(bundle_id: &str, out_path: &Path, storage: &Store) -> Result<(), BundleError> {
173 let bundle_rec = storage.read(bundle_id)?;
174
175 let expected_pt = payload_type("bundle");
177 if bundle_rec.payload_type != expected_pt {
178 return Err(BundleError::InvalidBundle(format!(
179 "artifact {} is {}, not a bundle",
180 bundle_id, bundle_rec.payload_type
181 )));
182 }
183
184 let stmt: BundleStatement = bundle_rec
186 .envelope
187 .unmarshal_statement()
188 .map_err(|e| BundleError::InvalidBundle(format!("cannot decode bundle: {e}")))?;
189
190 let mut artifact_envelopes = Vec::with_capacity(stmt.artifacts.len());
192 for art_ref in &stmt.artifacts {
193 let rec = storage
194 .read(&art_ref.id)
195 .map_err(|_| BundleError::ArtifactNotFound(art_ref.id.clone()))?;
196 artifact_envelopes.push(rec.envelope);
197 }
198
199 let export = ExportFile {
200 version: EXPORT_VERSION.into(),
201 bundle: bundle_rec.envelope,
202 artifacts: artifact_envelopes,
203 };
204
205 let json = serde_json::to_vec_pretty(&export)?;
206 std::fs::write(out_path, &json)?;
207
208 Ok(())
209}
210
211pub fn import(
224 path: &Path,
225 storage: &Store,
226 verifier: &Verifier,
227) -> Result<ArtifactId, BundleError> {
228 let bytes = std::fs::read(path)?;
229 let export: ExportFile = serde_json::from_slice(&bytes)?;
230
231 if export.version != EXPORT_VERSION {
232 return Err(BundleError::InvalidBundle(format!(
233 "unsupported export version: {} (expected {})",
234 export.version, EXPORT_VERSION
235 )));
236 }
237
238 let bundle_vr = verifier
244 .verify_any(&export.bundle)
245 .map_err(|source| BundleError::UnverifiedEnvelope { index: 0, source })?;
246 let mut artifact_verified_keys: Vec<Option<String>> =
247 Vec::with_capacity(export.artifacts.len());
248 for (i, env) in export.artifacts.iter().enumerate() {
249 let vr = verifier
250 .verify_any(env)
251 .map_err(|source| BundleError::UnverifiedEnvelope {
252 index: i + 1,
253 source,
254 })?;
255 artifact_verified_keys.push(vr.verified_key_ids.first().cloned());
256 }
257
258 for (env, vk) in export.artifacts.iter().zip(artifact_verified_keys.iter()) {
261 let record = record_from_envelope(env, vk.as_deref())?;
262 storage.write(&record)?;
263 }
264
265 let bundle_record = record_from_envelope(
266 &export.bundle,
267 bundle_vr.verified_key_ids.first().map(|s| s.as_str()),
268 )?;
269 let bundle_id = bundle_record.artifact_id.clone();
270 storage.write(&bundle_record)?;
271
272 Ok(bundle_id)
273}
274
275fn record_from_envelope(
283 envelope: &Envelope,
284 verified_key_id: Option<&str>,
285) -> Result<Record, BundleError> {
286 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
287
288 let payload_bytes = URL_SAFE_NO_PAD
289 .decode(&envelope.payload)
290 .map_err(|e| BundleError::InvalidBundle(format!("bad payload base64: {e}")))?;
291
292 let pae_bytes = crate::attestation::pae(&envelope.payload_type, &payload_bytes);
293 let artifact_id = crate::attestation::artifact_id_from_pae(&pae_bytes);
294 let digest = crate::attestation::digest_from_pae(&pae_bytes);
295
296 let signed_at = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
298 .ok()
299 .and_then(|v| {
300 v.get("timestamp")
301 .and_then(|t| t.as_str().map(|s| s.to_string()))
302 })
303 .unwrap_or_default();
304
305 let parent_id = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
307 .ok()
308 .and_then(|v| {
309 v.get("parentId")
310 .and_then(|t| t.as_str().map(|s| s.to_string()))
311 });
312
313 let key_id = verified_key_id
316 .map(|s| s.to_string())
317 .or_else(|| envelope.signatures.first().map(|s| s.keyid.clone()))
318 .unwrap_or_default();
319
320 Ok(Record {
321 artifact_id,
322 digest,
323 payload_type: envelope.payload_type.clone(),
324 key_id,
325 signed_at,
326 parent_id,
327 envelope: envelope.clone(),
328 hub_url: None,
329 })
330}
331
332fn now_secs() -> u64 {
333 use std::time::{SystemTime, UNIX_EPOCH};
334 SystemTime::now()
335 .duration_since(UNIX_EPOCH)
336 .unwrap_or_default()
337 .as_secs()
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::attestation::Ed25519Signer;
344 use crate::statements::{ActionStatement, ApprovalStatement};
345
346 fn tmp_store() -> (Store, std::path::PathBuf) {
347 let mut p = std::env::temp_dir();
348 p.push(format!("treeship-bundle-test-{}", {
349 use rand::RngCore;
350 let mut b = [0u8; 4];
351 rand::thread_rng().fill_bytes(&mut b);
352 b.iter().fold(String::new(), |mut s, byte| {
353 s.push_str(&format!("{:02x}", byte));
354 s
355 })
356 }));
357 let store = Store::open(&p).unwrap();
358 (store, p)
359 }
360
361 fn rm(p: std::path::PathBuf) {
362 let _ = std::fs::remove_dir_all(p);
363 }
364
365 fn sign_and_store(
366 store: &Store,
367 signer: &dyn Signer,
368 pt: &str,
369 stmt: &impl serde::Serialize,
370 ) -> String {
371 let result = sign(pt, stmt, signer).unwrap();
372 store
373 .write(&Record {
374 artifact_id: result.artifact_id.clone(),
375 digest: result.digest.clone(),
376 payload_type: pt.to_string(),
377 key_id: signer.key_id().to_string(),
378 signed_at: String::new(),
379 parent_id: None,
380 envelope: result.envelope,
381 hub_url: None,
382 })
383 .unwrap();
384 result.artifact_id
385 }
386
387 #[test]
388 fn create_bundle() {
389 let (store, dir) = tmp_store();
390 let signer = Ed25519Signer::generate("key_test").unwrap();
391
392 let a1 = sign_and_store(
393 &store,
394 &signer,
395 &payload_type("action"),
396 &ActionStatement::new("agent://a", "tool.call"),
397 );
398 let a2 = sign_and_store(
399 &store,
400 &signer,
401 &payload_type("approval"),
402 &ApprovalStatement::new("human://b", "nonce_1"),
403 );
404
405 let result = create(&[&a1, &a2], Some("test-bundle"), None, &store, &signer).unwrap();
406
407 assert!(result.artifact_id.starts_with("art_"));
408 assert_eq!(result.statement.artifacts.len(), 2);
409 assert_eq!(result.statement.tag.as_deref(), Some("test-bundle"));
410
411 assert!(store.exists(&result.artifact_id));
413 rm(dir);
414 }
415
416 #[test]
417 fn create_empty_fails() {
418 let (store, dir) = tmp_store();
419 let signer = Ed25519Signer::generate("key_test").unwrap();
420 let err = create(&[], None, None, &store, &signer).unwrap_err();
421 assert!(err.to_string().contains("no artifact IDs"));
422 rm(dir);
423 }
424
425 #[test]
426 fn create_missing_artifact_fails() {
427 let (store, dir) = tmp_store();
428 let signer = Ed25519Signer::generate("key_test").unwrap();
429 let err = create(
430 &["art_doesnotexist1234567890123456"],
431 None,
432 None,
433 &store,
434 &signer,
435 )
436 .unwrap_err();
437 assert!(err.to_string().contains("not found"));
438 rm(dir);
439 }
440
441 #[test]
442 fn export_and_import_roundtrip() {
443 let (store, dir) = tmp_store();
444 let signer = Ed25519Signer::generate("key_test").unwrap();
445 let verifier = crate::attestation::Verifier::from_signer(&signer);
446
447 let a1 = sign_and_store(
448 &store,
449 &signer,
450 &payload_type("action"),
451 &ActionStatement::new("agent://a", "tool.call"),
452 );
453 let a2 = sign_and_store(
454 &store,
455 &signer,
456 &payload_type("action"),
457 &ActionStatement::new("agent://b", "web.fetch"),
458 );
459
460 let bundle = create(&[&a1, &a2], Some("roundtrip"), None, &store, &signer).unwrap();
461
462 let export_path = dir.join("test.treeship");
464 export(&bundle.artifact_id, &export_path, &store).unwrap();
465 assert!(export_path.exists());
466
467 let bytes = std::fs::read(&export_path).unwrap();
469 let ef: ExportFile = serde_json::from_slice(&bytes).unwrap();
470 assert_eq!(ef.version, EXPORT_VERSION);
471 assert_eq!(ef.artifacts.len(), 2);
472
473 let (store2, dir2) = tmp_store();
475 let imported_id = import(&export_path, &store2, &verifier).unwrap();
476 assert_eq!(imported_id, bundle.artifact_id);
477
478 assert!(store2.exists(&a1));
480 assert!(store2.exists(&a2));
481 assert!(store2.exists(&bundle.artifact_id));
482
483 rm(dir);
484 rm(dir2);
485 }
486
487 #[test]
488 fn export_non_bundle_fails() {
489 let (store, dir) = tmp_store();
490 let signer = Ed25519Signer::generate("key_test").unwrap();
491 let a1 = sign_and_store(
492 &store,
493 &signer,
494 &payload_type("action"),
495 &ActionStatement::new("agent://a", "tool.call"),
496 );
497
498 let export_path = dir.join("bad.treeship");
499 let err = export(&a1, &export_path, &store).unwrap_err();
500 assert!(err.to_string().contains("not a bundle"));
501 rm(dir);
502 }
503
504 #[test]
505 fn import_bad_version_fails() {
506 let (store, dir) = tmp_store();
507 let signer = Ed25519Signer::generate("key_test").unwrap();
508 let verifier = crate::attestation::Verifier::from_signer(&signer);
509 let bad = ExportFile {
510 version: "bad/v99".into(),
511 bundle: Envelope {
512 payload: String::new(),
513 payload_type: String::new(),
514 signatures: vec![],
515 },
516 artifacts: vec![],
517 };
518 let path = dir.join("bad.treeship");
519 std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
520
521 let err = import(&path, &store, &verifier).unwrap_err();
522 assert!(err.to_string().contains("unsupported export version"));
523 rm(dir);
524 }
525
526 #[test]
527 fn import_rejects_envelope_with_invalid_signature() {
528 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
534
535 let (store, dir) = tmp_store();
536 let signer = Ed25519Signer::generate("key_test").unwrap();
537 let verifier = crate::attestation::Verifier::from_signer(&signer);
538
539 let a1 = sign_and_store(
541 &store,
542 &signer,
543 &payload_type("action"),
544 &ActionStatement::new("agent://a", "tool.call"),
545 );
546 let bundle = create(&[&a1], Some("tampered"), None, &store, &signer).unwrap();
547 let export_path = dir.join("tampered.treeship");
548 export(&bundle.artifact_id, &export_path, &store).unwrap();
549
550 let raw = std::fs::read(&export_path).unwrap();
554 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
555 ef.artifacts[0].signatures[0].sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
558 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
559
560 let (store2, dir2) = tmp_store();
564 let err = import(&export_path, &store2, &verifier).unwrap_err();
565 assert!(
566 matches!(err, BundleError::UnverifiedEnvelope { index: 1, .. }),
567 "expected UnverifiedEnvelope{{index:1, ..}}, got: {err}"
568 );
569 assert!(!store2.exists(&a1));
572 assert!(!store2.exists(&bundle.artifact_id));
573
574 rm(dir);
575 rm(dir2);
576 }
577
578 #[test]
579 fn import_attributes_record_to_verified_key_not_decoy() {
580 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
585
586 let (store, dir) = tmp_store();
587 let signer = Ed25519Signer::generate("key_real").unwrap();
588 let verifier = crate::attestation::Verifier::from_signer(&signer);
589
590 let a1 = sign_and_store(
591 &store,
592 &signer,
593 &payload_type("action"),
594 &ActionStatement::new("agent://a", "tool.call"),
595 );
596 let bundle = create(&[&a1], Some("b"), None, &store, &signer).unwrap();
597 let export_path = dir.join("b.treeship");
598 export(&bundle.artifact_id, &export_path, &store).unwrap();
599
600 let raw = std::fs::read(&export_path).unwrap();
603 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
604 let real_sig = ef.artifacts[0].signatures[0].clone();
605 let decoy = crate::attestation::Signature {
606 keyid: "key_ceo".into(),
607 sig: URL_SAFE_NO_PAD.encode([0u8; 64]),
608 };
609 ef.artifacts[0].signatures = vec![decoy, real_sig];
610 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
611
612 let (store2, dir2) = tmp_store();
614 import(&export_path, &store2, &verifier).unwrap();
615
616 let rec = store2.read(&a1).unwrap();
617 assert_eq!(
618 rec.key_id, "key_real",
619 "record must be attributed to the VERIFIED key, not the prepended decoy"
620 );
621
622 rm(dir);
623 rm(dir2);
624 }
625
626 #[test]
627 fn import_rejects_unsigned_envelope() {
628 let (store, dir) = tmp_store();
633 let signer = Ed25519Signer::generate("key_test").unwrap();
634 let verifier = crate::attestation::Verifier::from_signer(&signer);
635
636 let a1 = sign_and_store(
637 &store,
638 &signer,
639 &payload_type("action"),
640 &ActionStatement::new("agent://a", "tool.call"),
641 );
642 let bundle = create(&[&a1], Some("unsigned"), None, &store, &signer).unwrap();
643 let export_path = dir.join("unsigned.treeship");
644 export(&bundle.artifact_id, &export_path, &store).unwrap();
645
646 let raw = std::fs::read(&export_path).unwrap();
648 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
649 ef.bundle.signatures.clear();
650 for env in &mut ef.artifacts {
651 env.signatures.clear();
652 }
653 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
654
655 let (store2, dir2) = tmp_store();
656 let err = import(&export_path, &store2, &verifier).unwrap_err();
657 assert!(
658 matches!(err, BundleError::UnverifiedEnvelope { index: 0, .. }),
659 "expected UnverifiedEnvelope{{index:0, ..}} (bundle envelope first), got: {err}"
660 );
661
662 rm(dir);
663 rm(dir2);
664 }
665}