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 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 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
312fn 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 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 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}