Skip to main content

systemprompt_loader/bundle/
pack.rs

1//! Packing a services tree into a bundle archive.
2//!
3//! The file list is walked in sorted order and paths are stored with `/`
4//! separators, so packing the same tree twice on the same platform yields the
5//! same [`ServicesBundleManifest::content_hash`] and the same archive layout.
6//! Ownership is derived from the tree rather than declared, so a manifest
7//! cannot claim ids the bundle does not carry.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::path::{Path, PathBuf};
13use std::{fs, io};
14
15use chrono::Utc;
16use flate2::Compression;
17use flate2::write::GzEncoder;
18use sha2::{Digest, Sha256};
19use systemprompt_models::services::bundle::{
20    BUNDLE_ALLOWED_DIRS, BUNDLE_FORMAT_VERSION, BUNDLE_MANIFEST_FILE, BundleOwnership,
21    BundleSourceInfo, FileEntry, ServicesBundleManifest, SignedBundleManifest,
22};
23use tar::Builder;
24
25use super::error::{BundleError, BundleResult};
26use super::extract::BUNDLE_TREE_PREFIX;
27
28const OWNED_ID_DIRS: &[&str] = &[
29    "marketplaces",
30    "plugins",
31    "skills",
32    "rules",
33    "hooks",
34    "artifacts",
35];
36
37pub fn collect_files(root: &Path, directories: &[&str]) -> io::Result<Vec<FileEntry>> {
38    let mut files = Vec::new();
39    for dir in directories {
40        let dir_path = root.join(dir);
41        if dir_path.is_dir() {
42            collect_dir(&dir_path, root, &mut files)?;
43        }
44    }
45    files.sort_by(|a, b| a.path.cmp(&b.path));
46    Ok(files)
47}
48
49fn collect_dir(dir: &Path, base: &Path, files: &mut Vec<FileEntry>) -> io::Result<()> {
50    let mut entries: Vec<PathBuf> = fs::read_dir(dir)?
51        .collect::<io::Result<Vec<_>>>()?
52        .into_iter()
53        .map(|e| e.path())
54        .collect();
55    entries.sort();
56
57    for path in entries {
58        if path.is_dir() {
59            collect_dir(&path, base, files)?;
60        } else if path.is_file() {
61            let relative = path.strip_prefix(base).map_err(io::Error::other)?;
62            let content = fs::read(&path)?;
63            files.push(FileEntry {
64                path: relative_slug(relative),
65                sha256: hex::encode(Sha256::digest(&content)),
66                size: content.len() as u64,
67            });
68        }
69    }
70    Ok(())
71}
72
73fn relative_slug(path: &Path) -> String {
74    path.components()
75        .filter_map(|c| match c {
76            std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
77            _ => None,
78        })
79        .collect::<Vec<_>>()
80        .join("/")
81}
82
83#[must_use]
84pub fn manifest_checksum(files: &[FileEntry]) -> (String, u64) {
85    let mut hasher = Sha256::new();
86    let mut total = 0u64;
87    for file in files {
88        hasher.update(&file.sha256);
89        total += file.size;
90    }
91    (hex::encode(hasher.finalize()), total)
92}
93
94pub fn derive_ownership(root: &Path) -> io::Result<BundleOwnership> {
95    let mut owns = BundleOwnership::default();
96    for dir in BUNDLE_ALLOWED_DIRS {
97        if root.join(dir).is_dir() {
98            owns.dirs.push((*dir).to_owned());
99        }
100    }
101    for dir in OWNED_ID_DIRS {
102        let ids = child_dir_names(&root.join(dir))?;
103        match *dir {
104            "marketplaces" => owns.marketplaces = ids,
105            "plugins" => owns.plugins = ids,
106            "skills" => owns.skills = ids,
107            "rules" => owns.rules = ids,
108            "hooks" => owns.hooks = ids,
109            _ => owns.artifacts = ids,
110        }
111    }
112    Ok(owns)
113}
114
115fn child_dir_names(dir: &Path) -> io::Result<Vec<String>> {
116    if !dir.is_dir() {
117        return Ok(Vec::new());
118    }
119    let mut names: Vec<String> = fs::read_dir(dir)?
120        .collect::<io::Result<Vec<_>>>()?
121        .into_iter()
122        .filter(|e| e.path().is_dir())
123        .map(|e| e.file_name().to_string_lossy().into_owned())
124        .collect();
125    names.sort();
126    Ok(names)
127}
128
129pub fn build_manifest(
130    root: &Path,
131    version: &str,
132    requires_core: &str,
133    source: BundleSourceInfo,
134) -> BundleResult<ServicesBundleManifest> {
135    let files = collect_files(root, BUNDLE_ALLOWED_DIRS)?;
136    let (_checksum, total_size) = manifest_checksum(&files);
137    let owns = derive_ownership(root)?;
138    Ok(ServicesBundleManifest {
139        format: BUNDLE_FORMAT_VERSION,
140        version: version.to_owned(),
141        created_at: Utc::now(),
142        requires_core: requires_core.to_owned(),
143        source,
144        content_hash: ServicesBundleManifest::compute_content_hash(&files),
145        files,
146        total_size,
147        owns,
148    })
149}
150
151pub fn write_tarball(root: &Path, signed: &SignedBundleManifest, out: &Path) -> BundleResult<()> {
152    let manifest_json = serde_json::to_vec_pretty(signed)
153        .map_err(|e| BundleError::policy(format!("manifest is not serialisable: {e}")))?;
154
155    if let Some(parent) = out.parent() {
156        fs::create_dir_all(parent)?;
157    }
158    let file = fs::File::create(out)?;
159    let mut encoder = GzEncoder::new(file, Compression::default());
160    {
161        let mut tar = Builder::new(&mut encoder);
162        let mut header = tar::Header::new_gnu();
163        header.set_size(manifest_json.len() as u64);
164        header.set_mode(0o644);
165        header.set_cksum();
166        tar.append_data(&mut header, BUNDLE_MANIFEST_FILE, manifest_json.as_slice())
167            .map_err(|e| BundleError::extract(out, e))?;
168
169        for entry in &signed.manifest.files {
170            let full = root.join(&entry.path);
171            let name = format!("{BUNDLE_TREE_PREFIX}/{}", entry.path);
172            tar.append_path_with_name(&full, &name)
173                .map_err(|e| BundleError::extract(&full, e))?;
174        }
175        tar.finish().map_err(|e| BundleError::extract(out, e))?;
176    }
177    encoder.finish()?;
178    Ok(())
179}
180
181pub fn create_tarball_bytes(root: &Path, files: &[FileEntry]) -> BundleResult<Vec<u8>> {
182    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
183    {
184        let mut tar = Builder::new(&mut encoder);
185        for entry in files {
186            let full = root.join(&entry.path);
187            tar.append_path_with_name(&full, &entry.path)
188                .map_err(|e| BundleError::extract(&full, e))?;
189        }
190        tar.finish().map_err(|e| BundleError::extract(root, e))?;
191    }
192    Ok(encoder.finish()?)
193}