Skip to main content

lean_ctx/core/context_package/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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";
14/// An install materializes its files under the lock; bounded all the same.
15const 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    /// The projects (`project_hash::hash_project_root`) this package is
39    /// auto-loaded into (V03). Auto-load is bound to the project it was
40    /// enabled in; an entry written before the binding existed has
41    /// `auto_load` set and no project here, and is auto-loaded nowhere until
42    /// it is enabled again in a project (fail-safe).
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub auto_load_projects: Vec<String>,
45    /// How the package came to this machine (V02). An entry written before
46    /// this was recorded has none and counts as received.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub origin: Option<PackageOrigin>,
49}
50
51/// How a stored package came to this machine (V02).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum PackageOrigin {
55    /// Built here from this machine's own sources (`pack create`).
56    Built,
57    /// Imported, installed from a registry, received or added as an addon.
58    Received,
59}
60
61impl PackageEntry {
62    /// Whether this package is auto-loaded into `project_root`.
63    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    /// Auto-load is set, but bound to no project (an index entry from before
71    /// the binding): it loads nowhere.
72    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/// What an install may do to an already installed name@version, decided
92/// under the index lock.
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94enum InstallMode {
95    /// Received packages: different content at an installed version is refused.
96    Immutable,
97    /// A build of this machine's own sources replaces its previous build.
98    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    /// Installs a package received from elsewhere (import, remote, transfer,
122    /// addon add): an installed name@version never changes its content.
123    #[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    /// Installs a package this machine just built from its own sources
133    /// (`pack create`, skills pack create): a rebuild at the same
134    /// name@version replaces the previous local build.
135    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    /// `written` is the content member exactly as a package file carries
144    /// it. It is what gets stored: the content hash binds the writer's
145    /// bytes, and this engine's re-serialization of them — another writer's
146    /// field set, defaults, number forms — need not hash the same.
147    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        // E3: nothing of a package is stored — no blob, no module, no
157        // content file, no index entry — unless its content passes the
158        // current admission as it is. Every install door (import, addon
159        // install, remote, `pack create`) ends here, after its hash check.
160        admitted(manifest, content)?;
161
162        // What is stored must verify as it will be loaded: checked before
163        // anything is written, so an install that could not be loaded
164        // leaves nothing behind (no files, no index entry, no lock pin).
165        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        // From the version check to the index entry, one install at a time:
172        // a parallel install of the same name@version must not interleave
173        // its files with ours or drop our index entry.
174        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        // V02: a local build is this machine's own; anything else arrived.
186        // Re-importing the very bytes of a local build keeps it a build.
187        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            // A local rebuild: nothing of the previous build may linger. The
206            // skills/addon materializers rebuild their version dirs below.
207            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        // kind=skills (GH #727): materialize the verified blobs under the
215        // store *before* touching the index — a tampered blob aborts the
216        // whole install, never leaving a half-registered package.
217        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        // kind=addon: same pattern, executable payload. The modules are
232        // written before the index entry for the same reason — a tampered
233        // module aborts the install instead of leaving a half-registered
234        // package whose code is already on disk.
235        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            // Materialized skills files (GH #727) live outside the package
303            // dir — clean them up with the same lifetime.
304            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    /// The stored package as its two parts. Admitted like
344    /// [`Self::load_admitted`]; for callers that render or export the parts.
345    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    /// The stored package, hash verified first and then admitted under the
355    /// CURRENT policy — the only way to obtain what the loader applies.
356    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        // The stored bytes are the bound ones. Whether they may be used is
374        // decided under the CURRENT policy, not the one at install time.
375        AdmittedPackage::admit(manifest, content).map_err(|refusal| refusal.to_string())
376    }
377
378    /// Enable or disable auto-load of `name@version` for `project_root` only
379    /// (V03): other projects — a same-named one at another root included —
380    /// are not affected.
381    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    /// The packages auto-loaded into `project_root` (V03). An entry bound to
410    /// no project loads nowhere.
411    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    /// Export copies a stored package out. It goes through
422    /// [`Self::load_package`]: hash first, then the current admission — a
423    /// package that would be refused today is not exported, and nothing is
424    /// written to `output` (the write is the last step).
425    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    /// V02: a stored package leaves only as a local build or under its
438    /// author's signed grant (see [`super::rights`]).
439    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    /// Export with a fresh ed25519 signature over the manifest (GL #406) —
447    /// required by the hosted registry. The stored package stays untouched;
448    /// only the exported bundle carries the signature. `private` stamps
449    /// `visibility=private` into the bundle for the hosted registry (#524).
450    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        // V02: checked on the stored manifest, before re-signing — a fresh
460        // signature of this machine must not turn into the author's grant.
461        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    /// Read a package file into a bundle: extension, size cap, parse.
477    ///
478    /// Shared by both install doors so a `.ctxpkg` is admitted on exactly the
479    /// same terms whether it carries knowledge or code.
480    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    /// `Self::import_from_file`, also returning what the package's
519    /// signature covers as written (`None` = unsigned). An unsigned package
520    /// is admitted only under [`SignaturePolicy::AllowUnsigned`], the
521    /// caller's explicit opt-in (GHSA-gvg8-285q-m62g); an identity-only
522    /// signature is admitted with a warning (K02).
523    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        // Kind gate (GH #726): the local context registry stores knowledge,
533        // not capabilities. `import` reads a file the caller already has, so
534        // it is the wrong door for executable content — an addon must arrive
535        // through `addon add`, which is where consent is asked for.
536        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            // Point at the file the caller already has, not at a registry
540            // reference reconstructed from the package name: `addon add` now
541            // resolves a bare `ns/name` over the network, so suggesting that
542            // form would send someone holding a local file to fetch a possibly
543            // different copy of it — or to fail against a registry they never
544            // meant to use.
545            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    /// Install a `kind=addon` package.
559    ///
560    /// Deliberately a separate entry point from `Self::import_from_file`
561    /// rather than a flag on it: the two differ only in which kind they accept,
562    /// but they differ completely in consequence. Importing knowledge is
563    /// reversible and inert; installing an addon puts code where the extension
564    /// registry will load it. A caller has to name that intent, and the CLI
565    /// asks the user before calling this.
566    ///
567    /// Every other check is identical, and shared through
568    /// `Self::verify_bundle_integrity` — integrity digest, then signature —
569    /// so the executable path can never end up with *weaker* verification than
570    /// the inert one.
571    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    /// Content digest, then signature. Shared by both install doors so neither
595    /// can drift into accepting what the other rejects.
596    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        // A present-but-invalid signature is always tampering (the file was
612        // modified after signing) — reject. The manifest signature is checked
613        // over the manifest as written, so a field this engine does not know
614        // (a newer writer's) stays covered instead of failing the check.
615        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            // GHSA-gvg8-285q-m62g: unsigned content is only admitted on an
621            // explicit opt-in; remote and network paths never opt in.
622            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            // The key travels inside the package, so a valid signature alone
658            // proves only that *someone* signed it (F8).
659            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            // The key travels inside the package, so a registry could re-sign
670            // with its own key. Pin the publisher key per namespace on first
671            // install and refuse a different key afterwards.
672            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            // Only check here; the pin is recorded after a successful install
681            // so a failed install never leaves a trust anchor behind.
682            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    /// Install the package as written, then record the TOFU publisher pin
703    /// (GHSA-gvg8-285q-m62g): a failed install must not leave a pin for a key
704    /// nothing was installed from.
705    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                // A concurrent install pinned a different key in between.
729                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    /// Serializes index read-modify-writes across threads and processes.
752    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
846/// Validate a network context package without installing it. The transport
847/// authenticates its sender separately; executable addons use the consent gate.
848pub 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    // GHSA-rxv5-4c3x-g4q6 (P): a network peer never delivers unsigned content.
866    LocalRegistry::verify_bundle_integrity(&bundle, json, SignaturePolicy::RequireSigned)?;
867    admitted(&bundle.manifest, &bundle.content)?;
868    Ok((bundle.manifest, bundle.content))
869}
870
871/// Admission of a package document that is about to be copied byte for byte
872/// (the legacy-name migration). The caller has verified the document's
873/// hashes; this parses the same bytes and admits manifest and content as
874/// they are. Nothing is written here.
875pub(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
881/// An index entry as it is handed to a reader. The index is lean-ctx's own
882/// file, but two of its fields are caller text copied from the manifest at
883/// install: the description and the tags. They are stored copies without a
884/// source, so they are served under the originless rule — in a protected
885/// deployment the description is replaced by the content-free notice and
886/// the tags are dropped; otherwise they pass admission as stored text.
887/// Name and version are identifiers restricted to `[a-zA-Z0-9._@/-]` by
888/// `PackageManifest::validate`; layers, digest, size and dates are not text.
889/// Writers (`install`, `remove`, `set_auto_load`) work on the raw index, so
890/// a notice is never written back.
891fn 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
905/// Verify-only admission of hash-bound content, for every door of this
906/// module: the content is used as it is or the package is refused as a whole
907/// (see [`super::admission`]). Always called AFTER the door's hash check. The
908/// error is the typed refusal's content-free sentence.
909fn admitted(manifest: &PackageManifest, content: &PackageContent) -> Result<(), String> {
910    super::admission::admit_parts(manifest, content).map_err(|refusal| refusal.to_string())
911}
912
913/// A TOFU publisher pin to record once the install succeeded.
914#[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
929/// How a user admits an unverified package that an MCP import refused.
930const 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/// How strictly an install door treats package signatures
936/// (GHSA-gvg8-285q-m62g).
937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
938pub enum SignaturePolicy<'a> {
939    /// Local file the caller explicitly vouched for (`--allow-unsigned`).
940    AllowUnsigned,
941    /// A valid signature is required, from any key (local imports with
942    /// `--allow-untrusted-signer`; network peers whose admission adds its
943    /// own checks).
944    RequireSigned,
945    /// A valid signature from a key anchored for the package's publisher
946    /// subject or this machine's own publisher key (default for local CLI
947    /// imports of third-party files).
948    RequireTrustedPublisher,
949    /// A valid signature from a key anchored for the package's publisher
950    /// subject (MCP imports: the model cannot vouch for a file).
951    RequireTrustedSigner,
952    /// Remote registry installs: signed, the package belongs to the requested
953    /// `namespace`, and the key matches the publisher key pinned for it
954    /// (trust on first use).
955    // Never constructed outside tests since remote registry installs left with
956    // 3.12.0; removing it means removing the TOFU pin path (`PendingPin`) too.
957    #[allow(dead_code)]
958    RequirePinnedPublisher { namespace: &'a str },
959}
960
961/// Trust subject for a package publisher: the `@ns` scope of `@ns/name`,
962/// or the bare package name when unscoped.
963fn 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
971/// Whether the package signer key is anchored for its publisher subject
972/// (pinned in `trusted-signers.toml`, held by a local agent identity, or
973/// this machine's own ctxpkg publisher key).
974fn 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
984/// `None` when the package is signed by an anchored key; otherwise the signer
985/// key fingerprint (or `"unsigned"`) for a rejection message
986/// (GHSA-gvg8-285q-m62g). Call after the signature itself verified.
987pub 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
997/// One-line signature status for install output: integrity is not identity,
998/// so a signer key is only called trusted when it is anchored.
999pub 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
1014/// Verify integrity against the writer's bytes: `content_text` is the exact
1015/// document text of the content member (pretty or compact — compaction
1016/// normalizes whitespace without touching value literals).
1017fn 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    // Unique, synced temp file + rename: parallel writers never share a temp
1072    // name, and the published file survives a crash.
1073    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;