Fix round 1 from PR review of the tree/catalog parsing: - collect_dir now rejects an entry whose name is ".", "..", empty, or contains "/", "\" or NUL before it becomes part of an item's rel_path — a crafted git tree could otherwise walk a file outside the item's own folder once that path is joined against the item root downstream. - collect_dir caps recursion at 32 directory levels and counts directories (not just files) toward MAX_ITEM_FILES, so a tree that is wide or deep rather than merely file-heavy is still bounded. - hook.json and plugins/.claude-plugin/marketplace.json are now rejected unparsed above 1 MiB, rather than handed to serde_json regardless of size. A pre-read size query (checking a blob's size before reading it) is deferred per controller ruling — this round reads the blob and checks its length before parsing, which is enough for the JSON-parsing DoS shape being closed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1101 lines
40 KiB
Rust
1101 lines
40 KiB
Rust
//! Reading a marketplace repo: which items it offers, and the files of one item.
|
|
//!
|
|
//! Layout (spec §1): `agents/<key>.md`, `skills/<key>/SKILL.md`,
|
|
//! `commands/<key>.md`, `hooks/<key>/hook.json`, and `plugins/` as a standard
|
|
//! Claude Code marketplace. Every item is validated here — key pattern,
|
|
//! symlinks, size and file-count limits, plugin sources that stay inside
|
|
//! `plugins/` — so nothing downstream ever sees a name or a file it would
|
|
//! have to distrust. A broken item is listed with its reason; it never stops
|
|
//! the rest of the repo from loading.
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::marketplace::tree::{hex, EntryKind, TreeView};
|
|
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind};
|
|
|
|
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
|
|
pub const MAX_ITEM_FILES: usize = 200;
|
|
/// Preview text is truncated to this many bytes (on a char boundary).
|
|
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
|
|
/// How many directory levels `collect_dir` will descend into an item before
|
|
/// giving up on it as invalid. A crafted tree can nest directories far deeper
|
|
/// than any real item would (or, over a network `TreeView`, be effectively
|
|
/// unbounded), so this is a hard stop rather than a performance nicety.
|
|
const MAX_ITEM_DEPTH: usize = 32;
|
|
/// `hooks/<key>/hook.json` and `plugins/.claude-plugin/marketplace.json` are
|
|
/// parsed as JSON before anything else about the item is known, so they are
|
|
/// capped and rejected *unparsed* well below `MAX_ITEM_BYTES` — a bound on
|
|
/// the whole item is not a bound on what one `serde_json::from_str` call is
|
|
/// asked to chew through.
|
|
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
|
|
|
|
const PLUGIN_CATALOG_PATH: &str = "plugins/.claude-plugin/marketplace.json";
|
|
|
|
/// Hook events Claude Code understands. A `hook.json` naming anything else is
|
|
/// invalid rather than silently ignored by Claude Code at runtime.
|
|
const HOOK_EVENTS: &[&str] = &[
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PermissionRequest",
|
|
"Notification",
|
|
"UserPromptSubmit",
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
"Stop",
|
|
"SubagentStart",
|
|
"SubagentStop",
|
|
"PreCompact",
|
|
];
|
|
|
|
/// One file of an item, path relative to the item root (for single-file
|
|
/// items: the file name).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ItemFile {
|
|
pub rel_path: String,
|
|
pub data: Vec<u8>,
|
|
pub executable: bool,
|
|
}
|
|
|
|
pub fn hook_dir(key: &str) -> String {
|
|
format!("/home/claude/.claude/triple-c/hooks/{}", key)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Parsing helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Minimal YAML front matter: `key: value` lines between leading `---`
|
|
/// fences. Returns `(fields, body)`. Quotes around values are stripped. No
|
|
/// front matter → no fields, the whole text is the body.
|
|
fn front_matter(text: &str) -> (Vec<(String, String)>, &str) {
|
|
let rest = match text
|
|
.strip_prefix("---\n")
|
|
.or_else(|| text.strip_prefix("---\r\n"))
|
|
{
|
|
Some(rest) => rest,
|
|
None => return (Vec::new(), text),
|
|
};
|
|
let mut fields = Vec::new();
|
|
let mut offset = 0;
|
|
for line in rest.split_inclusive('\n') {
|
|
offset += line.len();
|
|
let trimmed = line.trim_end_matches(['\n', '\r']);
|
|
if trimmed == "---" {
|
|
return (fields, &rest[offset..]);
|
|
}
|
|
if let Some((k, v)) = trimmed.split_once(':') {
|
|
let k = k.trim();
|
|
if !k.is_empty() && !k.starts_with(' ') && !line.starts_with(' ') {
|
|
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
|
|
fields.push((k.to_string(), v));
|
|
}
|
|
}
|
|
}
|
|
// Unterminated front matter: treat the whole file as body.
|
|
(Vec::new(), text)
|
|
}
|
|
|
|
fn field<'a>(fields: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
|
fields
|
|
.iter()
|
|
.find(|(k, _)| k == name)
|
|
.map(|(_, v)| v.as_str())
|
|
.filter(|v| !v.is_empty())
|
|
}
|
|
|
|
fn truncate_preview(text: &str) -> String {
|
|
if text.len() <= MAX_PREVIEW_BYTES {
|
|
return text.to_string();
|
|
}
|
|
let mut cut = MAX_PREVIEW_BYTES;
|
|
while !text.is_char_boundary(cut) {
|
|
cut -= 1;
|
|
}
|
|
format!("{}\n…(truncated)", &text[..cut])
|
|
}
|
|
|
|
fn read_utf8(tree: &dyn TreeView, path: &str) -> Result<Option<String>, String> {
|
|
match tree.read_file(path)? {
|
|
None => Ok(None),
|
|
Some(bytes) => String::from_utf8(bytes)
|
|
.map(Some)
|
|
.map_err(|_| format!("{} is not UTF-8 text", path)),
|
|
}
|
|
}
|
|
|
|
/// The shared reason a key fails [`is_valid_item_key`], used wherever a name
|
|
/// (agent/skill/command/hook/plugin key) is rejected so the wording doesn't
|
|
/// drift between the three call sites.
|
|
fn invalid_name_reason(key: &str) -> String {
|
|
format!(
|
|
"{:?} is not a valid name (letters, digits, '.', '_' and '-', starting with a letter or digit, at most 64)",
|
|
key
|
|
)
|
|
}
|
|
|
|
/// Reject a git tree entry name that could escape the item root once it is
|
|
/// joined into a `/`-separated relative path: `.` and `..` (traversal), an
|
|
/// empty name (nothing to join), and any name containing `/`, `\` or a NUL
|
|
/// byte (a path separator on this or another OS, or a string terminator in
|
|
/// C-based tooling downstream). `TreeView` implementations are trusted to
|
|
/// return real entries, but a git tree is repo-controlled content, not
|
|
/// something this app authored, so a hostile blob naming a tree entry `..`
|
|
/// must not turn into a file written outside the item's own folder.
|
|
fn valid_entry_name(name: &str) -> bool {
|
|
!name.is_empty()
|
|
&& name != "."
|
|
&& name != ".."
|
|
&& !name.contains('/')
|
|
&& !name.contains('\\')
|
|
&& !name.contains('\0')
|
|
}
|
|
|
|
/// Normalise a plugin `source` into a path under `plugins/`, refusing
|
|
/// anything that is not a plain relative path staying inside `plugins/`.
|
|
fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
|
|
let source = source.as_str().ok_or_else(|| {
|
|
"remote plugin sources are not supported — the plugin must live in this repo's plugins/ folder"
|
|
.to_string()
|
|
})?;
|
|
if source.starts_with('/') || source.contains('\\') || source.contains(':') {
|
|
return Err(format!(
|
|
"plugin source {:?} must be a relative path inside plugins/",
|
|
source
|
|
));
|
|
}
|
|
let mut parts = Vec::new();
|
|
for part in source.split('/') {
|
|
match part {
|
|
"" | "." => {}
|
|
".." => {
|
|
return Err(format!(
|
|
"plugin source {:?} must stay inside plugins/",
|
|
source
|
|
));
|
|
}
|
|
p => parts.push(p),
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
return Err(format!(
|
|
"plugin source {:?} must name a folder inside plugins/",
|
|
source
|
|
));
|
|
}
|
|
Ok(format!("plugins/{}", parts.join("/")))
|
|
}
|
|
|
|
fn read_plugin_catalog(tree: &dyn TreeView) -> Result<Option<Vec<serde_json::Value>>, String> {
|
|
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else {
|
|
return Ok(None);
|
|
};
|
|
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
|
return Err(format!(
|
|
"{} is larger than {} MiB",
|
|
PLUGIN_CATALOG_PATH,
|
|
MAX_MANIFEST_BYTES / (1024 * 1024)
|
|
));
|
|
}
|
|
let json: serde_json::Value = serde_json::from_str(&text)
|
|
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
|
|
let plugins = json
|
|
.get("plugins")
|
|
.and_then(|p| p.as_array())
|
|
.ok_or_else(|| format!("{} has no \"plugins\" array", PLUGIN_CATALOG_PATH))?;
|
|
Ok(Some(plugins.clone()))
|
|
}
|
|
|
|
/// Plugins only: the plugin's entry from `plugins/.claude-plugin/marketplace.json`.
|
|
pub fn plugin_catalog_entry(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
|
|
let entries =
|
|
read_plugin_catalog(tree)?.ok_or_else(|| format!("{} is missing", PLUGIN_CATALOG_PATH))?;
|
|
entries
|
|
.into_iter()
|
|
.find(|e| e.get("name").and_then(|n| n.as_str()) == Some(key))
|
|
.ok_or_else(|| format!("plugin {} is not in {}", key, PLUGIN_CATALOG_PATH))
|
|
}
|
|
|
|
/// Repo path of an item: a file for agents/commands, a folder otherwise.
|
|
fn item_path(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<String, String> {
|
|
if !is_valid_item_key(key) {
|
|
return Err(invalid_name_reason(key));
|
|
}
|
|
Ok(match kind {
|
|
ItemKind::Agent => format!("agents/{}.md", key),
|
|
ItemKind::Command => format!("commands/{}.md", key),
|
|
ItemKind::Skill => format!("skills/{}", key),
|
|
ItemKind::Hook => format!("hooks/{}", key),
|
|
ItemKind::Plugin => {
|
|
let entry = plugin_catalog_entry(tree, key)?;
|
|
plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null))?
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Recursively collect a folder's files, enforcing the item rules.
|
|
///
|
|
/// `depth` is how many directory levels below the item root this call is
|
|
/// (the root call is `0`); `entries_seen` counts directories *and* files
|
|
/// together against [`MAX_ITEM_FILES`] — a tree with few files but many
|
|
/// directories is just as much a resource-exhaustion shape as many files,
|
|
/// and only counting files would let it through uncapped.
|
|
fn collect_dir(
|
|
tree: &dyn TreeView,
|
|
root: &str,
|
|
rel: &str,
|
|
depth: usize,
|
|
entries_seen: &mut usize,
|
|
out: &mut Vec<ItemFile>,
|
|
total: &mut u64,
|
|
) -> Result<(), String> {
|
|
if depth > MAX_ITEM_DEPTH {
|
|
return Err(format!(
|
|
"is nested more than {} directories deep",
|
|
MAX_ITEM_DEPTH
|
|
));
|
|
}
|
|
let path = if rel.is_empty() {
|
|
root.to_string()
|
|
} else {
|
|
format!("{}/{}", root, rel)
|
|
};
|
|
let entries = tree
|
|
.list_dir(&path)?
|
|
.ok_or_else(|| format!("{} is not a folder", path))?;
|
|
for entry in entries {
|
|
if !valid_entry_name(&entry.name) {
|
|
return Err(format!(
|
|
"contains an entry with an invalid name ({:?})",
|
|
entry.name
|
|
));
|
|
}
|
|
let child_rel = if rel.is_empty() {
|
|
entry.name.clone()
|
|
} else {
|
|
format!("{}/{}", rel, entry.name)
|
|
};
|
|
match entry.kind {
|
|
EntryKind::Symlink => {
|
|
return Err(format!(
|
|
"contains a symlink ({}), which is not allowed",
|
|
child_rel
|
|
));
|
|
}
|
|
EntryKind::Other => {
|
|
return Err(format!(
|
|
"contains a submodule or special entry ({})",
|
|
child_rel
|
|
));
|
|
}
|
|
EntryKind::Dir => {
|
|
*entries_seen += 1;
|
|
if *entries_seen > MAX_ITEM_FILES {
|
|
return Err(format!("has more than {} files", MAX_ITEM_FILES));
|
|
}
|
|
collect_dir(tree, root, &child_rel, depth + 1, entries_seen, out, total)?
|
|
}
|
|
EntryKind::File => {
|
|
*entries_seen += 1;
|
|
if *entries_seen > MAX_ITEM_FILES {
|
|
return Err(format!("has more than {} files", MAX_ITEM_FILES));
|
|
}
|
|
let data = tree
|
|
.read_file(&format!("{}/{}", root, child_rel))?
|
|
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
|
|
*total += data.len() as u64;
|
|
if *total > MAX_ITEM_BYTES {
|
|
return Err(format!(
|
|
"is larger than {} MiB",
|
|
MAX_ITEM_BYTES / (1024 * 1024)
|
|
));
|
|
}
|
|
out.push(ItemFile {
|
|
rel_path: child_rel,
|
|
data,
|
|
executable: entry.executable,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Kind of the entry at `path`, looked up through its parent listing.
|
|
fn entry_kind(tree: &dyn TreeView, path: &str) -> Result<Option<(EntryKind, bool)>, String> {
|
|
let (parent, name) = match path.rsplit_once('/') {
|
|
Some((p, n)) => (p, n),
|
|
None => ("", path),
|
|
};
|
|
Ok(tree
|
|
.list_dir(parent)?
|
|
.and_then(|entries| entries.into_iter().find(|e| e.name == name))
|
|
.map(|e| (e.kind, e.executable)))
|
|
}
|
|
|
|
/// All files of one item. Err if the item is missing/invalid or breaks the limits.
|
|
pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<ItemFile>, String> {
|
|
let path = item_path(tree, kind, key)?;
|
|
match kind {
|
|
ItemKind::Agent | ItemKind::Command => {
|
|
let (entry, executable) =
|
|
entry_kind(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
|
|
match entry {
|
|
EntryKind::File => {}
|
|
EntryKind::Symlink => {
|
|
return Err(format!("{} is a symlink, which is not allowed", path))
|
|
}
|
|
_ => return Err(format!("{} is not a regular file", path)),
|
|
}
|
|
let data = tree
|
|
.read_file(&path)?
|
|
.ok_or_else(|| format!("{} is missing", path))?;
|
|
if data.len() as u64 > MAX_ITEM_BYTES {
|
|
return Err(format!(
|
|
"is larger than {} MiB",
|
|
MAX_ITEM_BYTES / (1024 * 1024)
|
|
));
|
|
}
|
|
Ok(vec![ItemFile {
|
|
rel_path: format!("{}.md", key),
|
|
data,
|
|
executable,
|
|
}])
|
|
}
|
|
ItemKind::Skill | ItemKind::Hook | ItemKind::Plugin => {
|
|
match entry_kind(tree, &path)? {
|
|
Some((EntryKind::Dir, _)) => {}
|
|
Some((EntryKind::Symlink, _)) => {
|
|
return Err(format!("{} is a symlink, which is not allowed", path))
|
|
}
|
|
Some(_) => return Err(format!("{} is not a folder", path)),
|
|
None => return Err(format!("{} is missing", path)),
|
|
}
|
|
let mut out = Vec::new();
|
|
let mut total = 0u64;
|
|
let mut entries_seen = 0usize;
|
|
collect_dir(tree, &path, "", 0, &mut entries_seen, &mut out, &mut total)?;
|
|
let required = match kind {
|
|
ItemKind::Skill => Some("SKILL.md"),
|
|
ItemKind::Hook => Some("hook.json"),
|
|
_ => None,
|
|
};
|
|
if let Some(required) = required {
|
|
if !out.iter().any(|f| f.rel_path == required) {
|
|
return Err(format!("{} has no {}", path, required));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Content fingerprint for update detection: changes iff the item's files or,
|
|
/// for plugins, its catalog entry change. `Ok(None)` when the item is absent.
|
|
pub fn item_fingerprint(
|
|
tree: &dyn TreeView,
|
|
kind: ItemKind,
|
|
key: &str,
|
|
) -> Result<Option<String>, String> {
|
|
if kind == ItemKind::Plugin {
|
|
let entry = match plugin_catalog_entry(tree, key) {
|
|
Ok(entry) => entry,
|
|
Err(_) => return Ok(None),
|
|
};
|
|
let path = match plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null))
|
|
{
|
|
Ok(path) => path,
|
|
Err(_) => return Ok(None),
|
|
};
|
|
let Some(dir_id) = tree.entry_id(&path)? else {
|
|
return Ok(None);
|
|
};
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(dir_id.as_bytes());
|
|
hasher.update([0]);
|
|
// serde_json's Map is ordered by key (no preserve_order), so this is canonical.
|
|
hasher.update(entry.to_string().as_bytes());
|
|
return Ok(Some(hex(&hasher.finalize())));
|
|
}
|
|
if !is_valid_item_key(key) {
|
|
return Ok(None);
|
|
}
|
|
let path = item_path(tree, kind, key)?;
|
|
tree.entry_id(&path)
|
|
}
|
|
|
|
fn substitute_hook_dir(value: &mut serde_json::Value, dir: &str) {
|
|
match value {
|
|
serde_json::Value::String(s) => {
|
|
if s.contains("${HOOK_DIR}") {
|
|
*s = s.replace("${HOOK_DIR}", dir);
|
|
}
|
|
}
|
|
serde_json::Value::Array(items) => {
|
|
items.iter_mut().for_each(|v| substitute_hook_dir(v, dir))
|
|
}
|
|
serde_json::Value::Object(map) => {
|
|
map.values_mut().for_each(|v| substitute_hook_dir(v, dir))
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Validate a `hooks` object and return the command strings it runs.
|
|
fn validate_hooks(hooks: &serde_json::Value) -> Result<Vec<String>, String> {
|
|
let map = hooks
|
|
.as_object()
|
|
.ok_or_else(|| "\"hooks\" must be an object keyed by event name".to_string())?;
|
|
if map.is_empty() {
|
|
return Err("\"hooks\" is empty".to_string());
|
|
}
|
|
let mut commands = Vec::new();
|
|
for (event, matchers) in map {
|
|
if !HOOK_EVENTS.contains(&event.as_str()) {
|
|
return Err(format!("unknown hook event {:?}", event));
|
|
}
|
|
let matchers = matchers
|
|
.as_array()
|
|
.ok_or_else(|| format!("\"{}\" must be an array", event))?;
|
|
for matcher in matchers {
|
|
let handlers = matcher
|
|
.get("hooks")
|
|
.and_then(|h| h.as_array())
|
|
.ok_or_else(|| format!("each \"{}\" entry needs a \"hooks\" array", event))?;
|
|
for handler in handlers {
|
|
let kind = handler.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
if kind.is_empty() {
|
|
return Err(format!("a \"{}\" hook has no \"type\"", event));
|
|
}
|
|
if kind == "command" {
|
|
let command = handler
|
|
.get("command")
|
|
.and_then(|c| c.as_str())
|
|
.filter(|c| !c.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
format!("a \"{}\" command hook has no \"command\"", event)
|
|
})?;
|
|
commands.push(command.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(commands)
|
|
}
|
|
|
|
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
|
|
let path = format!("hooks/{}/hook.json", key);
|
|
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
|
|
if text.len() as u64 > MAX_MANIFEST_BYTES {
|
|
return Err(format!(
|
|
"{} is larger than {} MiB",
|
|
path,
|
|
MAX_MANIFEST_BYTES / (1024 * 1024)
|
|
));
|
|
}
|
|
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
|
|
}
|
|
|
|
/// Hooks only: the parsed `hooks` object with `${HOOK_DIR}` substituted.
|
|
pub fn rendered_hook_settings(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
|
|
if !is_valid_item_key(key) {
|
|
return Err(format!("{:?} is not a valid hook name", key));
|
|
}
|
|
let json = read_hook_json(tree, key)?;
|
|
let mut hooks = json
|
|
.get("hooks")
|
|
.cloned()
|
|
.ok_or_else(|| format!("hooks/{}/hook.json has no \"hooks\" object", key))?;
|
|
validate_hooks(&hooks)?;
|
|
substitute_hook_dir(&mut hooks, &hook_dir(key));
|
|
Ok(hooks)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Catalog
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
fn item(kind: ItemKind, key: &str, path: String) -> CatalogItem {
|
|
CatalogItem {
|
|
kind,
|
|
key: key.to_string(),
|
|
name: key.to_string(),
|
|
description: String::new(),
|
|
path,
|
|
invalid: None,
|
|
hook_commands: Vec::new(),
|
|
preview: String::new(),
|
|
}
|
|
}
|
|
|
|
/// Fill name/description/preview from a markdown file with front matter.
|
|
fn describe_markdown(it: &mut CatalogItem, text: &str, first_line_fallback: bool) {
|
|
let (fields, body) = front_matter(text);
|
|
if let Some(name) = field(&fields, "name") {
|
|
it.name = name.to_string();
|
|
}
|
|
if let Some(desc) = field(&fields, "description") {
|
|
it.description = desc.to_string();
|
|
} else if first_line_fallback {
|
|
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
|
|
it.description = line.trim_start_matches('#').trim().to_string();
|
|
}
|
|
}
|
|
it.preview = truncate_preview(body.trim_start_matches(['\n', '\r']));
|
|
}
|
|
|
|
/// Mark `it` invalid when its files break the rules.
|
|
fn validate_files(tree: &dyn TreeView, it: &mut CatalogItem) {
|
|
if it.invalid.is_some() {
|
|
return;
|
|
}
|
|
if let Err(reason) = item_files(tree, it.kind, &it.key) {
|
|
it.invalid = Some(reason);
|
|
}
|
|
}
|
|
|
|
fn parse_single_files(
|
|
tree: &dyn TreeView,
|
|
kind: ItemKind,
|
|
folder: &str,
|
|
out: &mut Vec<CatalogItem>,
|
|
) {
|
|
let entries = match tree.list_dir(folder) {
|
|
Ok(Some(entries)) => entries,
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
let mut it = item(kind, folder, folder.to_string());
|
|
it.invalid = Some(e);
|
|
out.push(it);
|
|
return;
|
|
}
|
|
};
|
|
for entry in entries {
|
|
let Some(stem) = entry.name.strip_suffix(".md") else {
|
|
continue;
|
|
};
|
|
let mut it = item(kind, stem, format!("{}/{}", folder, entry.name));
|
|
if !is_valid_item_key(stem) {
|
|
it.invalid = Some(invalid_name_reason(stem));
|
|
out.push(it);
|
|
continue;
|
|
}
|
|
match entry.kind {
|
|
EntryKind::File => match read_utf8(tree, &it.path) {
|
|
Ok(Some(text)) => describe_markdown(&mut it, &text, kind == ItemKind::Command),
|
|
Ok(None) => it.invalid = Some(format!("{} is missing", it.path)),
|
|
Err(e) => it.invalid = Some(e),
|
|
},
|
|
EntryKind::Symlink => {
|
|
it.invalid = Some(format!("{} is a symlink, which is not allowed", it.path))
|
|
}
|
|
_ => it.invalid = Some(format!("{} is not a regular file", it.path)),
|
|
}
|
|
validate_files(tree, &mut it);
|
|
out.push(it);
|
|
}
|
|
}
|
|
|
|
fn parse_folders(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Vec<CatalogItem>) {
|
|
let entries = match tree.list_dir(folder) {
|
|
Ok(Some(entries)) => entries,
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
let mut it = item(kind, folder, folder.to_string());
|
|
it.invalid = Some(e);
|
|
out.push(it);
|
|
return;
|
|
}
|
|
};
|
|
for entry in entries {
|
|
if entry.kind == EntryKind::File {
|
|
continue; // e.g. a README.md next to the item folders
|
|
}
|
|
let mut it = item(kind, &entry.name, format!("{}/{}", folder, entry.name));
|
|
if !is_valid_item_key(&entry.name) {
|
|
it.invalid = Some(invalid_name_reason(&entry.name));
|
|
out.push(it);
|
|
continue;
|
|
}
|
|
if entry.kind == EntryKind::Symlink {
|
|
it.invalid = Some(format!("{} is a symlink, which is not allowed", it.path));
|
|
out.push(it);
|
|
continue;
|
|
}
|
|
match kind {
|
|
ItemKind::Skill => match read_utf8(tree, &format!("{}/SKILL.md", it.path)) {
|
|
Ok(Some(text)) => describe_markdown(&mut it, &text, false),
|
|
Ok(None) => it.invalid = Some(format!("{} has no SKILL.md", it.path)),
|
|
Err(e) => it.invalid = Some(e),
|
|
},
|
|
ItemKind::Hook => match read_hook_json(tree, &entry.name) {
|
|
Ok(json) => {
|
|
if let Some(name) = json
|
|
.get("name")
|
|
.and_then(|n| n.as_str())
|
|
.filter(|n| !n.is_empty())
|
|
{
|
|
it.name = name.to_string();
|
|
}
|
|
if let Some(desc) = json.get("description").and_then(|d| d.as_str()) {
|
|
it.description = desc.to_string();
|
|
}
|
|
match rendered_hook_settings(tree, &entry.name) {
|
|
Ok(hooks) => match validate_hooks(&hooks) {
|
|
Ok(commands) => it.hook_commands = commands,
|
|
Err(e) => it.invalid = Some(e),
|
|
},
|
|
Err(e) => it.invalid = Some(e),
|
|
}
|
|
}
|
|
Err(e) => it.invalid = Some(e),
|
|
},
|
|
_ => {}
|
|
}
|
|
validate_files(tree, &mut it);
|
|
out.push(it);
|
|
}
|
|
}
|
|
|
|
fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
|
|
let entries = match read_plugin_catalog(tree) {
|
|
Ok(Some(entries)) => entries,
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
let mut it = item(ItemKind::Plugin, "catalog", PLUGIN_CATALOG_PATH.to_string());
|
|
it.name = PLUGIN_CATALOG_PATH.to_string();
|
|
it.invalid = Some(e);
|
|
out.push(it);
|
|
return;
|
|
}
|
|
};
|
|
for entry in entries {
|
|
let key = entry
|
|
.get("name")
|
|
.and_then(|n| n.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let mut it = item(ItemKind::Plugin, &key, PLUGIN_CATALOG_PATH.to_string());
|
|
if let Some(desc) = entry.get("description").and_then(|d| d.as_str()) {
|
|
it.description = desc.to_string();
|
|
}
|
|
if !is_valid_item_key(&key) {
|
|
it.invalid = Some(format!("plugin name {:?} is not a valid name", key));
|
|
out.push(it);
|
|
continue;
|
|
}
|
|
match plugin_source_path(entry.get("source").unwrap_or(&serde_json::Value::Null)) {
|
|
Ok(path) => {
|
|
it.path = path.clone();
|
|
if let Ok(Some(children)) = tree.list_dir(&path) {
|
|
it.preview = children
|
|
.iter()
|
|
.map(|c| {
|
|
if c.kind == EntryKind::Dir {
|
|
format!("{}/", c.name)
|
|
} else {
|
|
c.name.clone()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
}
|
|
}
|
|
Err(e) => it.invalid = Some(e),
|
|
}
|
|
validate_files(tree, &mut it);
|
|
out.push(it);
|
|
}
|
|
}
|
|
|
|
/// Parse every item in the repo. Never fails as a whole; broken items carry `invalid`.
|
|
/// Order: agents, skills, commands, hooks, plugins; each in listing order.
|
|
pub fn parse_catalog(tree: &dyn TreeView) -> Vec<CatalogItem> {
|
|
let mut out = Vec::new();
|
|
parse_single_files(tree, ItemKind::Agent, "agents", &mut out);
|
|
parse_folders(tree, ItemKind::Skill, "skills", &mut out);
|
|
parse_single_files(tree, ItemKind::Command, "commands", &mut out);
|
|
parse_folders(tree, ItemKind::Hook, "hooks", &mut out);
|
|
parse_plugins(tree, &mut out);
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::marketplace::tree::MemTree;
|
|
|
|
const HOOK_JSON: &str = r#"{
|
|
"name": "notify-on-stop",
|
|
"description": "Ping when Claude stops",
|
|
"hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "${HOOK_DIR}/notify.sh" } ] } ] }
|
|
}"#;
|
|
|
|
const PLUGIN_CATALOG: &str = r#"{
|
|
"name": "example",
|
|
"owner": { "name": "t" },
|
|
"plugins": [
|
|
{ "name": "example-plugin", "source": "./example-plugin", "description": "Adds a skill" }
|
|
]
|
|
}"#;
|
|
|
|
fn full_repo() -> MemTree {
|
|
MemTree::new()
|
|
.file("README.md", "# repo")
|
|
.file(
|
|
"agents/code-reviewer.md",
|
|
"---\nname: code-reviewer\ndescription: Reviews diffs\n---\nYou review code.\n",
|
|
)
|
|
.file(
|
|
"skills/example-skill/SKILL.md",
|
|
"---\nname: example-skill\ndescription: \"Says hi\"\n---\nSay hi.\n",
|
|
)
|
|
.file("skills/example-skill/ref/notes.md", "notes")
|
|
.file(
|
|
"commands/example-command.md",
|
|
"# Summarise the branch\n\nDo it.\n",
|
|
)
|
|
.file("hooks/notify-on-stop/hook.json", HOOK_JSON)
|
|
.exec_file("hooks/notify-on-stop/notify.sh", "#!/bin/sh\necho done\n")
|
|
.file("plugins/.claude-plugin/marketplace.json", PLUGIN_CATALOG)
|
|
.file(
|
|
"plugins/example-plugin/.claude-plugin/plugin.json",
|
|
r#"{"name":"example-plugin"}"#,
|
|
)
|
|
.file(
|
|
"plugins/example-plugin/skills/hello/SKILL.md",
|
|
"---\nname: hello\n---\nhi",
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn parses_every_kind() {
|
|
let items = parse_catalog(&full_repo());
|
|
let summary: Vec<_> = items
|
|
.iter()
|
|
.map(|i| (i.kind, i.key.as_str(), i.invalid.as_deref()))
|
|
.collect();
|
|
assert_eq!(
|
|
summary,
|
|
vec![
|
|
(ItemKind::Agent, "code-reviewer", None),
|
|
(ItemKind::Skill, "example-skill", None),
|
|
(ItemKind::Command, "example-command", None),
|
|
(ItemKind::Hook, "notify-on-stop", None),
|
|
(ItemKind::Plugin, "example-plugin", None),
|
|
]
|
|
);
|
|
assert_eq!(items[0].description, "Reviews diffs");
|
|
assert_eq!(items[0].preview, "You review code.\n");
|
|
assert_eq!(items[1].description, "Says hi");
|
|
assert_eq!(items[2].description, "Summarise the branch");
|
|
assert_eq!(items[3].name, "notify-on-stop");
|
|
assert_eq!(
|
|
items[3].hook_commands,
|
|
vec!["/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh".to_string()]
|
|
);
|
|
assert_eq!(items[4].path, "plugins/example-plugin");
|
|
assert_eq!(items[4].preview, ".claude-plugin/\nskills/");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_repo_has_no_items() {
|
|
assert!(parse_catalog(&MemTree::new().file("README.md", "x")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn name_falls_back_to_the_file_stem() {
|
|
let t = MemTree::new().file("agents/plain.md", "no front matter here");
|
|
let items = parse_catalog(&t);
|
|
assert_eq!(items[0].name, "plain");
|
|
assert_eq!(items[0].description, "");
|
|
assert!(items[0].invalid.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_symlink_items() {
|
|
let t = MemTree::new()
|
|
.symlink("agents/evil.md", "/etc/passwd")
|
|
.file("skills/s/SKILL.md", "x")
|
|
.symlink("skills/s/link", "../../..")
|
|
.symlink("hooks/h", "../skills/s");
|
|
let items = parse_catalog(&t);
|
|
assert_eq!(items.len(), 3);
|
|
for it in &items {
|
|
let reason = it
|
|
.invalid
|
|
.as_deref()
|
|
.unwrap_or_else(|| panic!("{} should be invalid", it.key));
|
|
assert!(reason.contains("symlink"), "{}: {}", it.key, reason);
|
|
}
|
|
assert!(item_files(&t, ItemKind::Skill, "s").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_escaping_plugin_source() {
|
|
for source in [
|
|
r#""../outside""#,
|
|
r#""./a/../../b""#,
|
|
r#""/abs""#,
|
|
r#""https://evil.example/x.git""#,
|
|
r#"{"source":"github","repo":"x/y"}"#,
|
|
r#""""#,
|
|
] {
|
|
let catalog = format!(r#"{{"plugins":[{{"name":"p","source":{}}}]}}"#, source);
|
|
let t = MemTree::new()
|
|
.file("plugins/.claude-plugin/marketplace.json", &catalog)
|
|
.file("plugins/p/x.md", "x")
|
|
.file("outside/x.md", "x");
|
|
let items = parse_catalog(&t);
|
|
assert!(
|
|
items[0].invalid.is_some(),
|
|
"source {} should be refused",
|
|
source
|
|
);
|
|
assert!(item_files(&t, ItemKind::Plugin, "p").is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_bad_keys() {
|
|
let t = MemTree::new()
|
|
.file("agents/-rf.md", "x")
|
|
.file("agents/a b.md", "x")
|
|
.file("skills/$(id)/SKILL.md", "x")
|
|
.file(
|
|
"plugins/.claude-plugin/marketplace.json",
|
|
r#"{"plugins":[{"name":"bad;name","source":"./p"}]}"#,
|
|
)
|
|
.file("plugins/p/x", "x");
|
|
let items = parse_catalog(&t);
|
|
assert_eq!(items.len(), 4);
|
|
assert!(items.iter().all(|i| i.invalid.is_some()), "{:?}", items);
|
|
assert!(item_files(&t, ItemKind::Agent, "-rf").is_err());
|
|
assert!(item_files(&t, ItemKind::Skill, "$(id)").is_err());
|
|
assert!(item_files(&t, ItemKind::Agent, "../x").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn enforces_item_limits() {
|
|
let mut many = MemTree::new().file("skills/big/SKILL.md", "x");
|
|
for i in 0..MAX_ITEM_FILES {
|
|
many = many.file(&format!("skills/big/f{}.txt", i), "x");
|
|
}
|
|
let err = item_files(&many, ItemKind::Skill, "big").unwrap_err();
|
|
assert!(err.contains("more than 200 files"), "{}", err);
|
|
|
|
let huge = "x".repeat(MAX_ITEM_BYTES as usize + 1);
|
|
let t = MemTree::new().file("agents/huge.md", &huge);
|
|
assert!(item_files(&t, ItemKind::Agent, "huge")
|
|
.unwrap_err()
|
|
.contains("larger than 2 MiB"));
|
|
assert!(parse_catalog(&t)[0].invalid.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_tree_entries_whose_name_could_escape_the_item() {
|
|
for bad_name in ["..", ".", "", "a\0b"] {
|
|
let t = MemTree::new()
|
|
.file("skills/s/SKILL.md", "x")
|
|
.raw_named_file("skills/s", bad_name, "evil");
|
|
let err = item_files(&t, ItemKind::Skill, "s").unwrap_err();
|
|
assert!(
|
|
err.contains("invalid name") || err.contains("invalid entry"),
|
|
"{:?}: {}",
|
|
bad_name,
|
|
err
|
|
);
|
|
let items = parse_catalog(&t);
|
|
let skill = items.iter().find(|i| i.key == "s").unwrap();
|
|
assert!(
|
|
skill.invalid.is_some(),
|
|
"{:?} should mark the item invalid",
|
|
bad_name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn directories_count_toward_the_item_file_limit() {
|
|
// Well under MAX_ITEM_FILES by file count alone (151 files), but 150
|
|
// directories on top of that pushes total entries past the limit —
|
|
// a shape the old "only count files" logic let through.
|
|
let mut t = MemTree::new().file("skills/wide/SKILL.md", "x");
|
|
for i in 0..150 {
|
|
t = t.file(&format!("skills/wide/d{}/f.txt", i), "x");
|
|
}
|
|
let err = item_files(&t, ItemKind::Skill, "wide").unwrap_err();
|
|
assert!(err.contains("more than 200 files"), "{}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn item_folders_cannot_nest_past_the_depth_cap() {
|
|
let mut nested = "skills/deep".to_string();
|
|
for i in 0..40 {
|
|
nested = format!("{}/d{}", nested, i);
|
|
}
|
|
let t = MemTree::new()
|
|
.file("skills/deep/SKILL.md", "x")
|
|
.file(&format!("{}/leaf.txt", nested), "x");
|
|
let err = item_files(&t, ItemKind::Skill, "deep").unwrap_err();
|
|
assert!(err.contains("nested more than 32"), "{}", err);
|
|
assert!(parse_catalog(&t)[0].invalid.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_json_over_1_mib_is_rejected_without_being_parsed() {
|
|
let huge = "x".repeat(MAX_MANIFEST_BYTES as usize + 1);
|
|
let t = MemTree::new().file("hooks/big/hook.json", &huge);
|
|
let items = parse_catalog(&t);
|
|
let hook = items.iter().find(|i| i.key == "big").unwrap();
|
|
assert!(
|
|
hook.invalid
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("larger than 1 MiB"),
|
|
"{:?}",
|
|
hook.invalid
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_catalog_over_1_mib_is_rejected_without_being_parsed() {
|
|
let huge = "x".repeat(MAX_MANIFEST_BYTES as usize + 1);
|
|
let t = MemTree::new().file("plugins/.claude-plugin/marketplace.json", &huge);
|
|
let items = parse_catalog(&t);
|
|
assert_eq!(items.len(), 1);
|
|
assert!(
|
|
items[0]
|
|
.invalid
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("larger than 1 MiB"),
|
|
"{:?}",
|
|
items[0].invalid
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hooks_must_name_known_events_and_commands() {
|
|
let t = MemTree::new()
|
|
.file(
|
|
"hooks/a/hook.json",
|
|
r#"{"hooks":{"NotAnEvent":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
|
|
)
|
|
.file(
|
|
"hooks/b/hook.json",
|
|
r#"{"hooks":{"Stop":[{"hooks":[{"type":"command"}]}]}}"#,
|
|
)
|
|
.file("hooks/c/hook.json", "not json")
|
|
.file("hooks/d/other.txt", "no hook.json");
|
|
let items = parse_catalog(&t);
|
|
assert_eq!(items.len(), 4);
|
|
assert!(items[0]
|
|
.invalid
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("unknown hook event"));
|
|
assert!(items[1]
|
|
.invalid
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("no \"command\""));
|
|
assert!(items[2]
|
|
.invalid
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("not valid JSON"));
|
|
assert!(items[3].invalid.as_deref().unwrap().contains("missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn broken_plugin_catalog_is_one_invalid_entry() {
|
|
let t = MemTree::new()
|
|
.file("agents/ok.md", "x")
|
|
.file("plugins/.claude-plugin/marketplace.json", "{");
|
|
let items = parse_catalog(&t);
|
|
assert_eq!(items.len(), 2);
|
|
assert!(items[0].invalid.is_none());
|
|
assert!(items[1]
|
|
.invalid
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("not valid JSON"));
|
|
}
|
|
|
|
#[test]
|
|
fn item_files_are_relative_to_the_item_and_keep_exec_bits() {
|
|
let t = full_repo();
|
|
let agent = item_files(&t, ItemKind::Agent, "code-reviewer").unwrap();
|
|
assert_eq!(agent.len(), 1);
|
|
assert_eq!(agent[0].rel_path, "code-reviewer.md");
|
|
|
|
let hook = item_files(&t, ItemKind::Hook, "notify-on-stop").unwrap();
|
|
let names: Vec<_> = hook
|
|
.iter()
|
|
.map(|f| (f.rel_path.as_str(), f.executable))
|
|
.collect();
|
|
assert_eq!(names, vec![("hook.json", false), ("notify.sh", true)]);
|
|
|
|
let skill = item_files(&t, ItemKind::Skill, "example-skill").unwrap();
|
|
assert!(skill.iter().any(|f| f.rel_path == "ref/notes.md"));
|
|
|
|
let plugin = item_files(&t, ItemKind::Plugin, "example-plugin").unwrap();
|
|
assert!(plugin.iter().any(|f| f.rel_path == "skills/hello/SKILL.md"));
|
|
}
|
|
|
|
#[test]
|
|
fn fingerprint_tracks_the_item_only() {
|
|
let a = full_repo();
|
|
let b = full_repo().file("agents/code-reviewer.md", "changed");
|
|
for (kind, key) in [
|
|
(ItemKind::Skill, "example-skill"),
|
|
(ItemKind::Hook, "notify-on-stop"),
|
|
(ItemKind::Plugin, "example-plugin"),
|
|
] {
|
|
assert_eq!(
|
|
item_fingerprint(&a, kind, key).unwrap(),
|
|
item_fingerprint(&b, kind, key).unwrap()
|
|
);
|
|
}
|
|
assert_ne!(
|
|
item_fingerprint(&a, ItemKind::Agent, "code-reviewer").unwrap(),
|
|
item_fingerprint(&b, ItemKind::Agent, "code-reviewer").unwrap()
|
|
);
|
|
assert_eq!(
|
|
item_fingerprint(&a, ItemKind::Agent, "absent").unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_fingerprint_changes_with_its_catalog_entry() {
|
|
let a = full_repo();
|
|
let b = full_repo().file(
|
|
"plugins/.claude-plugin/marketplace.json",
|
|
&PLUGIN_CATALOG.replace("Adds a skill", "Adds two skills"),
|
|
);
|
|
assert_ne!(
|
|
item_fingerprint(&a, ItemKind::Plugin, "example-plugin").unwrap(),
|
|
item_fingerprint(&b, ItemKind::Plugin, "example-plugin").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_settings_are_rendered_with_the_install_dir() {
|
|
let hooks = rendered_hook_settings(&full_repo(), "notify-on-stop").unwrap();
|
|
assert_eq!(
|
|
hooks["Stop"][0]["hooks"][0]["command"],
|
|
"/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh"
|
|
);
|
|
assert_eq!(hook_dir("x"), "/home/claude/.claude/triple-c/hooks/x");
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_catalog_entry_is_returned_verbatim() {
|
|
let entry = plugin_catalog_entry(&full_repo(), "example-plugin").unwrap();
|
|
assert_eq!(entry["description"], "Adds a skill");
|
|
assert!(plugin_catalog_entry(&full_repo(), "nope").is_err());
|
|
}
|
|
}
|