1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::path::{Path, PathBuf};
7
8use super::admission::AdmittedPackage;
9use super::content::{CheckpointPackageContentV1, PackageContent};
10use super::manifest::PackageManifest;
11
12const INDEX_FILE: &str = "package-index.json";
13const INDEX_LOCK_FILE: &str = ".package-index.lock";
14const INDEX_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
16pub(crate) const PACKAGES_DIR: &str = "packages";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub(crate) struct PackageIndex {
20 pub schema_version: u32,
21 pub updated_at: DateTime<Utc>,
22 pub entries: Vec<PackageEntry>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PackageEntry {
27 pub name: String,
28 pub version: String,
29 pub description: String,
30 pub installed_at: DateTime<Utc>,
31 pub layers: Vec<String>,
32 pub sha256: String,
33 pub byte_size: u64,
34 #[serde(default)]
35 pub tags: Vec<String>,
36 #[serde(default)]
37 pub auto_load: bool,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub auto_load_projects: Vec<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub origin: Option<PackageOrigin>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum PackageOrigin {
55 Built,
57 Received,
59}
60
61impl PackageEntry {
62 pub fn auto_loads_into(&self, project_root: &str) -> bool {
64 self.auto_load
65 && self
66 .auto_load_projects
67 .contains(&crate::core::project_hash::hash_project_root(project_root))
68 }
69
70 pub fn auto_load_unbound(&self) -> bool {
73 self.auto_load && self.auto_load_projects.is_empty()
74 }
75}
76
77impl PackageIndex {
78 fn new() -> Self {
79 Self {
80 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
81 updated_at: Utc::now(),
82 entries: Vec::new(),
83 }
84 }
85}
86
87pub struct LocalRegistry {
88 root: PathBuf,
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94enum InstallMode {
95 Immutable,
97 ReplaceLocalBuild,
99}
100
101impl LocalRegistry {
102 pub fn open() -> Result<Self, String> {
103 let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
104 let root = data_dir.join(PACKAGES_DIR);
105 std::fs::create_dir_all(&root).map_err(|e| format!("create packages dir: {e}"))?;
106 Ok(Self { root })
107 }
108
109 #[cfg(test)]
110 pub(crate) fn open_at(root: &Path) -> Result<Self, String> {
111 std::fs::create_dir_all(root).map_err(|e| format!("create packages dir: {e}"))?;
112 Ok(Self {
113 root: root.to_path_buf(),
114 })
115 }
116
117 pub fn root(&self) -> &Path {
118 &self.root
119 }
120
121 #[cfg(test)]
124 pub(crate) fn install(
125 &self,
126 manifest: &PackageManifest,
127 content: &PackageContent,
128 ) -> Result<PathBuf, String> {
129 self.install_with(manifest, content, InstallMode::Immutable, None)
130 }
131
132 pub fn install_local_build(
136 &self,
137 manifest: &PackageManifest,
138 content: &PackageContent,
139 ) -> Result<PathBuf, String> {
140 self.install_with(manifest, content, InstallMode::ReplaceLocalBuild, None)
141 }
142
143 fn install_with(
148 &self,
149 manifest: &PackageManifest,
150 content: &PackageContent,
151 mode: InstallMode,
152 written: Option<&str>,
153 ) -> Result<PathBuf, String> {
154 manifest.validate().map_err(|errs| errs.join("; "))?;
155
156 admitted(manifest, content)?;
161
162 let content_json = match written {
166 Some(text) => text.to_owned(),
167 None => serde_json::to_string_pretty(content).map_err(|e| e.to_string())?,
168 };
169 verify_integrity(manifest, &content_json)?;
170
171 let _lock = self.lock_index()?;
175 let installed = self
176 .load_index()?
177 .entries
178 .into_iter()
179 .find(|e| e.name == manifest.name && e.version == manifest.version);
180 let auto_load = installed.as_ref().is_some_and(|e| e.auto_load);
181 let auto_load_projects = installed
182 .as_ref()
183 .map(|e| e.auto_load_projects.clone())
184 .unwrap_or_default();
185 let origin = match mode {
188 InstallMode::ReplaceLocalBuild => PackageOrigin::Built,
189 InstallMode::Immutable => installed
190 .as_ref()
191 .filter(|e| e.sha256 == manifest.integrity.sha256)
192 .and_then(|e| e.origin)
193 .unwrap_or(PackageOrigin::Received),
194 };
195 if let Some(installed) = &installed
196 && installed.sha256 != manifest.integrity.sha256
197 {
198 if mode == InstallMode::Immutable {
199 return Err(format!(
200 "package {}@{} is already installed with different content; \
201 install it under a new version or remove the installed one first",
202 manifest.name, manifest.version
203 ));
204 }
205 let previous = self.package_dir(&manifest.name, &manifest.version);
208 if previous.exists() {
209 std::fs::remove_dir_all(&previous)
210 .map_err(|e| format!("remove previous build: {e}"))?;
211 }
212 }
213
214 if manifest.kind == super::manifest::PackageKind::Skills {
218 let docs = content
219 .documents
220 .as_ref()
221 .ok_or("kind=skills package has no documents payload")?;
222 let root = super::skills::materialize_documents(&self.root, manifest, docs)?;
223 tracing::info!(
224 "skills pack {}@{} materialized at {}",
225 manifest.name,
226 manifest.version,
227 root.display()
228 );
229 }
230
231 if manifest.kind == super::manifest::PackageKind::Addon {
236 let addon = content
237 .addon
238 .as_ref()
239 .ok_or("kind=addon package has no addon payload")?;
240 let root = super::addons::materialize_modules(&self.root, manifest, addon)?;
241 tracing::info!(
242 "addon pack {}@{} materialized at {} ({} module(s))",
243 manifest.name,
244 manifest.version,
245 root.display(),
246 addon.modules.len()
247 );
248 }
249
250 let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
251 std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
252
253 let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
254 atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
255
256 atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
257
258 let mut index = self.load_index()?;
259 index
260 .entries
261 .retain(|e| !(e.name == manifest.name && e.version == manifest.version));
262 index.entries.push(PackageEntry {
263 name: manifest.name.clone(),
264 version: manifest.version.clone(),
265 description: manifest.description.clone(),
266 installed_at: Utc::now(),
267 layers: manifest
268 .layers
269 .iter()
270 .map(|l| l.as_str().to_string())
271 .collect(),
272 sha256: manifest.integrity.sha256.clone(),
273 byte_size: manifest.integrity.byte_size,
274 tags: manifest.tags.clone(),
275 auto_load,
276 auto_load_projects,
277 origin: Some(origin),
278 });
279 index.updated_at = Utc::now();
280 self.save_index(&index)?;
281
282 Ok(pkg_dir)
283 }
284
285 pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
286 let _lock = self.lock_index()?;
287 let mut index = self.load_index()?;
288 let before = index.entries.len();
289
290 let to_remove: Vec<(String, String)> = index
291 .entries
292 .iter()
293 .filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
294 .map(|e| (e.name.clone(), e.version.clone()))
295 .collect();
296
297 for (n, v) in &to_remove {
298 let dir = self.package_dir(n, v);
299 if dir.exists() {
300 let _ = std::fs::remove_dir_all(&dir);
301 }
302 let skills = super::skills::skills_dir(&self.root, n, v);
305 if skills.exists() {
306 let _ = std::fs::remove_dir_all(&skills);
307 }
308 }
309
310 index.entries.retain(|e| {
311 !to_remove
312 .iter()
313 .any(|(n, v)| e.name == *n && e.version == *v)
314 });
315
316 let removed = (before - index.entries.len()) as u32;
317 if removed > 0 {
318 index.updated_at = Utc::now();
319 self.save_index(&index)?;
320 }
321
322 Ok(removed)
323 }
324
325 pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
326 let index = self.load_index()?;
327 Ok(index.entries.into_iter().map(served).collect())
328 }
329
330 pub(crate) fn get(
331 &self,
332 name: &str,
333 version: Option<&str>,
334 ) -> Result<Option<PackageEntry>, String> {
335 let index = self.load_index()?;
336 Ok(index
337 .entries
338 .into_iter()
339 .find(|e| e.name == name && version.is_none_or(|v| e.version == v))
340 .map(served))
341 }
342
343 pub fn load_package(
346 &self,
347 name: &str,
348 version: &str,
349 ) -> Result<(PackageManifest, PackageContent), String> {
350 self.load_admitted(name, version)
351 .map(AdmittedPackage::into_parts)
352 }
353
354 pub fn load_admitted(&self, name: &str, version: &str) -> Result<AdmittedPackage, String> {
357 let pkg_dir = self.package_dir(name, version);
358 if !pkg_dir.exists() {
359 return Err(format!("package {name}@{version} not found"));
360 }
361
362 let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
363 .map_err(|e| format!("read manifest: {e}"))?;
364 let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
365 .map_err(|e| format!("read content: {e}"))?;
366
367 let manifest: PackageManifest =
368 serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
369 let content: PackageContent =
370 serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
371
372 verify_integrity(&manifest, &content_json)?;
373 AdmittedPackage::admit(manifest, content).map_err(|refusal| refusal.to_string())
376 }
377
378 pub fn set_auto_load(
382 &self,
383 name: &str,
384 version: &str,
385 auto_load: bool,
386 project_root: &str,
387 ) -> Result<(), String> {
388 let project = crate::core::project_hash::hash_project_root(project_root);
389 let _lock = self.lock_index()?;
390 let mut index = self.load_index()?;
391 if let Some(entry) = index
392 .entries
393 .iter_mut()
394 .find(|e| e.name == name && e.version == version)
395 {
396 entry.auto_load_projects.retain(|bound| *bound != project);
397 if auto_load {
398 entry.auto_load_projects.push(project);
399 }
400 entry.auto_load = !entry.auto_load_projects.is_empty();
401 index.updated_at = Utc::now();
402 self.save_index(&index)?;
403 } else {
404 return Err(format!("package {name}@{version} not found in index"));
405 }
406 Ok(())
407 }
408
409 pub fn auto_load_packages(&self, project_root: &str) -> Result<Vec<PackageEntry>, String> {
412 let index = self.load_index()?;
413 Ok(index
414 .entries
415 .into_iter()
416 .filter(|e| e.auto_loads_into(project_root))
417 .map(served)
418 .collect())
419 }
420
421 pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
426 let (manifest, content) = self.load_package(name, version)?;
427 self.may_pass_on(&manifest)?;
428
429 let bundle = ExportBundle { manifest, content };
430 let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
431 let bytes = json.as_bytes();
432
433 atomic_write(output, bytes)?;
434 Ok(bytes.len() as u64)
435 }
436
437 fn may_pass_on(&self, manifest: &PackageManifest) -> Result<(), String> {
440 let origin = self
441 .get(&manifest.name, Some(&manifest.version))?
442 .and_then(|entry| entry.origin);
443 super::rights::check_stored(manifest, origin)
444 }
445
446 pub fn export_to_file_signed(
451 &self,
452 name: &str,
453 version: &str,
454 output: &Path,
455 signing_key: &ed25519_dalek::SigningKey,
456 private: bool,
457 ) -> Result<u64, String> {
458 let (mut manifest, content) = self.load_package(name, version)?;
459 self.may_pass_on(&manifest)?;
462
463 if private {
464 manifest.visibility = Some("private".to_string());
465 }
466 super::signing::sign_package(&mut manifest, &content, signing_key);
467
468 let bundle = ExportBundle { manifest, content };
469 let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
470 let bytes = json.as_bytes();
471
472 atomic_write(output, bytes)?;
473 Ok(bytes.len() as u64)
474 }
475
476 fn read_bundle(path: &Path) -> Result<(ExportBundle, String), String> {
481 if !crate::core::contracts::is_package_file(path) {
482 let ext = path
483 .extension()
484 .and_then(|e| e.to_str())
485 .unwrap_or("(none)");
486 return Err(format!(
487 "unsupported file extension '.{ext}' — expected .{} or .{}",
488 crate::core::contracts::PACKAGE_EXTENSION,
489 crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
490 ));
491 }
492
493 let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
494 if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
495 return Err(format!(
496 "package file too large ({} bytes, max {} bytes)",
497 meta.len(),
498 crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
499 ));
500 }
501
502 let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
503 let bundle: ExportBundle =
504 serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
505 Ok((bundle, json))
506 }
507
508 #[cfg(any(test, feature = "test-support"))]
509 pub fn import_from_file(
510 &self,
511 path: &Path,
512 policy: SignaturePolicy<'_>,
513 ) -> Result<PackageManifest, String> {
514 self.import_from_file_checked(path, policy)
515 .map(|(manifest, _)| manifest)
516 }
517
518 pub fn import_from_file_checked(
524 &self,
525 path: &Path,
526 policy: SignaturePolicy<'_>,
527 ) -> Result<(PackageManifest, Option<super::signing::SignatureCoverage>), String> {
528 let (bundle, json) = Self::read_bundle(path)?;
529
530 bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
531
532 super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
537 .map_err(|errs| errs.join("; "))?;
538 if bundle.manifest.kind == super::manifest::PackageKind::Addon {
539 return Err(format!(
546 "`{}` is a kind=addon package — install it with `lean-ctx addon add {}` \
547 (addon installs ask for consent before storing executable modules)",
548 bundle.manifest.name,
549 path.display(),
550 ));
551 }
552
553 let (coverage, pending) = Self::verify_bundle_integrity(&bundle, &json, policy)?;
554 self.install_then_pin(&bundle, &json, pending)?;
555 Ok((bundle.manifest, coverage))
556 }
557
558 pub fn install_addon_from_file(
572 &self,
573 path: &Path,
574 policy: SignaturePolicy<'_>,
575 ) -> Result<PackageManifest, String> {
576 let (bundle, json) = Self::read_bundle(path)?;
577 bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
578 super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
579 .map_err(|errs| errs.join("; "))?;
580
581 if bundle.manifest.kind != super::manifest::PackageKind::Addon {
582 return Err(format!(
583 "`{}` is a kind={} package, not an addon — install it with `lean-ctx pack import`",
584 bundle.manifest.name,
585 bundle.manifest.kind.as_str(),
586 ));
587 }
588
589 let (_, pending) = Self::verify_bundle_integrity(&bundle, &json, policy)?;
590 self.install_then_pin(&bundle, &json, pending)?;
591 Ok(bundle.manifest)
592 }
593
594 fn verify_bundle_integrity(
597 bundle: &ExportBundle,
598 json: &str,
599 policy: SignaturePolicy<'_>,
600 ) -> Result<
601 (
602 Option<super::signing::SignatureCoverage>,
603 Option<PendingPin>,
604 ),
605 String,
606 > {
607 let content_text = extract_top_level_value_text(json, "content")
608 .ok_or_else(|| "package has no top-level content member".to_string())?;
609 verify_integrity(&bundle.manifest, content_text)?;
610
611 let raw_manifest = serde_json::from_str::<serde_json::Value>(json)
616 .ok()
617 .and_then(|document| document.get("manifest").cloned())
618 .ok_or_else(|| "package has no top-level manifest member".to_string())?;
619 let Some(signature) = bundle.manifest.signature.as_ref() else {
620 return match policy {
623 SignaturePolicy::AllowUnsigned => Ok((None, None)),
624 SignaturePolicy::RequireTrustedSigner => Err(format!(
625 "`{}` is unsigned — its origin cannot be checked. {MCP_UNVERIFIED_HINT}",
626 bundle.manifest.name
627 )),
628 SignaturePolicy::RequireSigned
629 | SignaturePolicy::RequireTrustedPublisher
630 | SignaturePolicy::RequirePinnedPublisher { .. } => Err(format!(
631 "`{}` is unsigned — its origin cannot be checked. Re-export it with \
632 `lean-ctx pack export --sign`, or pass --allow-unsigned to import a \
633 local file you trust",
634 bundle.manifest.name
635 )),
636 };
637 };
638 let Some(coverage) = super::signing::signature_coverage_of(&raw_manifest)? else {
639 return Err(
640 "signature verification failed — the package was modified after signing".into(),
641 );
642 };
643 if policy == SignaturePolicy::RequireTrustedSigner
644 && let Some(fingerprint) = untrusted_package_signer(&bundle.manifest)
645 {
646 return Err(format!(
647 "`{}` is signed by key {fingerprint}, which is not pinned for `{}` in \
648 trusted-signers.toml — a valid signature from an unknown key proves \
649 nothing about the publisher. {MCP_UNVERIFIED_HINT}",
650 bundle.manifest.name,
651 publisher_subject(&bundle.manifest.name),
652 ));
653 }
654 if policy == SignaturePolicy::RequireTrustedPublisher
655 && let Some(fingerprint) = untrusted_package_signer(&bundle.manifest)
656 {
657 return Err(format!(
660 "`{}` is signed by key {fingerprint}, which is not pinned for `{}` in \
661 trusted-signers.toml — a valid signature from an unknown key proves \
662 nothing about the publisher. Pin the publisher key there, or pass \
663 --allow-untrusted-signer to import a local file you trust",
664 bundle.manifest.name,
665 publisher_subject(&bundle.manifest.name),
666 ));
667 }
668 if let SignaturePolicy::RequirePinnedPublisher { namespace } = policy {
669 let subject = publisher_subject(&bundle.manifest.name);
673 let requested = format!("@{}", namespace.trim_start_matches('@'));
674 if subject != requested {
675 return Err(format!(
676 "package `{}` does not belong to the requested namespace `{requested}`",
677 bundle.manifest.name
678 ));
679 }
680 if let Some(pinned_fingerprint) =
683 crate::core::signer_trust::pinned_key_conflict(&subject, &signature.public_key)
684 {
685 return Err(publisher_mismatch(
686 &subject,
687 &signature.public_key,
688 &pinned_fingerprint,
689 ));
690 }
691 return Ok((
692 Some(coverage),
693 Some(PendingPin {
694 subject,
695 public_key: signature.public_key.clone(),
696 }),
697 ));
698 }
699 Ok((Some(coverage), None))
700 }
701
702 fn install_then_pin(
706 &self,
707 bundle: &ExportBundle,
708 json: &str,
709 pending: Option<PendingPin>,
710 ) -> Result<(), String> {
711 let written = extract_top_level_value_text(json, "content")
712 .ok_or_else(|| "package has no top-level content member".to_string())?;
713 self.install_with(
714 &bundle.manifest,
715 &bundle.content,
716 InstallMode::Immutable,
717 Some(written),
718 )?;
719 let Some(pin) = pending else {
720 return Ok(());
721 };
722 match crate::core::signer_trust::pin_if_absent(
723 &pin.subject,
724 &pin.public_key,
725 "pack-install",
726 )? {
727 crate::core::signer_trust::PinOutcome::Mismatch { pinned_fingerprint } => {
728 Err(publisher_mismatch(
730 &pin.subject,
731 &pin.public_key,
732 &pinned_fingerprint,
733 ))
734 }
735 crate::core::signer_trust::PinOutcome::NewlyPinned => {
736 tracing::warn!(
737 "pinned publisher key {} for `{}` (trust on first use)",
738 crate::core::signer_trust::key_fingerprint(&pin.public_key),
739 pin.subject
740 );
741 Ok(())
742 }
743 crate::core::signer_trust::PinOutcome::AlreadyPinned => Ok(()),
744 }
745 }
746
747 fn package_dir(&self, name: &str, version: &str) -> PathBuf {
748 self.root.join(format!("{name}-{version}"))
749 }
750
751 fn lock_index(&self) -> Result<crate::core::agents::FileLock, String> {
753 crate::core::agents::FileLock::acquire_with_timeout(
754 &self.root.join(INDEX_LOCK_FILE),
755 INDEX_LOCK_WAIT,
756 )
757 }
758
759 fn load_index(&self) -> Result<PackageIndex, String> {
760 let path = self.root.join(INDEX_FILE);
761 if !path.exists() {
762 return Ok(PackageIndex::new());
763 }
764 let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
765 serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
766 }
767
768 fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
769 let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
770 atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
771 }
772}
773
774#[derive(Debug, Serialize, Deserialize)]
775struct ExportBundle {
776 manifest: PackageManifest,
777 content: PackageContent,
778}
779
780pub fn write_checkpoint_bundle(
781 mut manifest: PackageManifest,
782 content: PackageContent,
783 output: &Path,
784 signing_key: Option<&ed25519_dalek::SigningKey>,
785) -> Result<PackageManifest, String> {
786 if !crate::core::contracts::is_package_file(output) {
787 return Err("checkpoint output requires a supported package extension".into());
788 }
789 super::verify::validate_kind_coherence(&manifest, &content)
790 .map_err(|errors| errors.join("; "))?;
791 let checkpoint = content
792 .checkpoint
793 .as_ref()
794 .ok_or("checkpoint bundle has no content.checkpoint payload")?;
795 if !manifest.has_layer(super::manifest::PackageLayer::Checkpoint) {
796 return Err("checkpoint bundle has no checkpoint manifest layer".into());
797 }
798 if !crate::core::secret_detection::detect_secrets(
799 &serde_json::to_string(checkpoint).map_err(|error| error.to_string())?,
800 )
801 .is_empty()
802 {
803 return Err("checkpoint bundle contains credential-shaped material".into());
804 }
805 admitted(&manifest, &content)?;
806 if let Some(key) = signing_key {
807 super::signing::sign_package(&mut manifest, &content, key);
808 }
809 let bundle = ExportBundle {
810 manifest: manifest.clone(),
811 content,
812 };
813 let json = serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string())?;
814 let report = super::verify::verify_package_text(&json);
815 if !report.valid() {
816 return Err(format!(
817 "checkpoint package failed verification before writing: {}",
818 report.errors.join("; ")
819 ));
820 }
821 if let Some(parent) = output.parent() {
822 std::fs::create_dir_all(parent).map_err(|error| format!("create output dir: {error}"))?;
823 }
824 atomic_write(output, json.as_bytes())?;
825 Ok(manifest)
826}
827
828pub fn read_checkpoint_bundle(
829 path: &Path,
830) -> Result<(PackageManifest, CheckpointPackageContentV1), String> {
831 let json = super::verify::read_package_text(path)?;
832 let report = super::verify::verify_package_text(&json);
833 if !report.valid() {
834 return Err(report.errors.join("; "));
835 }
836 let bundle: ExportBundle =
837 serde_json::from_str(&json).map_err(|error| format!("parse package: {error}"))?;
838 admitted(&bundle.manifest, &bundle.content)?;
839 let checkpoint = bundle
840 .content
841 .checkpoint
842 .ok_or("package has no checkpoint content")?;
843 Ok((bundle.manifest, checkpoint))
844}
845
846pub fn parse_context_transfer(json: &str) -> Result<(PackageManifest, PackageContent), String> {
849 if json.len() as u64 > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
850 return Err("context package exceeds the package size limit".into());
851 }
852 let bundle: ExportBundle =
853 serde_json::from_str(json).map_err(|error| format!("parse package: {error}"))?;
854 bundle
855 .manifest
856 .validate()
857 .map_err(|errors| errors.join("; "))?;
858 super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
859 .map_err(|errors| errors.join("; "))?;
860 if bundle.manifest.kind == super::manifest::PackageKind::Addon {
861 return Err(
862 "addon packages require explicit capability consent and the addon trust chain".into(),
863 );
864 }
865 LocalRegistry::verify_bundle_integrity(&bundle, json, SignaturePolicy::RequireSigned)?;
867 admitted(&bundle.manifest, &bundle.content)?;
868 Ok((bundle.manifest, bundle.content))
869}
870
871pub(super) fn admit_package_bytes(bytes: &[u8]) -> Result<(), String> {
876 let bundle: ExportBundle =
877 serde_json::from_slice(bytes).map_err(|error| format!("parse package: {error}"))?;
878 admitted(&bundle.manifest, &bundle.content)
879}
880
881fn served(mut entry: PackageEntry) -> PackageEntry {
892 use crate::core::context_admission::stored::serve_stored;
893 if !entry.description.is_empty() {
894 entry.description = serve_stored(&entry.description, None, None)
895 .unwrap_or_else(|withheld| withheld.notice());
896 }
897 entry.tags = entry
898 .tags
899 .into_iter()
900 .filter_map(|tag| serve_stored(&tag, None, None).ok())
901 .collect();
902 entry
903}
904
905fn admitted(manifest: &PackageManifest, content: &PackageContent) -> Result<(), String> {
910 super::admission::admit_parts(manifest, content).map_err(|refusal| refusal.to_string())
911}
912
913#[derive(Debug)]
915struct PendingPin {
916 subject: String,
917 public_key: String,
918}
919
920fn publisher_mismatch(subject: &str, public_key: &str, pinned_fingerprint: &str) -> String {
921 format!(
922 "publisher key mismatch for `{subject}`: package signed by key {}, but key \
923 {pinned_fingerprint} is pinned in trusted-signers.toml — refusing a possibly \
924 re-signed artifact",
925 crate::core::signer_trust::key_fingerprint(public_key),
926 )
927}
928
929const MCP_UNVERIFIED_HINT: &str = "The user can pin the publisher key in trusted-signers.toml, \
931 run `lean-ctx pack import <file> --allow-untrusted-signer` (--allow-unsigned for an unsigned \
932 file) in their own terminal, or set `mcp_import_allow_unverified = true` in the global config. A tool \
933 argument cannot grant this.";
934
935#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938pub enum SignaturePolicy<'a> {
939 AllowUnsigned,
941 RequireSigned,
945 RequireTrustedPublisher,
949 RequireTrustedSigner,
952 #[allow(dead_code)]
958 RequirePinnedPublisher { namespace: &'a str },
959}
960
961fn publisher_subject(package_name: &str) -> String {
964 match package_name.split_once('/') {
965 Some((scope, _)) if scope.starts_with('@') => scope.to_string(),
966 Some((scope, _)) => format!("@{scope}"),
967 None => package_name.to_string(),
968 }
969}
970
971fn package_signer_trusted(manifest: &PackageManifest) -> bool {
975 manifest.signature.as_ref().is_some_and(|signature| {
976 crate::core::signer_trust::is_trusted(
977 &publisher_subject(&manifest.name),
978 &signature.public_key,
979 ) || super::keys::existing_public_key_hex()
980 .is_some_and(|own| own.eq_ignore_ascii_case(signature.public_key.trim()))
981 })
982}
983
984pub fn untrusted_package_signer(manifest: &PackageManifest) -> Option<String> {
988 if package_signer_trusted(manifest) {
989 return None;
990 }
991 Some(manifest.signature.as_ref().map_or_else(
992 || "unsigned".to_string(),
993 |signature| crate::core::signer_trust::key_fingerprint(&signature.public_key),
994 ))
995}
996
997pub fn describe_package_signature(manifest: &PackageManifest) -> String {
1000 let Some(signature) = manifest.signature.as_ref() else {
1001 return "WARNING unsigned (origin not verifiable)".to_string();
1002 };
1003 let fingerprint = crate::core::signer_trust::key_fingerprint(&signature.public_key);
1004 let subject = publisher_subject(&manifest.name);
1005 if package_signer_trusted(manifest) {
1006 format!("integrity ok, signer key {fingerprint} (trusted for {subject})")
1007 } else {
1008 format!("integrity ok, signer key {fingerprint} (untrusted)")
1009 }
1010}
1011
1012use super::verify::{compact_json_text, extract_top_level_value_text};
1013
1014fn verify_integrity(manifest: &PackageManifest, content_text: &str) -> Result<(), String> {
1018 let canonical = compact_json_text(content_text);
1019 let content_bytes = canonical.as_bytes();
1020
1021 let mut h1 = Sha256::new();
1022 h1.update(content_bytes);
1023 let actual_content_hash = crate::core::agent_identity::hex_encode(&h1.finalize());
1024
1025 if actual_content_hash != manifest.integrity.content_hash {
1026 return Err(format!(
1027 "integrity check failed: content_hash mismatch (expected {}, got {actual_content_hash})",
1028 manifest.integrity.content_hash
1029 ));
1030 }
1031
1032 let expected_sha256 = {
1033 let composite = format!(
1034 "{}:{}:{actual_content_hash}",
1035 manifest.name, manifest.version
1036 );
1037 let mut h2 = Sha256::new();
1038 h2.update(composite.as_bytes());
1039 crate::core::agent_identity::hex_encode(&h2.finalize())
1040 };
1041
1042 if manifest.integrity.sha256 != expected_sha256 {
1043 return Err(format!(
1044 "integrity check failed: sha256 mismatch (expected {expected_sha256}, got {})",
1045 manifest.integrity.sha256
1046 ));
1047 }
1048
1049 if manifest.integrity.byte_size != content_bytes.len() as u64 {
1050 return Err(format!(
1051 "integrity check failed: byte_size mismatch (expected {}, got {})",
1052 manifest.integrity.byte_size,
1053 content_bytes.len()
1054 ));
1055 }
1056
1057 Ok(())
1058}
1059
1060fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
1061 if path.exists()
1062 && path
1063 .symlink_metadata()
1064 .is_ok_and(|m| m.file_type().is_symlink())
1065 {
1066 return Err(format!(
1067 "refusing to write through symlink: {}",
1068 path.display()
1069 ));
1070 }
1071 crate::core::atomic_fs::try_atomic_write(path, data, None).map_err(|e| format!("write: {e}"))
1074}
1075
1076#[cfg(test)]
1077#[path = "registry_tests.rs"]
1078mod tests;