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 let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
73 std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
74
75 let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
76 atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
77
78 let content_json = serde_json::to_string_pretty(content).map_err(|e| e.to_string())?;
79 atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
80
81 let mut index = self.load_index()?;
82 index
83 .entries
84 .retain(|e| !(e.name == manifest.name && e.version == manifest.version));
85 index.entries.push(PackageEntry {
86 name: manifest.name.clone(),
87 version: manifest.version.clone(),
88 description: manifest.description.clone(),
89 installed_at: Utc::now(),
90 layers: manifest
91 .layers
92 .iter()
93 .map(|l| l.as_str().to_string())
94 .collect(),
95 sha256: manifest.integrity.sha256.clone(),
96 byte_size: manifest.integrity.byte_size,
97 tags: manifest.tags.clone(),
98 auto_load: false,
99 });
100 index.updated_at = Utc::now();
101 self.save_index(&index)?;
102
103 Ok(pkg_dir)
104 }
105
106 pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
107 let mut index = self.load_index()?;
108 let before = index.entries.len();
109
110 let to_remove: Vec<(String, String)> = index
111 .entries
112 .iter()
113 .filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
114 .map(|e| (e.name.clone(), e.version.clone()))
115 .collect();
116
117 for (n, v) in &to_remove {
118 let dir = self.package_dir(n, v);
119 if dir.exists() {
120 let _ = std::fs::remove_dir_all(&dir);
121 }
122 }
123
124 index.entries.retain(|e| {
125 !to_remove
126 .iter()
127 .any(|(n, v)| e.name == *n && e.version == *v)
128 });
129
130 let removed = (before - index.entries.len()) as u32;
131 if removed > 0 {
132 index.updated_at = Utc::now();
133 self.save_index(&index)?;
134 }
135
136 Ok(removed)
137 }
138
139 pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
140 let index = self.load_index()?;
141 Ok(index.entries)
142 }
143
144 pub fn get(&self, name: &str, version: Option<&str>) -> Result<Option<PackageEntry>, String> {
145 let index = self.load_index()?;
146 Ok(index
147 .entries
148 .into_iter()
149 .find(|e| e.name == name && version.is_none_or(|v| e.version == v)))
150 }
151
152 pub fn load_package(
153 &self,
154 name: &str,
155 version: &str,
156 ) -> Result<(PackageManifest, PackageContent), String> {
157 let pkg_dir = self.package_dir(name, version);
158 if !pkg_dir.exists() {
159 return Err(format!("package {name}@{version} not found"));
160 }
161
162 let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
163 .map_err(|e| format!("read manifest: {e}"))?;
164 let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
165 .map_err(|e| format!("read content: {e}"))?;
166
167 let manifest: PackageManifest =
168 serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
169 let content: PackageContent =
170 serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
171
172 verify_integrity(&manifest, &content)?;
173
174 Ok((manifest, content))
175 }
176
177 pub fn set_auto_load(&self, name: &str, version: &str, auto_load: bool) -> Result<(), String> {
178 let mut index = self.load_index()?;
179 if let Some(entry) = index
180 .entries
181 .iter_mut()
182 .find(|e| e.name == name && e.version == version)
183 {
184 entry.auto_load = auto_load;
185 index.updated_at = Utc::now();
186 self.save_index(&index)?;
187 } else {
188 return Err(format!("package {name}@{version} not found in index"));
189 }
190 Ok(())
191 }
192
193 pub fn auto_load_packages(&self) -> Result<Vec<PackageEntry>, String> {
194 let index = self.load_index()?;
195 Ok(index.entries.into_iter().filter(|e| e.auto_load).collect())
196 }
197
198 pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
199 let (manifest, content) = self.load_package(name, version)?;
200
201 let bundle = ExportBundle { manifest, content };
202 let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
203 let bytes = json.as_bytes();
204
205 atomic_write(output, bytes)?;
206 Ok(bytes.len() as u64)
207 }
208
209 pub fn import_from_file(&self, path: &Path) -> Result<PackageManifest, String> {
210 let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
211 let bundle: ExportBundle =
212 serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
213
214 bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
215
216 verify_integrity(&bundle.manifest, &bundle.content)?;
217
218 self.install(&bundle.manifest, &bundle.content)?;
219 Ok(bundle.manifest)
220 }
221
222 fn package_dir(&self, name: &str, version: &str) -> PathBuf {
223 self.root.join(format!("{name}-{version}"))
224 }
225
226 fn load_index(&self) -> Result<PackageIndex, String> {
227 let path = self.root.join(INDEX_FILE);
228 if !path.exists() {
229 return Ok(PackageIndex::new());
230 }
231 let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
232 serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
233 }
234
235 fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
236 let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
237 atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
238 }
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242struct ExportBundle {
243 manifest: PackageManifest,
244 content: PackageContent,
245}
246
247fn verify_integrity(manifest: &PackageManifest, content: &PackageContent) -> Result<(), String> {
248 let canonical = serde_json::to_string(content).map_err(|e| e.to_string())?;
249 let content_bytes = canonical.as_bytes();
250
251 let mut h1 = Sha256::new();
252 h1.update(content_bytes);
253 let actual_content_hash = format!("{:x}", h1.finalize());
254
255 if actual_content_hash != manifest.integrity.content_hash {
256 return Err(format!(
257 "integrity check failed: content_hash mismatch (expected {}, got {actual_content_hash})",
258 manifest.integrity.content_hash
259 ));
260 }
261
262 let expected_sha256 = {
263 let composite = format!(
264 "{}:{}:{actual_content_hash}",
265 manifest.name, manifest.version
266 );
267 let mut h2 = Sha256::new();
268 h2.update(composite.as_bytes());
269 format!("{:x}", h2.finalize())
270 };
271
272 if manifest.integrity.sha256 != expected_sha256 {
273 return Err(format!(
274 "integrity check failed: sha256 mismatch (expected {expected_sha256}, got {})",
275 manifest.integrity.sha256
276 ));
277 }
278
279 if manifest.integrity.byte_size != content_bytes.len() as u64 {
280 return Err(format!(
281 "integrity check failed: byte_size mismatch (expected {}, got {})",
282 manifest.integrity.byte_size,
283 content_bytes.len()
284 ));
285 }
286
287 Ok(())
288}
289
290fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
291 if path.exists()
292 && path
293 .symlink_metadata()
294 .is_ok_and(|m| m.file_type().is_symlink())
295 {
296 return Err(format!(
297 "refusing to write through symlink: {}",
298 path.display()
299 ));
300 }
301 let parent = path.parent().ok_or_else(|| "invalid path".to_string())?;
302 let tmp = parent.join(format!(
303 ".{}.tmp",
304 path.file_name().and_then(|s| s.to_str()).unwrap_or("pkg")
305 ));
306 std::fs::write(&tmp, data).map_err(|e| format!("write tmp: {e}"))?;
307 std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
308 Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::core::context_package::manifest::{CompatibilitySpec, PackageStats};
315
316 #[test]
317 fn registry_round_trip() {
318 let dir = tempfile::tempdir().unwrap();
319 let reg = LocalRegistry::open_at(dir.path()).unwrap();
320
321 assert!(reg.list().unwrap().is_empty());
322
323 let manifest = PackageManifest {
324 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
325 name: "test-pkg".into(),
326 version: "1.0.0".into(),
327 description: "test".into(),
328 author: None,
329 created_at: Utc::now(),
330 updated_at: None,
331 layers: vec![super::super::manifest::PackageLayer::Knowledge],
332 dependencies: vec![],
333 tags: vec!["rust".into()],
334 integrity: {
335 let c = PackageContent::default();
336 let j = serde_json::to_string(&c).unwrap();
337 let mut h = Sha256::new();
338 h.update(j.as_bytes());
339 let ch = format!("{:x}", h.finalize());
340 let composite = format!("test-pkg:1.0.0:{ch}");
341 let mut h2 = Sha256::new();
342 h2.update(composite.as_bytes());
343 let sha = format!("{:x}", h2.finalize());
344 super::super::manifest::PackageIntegrity {
345 sha256: sha,
346 content_hash: ch,
347 byte_size: j.len() as u64,
348 }
349 },
350 provenance: super::super::manifest::PackageProvenance {
351 tool: "lean-ctx".into(),
352 tool_version: "0.0.0".into(),
353 project_hash: None,
354 source_session_id: None,
355 },
356 compatibility: CompatibilitySpec::default(),
357 stats: PackageStats::default(),
358 };
359
360 let content = PackageContent::default();
361
362 reg.install(&manifest, &content).unwrap();
363 let list = reg.list().unwrap();
364 assert_eq!(list.len(), 1);
365 assert_eq!(list[0].name, "test-pkg");
366
367 let (loaded_m, _loaded_c) = reg.load_package("test-pkg", "1.0.0").unwrap();
368 assert_eq!(loaded_m.name, "test-pkg");
369
370 let removed = reg.remove("test-pkg", None).unwrap();
371 assert_eq!(removed, 1);
372 assert!(reg.list().unwrap().is_empty());
373 }
374
375 #[test]
376 fn export_import_round_trip() {
377 let dir = tempfile::tempdir().unwrap();
378 let reg = LocalRegistry::open_at(dir.path()).unwrap();
379
380 let content = PackageContent::default();
381 let content_json = serde_json::to_string(&content).unwrap();
382 let mut h = Sha256::new();
383 h.update(content_json.as_bytes());
384 let content_hash = format!("{:x}", h.finalize());
385
386 let manifest = PackageManifest {
387 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
388 name: "export-test".into(),
389 version: "2.0.0".into(),
390 description: "round trip test".into(),
391 author: Some("test".into()),
392 created_at: Utc::now(),
393 updated_at: None,
394 layers: vec![super::super::manifest::PackageLayer::Knowledge],
395 dependencies: vec![],
396 tags: vec![],
397 integrity: {
398 let composite = format!("export-test:2.0.0:{content_hash}");
399 let mut h2 = Sha256::new();
400 h2.update(composite.as_bytes());
401 super::super::manifest::PackageIntegrity {
402 sha256: format!("{:x}", h2.finalize()),
403 content_hash,
404 byte_size: content_json.len() as u64,
405 }
406 },
407 provenance: super::super::manifest::PackageProvenance {
408 tool: "lean-ctx".into(),
409 tool_version: "0.0.0".into(),
410 project_hash: None,
411 source_session_id: None,
412 },
413 compatibility: CompatibilitySpec::default(),
414 stats: PackageStats::default(),
415 };
416
417 reg.install(&manifest, &content).unwrap();
418
419 let export_path = dir.path().join("test.lctxpkg");
420 let bytes = reg
421 .export_to_file("export-test", "2.0.0", &export_path)
422 .unwrap();
423 assert!(bytes > 0);
424
425 let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
426 let imported = reg2.import_from_file(&export_path).unwrap();
427 assert_eq!(imported.name, "export-test");
428 assert_eq!(reg2.list().unwrap().len(), 1);
429 }
430}