返回 CodeWhale
document.rs
根目录 / crates / tui / src / plugins / marketplace / document.rs
1 //! Local catalog document loading shared by the `/plugin marketplace add`
2 //! command and the Runtime API marketplace endpoints (#5311 surface).
3 //!
4 //! One loader so both entry points apply the same rules: LOCAL file only
5 //! (never network), bounded size, no symlink documents, strict per-format
6 //! parsing, and refusal to persist a document that parsed to nothing but
7 //! errors. Candidate→install-spec resolution also lives here so the TUI
8 //! command and the HTTP API can never disagree about what would be fetched.
9
10 use std::io::Read;
11 use std::path::{Component, Path, PathBuf};
12
13 use super::parsers::{self, MarketplaceDocument};
14 use super::store::StoredMarketplaceCatalog;
15 use super::types::{
16 MarketplaceCandidate, MarketplaceCatalogId, MarketplaceFormat, MarketplaceInstallPlan,
17 MarketplaceSourceSpec,
18 };
19
20 /// Catalog documents are JSON text; four megabytes is far beyond any real
21 /// published catalog and caps the parse cost of a user-supplied file.
22 const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
23
24 /// A parsed catalog ready to store, plus the counts callers render back.
25 #[derive(Debug)]
26 pub struct LoadedCatalogDocument {
27 pub entry: StoredMarketplaceCatalog,
28 pub candidate_count: usize,
29 pub warning_count: usize,
30 }
31
32 /// Conservative catalog name: it becomes a key, appears in candidate IDs,
33 /// and is rendered back to the operator.
34 #[must_use]
35 pub fn valid_marketplace_name(name: &str) -> bool {
36 !name.is_empty()
37 && name.len() <= 64
38 && name
39 .chars()
40 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
41 }
42
43 /// Read and parse a LOCAL catalog document. Relative paths resolve against
44 /// `workspace`. No network is touched, here or anywhere in this module.
45 pub fn load_catalog_document(
46 name: &str,
47 workspace: &Path,
48 raw_path: &str,
49 ) -> Result<LoadedCatalogDocument, String> {
50 if !valid_marketplace_name(name) {
51 return Err(
52 "Marketplace name must be 1-64 characters of letters, digits, `-`, `_`, or `.`"
53 .to_string(),
54 );
55 }
56 let path = PathBuf::from(raw_path.trim());
57 let path = if path.is_absolute() {
58 path
59 } else {
60 workspace.join(path)
61 };
62 let path = canonical_document(&path)?;
63 let body = read_bounded(&path)?;
64 let root = serde_json::from_str::<serde_json::Value>(&body)
65 .map_err(|error| format!("Catalog at {} is not valid JSON: {error}", path.display()))?;
66
67 let document = MarketplaceDocument {
68 catalog_id: MarketplaceCatalogId::new(name),
69 format: MarketplaceFormat::Auto,
70 root,
71 base: Some(path.display().to_string()),
72 };
73 let catalog = parsers::parse_catalog(document);
74
75 // A document-level error (unknown/ambiguous format, not-an-object) means
76 // nothing useful was parsed; do not persist it.
77 if catalog.candidates.is_empty() && catalog.error_count() > 0 {
78 return Err(format!(
79 "Catalog `{name}` could not be parsed as any known marketplace format (kimi, claude, codex, codewhale):\n{}",
80 render_diagnostics_inline(&catalog.diagnostics)
81 ));
82 }
83
84 let entry = StoredMarketplaceCatalog {
85 added_at: chrono::Utc::now().to_rfc3339(),
86 source_path: path.display().to_string(),
87 catalog,
88 };
89 Ok(LoadedCatalogDocument {
90 candidate_count: entry.catalog.total_candidates(),
91 warning_count: entry.catalog.warning_count(),
92 entry,
93 })
94 }
95
96 /// What installing a stored candidate would do, resolved once for every
97 /// caller. `Supported.spec` is exactly what the reviewed installer accepts.
98 pub enum CatalogInstallResolution<'a> {
99 Supported {
100 spec: String,
101 source_kind: String,
102 },
103 /// A matching name is occupied; catalog metadata does not prove identity.
104 AlreadyPresent {
105 plugin: &'a crate::plugins::types::LoadedPlugin,
106 reason: String,
107 },
108 Unsupported {
109 reason: String,
110 },
111 HasErrors {
112 diagnostics: String,
113 },
114 }
115
116 /// Resolve a stored catalog candidate to its install spec. Relative local
117 /// paths resolve against the catalog document's own directory, not the
118 /// caller's working directory.
119 pub fn resolve_candidate_install<'a>(
120 entry: &StoredMarketplaceCatalog,
121 candidate: &MarketplaceCandidate,
122 registry: &'a crate::plugins::PluginRegistry,
123 ) -> CatalogInstallResolution<'a> {
124 if candidate.has_errors() {
125 return CatalogInstallResolution::HasErrors {
126 diagnostics: render_diagnostics_inline(&candidate.diagnostics),
127 };
128 }
129 if let Some(plugin) = registry.get(&candidate.name) {
130 if plugin.scope == crate::plugins::types::PluginScope::Builtin {
131 // A shipped bundle (Computer Use): the published "install"
132 // command must say so, not read as a failure (B5). The catalog
133 // entry still never replaces it.
134 return CatalogInstallResolution::AlreadyPresent {
135 plugin,
136 reason: format!(
137 "'{}' is built into Codewhale, so there is nothing to install; this catalog entry does not replace it. Review it with /plugin show {}.",
138 plugin.name(),
139 plugin.id.as_str()
140 ),
141 };
142 }
143 return CatalogInstallResolution::AlreadyPresent {
144 plugin,
145 reason: format!(
146 "A {} plugin named '{}' already exists. Review the existing bundle with /plugin show {}. Catalog metadata does not establish that it is the same bundle.",
147 plugin.scope.as_str(),
148 plugin.name(),
149 plugin.id.as_str()
150 ),
151 };
152 }
153 match &candidate.install_plan {
154 MarketplaceInstallPlan::Supported { spec, source_kind } => match resolve_spec(
155 &entry.source_path,
156 entry.catalog.format,
157 &candidate.source,
158 spec,
159 ) {
160 Ok(spec) => CatalogInstallResolution::Supported {
161 spec,
162 source_kind: source_kind.clone(),
163 },
164 Err(reason) => CatalogInstallResolution::Unsupported { reason },
165 },
166 MarketplaceInstallPlan::Unsupported { reason, .. } => {
167 CatalogInstallResolution::Unsupported {
168 reason: reason.clone(),
169 }
170 }
171 }
172 }
173
174 /// A catalog's local source must name a directory inside the catalog's own
175 /// tree. An absolute path, a root or drive prefix, or any `..` component would
176 /// let catalog metadata point the installer at an arbitrary directory on this
177 /// machine, so such an entry is not installable from the catalog; installing a
178 /// directory outside it stays an explicit `/plugin install path:...`.
179 ///
180 /// Known limitation: the check is textual. It inspects the source's path
181 /// components and never resolves the filesystem, so a symlink inside the
182 /// catalog tree (`plugins/foo -> /elsewhere`) still passes, and the install
183 /// follows it out of the catalog directory.
184 fn resolve_spec(
185 source_path: &str,
186 format: MarketplaceFormat,
187 source: &MarketplaceSourceSpec,
188 spec: &str,
189 ) -> Result<String, String> {
190 if let MarketplaceSourceSpec::LocalPath { path } = source
191 && !path
192 .components()
193 .all(|part| matches!(part, Component::Normal(_) | Component::CurDir))
194 {
195 return Err(format!(
196 "Local catalog source `{}` leaves the catalog directory; only paths inside it (no absolute paths or `..`) are installable from a catalog",
197 path.display()
198 ));
199 }
200 if let MarketplaceSourceSpec::LocalPath { path } = source
201 && let Some(dir) = Path::new(source_path).parent()
202 {
203 // Claude keeps its catalog in a manifest-only metadata directory;
204 // relative sources are rooted at the marketplace repository.
205 let dir = if format == MarketplaceFormat::Claude
206 && dir.file_name().is_some_and(|name| name == ".claude-plugin")
207 {
208 dir.parent().unwrap_or(dir)
209 } else {
210 dir
211 };
212 return Ok(format!("path:{}", dir.join(path).display()));
213 }
214 // Every other source kind (GitHub, archive/tarball URL) is remote. The
215 // installer parses the spec again, and any value it does not recognise
216 // as remote is a local directory there — so an archive `url` of `/etc`
217 // or `../..` would bypass the containment check above. Only a spec the
218 // installer's own parser reads as remote is installable from a catalog.
219 match crate::plugins::install::PluginInstallSource::parse(spec) {
220 Ok(crate::plugins::install::PluginInstallSource::Remote(_)) => Ok(spec.to_string()),
221 _ => Err(format!(
222 "Catalog source `{spec}` is not a remote (github: or http(s)://) source; a catalog may name a local directory only as a path inside the catalog"
223 )),
224 }
225 }
226
227 /// Resolve a user-supplied document path to an existing regular file without
228 /// following a final symlink (the document is untrusted input).
229 fn canonical_document(path: &Path) -> Result<PathBuf, String> {
230 let metadata = std::fs::symlink_metadata(path)
231 .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?;
232 if metadata.is_symlink() {
233 return Err(format!(
234 "Catalog path {} is a symlink; marketplace documents must be regular files",
235 path.display()
236 ));
237 }
238 if !metadata.is_file() {
239 return Err(format!(
240 "Catalog path {} is not a regular file",
241 path.display()
242 ));
243 }
244 Ok(path.to_path_buf())
245 }
246
247 fn read_bounded(path: &Path) -> Result<String, String> {
248 // Validate the opened handle, not just the path checked before opening.
249 let file = crate::plugins::registry::open_existing_regular_file(path, false)?
250 .ok_or_else(|| format!("Cannot read catalog at {}: file is missing", path.display()))?;
251 let mut text = String::new();
252 let mut limited = file.take(MAX_CATALOG_BYTES + 1);
253 limited
254 .read_to_string(&mut text)
255 .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?;
256 // Check bytes actually read: the file can grow after its metadata is read.
257 if text.len() as u64 > MAX_CATALOG_BYTES {
258 return Err(format!(
259 "Catalog at {} exceeds the {} byte limit",
260 path.display(),
261 MAX_CATALOG_BYTES
262 ));
263 }
264 Ok(text)
265 }
266
267 fn render_diagnostics_inline(diagnostics: &[super::types::MarketplaceDiagnostic]) -> String {
268 use crate::plugins::types::PluginDiagnosticLevel;
269 diagnostics
270 .iter()
271 .map(|d| {
272 format!(
273 "{} {}: {}",
274 match d.level {
275 PluginDiagnosticLevel::Error => "error",
276 PluginDiagnosticLevel::Warning => "warning",
277 },
278 d.code,
279 d.message
280 )
281 })
282 .collect::<Vec<_>>()
283 .join("; ")
284 }
285
286 #[cfg(test)]
287 mod tests {
288 use super::*;
289
290 #[test]
291 fn marketplace_names_are_conservative() {
292 assert!(valid_marketplace_name("official"));
293 assert!(valid_marketplace_name("My-Catalog_2.beta"));
294 assert!(!valid_marketplace_name(""));
295 assert!(!valid_marketplace_name("has space"));
296 assert!(!valid_marketplace_name("a".repeat(65).as_str()));
297 }
298
299 #[test]
300 fn catalog_read_enforces_actual_byte_limit() {
301 use std::io::Write as _;
302 let dir = tempfile::tempdir().unwrap();
303 let path = dir.path().join("catalog.json");
304 let body = " ".repeat(MAX_CATALOG_BYTES as usize);
305 std::fs::write(&path, &body).unwrap();
306 assert_eq!(read_bounded(&path).unwrap(), body);
307 let checked = canonical_document(&path).unwrap();
308 let mut file = std::fs::OpenOptions::new()
309 .append(true)
310 .open(&path)
311 .unwrap();
312 file.write_all(b" ").unwrap();
313 assert!(read_bounded(&checked).unwrap_err().contains("byte limit"));
314 }
315
316 #[cfg(unix)]
317 #[test]
318 fn catalog_read_refuses_symlink_substituted_after_path_check() {
319 let dir = tempfile::tempdir().unwrap();
320 let path = dir.path().join("catalog.json");
321 let other = dir.path().join("other.json");
322 std::fs::write(&path, "{}").unwrap();
323 std::fs::write(&other, "synthetic unrelated content").unwrap();
324 let checked = canonical_document(&path).unwrap();
325 std::fs::rename(&path, dir.path().join("original.json")).unwrap();
326 std::os::unix::fs::symlink(&other, &path).unwrap();
327 assert!(read_bounded(&checked).is_err());
328 assert_eq!(
329 std::fs::read_to_string(&other).unwrap(),
330 "synthetic unrelated content"
331 );
332 }
333
334 #[cfg(unix)]
335 #[test]
336 fn catalog_read_refuses_fifo_without_waiting_for_a_writer() {
337 const CHILD_PATH: &str = "CODEWHALE_TEST_CATALOG_FIFO";
338 if let Some(path) = std::env::var_os(CHILD_PATH) {
339 assert!(read_bounded(Path::new(&path)).is_err());
340 return;
341 }
342 // Isolate a regressed blocking open so the test can stop it safely.
343 let dir = tempfile::tempdir().unwrap();
344 let path = dir.path().join("catalog.json");
345 std::fs::write(&path, "{}").unwrap();
346 let checked = canonical_document(&path).unwrap();
347 std::fs::rename(&path, dir.path().join("original.json")).unwrap();
348 assert!(
349 std::process::Command::new("mkfifo")
350 .arg(&path)
351 .status()
352 .unwrap()
353 .success()
354 );
355 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
356 .args(["--exact", "plugins::marketplace::document::tests::catalog_read_refuses_fifo_without_waiting_for_a_writer"])
357 .env(CHILD_PATH, checked)
358 .stdout(std::process::Stdio::null())
359 .stderr(std::process::Stdio::null())
360 .spawn().unwrap();
361 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
362 loop {
363 if let Some(status) = child.try_wait().unwrap() {
364 assert!(status.success());
365 break;
366 }
367 if std::time::Instant::now() >= deadline {
368 let _ = child.kill();
369 let _ = child.wait();
370 panic!("catalog read waited for a FIFO writer");
371 }
372 std::thread::sleep(std::time::Duration::from_millis(10));
373 }
374 }
375
376 #[cfg(unix)]
377 #[test]
378 fn load_refuses_symlink_documents() {
379 let dir = tempfile::tempdir().unwrap();
380 let real = dir.path().join("real.json");
381 std::fs::write(&real, "{}").unwrap();
382 let link = dir.path().join("link.json");
383 std::os::unix::fs::symlink(&real, &link).unwrap();
384
385 let error = load_catalog_document("test", dir.path(), link.to_str().unwrap())
386 .expect_err("symlink document must be refused");
387 assert!(error.contains("symlink"), "{error}");
388 }
389
390 #[test]
391 fn load_refuses_unknown_format_documents() {
392 let dir = tempfile::tempdir().unwrap();
393 let doc = dir.path().join("catalog.json");
394 std::fs::write(&doc, r#"{"totally":"unknown"}"#).unwrap();
395
396 let error = load_catalog_document("test", dir.path(), doc.to_str().unwrap())
397 .expect_err("unknown format must be refused");
398 assert!(error.contains("could not be parsed"), "{error}");
399 }
400 }
401
401 lines RUST