use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use super::admission::AdmittedPackage;
use super::content::{CheckpointPackageContentV1, PackageContent};
use super::manifest::PackageManifest;
const INDEX_FILE: &str = "package-index.json";
const INDEX_LOCK_FILE: &str = ".package-index.lock";
const INDEX_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) const PACKAGES_DIR: &str = "packages";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PackageIndex {
pub schema_version: u32,
pub updated_at: DateTime<Utc>,
pub entries: Vec<PackageEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageEntry {
pub name: String,
pub version: String,
pub description: String,
pub installed_at: DateTime<Utc>,
pub layers: Vec<String>,
pub sha256: String,
pub byte_size: u64,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub auto_load: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auto_load_projects: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<PackageOrigin>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackageOrigin {
Built,
Received,
}
impl PackageEntry {
pub fn auto_loads_into(&self, project_root: &str) -> bool {
self.auto_load
&& self
.auto_load_projects
.contains(&crate::core::project_hash::hash_project_root(project_root))
}
pub fn auto_load_unbound(&self) -> bool {
self.auto_load && self.auto_load_projects.is_empty()
}
}
impl PackageIndex {
fn new() -> Self {
Self {
schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
updated_at: Utc::now(),
entries: Vec::new(),
}
}
}
pub struct LocalRegistry {
root: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallMode {
Immutable,
ReplaceLocalBuild,
}
impl LocalRegistry {
pub fn open() -> Result<Self, String> {
let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
let root = data_dir.join(PACKAGES_DIR);
std::fs::create_dir_all(&root).map_err(|e| format!("create packages dir: {e}"))?;
Ok(Self { root })
}
#[cfg(test)]
pub(crate) fn open_at(root: &Path) -> Result<Self, String> {
std::fs::create_dir_all(root).map_err(|e| format!("create packages dir: {e}"))?;
Ok(Self {
root: root.to_path_buf(),
})
}
pub fn root(&self) -> &Path {
&self.root
}
#[cfg(test)]
pub(crate) fn install(
&self,
manifest: &PackageManifest,
content: &PackageContent,
) -> Result<PathBuf, String> {
self.install_with(manifest, content, InstallMode::Immutable, None)
}
pub fn install_local_build(
&self,
manifest: &PackageManifest,
content: &PackageContent,
) -> Result<PathBuf, String> {
self.install_with(manifest, content, InstallMode::ReplaceLocalBuild, None)
}
fn install_with(
&self,
manifest: &PackageManifest,
content: &PackageContent,
mode: InstallMode,
written: Option<&str>,
) -> Result<PathBuf, String> {
manifest.validate().map_err(|errs| errs.join("; "))?;
admitted(manifest, content)?;
let content_json = match written {
Some(text) => text.to_owned(),
None => serde_json::to_string_pretty(content).map_err(|e| e.to_string())?,
};
verify_integrity(manifest, &content_json)?;
let _lock = self.lock_index()?;
let installed = self
.load_index()?
.entries
.into_iter()
.find(|e| e.name == manifest.name && e.version == manifest.version);
let auto_load = installed.as_ref().is_some_and(|e| e.auto_load);
let auto_load_projects = installed
.as_ref()
.map(|e| e.auto_load_projects.clone())
.unwrap_or_default();
let origin = match mode {
InstallMode::ReplaceLocalBuild => PackageOrigin::Built,
InstallMode::Immutable => installed
.as_ref()
.filter(|e| e.sha256 == manifest.integrity.sha256)
.and_then(|e| e.origin)
.unwrap_or(PackageOrigin::Received),
};
if let Some(installed) = &installed
&& installed.sha256 != manifest.integrity.sha256
{
if mode == InstallMode::Immutable {
return Err(format!(
"package {}@{} is already installed with different content; \
install it under a new version or remove the installed one first",
manifest.name, manifest.version
));
}
let previous = self.package_dir(&manifest.name, &manifest.version);
if previous.exists() {
std::fs::remove_dir_all(&previous)
.map_err(|e| format!("remove previous build: {e}"))?;
}
}
if manifest.kind == super::manifest::PackageKind::Skills {
let docs = content
.documents
.as_ref()
.ok_or("kind=skills package has no documents payload")?;
let root = super::skills::materialize_documents(&self.root, manifest, docs)?;
tracing::info!(
"skills pack {}@{} materialized at {}",
manifest.name,
manifest.version,
root.display()
);
}
if manifest.kind == super::manifest::PackageKind::Addon {
let addon = content
.addon
.as_ref()
.ok_or("kind=addon package has no addon payload")?;
let root = super::addons::materialize_modules(&self.root, manifest, addon)?;
tracing::info!(
"addon pack {}@{} materialized at {} ({} module(s))",
manifest.name,
manifest.version,
root.display(),
addon.modules.len()
);
}
let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
let mut index = self.load_index()?;
index
.entries
.retain(|e| !(e.name == manifest.name && e.version == manifest.version));
index.entries.push(PackageEntry {
name: manifest.name.clone(),
version: manifest.version.clone(),
description: manifest.description.clone(),
installed_at: Utc::now(),
layers: manifest
.layers
.iter()
.map(|l| l.as_str().to_string())
.collect(),
sha256: manifest.integrity.sha256.clone(),
byte_size: manifest.integrity.byte_size,
tags: manifest.tags.clone(),
auto_load,
auto_load_projects,
origin: Some(origin),
});
index.updated_at = Utc::now();
self.save_index(&index)?;
Ok(pkg_dir)
}
pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
let _lock = self.lock_index()?;
let mut index = self.load_index()?;
let before = index.entries.len();
let to_remove: Vec<(String, String)> = index
.entries
.iter()
.filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
.map(|e| (e.name.clone(), e.version.clone()))
.collect();
for (n, v) in &to_remove {
let dir = self.package_dir(n, v);
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
let skills = super::skills::skills_dir(&self.root, n, v);
if skills.exists() {
let _ = std::fs::remove_dir_all(&skills);
}
}
index.entries.retain(|e| {
!to_remove
.iter()
.any(|(n, v)| e.name == *n && e.version == *v)
});
let removed = (before - index.entries.len()) as u32;
if removed > 0 {
index.updated_at = Utc::now();
self.save_index(&index)?;
}
Ok(removed)
}
pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
let index = self.load_index()?;
Ok(index.entries.into_iter().map(served).collect())
}
pub(crate) fn get(
&self,
name: &str,
version: Option<&str>,
) -> Result<Option<PackageEntry>, String> {
let index = self.load_index()?;
Ok(index
.entries
.into_iter()
.find(|e| e.name == name && version.is_none_or(|v| e.version == v))
.map(served))
}
pub fn load_package(
&self,
name: &str,
version: &str,
) -> Result<(PackageManifest, PackageContent), String> {
self.load_admitted(name, version)
.map(AdmittedPackage::into_parts)
}
pub fn load_admitted(&self, name: &str, version: &str) -> Result<AdmittedPackage, String> {
let pkg_dir = self.package_dir(name, version);
if !pkg_dir.exists() {
return Err(format!("package {name}@{version} not found"));
}
let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
.map_err(|e| format!("read manifest: {e}"))?;
let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
.map_err(|e| format!("read content: {e}"))?;
let manifest: PackageManifest =
serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
let content: PackageContent =
serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
verify_integrity(&manifest, &content_json)?;
AdmittedPackage::admit(manifest, content).map_err(|refusal| refusal.to_string())
}
pub fn set_auto_load(
&self,
name: &str,
version: &str,
auto_load: bool,
project_root: &str,
) -> Result<(), String> {
let project = crate::core::project_hash::hash_project_root(project_root);
let _lock = self.lock_index()?;
let mut index = self.load_index()?;
if let Some(entry) = index
.entries
.iter_mut()
.find(|e| e.name == name && e.version == version)
{
entry.auto_load_projects.retain(|bound| *bound != project);
if auto_load {
entry.auto_load_projects.push(project);
}
entry.auto_load = !entry.auto_load_projects.is_empty();
index.updated_at = Utc::now();
self.save_index(&index)?;
} else {
return Err(format!("package {name}@{version} not found in index"));
}
Ok(())
}
pub fn auto_load_packages(&self, project_root: &str) -> Result<Vec<PackageEntry>, String> {
let index = self.load_index()?;
Ok(index
.entries
.into_iter()
.filter(|e| e.auto_loads_into(project_root))
.map(served)
.collect())
}
pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
let (manifest, content) = self.load_package(name, version)?;
self.may_pass_on(&manifest)?;
let bundle = ExportBundle { manifest, content };
let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
let bytes = json.as_bytes();
atomic_write(output, bytes)?;
Ok(bytes.len() as u64)
}
fn may_pass_on(&self, manifest: &PackageManifest) -> Result<(), String> {
let origin = self
.get(&manifest.name, Some(&manifest.version))?
.and_then(|entry| entry.origin);
super::rights::check_stored(manifest, origin)
}
pub fn export_to_file_signed(
&self,
name: &str,
version: &str,
output: &Path,
signing_key: &ed25519_dalek::SigningKey,
private: bool,
) -> Result<u64, String> {
let (mut manifest, content) = self.load_package(name, version)?;
self.may_pass_on(&manifest)?;
if private {
manifest.visibility = Some("private".to_string());
}
super::signing::sign_package(&mut manifest, &content, signing_key);
let bundle = ExportBundle { manifest, content };
let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
let bytes = json.as_bytes();
atomic_write(output, bytes)?;
Ok(bytes.len() as u64)
}
fn read_bundle(path: &Path) -> Result<(ExportBundle, String), String> {
if !crate::core::contracts::is_package_file(path) {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("(none)");
return Err(format!(
"unsupported file extension '.{ext}' — expected .{} or .{}",
crate::core::contracts::PACKAGE_EXTENSION,
crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
));
}
let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
return Err(format!(
"package file too large ({} bytes, max {} bytes)",
meta.len(),
crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
));
}
let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
let bundle: ExportBundle =
serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
Ok((bundle, json))
}
#[cfg(any(test, feature = "test-support"))]
pub fn import_from_file(
&self,
path: &Path,
policy: SignaturePolicy<'_>,
) -> Result<PackageManifest, String> {
self.import_from_file_checked(path, policy)
.map(|(manifest, _)| manifest)
}
pub fn import_from_file_checked(
&self,
path: &Path,
policy: SignaturePolicy<'_>,
) -> Result<(PackageManifest, Option<super::signing::SignatureCoverage>), String> {
let (bundle, json) = Self::read_bundle(path)?;
bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
.map_err(|errs| errs.join("; "))?;
if bundle.manifest.kind == super::manifest::PackageKind::Addon {
return Err(format!(
"`{}` is a kind=addon package — install it with `lean-ctx addon add {}` \
(addon installs ask for consent before storing executable modules)",
bundle.manifest.name,
path.display(),
));
}
let (coverage, pending) = Self::verify_bundle_integrity(&bundle, &json, policy)?;
self.install_then_pin(&bundle, &json, pending)?;
Ok((bundle.manifest, coverage))
}
pub fn install_addon_from_file(
&self,
path: &Path,
policy: SignaturePolicy<'_>,
) -> Result<PackageManifest, String> {
let (bundle, json) = Self::read_bundle(path)?;
bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
.map_err(|errs| errs.join("; "))?;
if bundle.manifest.kind != super::manifest::PackageKind::Addon {
return Err(format!(
"`{}` is a kind={} package, not an addon — install it with `lean-ctx pack import`",
bundle.manifest.name,
bundle.manifest.kind.as_str(),
));
}
let (_, pending) = Self::verify_bundle_integrity(&bundle, &json, policy)?;
self.install_then_pin(&bundle, &json, pending)?;
Ok(bundle.manifest)
}
fn verify_bundle_integrity(
bundle: &ExportBundle,
json: &str,
policy: SignaturePolicy<'_>,
) -> Result<
(
Option<super::signing::SignatureCoverage>,
Option<PendingPin>,
),
String,
> {
let content_text = extract_top_level_value_text(json, "content")
.ok_or_else(|| "package has no top-level content member".to_string())?;
verify_integrity(&bundle.manifest, content_text)?;
let raw_manifest = serde_json::from_str::<serde_json::Value>(json)
.ok()
.and_then(|document| document.get("manifest").cloned())
.ok_or_else(|| "package has no top-level manifest member".to_string())?;
let Some(signature) = bundle.manifest.signature.as_ref() else {
return match policy {
SignaturePolicy::AllowUnsigned => Ok((None, None)),
SignaturePolicy::RequireTrustedSigner => Err(format!(
"`{}` is unsigned — its origin cannot be checked. {MCP_UNVERIFIED_HINT}",
bundle.manifest.name
)),
SignaturePolicy::RequireSigned
| SignaturePolicy::RequireTrustedPublisher
| SignaturePolicy::RequirePinnedPublisher { .. } => Err(format!(
"`{}` is unsigned — its origin cannot be checked. Re-export it with \
`lean-ctx pack export --sign`, or pass --allow-unsigned to import a \
local file you trust",
bundle.manifest.name
)),
};
};
let Some(coverage) = super::signing::signature_coverage_of(&raw_manifest)? else {
return Err(
"signature verification failed — the package was modified after signing".into(),
);
};
if policy == SignaturePolicy::RequireTrustedSigner
&& let Some(fingerprint) = untrusted_package_signer(&bundle.manifest)
{
return Err(format!(
"`{}` is signed by key {fingerprint}, which is not pinned for `{}` in \
trusted-signers.toml — a valid signature from an unknown key proves \
nothing about the publisher. {MCP_UNVERIFIED_HINT}",
bundle.manifest.name,
publisher_subject(&bundle.manifest.name),
));
}
if policy == SignaturePolicy::RequireTrustedPublisher
&& let Some(fingerprint) = untrusted_package_signer(&bundle.manifest)
{
return Err(format!(
"`{}` is signed by key {fingerprint}, which is not pinned for `{}` in \
trusted-signers.toml — a valid signature from an unknown key proves \
nothing about the publisher. Pin the publisher key there, or pass \
--allow-untrusted-signer to import a local file you trust",
bundle.manifest.name,
publisher_subject(&bundle.manifest.name),
));
}
if let SignaturePolicy::RequirePinnedPublisher { namespace } = policy {
let subject = publisher_subject(&bundle.manifest.name);
let requested = format!("@{}", namespace.trim_start_matches('@'));
if subject != requested {
return Err(format!(
"package `{}` does not belong to the requested namespace `{requested}`",
bundle.manifest.name
));
}
if let Some(pinned_fingerprint) =
crate::core::signer_trust::pinned_key_conflict(&subject, &signature.public_key)
{
return Err(publisher_mismatch(
&subject,
&signature.public_key,
&pinned_fingerprint,
));
}
return Ok((
Some(coverage),
Some(PendingPin {
subject,
public_key: signature.public_key.clone(),
}),
));
}
Ok((Some(coverage), None))
}
fn install_then_pin(
&self,
bundle: &ExportBundle,
json: &str,
pending: Option<PendingPin>,
) -> Result<(), String> {
let written = extract_top_level_value_text(json, "content")
.ok_or_else(|| "package has no top-level content member".to_string())?;
self.install_with(
&bundle.manifest,
&bundle.content,
InstallMode::Immutable,
Some(written),
)?;
let Some(pin) = pending else {
return Ok(());
};
match crate::core::signer_trust::pin_if_absent(
&pin.subject,
&pin.public_key,
"pack-install",
)? {
crate::core::signer_trust::PinOutcome::Mismatch { pinned_fingerprint } => {
Err(publisher_mismatch(
&pin.subject,
&pin.public_key,
&pinned_fingerprint,
))
}
crate::core::signer_trust::PinOutcome::NewlyPinned => {
tracing::warn!(
"pinned publisher key {} for `{}` (trust on first use)",
crate::core::signer_trust::key_fingerprint(&pin.public_key),
pin.subject
);
Ok(())
}
crate::core::signer_trust::PinOutcome::AlreadyPinned => Ok(()),
}
}
fn package_dir(&self, name: &str, version: &str) -> PathBuf {
self.root.join(format!("{name}-{version}"))
}
fn lock_index(&self) -> Result<crate::core::agents::FileLock, String> {
crate::core::agents::FileLock::acquire_with_timeout(
&self.root.join(INDEX_LOCK_FILE),
INDEX_LOCK_WAIT,
)
}
fn load_index(&self) -> Result<PackageIndex, String> {
let path = self.root.join(INDEX_FILE);
if !path.exists() {
return Ok(PackageIndex::new());
}
let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
}
fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
}
}
#[derive(Debug, Serialize, Deserialize)]
struct ExportBundle {
manifest: PackageManifest,
content: PackageContent,
}
pub fn write_checkpoint_bundle(
mut manifest: PackageManifest,
content: PackageContent,
output: &Path,
signing_key: Option<&ed25519_dalek::SigningKey>,
) -> Result<PackageManifest, String> {
if !crate::core::contracts::is_package_file(output) {
return Err("checkpoint output requires a supported package extension".into());
}
super::verify::validate_kind_coherence(&manifest, &content)
.map_err(|errors| errors.join("; "))?;
let checkpoint = content
.checkpoint
.as_ref()
.ok_or("checkpoint bundle has no content.checkpoint payload")?;
if !manifest.has_layer(super::manifest::PackageLayer::Checkpoint) {
return Err("checkpoint bundle has no checkpoint manifest layer".into());
}
if !crate::core::secret_detection::detect_secrets(
&serde_json::to_string(checkpoint).map_err(|error| error.to_string())?,
)
.is_empty()
{
return Err("checkpoint bundle contains credential-shaped material".into());
}
admitted(&manifest, &content)?;
if let Some(key) = signing_key {
super::signing::sign_package(&mut manifest, &content, key);
}
let bundle = ExportBundle {
manifest: manifest.clone(),
content,
};
let json = serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string())?;
let report = super::verify::verify_package_text(&json);
if !report.valid() {
return Err(format!(
"checkpoint package failed verification before writing: {}",
report.errors.join("; ")
));
}
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent).map_err(|error| format!("create output dir: {error}"))?;
}
atomic_write(output, json.as_bytes())?;
Ok(manifest)
}
pub fn read_checkpoint_bundle(
path: &Path,
) -> Result<(PackageManifest, CheckpointPackageContentV1), String> {
let json = super::verify::read_package_text(path)?;
let report = super::verify::verify_package_text(&json);
if !report.valid() {
return Err(report.errors.join("; "));
}
let bundle: ExportBundle =
serde_json::from_str(&json).map_err(|error| format!("parse package: {error}"))?;
admitted(&bundle.manifest, &bundle.content)?;
let checkpoint = bundle
.content
.checkpoint
.ok_or("package has no checkpoint content")?;
Ok((bundle.manifest, checkpoint))
}
pub fn parse_context_transfer(json: &str) -> Result<(PackageManifest, PackageContent), String> {
if json.len() as u64 > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
return Err("context package exceeds the package size limit".into());
}
let bundle: ExportBundle =
serde_json::from_str(json).map_err(|error| format!("parse package: {error}"))?;
bundle
.manifest
.validate()
.map_err(|errors| errors.join("; "))?;
super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
.map_err(|errors| errors.join("; "))?;
if bundle.manifest.kind == super::manifest::PackageKind::Addon {
return Err(
"addon packages require explicit capability consent and the addon trust chain".into(),
);
}
LocalRegistry::verify_bundle_integrity(&bundle, json, SignaturePolicy::RequireSigned)?;
admitted(&bundle.manifest, &bundle.content)?;
Ok((bundle.manifest, bundle.content))
}
pub(super) fn admit_package_bytes(bytes: &[u8]) -> Result<(), String> {
let bundle: ExportBundle =
serde_json::from_slice(bytes).map_err(|error| format!("parse package: {error}"))?;
admitted(&bundle.manifest, &bundle.content)
}
fn served(mut entry: PackageEntry) -> PackageEntry {
use crate::core::context_admission::stored::serve_stored;
if !entry.description.is_empty() {
entry.description = serve_stored(&entry.description, None, None)
.unwrap_or_else(|withheld| withheld.notice());
}
entry.tags = entry
.tags
.into_iter()
.filter_map(|tag| serve_stored(&tag, None, None).ok())
.collect();
entry
}
fn admitted(manifest: &PackageManifest, content: &PackageContent) -> Result<(), String> {
super::admission::admit_parts(manifest, content).map_err(|refusal| refusal.to_string())
}
#[derive(Debug)]
struct PendingPin {
subject: String,
public_key: String,
}
fn publisher_mismatch(subject: &str, public_key: &str, pinned_fingerprint: &str) -> String {
format!(
"publisher key mismatch for `{subject}`: package signed by key {}, but key \
{pinned_fingerprint} is pinned in trusted-signers.toml — refusing a possibly \
re-signed artifact",
crate::core::signer_trust::key_fingerprint(public_key),
)
}
const MCP_UNVERIFIED_HINT: &str = "The user can pin the publisher key in trusted-signers.toml, \
run `lean-ctx pack import <file> --allow-untrusted-signer` (--allow-unsigned for an unsigned \
file) in their own terminal, or set `mcp_import_allow_unverified = true` in the global config. A tool \
argument cannot grant this.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignaturePolicy<'a> {
AllowUnsigned,
RequireSigned,
RequireTrustedPublisher,
RequireTrustedSigner,
#[allow(dead_code)]
RequirePinnedPublisher { namespace: &'a str },
}
fn publisher_subject(package_name: &str) -> String {
match package_name.split_once('/') {
Some((scope, _)) if scope.starts_with('@') => scope.to_string(),
Some((scope, _)) => format!("@{scope}"),
None => package_name.to_string(),
}
}
fn package_signer_trusted(manifest: &PackageManifest) -> bool {
manifest.signature.as_ref().is_some_and(|signature| {
crate::core::signer_trust::is_trusted(
&publisher_subject(&manifest.name),
&signature.public_key,
) || super::keys::existing_public_key_hex()
.is_some_and(|own| own.eq_ignore_ascii_case(signature.public_key.trim()))
})
}
pub fn untrusted_package_signer(manifest: &PackageManifest) -> Option<String> {
if package_signer_trusted(manifest) {
return None;
}
Some(manifest.signature.as_ref().map_or_else(
|| "unsigned".to_string(),
|signature| crate::core::signer_trust::key_fingerprint(&signature.public_key),
))
}
pub fn describe_package_signature(manifest: &PackageManifest) -> String {
let Some(signature) = manifest.signature.as_ref() else {
return "WARNING unsigned (origin not verifiable)".to_string();
};
let fingerprint = crate::core::signer_trust::key_fingerprint(&signature.public_key);
let subject = publisher_subject(&manifest.name);
if package_signer_trusted(manifest) {
format!("integrity ok, signer key {fingerprint} (trusted for {subject})")
} else {
format!("integrity ok, signer key {fingerprint} (untrusted)")
}
}
use super::verify::{compact_json_text, extract_top_level_value_text};
fn verify_integrity(manifest: &PackageManifest, content_text: &str) -> Result<(), String> {
let canonical = compact_json_text(content_text);
let content_bytes = canonical.as_bytes();
let mut h1 = Sha256::new();
h1.update(content_bytes);
let actual_content_hash = crate::core::agent_identity::hex_encode(&h1.finalize());
if actual_content_hash != manifest.integrity.content_hash {
return Err(format!(
"integrity check failed: content_hash mismatch (expected {}, got {actual_content_hash})",
manifest.integrity.content_hash
));
}
let expected_sha256 = {
let composite = format!(
"{}:{}:{actual_content_hash}",
manifest.name, manifest.version
);
let mut h2 = Sha256::new();
h2.update(composite.as_bytes());
crate::core::agent_identity::hex_encode(&h2.finalize())
};
if manifest.integrity.sha256 != expected_sha256 {
return Err(format!(
"integrity check failed: sha256 mismatch (expected {expected_sha256}, got {})",
manifest.integrity.sha256
));
}
if manifest.integrity.byte_size != content_bytes.len() as u64 {
return Err(format!(
"integrity check failed: byte_size mismatch (expected {}, got {})",
manifest.integrity.byte_size,
content_bytes.len()
));
}
Ok(())
}
fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
if path.exists()
&& path
.symlink_metadata()
.is_ok_and(|m| m.file_type().is_symlink())
{
return Err(format!(
"refusing to write through symlink: {}",
path.display()
));
}
crate::core::atomic_fs::try_atomic_write(path, data, None).map_err(|e| format!("write: {e}"))
}
#[cfg(test)]
#[path = "registry_tests.rs"]
mod tests;