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#[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 { index: usize, source: VerifyError },
23 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#[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#[derive(Debug, serde::Serialize, serde::Deserialize)]
69pub struct ExportFile {
70 pub version: String,
72
73 pub bundle: Envelope,
75
76 pub artifacts: Vec<Envelope>,
78}
79
80const EXPORT_VERSION: &str = "treeship-export/v1";
81
82pub 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 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
146pub 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 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 let stmt: BundleStatement = bundle_rec.envelope.unmarshal_statement()
168 .map_err(|e| BundleError::InvalidBundle(format!("cannot decode bundle: {e}")))?;
169
170 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
190pub 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 let bundle_vr = verifier.verify_any(&export.bundle)
223 .map_err(|source| BundleError::UnverifiedEnvelope { index: 0, source })?;
224 let mut artifact_verified_keys: Vec<Option<String>> = Vec::with_capacity(export.artifacts.len());
225 for (i, env) in export.artifacts.iter().enumerate() {
226 let vr = verifier.verify_any(env)
227 .map_err(|source| BundleError::UnverifiedEnvelope { index: i + 1, source })?;
228 artifact_verified_keys.push(vr.verified_key_ids.first().cloned());
229 }
230
231 for (env, vk) in export.artifacts.iter().zip(artifact_verified_keys.iter()) {
234 let record = record_from_envelope(env, vk.as_deref())?;
235 storage.write(&record)?;
236 }
237
238 let bundle_record = record_from_envelope(
239 &export.bundle,
240 bundle_vr.verified_key_ids.first().map(|s| s.as_str()),
241 )?;
242 let bundle_id = bundle_record.artifact_id.clone();
243 storage.write(&bundle_record)?;
244
245 Ok(bundle_id)
246}
247
248fn record_from_envelope(
256 envelope: &Envelope,
257 verified_key_id: Option<&str>,
258) -> Result<Record, BundleError> {
259 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
260
261 let payload_bytes = URL_SAFE_NO_PAD.decode(&envelope.payload)
262 .map_err(|e| BundleError::InvalidBundle(format!("bad payload base64: {e}")))?;
263
264 let pae_bytes = crate::attestation::pae(&envelope.payload_type, &payload_bytes);
265 let artifact_id = crate::attestation::artifact_id_from_pae(&pae_bytes);
266 let digest = crate::attestation::digest_from_pae(&pae_bytes);
267
268 let signed_at = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
270 .ok()
271 .and_then(|v| v.get("timestamp").and_then(|t| t.as_str().map(|s| s.to_string())))
272 .unwrap_or_default();
273
274 let parent_id = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
276 .ok()
277 .and_then(|v| v.get("parentId").and_then(|t| t.as_str().map(|s| s.to_string())));
278
279 let key_id = verified_key_id
282 .map(|s| s.to_string())
283 .or_else(|| envelope.signatures.first().map(|s| s.keyid.clone()))
284 .unwrap_or_default();
285
286 Ok(Record {
287 artifact_id,
288 digest,
289 payload_type: envelope.payload_type.clone(),
290 key_id,
291 signed_at,
292 parent_id,
293 envelope: envelope.clone(),
294 hub_url: None,
295 })
296}
297
298fn now_secs() -> u64 {
299 use std::time::{SystemTime, UNIX_EPOCH};
300 SystemTime::now()
301 .duration_since(UNIX_EPOCH)
302 .unwrap_or_default()
303 .as_secs()
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::attestation::Ed25519Signer;
310 use crate::statements::{ActionStatement, ApprovalStatement};
311
312 fn tmp_store() -> (Store, std::path::PathBuf) {
313 let mut p = std::env::temp_dir();
314 p.push(format!("treeship-bundle-test-{}", {
315 use rand::RngCore;
316 let mut b = [0u8; 4];
317 rand::thread_rng().fill_bytes(&mut b);
318 b.iter().fold(String::new(), |mut s, byte| {
319 s.push_str(&format!("{:02x}", byte));
320 s
321 })
322 }));
323 let store = Store::open(&p).unwrap();
324 (store, p)
325 }
326
327 fn rm(p: std::path::PathBuf) { let _ = std::fs::remove_dir_all(p); }
328
329 fn sign_and_store(store: &Store, signer: &dyn Signer, pt: &str, stmt: &impl serde::Serialize) -> String {
330 let result = sign(pt, stmt, signer).unwrap();
331 store.write(&Record {
332 artifact_id: result.artifact_id.clone(),
333 digest: result.digest.clone(),
334 payload_type: pt.to_string(),
335 key_id: signer.key_id().to_string(),
336 signed_at: String::new(),
337 parent_id: None,
338 envelope: result.envelope,
339 hub_url: None,
340 }).unwrap();
341 result.artifact_id
342 }
343
344 #[test]
345 fn create_bundle() {
346 let (store, dir) = tmp_store();
347 let signer = Ed25519Signer::generate("key_test").unwrap();
348
349 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
350 &ActionStatement::new("agent://a", "tool.call"));
351 let a2 = sign_and_store(&store, &signer, &payload_type("approval"),
352 &ApprovalStatement::new("human://b", "nonce_1"));
353
354 let result = create(
355 &[&a1, &a2],
356 Some("test-bundle"),
357 None,
358 &store,
359 &signer,
360 ).unwrap();
361
362 assert!(result.artifact_id.starts_with("art_"));
363 assert_eq!(result.statement.artifacts.len(), 2);
364 assert_eq!(result.statement.tag.as_deref(), Some("test-bundle"));
365
366 assert!(store.exists(&result.artifact_id));
368 rm(dir);
369 }
370
371 #[test]
372 fn create_empty_fails() {
373 let (store, dir) = tmp_store();
374 let signer = Ed25519Signer::generate("key_test").unwrap();
375 let err = create(&[], None, None, &store, &signer).unwrap_err();
376 assert!(err.to_string().contains("no artifact IDs"));
377 rm(dir);
378 }
379
380 #[test]
381 fn create_missing_artifact_fails() {
382 let (store, dir) = tmp_store();
383 let signer = Ed25519Signer::generate("key_test").unwrap();
384 let err = create(&["art_doesnotexist1234567890123456"], None, None, &store, &signer).unwrap_err();
385 assert!(err.to_string().contains("not found"));
386 rm(dir);
387 }
388
389 #[test]
390 fn export_and_import_roundtrip() {
391 let (store, dir) = tmp_store();
392 let signer = Ed25519Signer::generate("key_test").unwrap();
393 let verifier = crate::attestation::Verifier::from_signer(&signer);
394
395 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
396 &ActionStatement::new("agent://a", "tool.call"));
397 let a2 = sign_and_store(&store, &signer, &payload_type("action"),
398 &ActionStatement::new("agent://b", "web.fetch"));
399
400 let bundle = create(&[&a1, &a2], Some("roundtrip"), None, &store, &signer).unwrap();
401
402 let export_path = dir.join("test.treeship");
404 export(&bundle.artifact_id, &export_path, &store).unwrap();
405 assert!(export_path.exists());
406
407 let bytes = std::fs::read(&export_path).unwrap();
409 let ef: ExportFile = serde_json::from_slice(&bytes).unwrap();
410 assert_eq!(ef.version, EXPORT_VERSION);
411 assert_eq!(ef.artifacts.len(), 2);
412
413 let (store2, dir2) = tmp_store();
415 let imported_id = import(&export_path, &store2, &verifier).unwrap();
416 assert_eq!(imported_id, bundle.artifact_id);
417
418 assert!(store2.exists(&a1));
420 assert!(store2.exists(&a2));
421 assert!(store2.exists(&bundle.artifact_id));
422
423 rm(dir);
424 rm(dir2);
425 }
426
427 #[test]
428 fn export_non_bundle_fails() {
429 let (store, dir) = tmp_store();
430 let signer = Ed25519Signer::generate("key_test").unwrap();
431 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
432 &ActionStatement::new("agent://a", "tool.call"));
433
434 let export_path = dir.join("bad.treeship");
435 let err = export(&a1, &export_path, &store).unwrap_err();
436 assert!(err.to_string().contains("not a bundle"));
437 rm(dir);
438 }
439
440 #[test]
441 fn import_bad_version_fails() {
442 let (store, dir) = tmp_store();
443 let signer = Ed25519Signer::generate("key_test").unwrap();
444 let verifier = crate::attestation::Verifier::from_signer(&signer);
445 let bad = ExportFile {
446 version: "bad/v99".into(),
447 bundle: Envelope {
448 payload: String::new(),
449 payload_type: String::new(),
450 signatures: vec![],
451 },
452 artifacts: vec![],
453 };
454 let path = dir.join("bad.treeship");
455 std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
456
457 let err = import(&path, &store, &verifier).unwrap_err();
458 assert!(err.to_string().contains("unsupported export version"));
459 rm(dir);
460 }
461
462 #[test]
463 fn import_rejects_envelope_with_invalid_signature() {
464 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
470
471 let (store, dir) = tmp_store();
472 let signer = Ed25519Signer::generate("key_test").unwrap();
473 let verifier = crate::attestation::Verifier::from_signer(&signer);
474
475 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
477 &ActionStatement::new("agent://a", "tool.call"));
478 let bundle = create(&[&a1], Some("tampered"), None, &store, &signer).unwrap();
479 let export_path = dir.join("tampered.treeship");
480 export(&bundle.artifact_id, &export_path, &store).unwrap();
481
482 let raw = std::fs::read(&export_path).unwrap();
486 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
487 ef.artifacts[0].signatures[0].sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
490 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
491
492 let (store2, dir2) = tmp_store();
496 let err = import(&export_path, &store2, &verifier).unwrap_err();
497 assert!(
498 matches!(err, BundleError::UnverifiedEnvelope { index: 1, .. }),
499 "expected UnverifiedEnvelope{{index:1, ..}}, got: {err}"
500 );
501 assert!(!store2.exists(&a1));
504 assert!(!store2.exists(&bundle.artifact_id));
505
506 rm(dir);
507 rm(dir2);
508 }
509
510 #[test]
511 fn import_attributes_record_to_verified_key_not_decoy() {
512 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
517
518 let (store, dir) = tmp_store();
519 let signer = Ed25519Signer::generate("key_real").unwrap();
520 let verifier = crate::attestation::Verifier::from_signer(&signer);
521
522 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
523 &ActionStatement::new("agent://a", "tool.call"));
524 let bundle = create(&[&a1], Some("b"), None, &store, &signer).unwrap();
525 let export_path = dir.join("b.treeship");
526 export(&bundle.artifact_id, &export_path, &store).unwrap();
527
528 let raw = std::fs::read(&export_path).unwrap();
531 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
532 let real_sig = ef.artifacts[0].signatures[0].clone();
533 let decoy = crate::attestation::Signature {
534 keyid: "key_ceo".into(),
535 sig: URL_SAFE_NO_PAD.encode([0u8; 64]),
536 };
537 ef.artifacts[0].signatures = vec![decoy, real_sig];
538 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
539
540 let (store2, dir2) = tmp_store();
542 import(&export_path, &store2, &verifier).unwrap();
543
544 let rec = store2.read(&a1).unwrap();
545 assert_eq!(
546 rec.key_id, "key_real",
547 "record must be attributed to the VERIFIED key, not the prepended decoy"
548 );
549
550 rm(dir);
551 rm(dir2);
552 }
553
554 #[test]
555 fn import_rejects_unsigned_envelope() {
556 let (store, dir) = tmp_store();
561 let signer = Ed25519Signer::generate("key_test").unwrap();
562 let verifier = crate::attestation::Verifier::from_signer(&signer);
563
564 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
565 &ActionStatement::new("agent://a", "tool.call"));
566 let bundle = create(&[&a1], Some("unsigned"), None, &store, &signer).unwrap();
567 let export_path = dir.join("unsigned.treeship");
568 export(&bundle.artifact_id, &export_path, &store).unwrap();
569
570 let raw = std::fs::read(&export_path).unwrap();
572 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
573 ef.bundle.signatures.clear();
574 for env in &mut ef.artifacts { env.signatures.clear(); }
575 std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
576
577 let (store2, dir2) = tmp_store();
578 let err = import(&export_path, &store2, &verifier).unwrap_err();
579 assert!(
580 matches!(err, BundleError::UnverifiedEnvelope { index: 0, .. }),
581 "expected UnverifiedEnvelope{{index:0, ..}} (bundle envelope first), got: {err}"
582 );
583
584 rm(dir);
585 rm(dir2);
586 }
587}