lean_ctx/core/context_package/
addons.rs1use std::path::{Path, PathBuf};
14
15use super::content::AddonContent;
16use super::manifest::PackageManifest;
17use super::verify;
18
19pub(crate) fn addons_dir(store_root: &Path, name: &str, version: &str) -> PathBuf {
25 let safe_name = name.replace('/', "__");
26 store_root.join("addons").join(safe_name).join(version)
27}
28
29pub fn store_root() -> Result<PathBuf, String> {
37 Ok(crate::core::data_dir::lean_ctx_data_dir()?.join(super::registry::PACKAGES_DIR))
38}
39
40pub fn addons_root(store_root: &Path) -> PathBuf {
42 store_root.join("addons")
43}
44
45pub(crate) fn materialize_modules(
52 store_root: &Path,
53 manifest: &PackageManifest,
54 addon: &AddonContent,
55) -> Result<PathBuf, String> {
56 let version_root = addons_dir(store_root, &manifest.name, &manifest.version);
57
58 if version_root.exists() {
59 std::fs::remove_dir_all(&version_root).map_err(|e| e.to_string())?;
60 }
61 std::fs::create_dir_all(&version_root)
66 .map_err(|e| format!("create {}: {e}", version_root.display()))?;
67
68 for blob in &addon.modules {
69 verify::validate_document_path(&blob.path)?;
70 let plain = blob.decode_verified()?;
71 let dest = version_root.join(&blob.path);
72 if let Some(parent) = dest.parent() {
73 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
74 }
75 std::fs::write(&dest, &plain).map_err(|e| format!("write {}: {e}", dest.display()))?;
76 #[cfg(unix)]
80 {
81 use std::os::unix::fs::PermissionsExt;
82 let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o444));
83 }
84 }
85
86 let manifest_dest = version_root.join("lean-ctx-addon.toml");
89 std::fs::write(&manifest_dest, addon.manifest_toml.as_bytes())
90 .map_err(|e| format!("write {}: {e}", manifest_dest.display()))?;
91
92 Ok(version_root)
93}
94
95#[cfg(any(feature = "wasm", test))]
106pub fn installed_modules(store_root: &Path) -> Vec<PathBuf> {
121 let root = addons_root(store_root);
122 let Ok(addon_dirs) = std::fs::read_dir(&root) else {
123 return Vec::new();
124 };
125
126 let mut out = Vec::new();
127 for addon_dir in addon_dirs.flatten().filter(|e| e.path().is_dir()) {
128 let Ok(versions) = std::fs::read_dir(addon_dir.path()) else {
129 continue;
130 };
131 let newest = versions
132 .flatten()
133 .filter(|e| e.path().is_dir())
134 .max_by_key(|e| {
135 e.metadata()
136 .and_then(|m| m.modified())
137 .unwrap_or(std::time::UNIX_EPOCH)
138 });
139 if let Some(version_dir) = newest {
140 collect_modules(&version_dir.path(), &mut out, 0);
141 }
142 }
143 out.sort();
144 out
145}
146
147#[cfg(any(feature = "wasm", test))]
151fn collect_modules(dir: &Path, out: &mut Vec<PathBuf>, depth: usize) {
152 if depth > 6 {
153 return;
154 }
155 let Ok(entries) = std::fs::read_dir(dir) else {
156 return;
157 };
158 for entry in entries.flatten() {
159 let path = entry.path();
160 if path.is_dir() {
161 collect_modules(&path, out, depth + 1);
162 } else if path.extension().and_then(|e| e.to_str()) == Some("wasm") {
163 out.push(path);
164 }
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::super::content::DocumentBlob;
171 use super::*;
172
173 fn manifest(name: &str, version: &str) -> PackageManifest {
174 use super::super::manifest::{
175 CompatibilitySpec, PackageIntegrity, PackageKind, PackageProvenance, PackageStats,
176 };
177 PackageManifest {
178 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
179 conformance_level: None,
180 kind: PackageKind::Addon,
181 name: name.to_string(),
182 version: version.to_string(),
183 description: "test addon".to_string(),
184 author: None,
185 scope: name
186 .starts_with('@')
187 .then(|| name.split('/').next().unwrap_or_default().to_string()),
188 created_at: chrono::Utc::now(),
189 updated_at: None,
190 layers: Vec::new(),
191 dependencies: Vec::new(),
192 tags: Vec::new(),
193 visibility: None,
194 integrity: PackageIntegrity {
195 sha256: "0".repeat(64),
196 content_hash: "0".repeat(64),
197 byte_size: 0,
198 },
199 provenance: PackageProvenance {
200 tool: "lean-ctx".into(),
201 tool_version: env!("CARGO_PKG_VERSION").into(),
202 project_hash: None,
203 source_session_id: None,
204 },
205 compatibility: CompatibilitySpec::default(),
206 stats: PackageStats::default(),
207 signature: None,
208 graph_summary: None,
209 marketplace: None,
210 redistributable: None,
211 }
212 }
213
214 fn empty_wasm() -> Vec<u8> {
216 vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]
217 }
218
219 #[test]
220 fn materializes_modules_and_the_authoring_manifest() {
221 let tmp = tempfile::tempdir().unwrap();
222 let addon = AddonContent {
223 manifest_toml: "[addon]\nname = \"demo\"\n".to_string(),
224 modules: vec![DocumentBlob::from_plaintext("demo.wasm", &empty_wasm()).unwrap()],
225 };
226 let root = materialize_modules(tmp.path(), &manifest("@ns/demo", "1.0.0"), &addon).unwrap();
227
228 assert!(root.join("demo.wasm").is_file());
229 assert_eq!(
230 std::fs::read(root.join("demo.wasm")).unwrap(),
231 empty_wasm(),
232 "the stored bytes must be the verified plaintext"
233 );
234 assert!(
235 root.join("lean-ctx-addon.toml").is_file(),
236 "the authoring manifest travels with the modules"
237 );
238 assert!(
239 root.to_string_lossy().contains("@ns__demo"),
240 "scoped names are flattened like every other pack kind: {root:?}"
241 );
242 }
243
244 #[test]
249 fn an_addon_without_modules_still_materializes_its_manifest() {
250 let tmp = tempfile::tempdir().unwrap();
251 let addon = AddonContent {
252 manifest_toml: "[addon]\nname = \"@ns/server-only\"\n[mcp]\ncommand = \"srv\"\n"
253 .to_string(),
254 modules: Vec::new(),
255 };
256
257 let root =
258 materialize_modules(tmp.path(), &manifest("@ns/server-only", "1.0.0"), &addon).unwrap();
259 assert!(root.is_dir(), "the version directory exists");
260 assert!(
261 root.join("lean-ctx-addon.toml").is_file(),
262 "the authoring manifest was written"
263 );
264 }
265
266 #[test]
269 fn reinstall_drops_modules_the_new_version_no_longer_ships() {
270 let tmp = tempfile::tempdir().unwrap();
271 let m = manifest("@ns/demo", "1.0.0");
272
273 let two = AddonContent {
274 manifest_toml: "[addon]\n".into(),
275 modules: vec![
276 DocumentBlob::from_plaintext("a.wasm", &empty_wasm()).unwrap(),
277 DocumentBlob::from_plaintext("b.wasm", &empty_wasm()).unwrap(),
278 ],
279 };
280 materialize_modules(tmp.path(), &m, &two).unwrap();
281 assert_eq!(installed_modules(tmp.path()).len(), 2);
282
283 let one = AddonContent {
284 manifest_toml: "[addon]\n".into(),
285 modules: vec![DocumentBlob::from_plaintext("a.wasm", &empty_wasm()).unwrap()],
286 };
287 let root = materialize_modules(tmp.path(), &m, &one).unwrap();
288 assert!(!root.join("b.wasm").exists(), "b.wasm must be gone");
289 assert_eq!(installed_modules(tmp.path()).len(), 1);
290 }
291
292 #[test]
293 fn discovery_is_sorted_and_ignores_non_modules() {
294 let tmp = tempfile::tempdir().unwrap();
295 let addon = AddonContent {
296 manifest_toml: "[addon]\n".into(),
297 modules: vec![
298 DocumentBlob::from_plaintext("z.wasm", &empty_wasm()).unwrap(),
299 DocumentBlob::from_plaintext("a.wasm", &empty_wasm()).unwrap(),
300 ],
301 };
302 materialize_modules(tmp.path(), &manifest("@ns/demo", "1.0.0"), &addon).unwrap();
303
304 let found = installed_modules(tmp.path());
305 assert_eq!(found.len(), 2, "the .toml must not be picked up: {found:?}");
306 let names: Vec<String> = found
307 .iter()
308 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
309 .collect();
310 assert_eq!(
311 names,
312 vec!["a.wasm", "z.wasm"],
313 "registration order must be deterministic (#498)"
314 );
315 }
316
317 #[test]
323 fn only_the_most_recently_installed_version_is_loaded() {
324 let tmp = tempfile::tempdir().unwrap();
325 let addon = |body: &[u8]| AddonContent {
326 manifest_toml: "[addon]\nname = \"@ns/demo\"\n".into(),
327 modules: vec![DocumentBlob::from_plaintext("demo.wasm", body).unwrap()],
328 };
329
330 let old = empty_wasm();
332 let mut new = empty_wasm();
333 new.extend_from_slice(&[0x00; 4]);
334 materialize_modules(tmp.path(), &manifest("@ns/demo", "9.0.0"), &addon(&old)).unwrap();
335 std::thread::sleep(std::time::Duration::from_millis(20));
336 materialize_modules(tmp.path(), &manifest("@ns/demo", "10.0.0"), &addon(&new)).unwrap();
337
338 let found = installed_modules(tmp.path());
339 assert_eq!(found.len(), 1, "one version's modules, not both: {found:?}");
340 assert_eq!(
341 std::fs::read(&found[0]).unwrap(),
342 new,
343 "the upgrade must win, not the version that sorts first"
344 );
345 assert!(
346 found[0].to_string_lossy().contains("10.0.0"),
347 "{:?}",
348 found[0]
349 );
350 }
351
352 #[test]
353 fn an_empty_store_yields_no_modules() {
354 let tmp = tempfile::tempdir().unwrap();
355 {
356 let found = &installed_modules(tmp.path());
357 assert!(found.is_empty(), "{found:?}");
358 }
359 }
360}