Skip to main content

shape_runtime/
package_bundle.rs

1//! Package bundle format for distributable .shapec files
2//!
3//! A package bundle contains pre-compiled bytecode for all modules in a Shape
4//! package, plus metadata for versioning and freshness checks.
5//!
6//! File format: `[8 bytes "SHAPEPKG"] [4 bytes format_version LE] [MessagePack payload]`
7
8use crate::module_manifest::ModuleManifest;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13const MAGIC: &[u8; 8] = b"SHAPEPKG";
14const FORMAT_VERSION: u32 = 2;
15/// Minimum version we can still load (v1 bundles lack blob_store/manifests).
16const MIN_FORMAT_VERSION: u32 = 1;
17
18fn default_bundle_kind() -> String {
19    "portable-bytecode".to_string()
20}
21
22/// Metadata about a compiled package bundle.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BundleMetadata {
25    /// Package name from shape.toml [project].name
26    pub name: String,
27    /// Package version from shape.toml [project].version
28    pub version: String,
29    /// Shape compiler version that produced this bundle
30    pub compiler_version: String,
31    /// SHA-256 hash of all source files combined
32    pub source_hash: String,
33    /// Bundle compatibility kind.
34    /// `portable-bytecode` bundles are cross-platform and contain no host-native machine code.
35    #[serde(default = "default_bundle_kind")]
36    pub bundle_kind: String,
37    /// Host identifier of the build machine (for diagnostics only).
38    #[serde(default)]
39    pub build_host: String,
40    /// Whether declared native dependencies are host-portable (no host-specific path/vendoring required).
41    #[serde(default = "default_native_portable")]
42    pub native_portable: bool,
43    /// Entry module path, if any
44    pub entry_module: Option<String>,
45    /// Build timestamp (unix seconds from SystemTime)
46    pub built_at: u64,
47}
48
49fn default_native_portable() -> bool {
50    true
51}
52
53/// A single compiled module within a bundle.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct BundledModule {
56    /// Module path using :: separator (e.g., "utils::helpers")
57    pub module_path: String,
58    /// MessagePack-serialized BytecodeProgram as raw bytes
59    pub bytecode_bytes: Vec<u8>,
60    /// Names of exported symbols
61    pub export_names: Vec<String>,
62    /// SHA-256 hash of the individual source file
63    pub source_hash: String,
64}
65
66/// A compiled package bundle containing all modules and metadata.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PackageBundle {
69    /// Bundle metadata
70    pub metadata: BundleMetadata,
71    /// Compiled modules
72    pub modules: Vec<BundledModule>,
73    /// Declared dependency versions (name -> version string)
74    pub dependencies: HashMap<String, String>,
75    /// Content-addressed blob store: hash -> raw blob bytes.
76    /// Blobs are deduplicated across modules so shared functions are stored once.
77    #[serde(default)]
78    pub blob_store: HashMap<[u8; 32], Vec<u8>>,
79    /// Module manifests for content-addressed resolution.
80    /// Each manifest maps export names to blob hashes in `blob_store`.
81    #[serde(default)]
82    pub manifests: Vec<ModuleManifest>,
83    /// Native dependency scopes for this package and all transitive dependencies.
84    /// Used by consumers of `.shapec` bundles to lock/validate native prerequisites.
85    #[serde(default)]
86    pub native_dependency_scopes: Vec<BundledNativeDependencyScope>,
87}
88
89/// Native dependency scope embedded in a `.shapec` bundle.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct BundledNativeDependencyScope {
92    /// Package name declaring the dependencies.
93    pub package_name: String,
94    /// Package version declaring the dependencies.
95    pub package_version: String,
96    /// Canonical package identity key (`name@version`).
97    pub package_key: String,
98    /// Native dependencies declared by this package.
99    pub dependencies: HashMap<String, crate::project::NativeDependencySpec>,
100}
101
102impl PackageBundle {
103    /// Serialize the bundle to bytes with magic header.
104    pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
105        let payload =
106            rmp_serde::to_vec(self).map_err(|e| format!("Failed to serialize bundle: {}", e))?;
107
108        let mut buf = Vec::with_capacity(12 + payload.len());
109        buf.extend_from_slice(MAGIC);
110        buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
111        buf.extend_from_slice(&payload);
112        Ok(buf)
113    }
114
115    /// Deserialize a bundle from bytes, validating magic and version.
116    ///
117    /// Supports both v1 (no blob_store/manifests) and v2 bundles. The v1
118    /// fields are filled with defaults via `#[serde(default)]`.
119    pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
120        if data.len() < 12 {
121            return Err("Bundle too small: missing header".to_string());
122        }
123
124        if &data[..8] != MAGIC {
125            return Err("Invalid bundle: bad magic bytes".to_string());
126        }
127
128        let version = u32::from_le_bytes(
129            data[8..12]
130                .try_into()
131                .map_err(|_| "Invalid version bytes".to_string())?,
132        );
133        if version < MIN_FORMAT_VERSION || version > FORMAT_VERSION {
134            return Err(format!(
135                "Unsupported bundle format version: expected {}-{}, got {}",
136                MIN_FORMAT_VERSION, FORMAT_VERSION, version
137            ));
138        }
139
140        rmp_serde::from_slice(&data[12..])
141            .map_err(|e| format!("Failed to deserialize bundle: {}", e))
142    }
143
144    /// Write the bundle to a file.
145    pub fn write_to_file(&self, path: &Path) -> Result<(), String> {
146        let bytes = self.to_bytes()?;
147        std::fs::write(path, bytes)
148            .map_err(|e| format!("Failed to write bundle to '{}': {}", path.display(), e))
149    }
150
151    /// Read a bundle from a file.
152    pub fn read_from_file(path: &Path) -> Result<Self, String> {
153        let data = std::fs::read(path)
154            .map_err(|e| format!("Failed to read bundle from '{}': {}", path.display(), e))?;
155        Self::from_bytes(&data)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn sample_bundle() -> PackageBundle {
164        PackageBundle {
165            metadata: BundleMetadata {
166                name: "test-pkg".to_string(),
167                version: "0.1.0".to_string(),
168                compiler_version: "0.5.0".to_string(),
169                source_hash: "abc123".to_string(),
170                bundle_kind: default_bundle_kind(),
171                build_host: "x86_64-linux".to_string(),
172                native_portable: true,
173                entry_module: Some("main".to_string()),
174                built_at: 1700000000,
175            },
176            modules: vec![
177                BundledModule {
178                    module_path: "main".to_string(),
179                    bytecode_bytes: vec![1, 2, 3, 4],
180                    export_names: vec!["run".to_string()],
181                    source_hash: "def456".to_string(),
182                },
183                BundledModule {
184                    module_path: "utils::helpers".to_string(),
185                    bytecode_bytes: vec![5, 6, 7],
186                    export_names: vec!["helper".to_string(), "format".to_string()],
187                    source_hash: "ghi789".to_string(),
188                },
189            ],
190            dependencies: {
191                let mut deps = HashMap::new();
192                deps.insert("my-lib".to_string(), "1.0.0".to_string());
193                deps
194            },
195            blob_store: HashMap::new(),
196            manifests: vec![],
197            native_dependency_scopes: vec![],
198        }
199    }
200
201    #[test]
202    fn test_roundtrip_serialize_deserialize() {
203        let bundle = sample_bundle();
204        let bytes = bundle.to_bytes().expect("serialization should succeed");
205        let restored = PackageBundle::from_bytes(&bytes).expect("deserialization should succeed");
206
207        assert_eq!(restored.metadata.name, "test-pkg");
208        assert_eq!(restored.metadata.version, "0.1.0");
209        assert_eq!(restored.modules.len(), 2);
210        assert_eq!(restored.modules[0].module_path, "main");
211        assert_eq!(restored.modules[0].bytecode_bytes, vec![1, 2, 3, 4]);
212        assert_eq!(restored.modules[1].module_path, "utils::helpers");
213        assert_eq!(restored.dependencies.get("my-lib").unwrap(), "1.0.0");
214        assert!(restored.blob_store.is_empty());
215        assert!(restored.manifests.is_empty());
216    }
217
218    #[test]
219    fn test_magic_bytes_validation() {
220        let mut bad_data = vec![0u8; 20];
221        bad_data[..8].copy_from_slice(b"BADMAGIC");
222        let result = PackageBundle::from_bytes(&bad_data);
223        assert!(result.is_err());
224        assert!(result.unwrap_err().contains("bad magic bytes"));
225    }
226
227    #[test]
228    fn test_version_validation() {
229        let mut data = vec![0u8; 20];
230        data[..8].copy_from_slice(MAGIC);
231        data[8..12].copy_from_slice(&99u32.to_le_bytes());
232        let result = PackageBundle::from_bytes(&data);
233        assert!(result.is_err());
234        assert!(
235            result
236                .unwrap_err()
237                .contains("Unsupported bundle format version")
238        );
239    }
240
241    #[test]
242    fn test_too_small_data() {
243        let result = PackageBundle::from_bytes(&[1, 2, 3]);
244        assert!(result.is_err());
245        assert!(result.unwrap_err().contains("too small"));
246    }
247
248    #[test]
249    fn test_empty_bundle() {
250        let bundle = PackageBundle {
251            metadata: BundleMetadata {
252                name: "empty".to_string(),
253                version: "0.0.1".to_string(),
254                compiler_version: "0.5.0".to_string(),
255                source_hash: "empty".to_string(),
256                bundle_kind: default_bundle_kind(),
257                build_host: "x86_64-linux".to_string(),
258                native_portable: true,
259                entry_module: None,
260                built_at: 0,
261            },
262            modules: vec![],
263            dependencies: HashMap::new(),
264            blob_store: HashMap::new(),
265            manifests: vec![],
266            native_dependency_scopes: vec![],
267        };
268
269        let bytes = bundle.to_bytes().expect("should serialize");
270        let restored = PackageBundle::from_bytes(&bytes).expect("should deserialize");
271        assert_eq!(restored.metadata.name, "empty");
272        assert!(restored.modules.is_empty());
273        assert!(restored.dependencies.is_empty());
274    }
275
276    #[test]
277    fn test_file_roundtrip() {
278        let tmp = tempfile::tempdir().expect("temp dir");
279        let path = tmp.path().join("test.shapec");
280
281        let bundle = sample_bundle();
282        bundle.write_to_file(&path).expect("write should succeed");
283        let restored = PackageBundle::read_from_file(&path).expect("read should succeed");
284
285        assert_eq!(restored.metadata.name, "test-pkg");
286        assert_eq!(restored.modules.len(), 2);
287    }
288
289    #[test]
290    fn test_bundle_with_blob_store_and_manifests() {
291        let blob_hash = [0xAB; 32];
292        let blob_data = vec![10, 20, 30, 40];
293
294        let mut manifest = ModuleManifest::new("mymod".into(), "1.0.0".into());
295        manifest.add_export("greet".into(), blob_hash);
296        manifest.finalize();
297
298        let bundle = PackageBundle {
299            metadata: BundleMetadata {
300                name: "ca-pkg".to_string(),
301                version: "2.0.0".to_string(),
302                compiler_version: "0.6.0".to_string(),
303                source_hash: "ca_hash".to_string(),
304                bundle_kind: default_bundle_kind(),
305                build_host: "x86_64-linux".to_string(),
306                native_portable: true,
307                entry_module: None,
308                built_at: 1700000001,
309            },
310            modules: vec![],
311            dependencies: HashMap::new(),
312            blob_store: {
313                let mut bs = HashMap::new();
314                bs.insert(blob_hash, blob_data.clone());
315                bs
316            },
317            manifests: vec![manifest],
318            native_dependency_scopes: vec![],
319        };
320
321        let bytes = bundle.to_bytes().expect("serialization should succeed");
322        let restored = PackageBundle::from_bytes(&bytes).expect("deserialization should succeed");
323
324        assert_eq!(restored.metadata.name, "ca-pkg");
325        assert_eq!(restored.manifests.len(), 1);
326        assert_eq!(restored.manifests[0].name, "mymod");
327        assert!(restored.manifests[0].verify_integrity());
328        assert_eq!(restored.blob_store.get(&blob_hash), Some(&blob_data));
329        assert!(restored.modules.is_empty());
330    }
331
332    #[test]
333    fn test_bundle_blob_deduplication() {
334        let shared_hash = [0x01; 32];
335        let shared_blob = vec![99, 88, 77];
336
337        let mut m1 = ModuleManifest::new("mod_a".into(), "1.0.0".into());
338        m1.add_export("fn_a".into(), shared_hash);
339        m1.finalize();
340
341        let mut m2 = ModuleManifest::new("mod_b".into(), "1.0.0".into());
342        m2.add_export("fn_b".into(), shared_hash);
343        m2.finalize();
344
345        let bundle = PackageBundle {
346            metadata: BundleMetadata {
347                name: "dedup-pkg".to_string(),
348                version: "1.0.0".to_string(),
349                compiler_version: "0.6.0".to_string(),
350                source_hash: "dedup".to_string(),
351                bundle_kind: default_bundle_kind(),
352                build_host: "x86_64-linux".to_string(),
353                native_portable: true,
354                entry_module: None,
355                built_at: 0,
356            },
357            modules: vec![],
358            dependencies: HashMap::new(),
359            blob_store: {
360                let mut bs = HashMap::new();
361                bs.insert(shared_hash, shared_blob.clone());
362                bs
363            },
364            manifests: vec![m1, m2],
365            native_dependency_scopes: vec![],
366        };
367
368        let bytes = bundle.to_bytes().expect("serialize");
369        let restored = PackageBundle::from_bytes(&bytes).expect("deserialize");
370
371        // Both manifests reference the same hash, but blob_store has it once.
372        assert_eq!(restored.blob_store.len(), 1);
373        assert_eq!(restored.blob_store.get(&shared_hash), Some(&shared_blob));
374        assert_eq!(restored.manifests.len(), 2);
375    }
376}