use std::collections::HashSet;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
pub enum PackageDiscoveryError {
#[error("invalid package manifest at {path}: {reason}")]
InvalidManifest { path: PathBuf, reason: String },
#[error("missing required field '{field}' in package at {path}")]
MissingField { field: String, path: PathBuf },
#[error("duplicate package name '{name}' in discovery layer at {path}")]
DuplicateName { name: String, path: PathBuf },
#[error("invalid package name in {path}: {reason}")]
InvalidName { path: PathBuf, reason: String },
#[error("invalid description in package at {path}: {reason}")]
InvalidDescription { path: PathBuf, reason: String },
#[error("missing {kind} '{name}' in package '{package_name}'")]
MissingAsset {
package_name: String,
kind: String,
name: String,
},
#[error(
"security: resource path escapes package directory for {package_name}: {path} ({reason})"
)]
SecurityDiagnostic {
package_name: String,
path: PathBuf,
reason: String,
},
#[error("I/O error discovering packages: {0}")]
Io(#[from] std::io::Error),
}
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug, Clone, Deserialize)]
struct TomlPackageFile {
name: Option<String>,
description: Option<String>,
version: Option<String>,
extensions: Option<Vec<String>>,
skills: Option<Vec<String>>,
fragments: Option<Vec<String>>,
themes: Option<Vec<String>>,
disabled: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PackageManifest {
pub name: String,
pub description: String,
pub version: Option<String>,
pub extensions: Option<Vec<String>>,
pub skills: Option<Vec<String>>,
pub fragments: Option<Vec<String>>,
pub themes: Option<Vec<String>>,
pub disabled: Vec<String>,
}
impl PackageManifest {
pub fn from_toml(content: &str, path: &Path) -> Result<Self, PackageDiscoveryError> {
let file: TomlPackageFile =
toml::from_str(content).map_err(|e| PackageDiscoveryError::InvalidManifest {
path: path.to_path_buf(),
reason: e.to_string(),
})?;
let name = file.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
PackageDiscoveryError::MissingField {
field: "name".into(),
path: path.to_path_buf(),
}
})?;
validate_package_name(&name, path)?;
let description = file
.description
.filter(|d| !d.trim().is_empty())
.ok_or_else(|| PackageDiscoveryError::MissingField {
field: "description".into(),
path: path.to_path_buf(),
})?;
validate_description(&description, path)?;
Ok(Self {
name,
description,
version: file.version,
extensions: file.extensions,
skills: file.skills,
fragments: file.fragments,
themes: file.themes,
disabled: file.disabled.unwrap_or_default(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceKind {
Extension,
Skill,
Fragment,
Theme,
}
impl std::fmt::Display for ResourceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Extension => write!(f, "extension"),
Self::Skill => write!(f, "skill"),
Self::Fragment => write!(f, "fragment"),
Self::Theme => write!(f, "theme"),
}
}
}
struct ResourceTypeSpec {
kind: ResourceKind,
subdir: &'static str,
marker: &'static str,
}
const RESOURCE_TYPES: &[ResourceTypeSpec] = &[
ResourceTypeSpec {
kind: ResourceKind::Extension,
subdir: "extensions",
marker: "extension.toml",
},
ResourceTypeSpec {
kind: ResourceKind::Skill,
subdir: "skills",
marker: "SKILL.md",
},
ResourceTypeSpec {
kind: ResourceKind::Fragment,
subdir: "fragments",
marker: "FRAGMENT.md",
},
ResourceTypeSpec {
kind: ResourceKind::Theme,
subdir: "themes",
marker: "theme.toml",
},
];
#[derive(Debug, Clone)]
pub struct ComposedResource {
pub kind: ResourceKind,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct PackageComposedResourceLayers {
pub extensions: Vec<crate::resource::DiscoveryLayer>,
pub skills: Vec<crate::resource::DiscoveryLayer>,
pub fragments: Vec<crate::resource::DiscoveryLayer>,
pub themes: Vec<crate::resource::DiscoveryLayer>,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PackageResource {
pub manifest: PackageManifest,
pub path: PathBuf,
pub package_toml_path: PathBuf,
pub layer_precedence: u32,
}
impl PackageResource {
pub fn compose(&self) -> Result<Vec<ComposedResource>, PackageDiscoveryError> {
let mut resources = Vec::new();
let disabled: HashSet<&str> = self.manifest.disabled.iter().map(|s| s.as_str()).collect();
let include_lists: [(ResourceKind, &Option<Vec<String>>); 4] = [
(ResourceKind::Extension, &self.manifest.extensions),
(ResourceKind::Skill, &self.manifest.skills),
(ResourceKind::Fragment, &self.manifest.fragments),
(ResourceKind::Theme, &self.manifest.themes),
];
for spec in RESOURCE_TYPES {
let include_list = include_lists
.iter()
.find(|(k, _)| *k == spec.kind)
.map(|(_, l)| *l)
.unwrap_or(&None);
self.compose_type(spec, include_list, &disabled, &mut resources)?;
}
Ok(resources)
}
fn compose_type(
&self,
spec: &ResourceTypeSpec,
include_list: &Option<Vec<String>>,
disabled: &HashSet<&str>,
resources: &mut Vec<ComposedResource>,
) -> Result<(), PackageDiscoveryError> {
let type_dir = self.path.join(spec.subdir);
if !type_dir.is_dir() {
if let Some(includes) = include_list {
for name in includes {
if !disabled.contains(name.as_str()) {
return Err(PackageDiscoveryError::MissingAsset {
package_name: self.manifest.name.clone(),
kind: spec.kind.to_string(),
name: name.clone(),
});
}
}
}
return Ok(());
}
let canonical_package = self.path.canonicalize()?;
if let Some(includes) = include_list {
for name in includes {
if disabled.contains(name.as_str()) {
continue;
}
let resource_dir = type_dir.join(name);
if !resource_dir.is_dir() {
return Err(PackageDiscoveryError::MissingAsset {
package_name: self.manifest.name.clone(),
kind: spec.kind.to_string(),
name: name.clone(),
});
}
let marker = resource_dir.join(spec.marker);
if !marker.exists() {
return Err(PackageDiscoveryError::MissingAsset {
package_name: self.manifest.name.clone(),
kind: spec.kind.to_string(),
name: name.clone(),
});
}
let canonical_resource = resource_dir.canonicalize()?;
if !canonical_resource.starts_with(&canonical_package) {
return Err(PackageDiscoveryError::SecurityDiagnostic {
package_name: self.manifest.name.clone(),
path: canonical_resource,
reason: format!(
"resource path escapes package directory for {} '{}'",
spec.kind, name
),
});
}
resources.push(ComposedResource {
kind: spec.kind,
name: name.clone(),
path: resource_dir,
});
}
} else {
let entries = std::fs::read_dir(&type_dir)?;
for entry in entries {
let entry = entry?;
let resource_dir = entry.path();
if !resource_dir.is_dir() {
continue;
}
let resource_name = match resource_dir.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if disabled.contains(resource_name.as_str()) {
continue;
}
let marker = resource_dir.join(spec.marker);
if !marker.exists() {
continue;
}
let canonical_resource = resource_dir.canonicalize()?;
if !canonical_resource.starts_with(&canonical_package) {
return Err(PackageDiscoveryError::SecurityDiagnostic {
package_name: self.manifest.name.clone(),
path: canonical_resource,
reason: format!(
"resource path escapes package directory for {} '{}'",
spec.kind, resource_name
),
});
}
resources.push(ComposedResource {
kind: spec.kind,
name: resource_name,
path: resource_dir,
});
}
}
Ok(())
}
}
pub fn discover_packages(
layers: &[crate::resource::DiscoveryLayer],
) -> Result<Vec<PackageResource>, PackageDiscoveryError> {
let mut seen: std::collections::HashMap<String, PackageResource> =
std::collections::HashMap::new();
for layer in layers {
let scan_dir = layer.scan_dir();
if !scan_dir.is_dir() {
continue;
}
if scan_dir.join("package.toml").exists() {
discover_package_dir(&scan_dir, layer, &mut seen)?;
continue;
}
let entries = match std::fs::read_dir(&scan_dir) {
Ok(entries) => entries,
Err(e) => return Err(PackageDiscoveryError::Io(e)),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let pkg_toml = path.join("package.toml");
if !pkg_toml.exists() {
continue;
}
discover_package_dir(&path, layer, &mut seen)?;
}
}
let mut resources: Vec<PackageResource> = seen.into_values().collect();
resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Ok(resources)
}
fn discover_package_dir(
path: &Path,
layer: &crate::resource::DiscoveryLayer,
seen: &mut std::collections::HashMap<String, PackageResource>,
) -> Result<(), PackageDiscoveryError> {
let pkg_toml = path.join("package.toml");
let content = std::fs::read_to_string(&pkg_toml)?;
let manifest = PackageManifest::from_toml(&content, &pkg_toml)?;
let canonical = path.canonicalize()?;
match seen.get(&manifest.name) {
Some(existing) if layer.precedence == existing.layer_precedence => {
return Err(PackageDiscoveryError::DuplicateName {
name: manifest.name,
path: canonical,
});
}
Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
Some(_) | None => {
seen.insert(
manifest.name.clone(),
PackageResource {
manifest,
path: canonical,
package_toml_path: pkg_toml,
layer_precedence: layer.precedence,
},
);
}
}
Ok(())
}
pub fn package_composed_resource_layers(
packages: &[PackageResource],
) -> PackageComposedResourceLayers {
let mut result = PackageComposedResourceLayers::default();
let mut ordered: Vec<&PackageResource> = packages.iter().collect();
ordered.sort_by(|a, b| {
a.layer_precedence
.cmp(&b.layer_precedence)
.then_with(|| a.manifest.name.cmp(&b.manifest.name))
});
for package in ordered {
let mut resources = match package.compose() {
Ok(resources) => resources,
Err(e) => {
result
.diagnostics
.push(format!("package '{}': {e}", package.manifest.name));
continue;
}
};
resources.sort_by(|a, b| {
resource_kind_order(a.kind)
.cmp(&resource_kind_order(b.kind))
.then_with(|| a.name.cmp(&b.name))
});
for resource in resources {
let layer = crate::resource::DiscoveryLayer {
root: resource.path,
subdirectory: None,
precedence: package.layer_precedence,
};
match resource.kind {
ResourceKind::Extension => result.extensions.push(layer),
ResourceKind::Skill => result.skills.push(layer),
ResourceKind::Fragment => result.fragments.push(layer),
ResourceKind::Theme => result.themes.push(layer),
}
}
}
result
}
fn resource_kind_order(kind: ResourceKind) -> u8 {
match kind {
ResourceKind::Extension => 0,
ResourceKind::Skill => 1,
ResourceKind::Fragment => 2,
ResourceKind::Theme => 3,
}
}
pub struct PackageRegistry {
packages: Vec<PackageResource>,
}
impl PackageRegistry {
pub fn from_resources(packages: Vec<PackageResource>) -> Self {
Self { packages }
}
pub fn names(&self) -> Vec<&str> {
self.packages
.iter()
.map(|p| p.manifest.name.as_str())
.collect()
}
pub fn get(&self, name: &str) -> Option<&PackageResource> {
self.packages.iter().find(|p| p.manifest.name == name)
}
pub fn format_for_prompt(&self) -> String {
if self.packages.is_empty() {
return String::new();
}
let parts: Vec<String> = self
.packages
.iter()
.map(|p| {
let version = p
.manifest
.version
.as_deref()
.map(|v| format!(" v{v}"))
.unwrap_or_default();
format!(
"- {}: {}{}",
p.manifest.name, p.manifest.description, version
)
})
.collect();
parts.join("\n")
}
}
fn validate_package_name(name: &str, path: &Path) -> Result<(), PackageDiscoveryError> {
if name.len() > MAX_NAME_LEN {
return Err(PackageDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
name.len()
),
});
}
for ch in name.chars() {
let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
if !valid {
return Err(PackageDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name contains invalid character '{ch}': \
only lowercase a-z, 0-9, and hyphens are allowed"
),
});
}
}
Ok(())
}
fn validate_description(desc: &str, path: &Path) -> Result<(), PackageDiscoveryError> {
if desc.len() > MAX_DESCRIPTION_LEN {
return Err(PackageDiscoveryError::InvalidDescription {
path: path.to_path_buf(),
reason: format!(
"description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
({} found)",
desc.len()
),
});
}
Ok(())
}