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        let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
75        std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
76
77        let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
78        atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
79
80        let content_json = serde_json::to_string_pretty(content).map_err(|e| e.to_string())?;
81        atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
82
83        let mut index = self.load_index()?;
84        index
85            .entries
86            .retain(|e| !(e.name == manifest.name && e.version == manifest.version));
87        index.entries.push(PackageEntry {
88            name: manifest.name.clone(),
89            version: manifest.version.clone(),
90            description: manifest.description.clone(),
91            installed_at: Utc::now(),
92            layers: manifest
93                .layers
94                .iter()
95                .map(|l| l.as_str().to_string())
96                .collect(),
97            sha256: manifest.integrity.sha256.clone(),
98            byte_size: manifest.integrity.byte_size,
99            tags: manifest.tags.clone(),
100            auto_load: false,
101        });
102        index.updated_at = Utc::now();
103        self.save_index(&index)?;
104
105        Ok(pkg_dir)
106    }
107
108    pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
109        let mut index = self.load_index()?;
110        let before = index.entries.len();
111
112        let to_remove: Vec<(String, String)> = index
113            .entries
114            .iter()
115            .filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
116            .map(|e| (e.name.clone(), e.version.clone()))
117            .collect();
118
119        for (n, v) in &to_remove {
120            let dir = self.package_dir(n, v);
121            if dir.exists() {
122                let _ = std::fs::remove_dir_all(&dir);
123            }
124        }
125
126        index.entries.retain(|e| {
127            !to_remove
128                .iter()
129                .any(|(n, v)| e.name == *n && e.version == *v)
130        });
131
132        let removed = (before - index.entries.len()) as u32;
133        if removed > 0 {
134            index.updated_at = Utc::now();
135            self.save_index(&index)?;
136        }
137
138        Ok(removed)
139    }
140
141    pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
142        let index = self.load_index()?;
143        Ok(index.entries)
144    }
145
146    pub fn get(&self, name: &str, version: Option<&str>) -> Result<Option<PackageEntry>, String> {
147        let index = self.load_index()?;
148        Ok(index
149            .entries
150            .into_iter()
151            .find(|e| e.name == name && version.is_none_or(|v| e.version == v)))
152    }
153
154    pub fn load_package(
155        &self,
156        name: &str,
157        version: &str,
158    ) -> Result<(PackageManifest, PackageContent), String> {
159        let pkg_dir = self.package_dir(name, version);
160        if !pkg_dir.exists() {
161            return Err(format!("package {name}@{version} not found"));
162        }
163
164        let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
165            .map_err(|e| format!("read manifest: {e}"))?;
166        let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
167            .map_err(|e| format!("read content: {e}"))?;
168
169        let manifest: PackageManifest =
170            serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
171        let content: PackageContent =
172            serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
173
174        verify_integrity(&manifest, &content_json)?;
175
176        Ok((manifest, content))
177    }
178
179    pub fn set_auto_load(&self, name: &str, version: &str, auto_load: bool) -> Result<(), String> {
180        let mut index = self.load_index()?;
181        if let Some(entry) = index
182            .entries
183            .iter_mut()
184            .find(|e| e.name == name && e.version == version)
185        {
186            entry.auto_load = auto_load;
187            index.updated_at = Utc::now();
188            self.save_index(&index)?;
189        } else {
190            return Err(format!("package {name}@{version} not found in index"));
191        }
192        Ok(())
193    }
194
195    pub fn auto_load_packages(&self) -> Result<Vec<PackageEntry>, String> {
196        let index = self.load_index()?;
197        Ok(index.entries.into_iter().filter(|e| e.auto_load).collect())
198    }
199
200    pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
201        let (manifest, content) = self.load_package(name, version)?;
202
203        let bundle = ExportBundle { manifest, content };
204        let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
205        let bytes = json.as_bytes();
206
207        atomic_write(output, bytes)?;
208        Ok(bytes.len() as u64)
209    }
210
211    /// Export with a fresh ed25519 signature over the manifest (GL #406) —
212    /// required by the hosted registry. The stored package stays untouched;
213    /// only the exported bundle carries the signature. `private` stamps
214    /// `visibility=private` into the bundle for the hosted registry (#524).
215    pub fn export_to_file_signed(
216        &self,
217        name: &str,
218        version: &str,
219        output: &Path,
220        signing_key: &ed25519_dalek::SigningKey,
221        private: bool,
222    ) -> Result<u64, String> {
223        let (mut manifest, content) = self.load_package(name, version)?;
224
225        if private {
226            manifest.visibility = Some("private".to_string());
227        }
228        super::signing::sign_package(&mut manifest, &content, signing_key);
229
230        let bundle = ExportBundle { manifest, content };
231        let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
232        let bytes = json.as_bytes();
233
234        atomic_write(output, bytes)?;
235        Ok(bytes.len() as u64)
236    }
237
238    pub fn import_from_file(&self, path: &Path) -> Result<PackageManifest, String> {
239        if !crate::core::contracts::is_package_file(path) {
240            let ext = path
241                .extension()
242                .and_then(|e| e.to_str())
243                .unwrap_or("(none)");
244            return Err(format!(
245                "unsupported file extension '.{ext}' — expected .{} or .{}",
246                crate::core::contracts::PACKAGE_EXTENSION,
247                crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
248            ));
249        }
250
251        let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
252        if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
253            return Err(format!(
254                "package file too large ({} bytes, max {} bytes)",
255                meta.len(),
256                crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
257            ));
258        }
259
260        let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
261        let bundle: ExportBundle =
262            serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
263
264        bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
265
266        let content_text = extract_top_level_value_text(&json, "content")
267            .ok_or_else(|| "package has no top-level content member".to_string())?;
268        verify_integrity(&bundle.manifest, content_text)?;
269
270        // A present-but-invalid signature is always tampering (the file was
271        // modified after signing) — reject. Unsigned packages stay importable
272        // for local workflows; registries enforce signing at publish time.
273        if bundle.manifest.signature.is_some()
274            && !super::signing::verify_signature(&bundle.manifest)?
275        {
276            return Err(
277                "signature verification failed — the package was modified after signing".into(),
278            );
279        }
280
281        self.install(&bundle.manifest, &bundle.content)?;
282        Ok(bundle.manifest)
283    }
284
285    fn package_dir(&self, name: &str, version: &str) -> PathBuf {
286        self.root.join(format!("{name}-{version}"))
287    }
288
289    fn load_index(&self) -> Result<PackageIndex, String> {
290        let path = self.root.join(INDEX_FILE);
291        if !path.exists() {
292            return Ok(PackageIndex::new());
293        }
294        let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
295        serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
296    }
297
298    fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
299        let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
300        atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
301    }
302}
303
304#[derive(Debug, Serialize, Deserialize)]
305struct ExportBundle {
306    manifest: PackageManifest,
307    content: PackageContent,
308}
309
310use super::verify::{compact_json_text, extract_top_level_value_text};
311
312/// Verify integrity against the writer's bytes: `content_text` is the exact
313/// document text of the content member (pretty or compact — compaction
314/// normalizes whitespace without touching value literals).
315fn verify_integrity(manifest: &PackageManifest, content_text: &str) -> Result<(), String> {
316    let canonical = compact_json_text(content_text);
317    let content_bytes = canonical.as_bytes();
318
319    let mut h1 = Sha256::new();
320    h1.update(content_bytes);
321    let actual_content_hash = crate::core::agent_identity::hex_encode(&h1.finalize());
322
323    if actual_content_hash != manifest.integrity.content_hash {
324        return Err(format!(
325            "integrity check failed: content_hash mismatch (expected {}, got {actual_content_hash})",
326            manifest.integrity.content_hash
327        ));
328    }
329
330    let expected_sha256 = {
331        let composite = format!(
332            "{}:{}:{actual_content_hash}",
333            manifest.name, manifest.version
334        );
335        let mut h2 = Sha256::new();
336        h2.update(composite.as_bytes());
337        crate::core::agent_identity::hex_encode(&h2.finalize())
338    };
339
340    if manifest.integrity.sha256 != expected_sha256 {
341        return Err(format!(
342            "integrity check failed: sha256 mismatch (expected {expected_sha256}, got {})",
343            manifest.integrity.sha256
344        ));
345    }
346
347    if manifest.integrity.byte_size != content_bytes.len() as u64 {
348        return Err(format!(
349            "integrity check failed: byte_size mismatch (expected {}, got {})",
350            manifest.integrity.byte_size,
351            content_bytes.len()
352        ));
353    }
354
355    Ok(())
356}
357
358fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
359    if path.exists()
360        && path
361            .symlink_metadata()
362            .is_ok_and(|m| m.file_type().is_symlink())
363    {
364        return Err(format!(
365            "refusing to write through symlink: {}",
366            path.display()
367        ));
368    }
369    let parent = path.parent().ok_or_else(|| "invalid path".to_string())?;
370    let tmp = parent.join(format!(
371        ".{}.tmp",
372        path.file_name().and_then(|s| s.to_str()).unwrap_or("pkg")
373    ));
374    std::fs::write(&tmp, data).map_err(|e| format!("write tmp: {e}"))?;
375    std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
376    Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::core::context_package::manifest::{CompatibilitySpec, PackageStats};
383
384    #[test]
385    fn registry_round_trip() {
386        let dir = tempfile::tempdir().unwrap();
387        let reg = LocalRegistry::open_at(dir.path()).unwrap();
388
389        assert!(reg.list().unwrap().is_empty());
390
391        let manifest = PackageManifest {
392            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
393            conformance_level: None,
394            name: "test-pkg".into(),
395            version: "1.0.0".into(),
396            description: "test".into(),
397            author: None,
398            scope: None,
399            created_at: Utc::now(),
400            updated_at: None,
401            layers: vec![super::super::manifest::PackageLayer::Knowledge],
402            dependencies: vec![],
403            tags: vec!["rust".into()],
404            visibility: None,
405            integrity: {
406                let c = PackageContent::default();
407                let j = serde_json::to_string(&c).unwrap();
408                let mut h = Sha256::new();
409                h.update(j.as_bytes());
410                let ch = crate::core::agent_identity::hex_encode(&h.finalize());
411                let composite = format!("test-pkg:1.0.0:{ch}");
412                let mut h2 = Sha256::new();
413                h2.update(composite.as_bytes());
414                let sha = crate::core::agent_identity::hex_encode(&h2.finalize());
415                super::super::manifest::PackageIntegrity {
416                    sha256: sha,
417                    content_hash: ch,
418                    byte_size: j.len() as u64,
419                }
420            },
421            provenance: super::super::manifest::PackageProvenance {
422                tool: "lean-ctx".into(),
423                tool_version: "0.0.0".into(),
424                project_hash: None,
425                source_session_id: None,
426            },
427            compatibility: CompatibilitySpec::default(),
428            stats: PackageStats::default(),
429            signature: None,
430            graph_summary: None,
431            marketplace: None,
432        };
433
434        let content = PackageContent::default();
435
436        reg.install(&manifest, &content).unwrap();
437        let list = reg.list().unwrap();
438        assert_eq!(list.len(), 1);
439        assert_eq!(list[0].name, "test-pkg");
440
441        let (loaded_m, _loaded_c) = reg.load_package("test-pkg", "1.0.0").unwrap();
442        assert_eq!(loaded_m.name, "test-pkg");
443
444        let removed = reg.remove("test-pkg", None).unwrap();
445        assert_eq!(removed, 1);
446        assert!(reg.list().unwrap().is_empty());
447    }
448
449    #[test]
450    fn export_import_round_trip() {
451        let dir = tempfile::tempdir().unwrap();
452        let reg = LocalRegistry::open_at(dir.path()).unwrap();
453
454        let content = PackageContent::default();
455        let content_json = serde_json::to_string(&content).unwrap();
456        let mut h = Sha256::new();
457        h.update(content_json.as_bytes());
458        let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
459
460        let manifest = PackageManifest {
461            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
462            conformance_level: None,
463            name: "export-test".into(),
464            version: "2.0.0".into(),
465            description: "round trip test".into(),
466            author: Some("test".into()),
467            scope: None,
468            created_at: Utc::now(),
469            updated_at: None,
470            layers: vec![super::super::manifest::PackageLayer::Knowledge],
471            dependencies: vec![],
472            tags: vec![],
473            visibility: None,
474            integrity: {
475                let composite = format!("export-test:2.0.0:{content_hash}");
476                let mut h2 = Sha256::new();
477                h2.update(composite.as_bytes());
478                super::super::manifest::PackageIntegrity {
479                    sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
480                    content_hash,
481                    byte_size: content_json.len() as u64,
482                }
483            },
484            provenance: super::super::manifest::PackageProvenance {
485                tool: "lean-ctx".into(),
486                tool_version: "0.0.0".into(),
487                project_hash: None,
488                source_session_id: None,
489            },
490            compatibility: CompatibilitySpec::default(),
491            stats: PackageStats::default(),
492            signature: None,
493            graph_summary: None,
494            marketplace: None,
495        };
496
497        reg.install(&manifest, &content).unwrap();
498
499        let export_path = dir.path().join("test.ctxpkg");
500        let bytes = reg
501            .export_to_file("export-test", "2.0.0", &export_path)
502            .unwrap();
503        assert!(bytes > 0);
504
505        let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
506        let imported = reg2.import_from_file(&export_path).unwrap();
507        assert_eq!(imported.name, "export-test");
508        assert_eq!(reg2.list().unwrap().len(), 1);
509    }
510
511    #[test]
512    fn import_rejects_tampered_signature() {
513        let dir = tempfile::tempdir().unwrap();
514        let reg = LocalRegistry::open_at(dir.path()).unwrap();
515
516        let content = PackageContent::default();
517        let content_json = serde_json::to_string(&content).unwrap();
518        let mut h = Sha256::new();
519        h.update(content_json.as_bytes());
520        let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
521        let composite = format!("signed-test:1.0.0:{content_hash}");
522        let mut h2 = Sha256::new();
523        h2.update(composite.as_bytes());
524
525        let mut manifest = PackageManifest {
526            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
527            conformance_level: None,
528            name: "signed-test".into(),
529            version: "1.0.0".into(),
530            description: "signature gate test".into(),
531            author: None,
532            scope: None,
533            created_at: Utc::now(),
534            updated_at: None,
535            layers: vec![super::super::manifest::PackageLayer::Knowledge],
536            dependencies: vec![],
537            tags: vec![],
538            visibility: None,
539            integrity: super::super::manifest::PackageIntegrity {
540                sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
541                content_hash,
542                byte_size: content_json.len() as u64,
543            },
544            provenance: super::super::manifest::PackageProvenance {
545                tool: "lean-ctx".into(),
546                tool_version: "0.0.0".into(),
547                project_hash: None,
548                source_session_id: None,
549            },
550            compatibility: CompatibilitySpec::default(),
551            stats: PackageStats::default(),
552            signature: None,
553            graph_summary: None,
554            marketplace: None,
555        };
556
557        let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
558        super::super::signing::sign_package(&mut manifest, &content, &signing_key);
559
560        // Valid signature imports fine.
561        let bundle = ExportBundle {
562            manifest: manifest.clone(),
563            content: content.clone(),
564        };
565        let good = dir.path().join("good.ctxpkg");
566        std::fs::write(&good, serde_json::to_string(&bundle).unwrap()).unwrap();
567        let reg_good = LocalRegistry::open_at(&dir.path().join("good-reg")).unwrap();
568        assert!(reg_good.import_from_file(&good).is_ok());
569
570        // Corrupted signature value must be rejected.
571        let mut tampered = manifest.clone();
572        if let Some(sig) = tampered.signature.as_mut() {
573            sig.value = format!("0000{}", &sig.value[4..]);
574        }
575        let bundle = ExportBundle {
576            manifest: tampered,
577            content,
578        };
579        let bad = dir.path().join("bad.ctxpkg");
580        std::fs::write(&bad, serde_json::to_string(&bundle).unwrap()).unwrap();
581        let err = reg.import_from_file(&bad).unwrap_err();
582        assert!(
583            err.contains("signature verification failed"),
584            "unexpected error: {err}"
585        );
586    }
587
588    #[test]
589    fn legacy_lctxpkg_extension_accepted() {
590        let dir = tempfile::tempdir().unwrap();
591        let reg = LocalRegistry::open_at(dir.path()).unwrap();
592
593        let content = PackageContent::default();
594        let content_json = serde_json::to_string(&content).unwrap();
595        let mut h = Sha256::new();
596        h.update(content_json.as_bytes());
597        let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
598        let composite = format!("legacy-test:1.0.0:{content_hash}");
599        let mut h2 = Sha256::new();
600        h2.update(composite.as_bytes());
601
602        let manifest = PackageManifest {
603            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
604            conformance_level: None,
605            name: "legacy-test".into(),
606            version: "1.0.0".into(),
607            description: "legacy extension test".into(),
608            author: None,
609            scope: None,
610            created_at: Utc::now(),
611            updated_at: None,
612            layers: vec![super::super::manifest::PackageLayer::Knowledge],
613            dependencies: vec![],
614            tags: vec![],
615            visibility: None,
616            integrity: super::super::manifest::PackageIntegrity {
617                sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
618                content_hash,
619                byte_size: content_json.len() as u64,
620            },
621            provenance: super::super::manifest::PackageProvenance {
622                tool: "lean-ctx".into(),
623                tool_version: "0.0.0".into(),
624                project_hash: None,
625                source_session_id: None,
626            },
627            compatibility: CompatibilitySpec::default(),
628            stats: PackageStats::default(),
629            signature: None,
630            graph_summary: None,
631            marketplace: None,
632        };
633
634        reg.install(&manifest, &content).unwrap();
635
636        let legacy_path = dir.path().join("test.lctxpkg");
637        reg.export_to_file("legacy-test", "1.0.0", &legacy_path)
638            .unwrap();
639
640        let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
641        let imported = reg2.import_from_file(&legacy_path).unwrap();
642        assert_eq!(imported.name, "legacy-test");
643    }
644
645    #[test]
646    fn unsupported_extension_rejected() {
647        let dir = tempfile::tempdir().unwrap();
648        let reg = LocalRegistry::open_at(dir.path()).unwrap();
649        let bad_path = dir.path().join("test.json");
650        std::fs::write(&bad_path, "{}").unwrap();
651        assert!(reg.import_from_file(&bad_path).is_err());
652    }
653}