1use std::path::Path;
2
3use crate::{
4 attestation::{sign, ArtifactId, Envelope, Signer, SignError},
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}
19
20impl std::fmt::Display for BundleError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::Storage(e) => write!(f, "bundle storage: {e}"),
24 Self::Sign(e) => write!(f, "bundle sign: {e}"),
25 Self::Io(e) => write!(f, "bundle io: {e}"),
26 Self::Json(e) => write!(f, "bundle json: {e}"),
27 Self::ArtifactNotFound(id)=> write!(f, "artifact not found: {id}"),
28 Self::InvalidBundle(msg) => write!(f, "invalid bundle: {msg}"),
29 }
30 }
31}
32
33impl std::error::Error for BundleError {}
34impl From<StorageError> for BundleError { fn from(e: StorageError) -> Self { Self::Storage(e) } }
35impl From<SignError> for BundleError { fn from(e: SignError) -> Self { Self::Sign(e) } }
36impl From<std::io::Error> for BundleError { fn from(e: std::io::Error) -> Self { Self::Io(e) } }
37impl From<serde_json::Error> for BundleError { fn from(e: serde_json::Error) -> Self { Self::Json(e) } }
38
39#[derive(Debug)]
41pub struct CreateResult {
42 pub artifact_id: ArtifactId,
43 pub digest: String,
44 pub record: Record,
45 pub statement: BundleStatement,
46}
47
48#[derive(Debug, serde::Serialize, serde::Deserialize)]
50pub struct ExportFile {
51 pub version: String,
53
54 pub bundle: Envelope,
56
57 pub artifacts: Vec<Envelope>,
59}
60
61const EXPORT_VERSION: &str = "treeship-export/v1";
62
63pub fn create(
68 artifact_ids: &[&str],
69 tag: Option<&str>,
70 description: Option<&str>,
71 storage: &Store,
72 signer: &dyn Signer,
73) -> Result<CreateResult, BundleError> {
74 if artifact_ids.is_empty() {
75 return Err(BundleError::InvalidBundle("no artifact IDs provided".into()));
76 }
77
78 let mut refs = Vec::with_capacity(artifact_ids.len());
80 let mut records = Vec::with_capacity(artifact_ids.len());
81
82 for &id in artifact_ids {
83 let rec = storage.read(id)
84 .map_err(|_| BundleError::ArtifactNotFound(id.to_string()))?;
85 refs.push(ArtifactRef {
86 id: rec.artifact_id.clone(),
87 digest: rec.digest.clone(),
88 type_: rec.payload_type.clone(),
89 });
90 records.push(rec);
91 }
92
93 let stmt = BundleStatement {
94 type_: crate::statements::TYPE_BUNDLE.into(),
95 timestamp: crate::statements::unix_to_rfc3339(now_secs()),
96 tag: tag.map(|s| s.to_string()),
97 description: description.map(|s| s.to_string()),
98 artifacts: refs,
99 policy_ref: None,
100 meta: None,
101 };
102
103 let pt = payload_type("bundle");
104 let result = sign(&pt, &stmt, signer)?;
105
106 let record = Record {
107 artifact_id: result.artifact_id.clone(),
108 digest: result.digest.clone(),
109 payload_type: pt,
110 key_id: signer.key_id().to_string(),
111 signed_at: stmt.timestamp.clone(),
112 parent_id: None,
113 envelope: result.envelope,
114 hub_url: None,
115 };
116
117 storage.write(&record)?;
118
119 Ok(CreateResult {
120 artifact_id: result.artifact_id,
121 digest: result.digest,
122 record,
123 statement: stmt,
124 })
125}
126
127pub fn export(
132 bundle_id: &str,
133 out_path: &Path,
134 storage: &Store,
135) -> Result<(), BundleError> {
136 let bundle_rec = storage.read(bundle_id)?;
137
138 let expected_pt = payload_type("bundle");
140 if bundle_rec.payload_type != expected_pt {
141 return Err(BundleError::InvalidBundle(format!(
142 "artifact {} is {}, not a bundle",
143 bundle_id, bundle_rec.payload_type
144 )));
145 }
146
147 let stmt: BundleStatement = bundle_rec.envelope.unmarshal_statement()
149 .map_err(|e| BundleError::InvalidBundle(format!("cannot decode bundle: {e}")))?;
150
151 let mut artifact_envelopes = Vec::with_capacity(stmt.artifacts.len());
153 for art_ref in &stmt.artifacts {
154 let rec = storage.read(&art_ref.id)
155 .map_err(|_| BundleError::ArtifactNotFound(art_ref.id.clone()))?;
156 artifact_envelopes.push(rec.envelope);
157 }
158
159 let export = ExportFile {
160 version: EXPORT_VERSION.into(),
161 bundle: bundle_rec.envelope,
162 artifacts: artifact_envelopes,
163 };
164
165 let json = serde_json::to_vec_pretty(&export)?;
166 std::fs::write(out_path, &json)?;
167
168 Ok(())
169}
170
171pub fn import(
176 path: &Path,
177 storage: &Store,
178) -> Result<ArtifactId, BundleError> {
179 let bytes = std::fs::read(path)?;
180 let export: ExportFile = serde_json::from_slice(&bytes)?;
181
182 if export.version != EXPORT_VERSION {
183 return Err(BundleError::InvalidBundle(format!(
184 "unsupported export version: {} (expected {})",
185 export.version, EXPORT_VERSION
186 )));
187 }
188
189 for env in &export.artifacts {
191 let record = record_from_envelope(env)?;
192 storage.write(&record)?;
193 }
194
195 let bundle_record = record_from_envelope(&export.bundle)?;
197 let bundle_id = bundle_record.artifact_id.clone();
198 storage.write(&bundle_record)?;
199
200 Ok(bundle_id)
201}
202
203fn record_from_envelope(envelope: &Envelope) -> Result<Record, BundleError> {
205 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
206
207 let payload_bytes = URL_SAFE_NO_PAD.decode(&envelope.payload)
208 .map_err(|e| BundleError::InvalidBundle(format!("bad payload base64: {e}")))?;
209
210 let pae_bytes = crate::attestation::pae(&envelope.payload_type, &payload_bytes);
211 let artifact_id = crate::attestation::artifact_id_from_pae(&pae_bytes);
212 let digest = crate::attestation::digest_from_pae(&pae_bytes);
213
214 let signed_at = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
216 .ok()
217 .and_then(|v| v.get("timestamp").and_then(|t| t.as_str().map(|s| s.to_string())))
218 .unwrap_or_default();
219
220 let parent_id = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
222 .ok()
223 .and_then(|v| v.get("parentId").and_then(|t| t.as_str().map(|s| s.to_string())));
224
225 let key_id = envelope.signatures.first()
226 .map(|s| s.keyid.clone())
227 .unwrap_or_default();
228
229 Ok(Record {
230 artifact_id,
231 digest,
232 payload_type: envelope.payload_type.clone(),
233 key_id,
234 signed_at,
235 parent_id,
236 envelope: envelope.clone(),
237 hub_url: None,
238 })
239}
240
241fn now_secs() -> u64 {
242 use std::time::{SystemTime, UNIX_EPOCH};
243 SystemTime::now()
244 .duration_since(UNIX_EPOCH)
245 .unwrap_or_default()
246 .as_secs()
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::attestation::Ed25519Signer;
253 use crate::statements::{ActionStatement, ApprovalStatement};
254
255 fn tmp_store() -> (Store, std::path::PathBuf) {
256 let mut p = std::env::temp_dir();
257 p.push(format!("treeship-bundle-test-{}", {
258 use rand::RngCore;
259 let mut b = [0u8; 4];
260 rand::thread_rng().fill_bytes(&mut b);
261 b.iter().fold(String::new(), |mut s, byte| {
262 s.push_str(&format!("{:02x}", byte));
263 s
264 })
265 }));
266 let store = Store::open(&p).unwrap();
267 (store, p)
268 }
269
270 fn rm(p: std::path::PathBuf) { let _ = std::fs::remove_dir_all(p); }
271
272 fn sign_and_store(store: &Store, signer: &dyn Signer, pt: &str, stmt: &impl serde::Serialize) -> String {
273 let result = sign(pt, stmt, signer).unwrap();
274 store.write(&Record {
275 artifact_id: result.artifact_id.clone(),
276 digest: result.digest.clone(),
277 payload_type: pt.to_string(),
278 key_id: signer.key_id().to_string(),
279 signed_at: String::new(),
280 parent_id: None,
281 envelope: result.envelope,
282 hub_url: None,
283 }).unwrap();
284 result.artifact_id
285 }
286
287 #[test]
288 fn create_bundle() {
289 let (store, dir) = tmp_store();
290 let signer = Ed25519Signer::generate("key_test").unwrap();
291
292 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
293 &ActionStatement::new("agent://a", "tool.call"));
294 let a2 = sign_and_store(&store, &signer, &payload_type("approval"),
295 &ApprovalStatement::new("human://b", "nonce_1"));
296
297 let result = create(
298 &[&a1, &a2],
299 Some("test-bundle"),
300 None,
301 &store,
302 &signer,
303 ).unwrap();
304
305 assert!(result.artifact_id.starts_with("art_"));
306 assert_eq!(result.statement.artifacts.len(), 2);
307 assert_eq!(result.statement.tag.as_deref(), Some("test-bundle"));
308
309 assert!(store.exists(&result.artifact_id));
311 rm(dir);
312 }
313
314 #[test]
315 fn create_empty_fails() {
316 let (store, dir) = tmp_store();
317 let signer = Ed25519Signer::generate("key_test").unwrap();
318 let err = create(&[], None, None, &store, &signer).unwrap_err();
319 assert!(err.to_string().contains("no artifact IDs"));
320 rm(dir);
321 }
322
323 #[test]
324 fn create_missing_artifact_fails() {
325 let (store, dir) = tmp_store();
326 let signer = Ed25519Signer::generate("key_test").unwrap();
327 let err = create(&["art_doesnotexist1234567890123456"], None, None, &store, &signer).unwrap_err();
328 assert!(err.to_string().contains("not found"));
329 rm(dir);
330 }
331
332 #[test]
333 fn export_and_import_roundtrip() {
334 let (store, dir) = tmp_store();
335 let signer = Ed25519Signer::generate("key_test").unwrap();
336
337 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
338 &ActionStatement::new("agent://a", "tool.call"));
339 let a2 = sign_and_store(&store, &signer, &payload_type("action"),
340 &ActionStatement::new("agent://b", "web.fetch"));
341
342 let bundle = create(&[&a1, &a2], Some("roundtrip"), None, &store, &signer).unwrap();
343
344 let export_path = dir.join("test.treeship");
346 export(&bundle.artifact_id, &export_path, &store).unwrap();
347 assert!(export_path.exists());
348
349 let bytes = std::fs::read(&export_path).unwrap();
351 let ef: ExportFile = serde_json::from_slice(&bytes).unwrap();
352 assert_eq!(ef.version, EXPORT_VERSION);
353 assert_eq!(ef.artifacts.len(), 2);
354
355 let (store2, dir2) = tmp_store();
357 let imported_id = import(&export_path, &store2).unwrap();
358 assert_eq!(imported_id, bundle.artifact_id);
359
360 assert!(store2.exists(&a1));
362 assert!(store2.exists(&a2));
363 assert!(store2.exists(&bundle.artifact_id));
364
365 rm(dir);
366 rm(dir2);
367 }
368
369 #[test]
370 fn export_non_bundle_fails() {
371 let (store, dir) = tmp_store();
372 let signer = Ed25519Signer::generate("key_test").unwrap();
373 let a1 = sign_and_store(&store, &signer, &payload_type("action"),
374 &ActionStatement::new("agent://a", "tool.call"));
375
376 let export_path = dir.join("bad.treeship");
377 let err = export(&a1, &export_path, &store).unwrap_err();
378 assert!(err.to_string().contains("not a bundle"));
379 rm(dir);
380 }
381
382 #[test]
383 fn import_bad_version_fails() {
384 let (store, dir) = tmp_store();
385 let bad = ExportFile {
386 version: "bad/v99".into(),
387 bundle: Envelope {
388 payload: String::new(),
389 payload_type: String::new(),
390 signatures: vec![],
391 },
392 artifacts: vec![],
393 };
394 let path = dir.join("bad.treeship");
395 std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
396
397 let err = import(&path, &store).unwrap_err();
398 assert!(err.to_string().contains("unsupported export version"));
399 rm(dir);
400 }
401}