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 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 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
242fn 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 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 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 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 let export_path = dir.join("test.treeship");
386 export(&bundle.artifact_id, &export_path, &store).unwrap();
387 assert!(export_path.exists());
388
389 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 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 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 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 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 let raw = std::fs::read(&export_path).unwrap();
468 let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
469 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 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 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 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 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}