mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(plugins): keep catalog sources inside the catalog and names unambiguous
F01-03 (Refs #6561), F01-04 catalog half (Refs #6556). F01-03: `resolve_spec` joined a relative local source to the catalog directory without checking `..`, and passed an absolute local source through unchanged, so catalog metadata could point the installer at any directory on the machine (Codex `{"source":"local","path":"/etc"}`, Claude `./../x`, Kimi raw paths). A catalog local source must now be relative with only normal/`.` components; otherwise the candidate resolves to Unsupported with the reason. Installing an outside directory stays an explicit `/plugin install path:...`. F01-04 (catalog): the Codewhale-native parser stored entry names verbatim (control characters, unbounded length) while the other formats normalize to plugin names, and no format detected duplicate names, although install, `/plugin show`, and registry lookup all resolve by name (first match wins). Native names must satisfy the plugin-name rules or the entry is dropped with INVALID_NAME; after every format's parse, entries sharing a name each get a DUPLICATE_NAME error, so none of them is installable. Validation: new tests `catalog_local_sources_cannot_leave_the_catalog_directory` and `catalog_names_are_validated_and_duplicates_fail_closed`; all 33 plugins::marketplace tests pass (incl. the bundled first-party catalog: 0 errors). Focused run: `403 passed; 1 failed` (failure unrelated: T07-01 fixture). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
88cdc1ca5c
commit
d940a3e894
4 files changed
+153
-16
No files matched your search
@@ -8,7 +8,7 @@
|
||||
//! command and the HTTP API can never disagree about what would be fetched.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use super::parsers::{self, MarketplaceDocument};
|
||||
use super::store::StoredMarketplaceCatalog;
|
||||
@@ -151,17 +151,18 @@ pub fn resolve_candidate_install<'a>(
|
||||
};
|
||||
}
|
||||
match &candidate.install_plan {
|
||||
MarketplaceInstallPlan::Supported { spec, source_kind } => {
|
||||
CatalogInstallResolution::Supported {
|
||||
spec: resolve_spec(
|
||||
&entry.source_path,
|
||||
entry.catalog.format,
|
||||
&candidate.source,
|
||||
spec,
|
||||
),
|
||||
MarketplaceInstallPlan::Supported { spec, source_kind } => match resolve_spec(
|
||||
&entry.source_path,
|
||||
entry.catalog.format,
|
||||
&candidate.source,
|
||||
spec,
|
||||
) {
|
||||
Ok(spec) => CatalogInstallResolution::Supported {
|
||||
spec,
|
||||
source_kind: source_kind.clone(),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(reason) => CatalogInstallResolution::Unsupported { reason },
|
||||
},
|
||||
MarketplaceInstallPlan::Unsupported { reason, .. } => {
|
||||
CatalogInstallResolution::Unsupported {
|
||||
reason: reason.clone(),
|
||||
@@ -170,14 +171,28 @@ pub fn resolve_candidate_install<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// A catalog's local source must name a directory inside the catalog's own
|
||||
/// tree. An absolute path, a root or drive prefix, or any `..` component would
|
||||
/// let catalog metadata point the installer at an arbitrary directory on this
|
||||
/// machine, so such an entry is not installable from the catalog; installing a
|
||||
/// directory outside it stays an explicit `/plugin install path:...`.
|
||||
fn resolve_spec(
|
||||
source_path: &str,
|
||||
format: MarketplaceFormat,
|
||||
source: &MarketplaceSourceSpec,
|
||||
spec: &str,
|
||||
) -> String {
|
||||
) -> Result<String, String> {
|
||||
if let MarketplaceSourceSpec::LocalPath { path } = source
|
||||
&& !path
|
||||
.components()
|
||||
.all(|part| matches!(part, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(format!(
|
||||
"Local catalog source `{}` leaves the catalog directory; only paths inside it (no absolute paths or `..`) are installable from a catalog",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if let MarketplaceSourceSpec::LocalPath { path } = source
|
||||
&& path.is_relative()
|
||||
&& let Some(dir) = Path::new(source_path).parent()
|
||||
{
|
||||
// Claude keeps its catalog in a manifest-only metadata directory;
|
||||
@@ -189,9 +204,9 @@ fn resolve_spec(
|
||||
} else {
|
||||
dir
|
||||
};
|
||||
return format!("path:{}", dir.join(path).display());
|
||||
return Ok(format!("path:{}", dir.join(path).display()));
|
||||
}
|
||||
spec.to_string()
|
||||
Ok(spec.to_string())
|
||||
}
|
||||
|
||||
/// Resolve a user-supplied document path to an existing regular file without
|
||||
|
||||
@@ -171,6 +171,23 @@ fn parse_codewhale_entry(
|
||||
));
|
||||
return None;
|
||||
};
|
||||
// The name is the candidate's identity (registry lookup, `/plugin show`,
|
||||
// install), so it is held to the plugin-name rules the other formats
|
||||
// normalize to, rather than stored verbatim with controls or unbounded.
|
||||
if crate::plugins::manifest::validate_plugin_name(name).is_err()
|
||||
&& !crate::plugins::agent_plugin::is_standard_plugin_name(name)
|
||||
{
|
||||
diagnostics.push(MarketplaceDiagnostic::error(
|
||||
"INVALID_NAME",
|
||||
format!(
|
||||
"Codewhale plugin at index {index} has an invalid name {:?}: use lowercase letters, digits, and internal hyphens or dots",
|
||||
name.chars().take(80).collect::<String>()
|
||||
),
|
||||
None,
|
||||
Some(index),
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let name = name.to_string();
|
||||
|
||||
let (source, bad_source) = str_field(obj, "source");
|
||||
|
||||
@@ -77,12 +77,39 @@ pub fn parse_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
|
||||
explicit => explicit,
|
||||
};
|
||||
|
||||
match format {
|
||||
let mut catalog = match format {
|
||||
MarketplaceFormat::Kimi => kimi::parse_kimi_catalog(document),
|
||||
MarketplaceFormat::Claude => claude::parse_claude_catalog(document),
|
||||
MarketplaceFormat::Codex => codex::parse_codex_catalog(document),
|
||||
MarketplaceFormat::Codewhale => codewhale::parse_codewhale_catalog(document),
|
||||
MarketplaceFormat::Auto => unreachable!("resolved above"),
|
||||
};
|
||||
flag_duplicate_names(&mut catalog);
|
||||
catalog
|
||||
}
|
||||
|
||||
/// A candidate's name is its identity: `/plugin show`, install, and registry
|
||||
/// lookups all resolve by it, and a lookup returns the first match. Entries
|
||||
/// sharing a name (after each format's normalization) are ambiguous, so every
|
||||
/// one of them carries an error and none installs from the catalog.
|
||||
fn flag_duplicate_names(catalog: &mut super::types::MarketplaceCatalog) {
|
||||
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for candidate in &catalog.candidates {
|
||||
*counts.entry(candidate.name.clone()).or_default() += 1;
|
||||
}
|
||||
for candidate in &mut catalog.candidates {
|
||||
let count = counts.get(&candidate.name).copied().unwrap_or(0);
|
||||
if count > 1 {
|
||||
candidate.diagnostics.push(MarketplaceDiagnostic::error(
|
||||
"DUPLICATE_NAME",
|
||||
format!(
|
||||
"{count} catalog entries are named `{}`; the entry's identity is ambiguous",
|
||||
candidate.name
|
||||
),
|
||||
Some(candidate.name.clone()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -607,3 +607,81 @@ fn claude_relative_install_source_is_rooted_outside_catalog_metadata() {
|
||||
tmp.path().join("external_plugins/linear")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_names_are_validated_and_duplicates_fail_closed() {
|
||||
let body = r#"{
|
||||
"name": "team",
|
||||
"plugins": [
|
||||
{"name": "fmt", "source": "github:owner/fmt"},
|
||||
{"name": "fmt", "source": "github:someone-else/fmt"},
|
||||
{"name": "bad\u001b[31mname", "source": "github:owner/x"},
|
||||
{"name": "ok", "source": "github:owner/ok"}
|
||||
]
|
||||
}"#;
|
||||
let catalog = parse_auto("native", body);
|
||||
assert_eq!(catalog.format, MarketplaceFormat::Codewhale);
|
||||
assert_eq!(
|
||||
catalog.total_candidates(),
|
||||
3,
|
||||
"a control-bearing name never becomes a candidate"
|
||||
);
|
||||
assert!(catalog.diagnostics.iter().any(|d| d.code == "INVALID_NAME"));
|
||||
let duplicates: Vec<_> = catalog
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.name == "fmt")
|
||||
.collect();
|
||||
assert_eq!(duplicates.len(), 2);
|
||||
for candidate in duplicates {
|
||||
assert!(
|
||||
candidate
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == "DUPLICATE_NAME"),
|
||||
"an ambiguous identity must not be installable"
|
||||
);
|
||||
assert!(candidate.has_errors());
|
||||
}
|
||||
assert!(!catalog.candidate_by_name("ok").unwrap().has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_local_sources_cannot_leave_the_catalog_directory() {
|
||||
use super::document::{
|
||||
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("marketplace.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"name":"n","plugins":[
|
||||
{"name":"absolute","source":{"source":"local","path":"/etc"}},
|
||||
{"name":"parent","source":{"source":"local","path":"./plugins/../../outside"}},
|
||||
{"name":"inside","source":{"source":"local","path":"./plugins/inside"}}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = load_catalog_document("n", tmp.path(), path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(loaded.entry.catalog.format, MarketplaceFormat::Codex);
|
||||
let registry = crate::plugins::PluginRegistry::empty(tmp.path());
|
||||
for name in ["absolute", "parent"] {
|
||||
let candidate = loaded.entry.catalog.candidate_by_name(name).unwrap();
|
||||
match resolve_candidate_install(&loaded.entry, candidate, ®istry) {
|
||||
CatalogInstallResolution::Unsupported { reason } => {
|
||||
assert!(reason.contains("leaves the catalog directory"), "{reason}");
|
||||
}
|
||||
_ => panic!("`{name}` must not resolve outside the catalog"),
|
||||
}
|
||||
}
|
||||
let inside = loaded.entry.catalog.candidate_by_name("inside").unwrap();
|
||||
let CatalogInstallResolution::Supported { spec, .. } =
|
||||
resolve_candidate_install(&loaded.entry, inside, ®istry)
|
||||
else {
|
||||
panic!("a path inside the catalog stays installable")
|
||||
};
|
||||
assert_eq!(
|
||||
std::path::Path::new(spec.strip_prefix("path:").unwrap()),
|
||||
tmp.path().join("./plugins/inside")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user