systemprompt_loader/bundle/
pack.rs1use 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}