Skip to main content

lean_ctx/core/context_package/
registry.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6use super::content::PackageContent;
7use super::manifest::PackageManifest;
8
9const INDEX_FILE: &str = "package-index.json";
10const PACKAGES_DIR: &str = "packages";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PackageIndex {
14    pub schema_version: u32,
15    pub updated_at: DateTime<Utc>,
16    pub entries: Vec<PackageEntry>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PackageEntry {
21    pub name: String,
22    pub version: String,
23    pub description: String,
24    pub installed_at: DateTime<Utc>,
25    pub layers: Vec<String>,
26    pub sha256: String,
27    pub byte_size: u64,
28    #[serde(default)]
29    pub tags: Vec<String>,
30    #[serde(default)]
31    pub auto_load: bool,
32}
33
34impl PackageIndex {
35    fn new() -> Self {
36        Self {
37            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
38            updated_at: Utc::now(),
39            entries: Vec::new(),
40        }
41    }
42}
43
44pub struct LocalRegistry {
45    root: PathBuf,
46}
47
48impl LocalRegistry {
49    pub fn open() -> Result<Self, String> {
50        let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
51        let root = data_dir.join(PACKAGES_DIR);
52        std::fs::create_dir_all(&root).map_err(|e| format!("create packages dir: {e}"))?;
53        Ok(Self { root })
54    }
55
56    pub fn open_at(root: &Path) -> Result<Self, String> {
57        std::fs::create_dir_all(root).map_err(|e| format!("create packages dir: {e}"))?;
58        Ok(Self {
59            root: root.to_path_buf(),
60        })
61    }
62
63    pub fn root(&self) -> &Path {
64        &self.root
65    }
66
67    pub fn install(
68        &self,
69        manifest: &PackageManifest,
70        content: &PackageContent,
71    ) -> Result<PathBuf, String> {
72        manifest.validate().map_err(|errs| errs.join("; "))?;
73
74        // kind=skills (GH #727): materialize the verified blobs under the
75        // store *before* touching the index — a tampered blob aborts the
76        // whole install, never leaving a half-registered package.
77        if manifest.kind == super::manifest::PackageKind::Skills {
78            let docs = content
79                .documents
80                .as_ref()
81                .ok_or("kind=skills package has no documents payload")?;
82            let root = super::skills::materialize_documents(&self.root, manifest, docs)?;
83            tracing::info!(
84                "skills pack {}@{} materialized at {}",
85                manifest.name,
86                manifest.version,
87                root.display()
88            );
89        }
90
91        let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
92        std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
93
94        let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
95        atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
96
97        let content_json = serde_json::to_string_pretty(content).map_err(|e| e.to_string())?;
98        atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
99
100        let mut index = self.load_index()?;
101        index
102            .entries
103            .retain(|e| !(e.name == manifest.name && e.version == manifest.version));
104        index.entries.push(PackageEntry {
105            name: manifest.name.clone(),
106            version: manifest.version.clone(),
107            description: manifest.description.clone(),
108            installed_at: Utc::now(),
109            layers: manifest
110                .layers
111                .iter()
112                .map(|l| l.as_str().to_string())
113                .collect(),
114            sha256: manifest.integrity.sha256.clone(),
115            byte_size: manifest.integrity.byte_size,
116            tags: manifest.tags.clone(),
117            auto_load: false,
118        });
119        index.updated_at = Utc::now();
120        self.save_index(&index)?;
121
122        Ok(pkg_dir)
123    }
124
125    pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
126        let mut index = self.load_index()?;
127        let before = index.entries.len();
128
129        let to_remove: Vec<(String, String)> = index
130            .entries
131            .iter()
132            .filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
133            .map(|e| (e.name.clone(), e.version.clone()))
134            .collect();
135
136        for (n, v) in &to_remove {
137            let dir = self.package_dir(n, v);
138            if dir.exists() {
139                let _ = std::fs::remove_dir_all(&dir);
140            }
141            // Materialized skills files (GH #727) live outside the package
142            // dir — clean them up with the same lifetime.
143            let skills = super::skills::skills_dir(&self.root, n, v);
144            if skills.exists() {
145                let _ = std::fs::remove_dir_all(&skills);
146            }
147        }
148
149        index.entries.retain(|e| {
150            !to_remove
151                .iter()
152                .any(|(n, v)| e.name == *n && e.version == *v)
153        });
154
155        let removed = (before - index.entries.len()) as u32;
156        if removed > 0 {
157            index.updated_at = Utc::now();
158            self.save_index(&index)?;
159        }
160
161        Ok(removed)
162    }
163
164    pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
165        let index = self.load_index()?;
166        Ok(index.entries)
167    }
168
169    pub fn get(&self, name: &str, version: Option<&str>) -> Result<Option<PackageEntry>, String> {
170        let index = self.load_index()?;
171        Ok(index
172            .entries
173            .into_iter()
174            .find(|e| e.name == name && version.is_none_or(|v| e.version == v)))
175    }
176
177    pub fn load_package(
178        &self,
179        name: &str,
180        version: &str,
181    ) -> Result<(PackageManifest, PackageContent), String> {
182        let pkg_dir = self.package_dir(name, version);
183        if !pkg_dir.exists() {
184            return Err(format!("package {name}@{version} not found"));
185        }
186
187        let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
188            .map_err(|e| format!("read manifest: {e}"))?;
189        let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
190            .map_err(|e| format!("read content: {e}"))?;
191
192        let manifest: PackageManifest =
193            serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
194        let content: PackageContent =
195            serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
196
197        verify_integrity(&manifest, &content_json)?;
198
199        Ok((manifest, content))
200    }
201
202    pub fn set_auto_load(&self, name: &str, version: &str, auto_load: bool) -> Result<(), String> {
203        let mut index = self.load_index()?;
204        if let Some(entry) = index
205            .entries
206            .iter_mut()
207            .find(|e| e.name == name && e.version == version)
208        {
209            entry.auto_load = auto_load;
210            index.updated_at = Utc::now();
211            self.save_index(&index)?;
212        } else {
213            return Err(format!("package {name}@{version} not found in index"));
214        }
215        Ok(())
216    }
217
218    pub fn auto_load_packages(&self) -> Result<Vec<PackageEntry>, String> {
219        let index = self.load_index()?;
220        Ok(index.entries.into_iter().filter(|e| e.auto_load).collect())
221    }
222
223    pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
224        let (manifest, content) = self.load_package(name, version)?;
225
226        let bundle = ExportBundle { manifest, content };
227        let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
228        let bytes = json.as_bytes();
229
230        atomic_write(output, bytes)?;
231        Ok(bytes.len() as u64)
232    }
233
234    /// Export with a fresh ed25519 signature over the manifest (GL #406) —
235    /// required by the hosted registry. The stored package stays untouched;
236    /// only the exported bundle carries the signature. `private` stamps
237    /// `visibility=private` into the bundle for the hosted registry (#524).
238    pub fn export_to_file_signed(
239        &self,
240        name: &str,
241        version: &str,
242        output: &Path,
243        signing_key: &ed25519_dalek::SigningKey,
244        private: bool,
245    ) -> Result<u64, String> {
246        let (mut manifest, content) = self.load_package(name, version)?;
247
248        if private {
249            manifest.visibility = Some("private".to_string());
250        }
251        super::signing::sign_package(&mut manifest, &content, signing_key);
252
253        let bundle = ExportBundle { manifest, content };
254        let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
255        let bytes = json.as_bytes();
256
257        atomic_write(output, bytes)?;
258        Ok(bytes.len() as u64)
259    }
260
261    pub fn import_from_file(&self, path: &Path) -> Result<PackageManifest, String> {
262        if !crate::core::contracts::is_package_file(path) {
263            let ext = path
264                .extension()
265                .and_then(|e| e.to_str())
266                .unwrap_or("(none)");
267            return Err(format!(
268                "unsupported file extension '.{ext}' — expected .{} or .{}",
269                crate::core::contracts::PACKAGE_EXTENSION,
270                crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
271            ));
272        }
273
274        let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
275        if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
276            return Err(format!(
277                "package file too large ({} bytes, max {} bytes)",
278                meta.len(),
279                crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
280            ));
281        }
282
283        let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
284        let bundle: ExportBundle =
285            serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
286
287        bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
288
289        // Kind gate (GH #726): the local context registry stores knowledge,
290        // not capabilities. A kind=addon pack routes through the addon trust
291        // chain (consent, sandbox, binhash) — importing it here would sidestep
292        // every one of those gates.
293        super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
294            .map_err(|errs| errs.join("; "))?;
295        if bundle.manifest.kind == super::manifest::PackageKind::Addon {
296            return Err(format!(
297                "`{}` is a kind=addon package — install it with `lean-ctx addon add {}` \
298                 (addon installs need capability consent + the addon trust chain)",
299                bundle.manifest.name,
300                bundle.manifest.name.trim_start_matches('@'),
301            ));
302        }
303
304        let content_text = extract_top_level_value_text(&json, "content")
305            .ok_or_else(|| "package has no top-level content member".to_string())?;
306        verify_integrity(&bundle.manifest, content_text)?;
307
308        // A present-but-invalid signature is always tampering (the file was
309        // modified after signing) — reject. Unsigned packages stay importable
310        // for local workflows; registries enforce signing at publish time.
311        if bundle.manifest.signature.is_some()
312            && !super::signing::verify_signature(&bundle.manifest)?
313        {
314            return Err(
315                "signature verification failed — the package was modified after signing".into(),
316            );
317        }
318
319        self.install(&bundle.manifest, &bundle.content)?;
320        Ok(bundle.manifest)
321    }
322
323    fn package_dir(&self, name: &str, version: &str) -> PathBuf {
324        self.root.join(format!("{name}-{version}"))
325    }
326
327    fn load_index(&self) -> Result<PackageIndex, String> {
328        let path = self.root.join(INDEX_FILE);
329        if !path.exists() {
330            return Ok(PackageIndex::new());
331        }
332        let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
333        serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
334    }
335
336    fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
337        let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
338        atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
339    }
340}
341
342#[derive(Debug, Serialize, Deserialize)]
343struct ExportBundle {
344    manifest: PackageManifest,
345    content: PackageContent,
346}
347
348use super::verify::{compact_json_text, extract_top_level_value_text};
349
350/// Verify integrity against the writer's bytes: `content_text` is the exact
351/// document text of the content member (pretty or compact — compaction
352/// normalizes whitespace without touching value literals).
353fn verify_integrity(manifest: &PackageManifest, content_text: &str) -> Result<(), String> {
354    let canonical = compact_json_text(content_text);
355    let content_bytes = canonical.as_bytes();
356
357    let mut h1 = Sha256::new();
358    h1.update(content_bytes);
359    let actual_content_hash = crate::core::agent_identity::hex_encode(&h1.finalize());
360
361    if actual_content_hash != manifest.integrity.content_hash {
362        return Err(format!(
363            "integrity check failed: content_hash mismatch (expected {}, got {actual_content_hash})",
364            manifest.integrity.content_hash
365        ));
366    }
367
368    let expected_sha256 = {
369        let composite = format!(
370            "{}:{}:{actual_content_hash}",
371            manifest.name, manifest.version
372        );
373        let mut h2 = Sha256::new();
374        h2.update(composite.as_bytes());
375        crate::core::agent_identity::hex_encode(&h2.finalize())
376    };
377
378    if manifest.integrity.sha256 != expected_sha256 {
379        return Err(format!(
380            "integrity check failed: sha256 mismatch (expected {expected_sha256}, got {})",
381            manifest.integrity.sha256
382        ));
383    }
384
385    if manifest.integrity.byte_size != content_bytes.len() as u64 {
386        return Err(format!(
387            "integrity check failed: byte_size mismatch (expected {}, got {})",
388            manifest.integrity.byte_size,
389            content_bytes.len()
390        ));
391    }
392
393    Ok(())
394}
395
396fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
397    if path.exists()
398        && path
399            .symlink_metadata()
400            .is_ok_and(|m| m.file_type().is_symlink())
401    {
402        return Err(format!(
403            "refusing to write through symlink: {}",
404            path.display()
405        ));
406    }
407    let parent = path.parent().ok_or_else(|| "invalid path".to_string())?;
408    let tmp = parent.join(format!(
409        ".{}.tmp",
410        path.file_name().and_then(|s| s.to_str()).unwrap_or("pkg")
411    ));
412    std::fs::write(&tmp, data).map_err(|e| format!("write tmp: {e}"))?;
413    std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
414    Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::core::context_package::manifest::{CompatibilitySpec, PackageStats};
421
422    #[test]
423    fn registry_round_trip() {
424        let dir = tempfile::tempdir().unwrap();
425        let reg = LocalRegistry::open_at(dir.path()).unwrap();
426
427        assert!(reg.list().unwrap().is_empty());
428
429        let manifest = PackageManifest {
430            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
431            conformance_level: None,
432            kind: crate::core::context_package::manifest::PackageKind::default(),
433            name: "test-pkg".into(),
434            version: "1.0.0".into(),
435            description: "test".into(),
436            author: None,
437            scope: None,
438            created_at: Utc::now(),
439            updated_at: None,
440            layers: vec![super::super::manifest::PackageLayer::Knowledge],
441            dependencies: vec![],
442            tags: vec!["rust".into()],
443            visibility: None,
444            integrity: {
445                let c = PackageContent::default();
446                let j = serde_json::to_string(&c).unwrap();
447                let mut h = Sha256::new();
448                h.update(j.as_bytes());
449                let ch = crate::core::agent_identity::hex_encode(&h.finalize());
450                let composite = format!("test-pkg:1.0.0:{ch}");
451                let mut h2 = Sha256::new();
452                h2.update(composite.as_bytes());
453                let sha = crate::core::agent_identity::hex_encode(&h2.finalize());
454                super::super::manifest::PackageIntegrity {
455                    sha256: sha,
456                    content_hash: ch,
457                    byte_size: j.len() as u64,
458                }
459            },
460            provenance: super::super::manifest::PackageProvenance {
461                tool: "lean-ctx".into(),
462                tool_version: "0.0.0".into(),
463                project_hash: None,
464                source_session_id: None,
465            },
466            compatibility: CompatibilitySpec::default(),
467            stats: PackageStats::default(),
468            signature: None,
469            graph_summary: None,
470            marketplace: None,
471        };
472
473        let content = PackageContent::default();
474
475        reg.install(&manifest, &content).unwrap();
476        let list = reg.list().unwrap();
477        assert_eq!(list.len(), 1);
478        assert_eq!(list[0].name, "test-pkg");
479
480        let (loaded_m, _loaded_c) = reg.load_package("test-pkg", "1.0.0").unwrap();
481        assert_eq!(loaded_m.name, "test-pkg");
482
483        let removed = reg.remove("test-pkg", None).unwrap();
484        assert_eq!(removed, 1);
485        assert!(reg.list().unwrap().is_empty());
486    }
487
488    #[test]
489    fn export_import_round_trip() {
490        let dir = tempfile::tempdir().unwrap();
491        let reg = LocalRegistry::open_at(dir.path()).unwrap();
492
493        let content = PackageContent::default();
494        let content_json = serde_json::to_string(&content).unwrap();
495        let mut h = Sha256::new();
496        h.update(content_json.as_bytes());
497        let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
498
499        let manifest = PackageManifest {
500            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
501            conformance_level: None,
502            kind: crate::core::context_package::manifest::PackageKind::default(),
503            name: "export-test".into(),
504            version: "2.0.0".into(),
505            description: "round trip test".into(),
506            author: Some("test".into()),
507            scope: None,
508            created_at: Utc::now(),
509            updated_at: None,
510            layers: vec![super::super::manifest::PackageLayer::Knowledge],
511            dependencies: vec![],
512            tags: vec![],
513            visibility: None,
514            integrity: {
515                let composite = format!("export-test:2.0.0:{content_hash}");
516                let mut h2 = Sha256::new();
517                h2.update(composite.as_bytes());
518                super::super::manifest::PackageIntegrity {
519                    sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
520                    content_hash,
521                    byte_size: content_json.len() as u64,
522                }
523            },
524            provenance: super::super::manifest::PackageProvenance {
525                tool: "lean-ctx".into(),
526                tool_version: "0.0.0".into(),
527                project_hash: None,
528                source_session_id: None,
529            },
530            compatibility: CompatibilitySpec::default(),
531            stats: PackageStats::default(),
532            signature: None,
533            graph_summary: None,
534            marketplace: None,
535        };
536
537        reg.install(&manifest, &content).unwrap();
538
539        let export_path = dir.path().join("test.ctxpkg");
540        let bytes = reg
541            .export_to_file("export-test", "2.0.0", &export_path)
542            .unwrap();
543        assert!(bytes > 0);
544
545        let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
546        let imported = reg2.import_from_file(&export_path).unwrap();
547        assert_eq!(imported.name, "export-test");
548        assert_eq!(reg2.list().unwrap().len(), 1);
549    }
550
551    #[test]
552    fn import_rejects_tampered_signature() {
553        let dir = tempfile::tempdir().unwrap();
554        let reg = LocalRegistry::open_at(dir.path()).unwrap();
555
556        let content = PackageContent::default();
557        let content_json = serde_json::to_string(&content).unwrap();
558        let mut h = Sha256::new();
559        h.update(content_json.as_bytes());
560        let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
561        let composite = format!("signed-test:1.0.0:{content_hash}");
562        let mut h2 = Sha256::new();
563        h2.update(composite.as_bytes());
564
565        let mut manifest = PackageManifest {
566            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
567            conformance_level: None,
568            kind: crate::core::context_package::manifest::PackageKind::default(),
569            name: "signed-test".into(),
570            version: "1.0.0".into(),
571            description: "signature gate test".into(),
572            author: None,
573            scope: None,
574            created_at: Utc::now(),
575            updated_at: None,
576            layers: vec![super::super::manifest::PackageLayer::Knowledge],
577            dependencies: vec![],
578            tags: vec![],
579            visibility: None,
580            integrity: super::super::manifest::PackageIntegrity {
581                sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
582                content_hash,
583                byte_size: content_json.len() as u64,
584            },
585            provenance: super::super::manifest::PackageProvenance {
586                tool: "lean-ctx".into(),
587                tool_version: "0.0.0".into(),
588                project_hash: None,
589                source_session_id: None,
590            },
591            compatibility: CompatibilitySpec::default(),
592            stats: PackageStats::default(),
593            signature: None,
594            graph_summary: None,
595            marketplace: None,
596        };
597
598        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
599        super::super::signing::sign_package(&mut manifest, &content, &signing_key);
600
601        // Valid signature imports fine.
602        let bundle = ExportBundle {
603            manifest: manifest.clone(),
604            content: content.clone(),
605        };
606        let good = dir.path().join("good.ctxpkg");
607        std::fs::write(&good, serde_json::to_string(&bundle).unwrap()).unwrap();
608        let reg_good = LocalRegistry::open_at(&dir.path().join("good-reg")).unwrap();
609        assert!(reg_good.import_from_file(&good).is_ok());
610
611        // Corrupted signature value must be rejected.
612        let mut tampered = manifest.clone();
613        if let Some(sig) = tampered.signature.as_mut() {
614            sig.value = format!("0000{}", &sig.value[4..]);
615        }
616        let bundle = ExportBundle {
617            manifest: tampered,
618            content,
619        };
620        let bad = dir.path().join("bad.ctxpkg");
621        std::fs::write(&bad, serde_json::to_string(&bundle).unwrap()).unwrap();
622        let err = reg.import_from_file(&bad).unwrap_err();
623        assert!(
624            err.contains("signature verification failed"),
625            "unexpected error: {err}"
626        );
627    }
628
629    #[test]
630    fn legacy_lctxpkg_extension_accepted() {
631        let dir = tempfile::tempdir().unwrap();
632        let reg = LocalRegistry::open_at(dir.path()).unwrap();
633
634        let content = PackageContent::default();
635        let content_json = serde_json::to_string(&content).unwrap();
636        let mut h = Sha256::new();
637        h.update(content_json.as_bytes());
638        let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
639        let composite = format!("legacy-test:1.0.0:{content_hash}");
640        let mut h2 = Sha256::new();
641        h2.update(composite.as_bytes());
642
643        let manifest = PackageManifest {
644            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
645            conformance_level: None,
646            kind: crate::core::context_package::manifest::PackageKind::default(),
647            name: "legacy-test".into(),
648            version: "1.0.0".into(),
649            description: "legacy extension test".into(),
650            author: None,
651            scope: None,
652            created_at: Utc::now(),
653            updated_at: None,
654            layers: vec![super::super::manifest::PackageLayer::Knowledge],
655            dependencies: vec![],
656            tags: vec![],
657            visibility: None,
658            integrity: super::super::manifest::PackageIntegrity {
659                sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
660                content_hash,
661                byte_size: content_json.len() as u64,
662            },
663            provenance: super::super::manifest::PackageProvenance {
664                tool: "lean-ctx".into(),
665                tool_version: "0.0.0".into(),
666                project_hash: None,
667                source_session_id: None,
668            },
669            compatibility: CompatibilitySpec::default(),
670            stats: PackageStats::default(),
671            signature: None,
672            graph_summary: None,
673            marketplace: None,
674        };
675
676        reg.install(&manifest, &content).unwrap();
677
678        let legacy_path = dir.path().join("test.lctxpkg");
679        reg.export_to_file("legacy-test", "1.0.0", &legacy_path)
680            .unwrap();
681
682        let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
683        let imported = reg2.import_from_file(&legacy_path).unwrap();
684        assert_eq!(imported.name, "legacy-test");
685    }
686
687    #[test]
688    fn unsupported_extension_rejected() {
689        let dir = tempfile::tempdir().unwrap();
690        let reg = LocalRegistry::open_at(dir.path()).unwrap();
691        let bad_path = dir.path().join("test.json");
692        std::fs::write(&bad_path, "{}").unwrap();
693        assert!(reg.import_from_file(&bad_path).is_err());
694    }
695
696    /// A `kind=addon` pack must not enter the context registry (GH #726) —
697    /// it would bypass the addon trust chain (consent, sandbox, binhash).
698    #[test]
699    fn import_refuses_addon_kind_packs() {
700        let dir = tempfile::tempdir().unwrap();
701        let toml_path = dir.path().join("lean-ctx-addon.toml");
702        std::fs::write(
703            &toml_path,
704            r#"
705[addon]
706name = "gate-check"
707version = "1.0.0"
708description = "import gate test"
709
710[mcp]
711transport = "stdio"
712command = "gate-check"
713args = ["serve"]
714"#,
715        )
716        .unwrap();
717        let plan = crate::core::addons::publish::build_addon_pack(&toml_path, "acme")
718            .expect("build addon pack");
719
720        let pkg_path = dir.path().join("gate-check.ctxpkg");
721        std::fs::write(&pkg_path, &plan.bundle_json).unwrap();
722
723        let reg = LocalRegistry::open_at(dir.path()).unwrap();
724        let err = reg.import_from_file(&pkg_path).expect_err("must refuse");
725        assert!(err.contains("addon add"), "got: {err}");
726    }
727}