返回 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::{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 return CatalogInstallResolution::AlreadyPresent {
131 plugin,
132 reason: format!(
133 "A {} plugin named '{}' already exists. Review the existing bundle with /plugin show {}. Catalog metadata does not establish that it is the same bundle.",
134 plugin.scope.as_str(),
135 plugin.name(),
136 plugin.id.as_str()
137 ),
138 };
139 }
140 match &candidate.install_plan {
141 MarketplaceInstallPlan::Supported { spec, source_kind } => {
142 CatalogInstallResolution::Supported {
143 spec: resolve_spec(
144 &entry.source_path,
145 entry.catalog.format,
146 &candidate.source,
147 spec,
148 ),
149 source_kind: source_kind.clone(),
150 }
151 }
152 MarketplaceInstallPlan::Unsupported { reason, .. } => {
153 CatalogInstallResolution::Unsupported {
154 reason: reason.clone(),
155 }
156 }
157 }
158 }
159
160 fn resolve_spec(
161 source_path: &str,
162 format: MarketplaceFormat,
163 source: &MarketplaceSourceSpec,
164 spec: &str,
165 ) -> String {
166 if let MarketplaceSourceSpec::LocalPath { path } = source
167 && path.is_relative()
168 && let Some(dir) = Path::new(source_path).parent()
169 {
170 // Claude keeps its catalog in a manifest-only metadata directory;
171 // relative sources are rooted at the marketplace repository.
172 let dir = if format == MarketplaceFormat::Claude
173 && dir.file_name().is_some_and(|name| name == ".claude-plugin")
174 {
175 dir.parent().unwrap_or(dir)
176 } else {
177 dir
178 };
179 return format!("path:{}", dir.join(path).display());
180 }
181 spec.to_string()
182 }
183
184 /// Resolve a user-supplied document path to an existing regular file without
185 /// following a final symlink (the document is untrusted input).
186 fn canonical_document(path: &Path) -> Result<PathBuf, String> {
187 let metadata = std::fs::symlink_metadata(path)
188 .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?;
189 if metadata.is_symlink() {
190 return Err(format!(
191 "Catalog path {} is a symlink; marketplace documents must be regular files",
192 path.display()
193 ));
194 }
195 if !metadata.is_file() {
196 return Err(format!(
197 "Catalog path {} is not a regular file",
198 path.display()
199 ));
200 }
201 Ok(path.to_path_buf())
202 }
203
204 fn read_bounded(path: &Path) -> Result<String, String> {
205 // Validate the opened handle, not just the path checked before opening.
206 let file = crate::plugins::registry::open_existing_regular_file(path, false)?
207 .ok_or_else(|| format!("Cannot read catalog at {}: file is missing", path.display()))?;
208 let mut text = String::new();
209 let mut limited = file.take(MAX_CATALOG_BYTES + 1);
210 limited
211 .read_to_string(&mut text)
212 .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?;
213 // Check bytes actually read: the file can grow after its metadata is read.
214 if text.len() as u64 > MAX_CATALOG_BYTES {
215 return Err(format!(
216 "Catalog at {} exceeds the {} byte limit",
217 path.display(),
218 MAX_CATALOG_BYTES
219 ));
220 }
221 Ok(text)
222 }
223
224 fn render_diagnostics_inline(diagnostics: &[super::types::MarketplaceDiagnostic]) -> String {
225 use crate::plugins::types::PluginDiagnosticLevel;
226 diagnostics
227 .iter()
228 .map(|d| {
229 format!(
230 "{} {}: {}",
231 match d.level {
232 PluginDiagnosticLevel::Error => "error",
233 PluginDiagnosticLevel::Warning => "warning",
234 },
235 d.code,
236 d.message
237 )
238 })
239 .collect::<Vec<_>>()
240 .join("; ")
241 }
242
243 #[cfg(test)]
244 mod tests {
245 use super::*;
246
247 #[test]
248 fn marketplace_names_are_conservative() {
249 assert!(valid_marketplace_name("official"));
250 assert!(valid_marketplace_name("My-Catalog_2.beta"));
251 assert!(!valid_marketplace_name(""));
252 assert!(!valid_marketplace_name("has space"));
253 assert!(!valid_marketplace_name("a".repeat(65).as_str()));
254 }
255
256 #[test]
257 fn catalog_read_enforces_actual_byte_limit() {
258 use std::io::Write as _;
259 let dir = tempfile::tempdir().unwrap();
260 let path = dir.path().join("catalog.json");
261 let body = " ".repeat(MAX_CATALOG_BYTES as usize);
262 std::fs::write(&path, &body).unwrap();
263 assert_eq!(read_bounded(&path).unwrap(), body);
264 let checked = canonical_document(&path).unwrap();
265 let mut file = std::fs::OpenOptions::new()
266 .append(true)
267 .open(&path)
268 .unwrap();
269 file.write_all(b" ").unwrap();
270 assert!(read_bounded(&checked).unwrap_err().contains("byte limit"));
271 }
272
273 #[cfg(unix)]
274 #[test]
275 fn catalog_read_refuses_symlink_substituted_after_path_check() {
276 let dir = tempfile::tempdir().unwrap();
277 let path = dir.path().join("catalog.json");
278 let other = dir.path().join("other.json");
279 std::fs::write(&path, "{}").unwrap();
280 std::fs::write(&other, "synthetic unrelated content").unwrap();
281 let checked = canonical_document(&path).unwrap();
282 std::fs::rename(&path, dir.path().join("original.json")).unwrap();
283 std::os::unix::fs::symlink(&other, &path).unwrap();
284 assert!(read_bounded(&checked).is_err());
285 assert_eq!(
286 std::fs::read_to_string(&other).unwrap(),
287 "synthetic unrelated content"
288 );
289 }
290
291 #[cfg(unix)]
292 #[test]
293 fn catalog_read_refuses_fifo_without_waiting_for_a_writer() {
294 const CHILD_PATH: &str = "CODEWHALE_TEST_CATALOG_FIFO";
295 if let Some(path) = std::env::var_os(CHILD_PATH) {
296 assert!(read_bounded(Path::new(&path)).is_err());
297 return;
298 }
299 // Isolate a regressed blocking open so the test can stop it safely.
300 let dir = tempfile::tempdir().unwrap();
301 let path = dir.path().join("catalog.json");
302 std::fs::write(&path, "{}").unwrap();
303 let checked = canonical_document(&path).unwrap();
304 std::fs::rename(&path, dir.path().join("original.json")).unwrap();
305 assert!(
306 std::process::Command::new("mkfifo")
307 .arg(&path)
308 .status()
309 .unwrap()
310 .success()
311 );
312 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
313 .args(["--exact", "plugins::marketplace::document::tests::catalog_read_refuses_fifo_without_waiting_for_a_writer"])
314 .env(CHILD_PATH, checked)
315 .stdout(std::process::Stdio::null())
316 .stderr(std::process::Stdio::null())
317 .spawn().unwrap();
318 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
319 loop {
320 if let Some(status) = child.try_wait().unwrap() {
321 assert!(status.success());
322 break;
323 }
324 if std::time::Instant::now() >= deadline {
325 let _ = child.kill();
326 let _ = child.wait();
327 panic!("catalog read waited for a FIFO writer");
328 }
329 std::thread::sleep(std::time::Duration::from_millis(10));
330 }
331 }
332
333 #[cfg(unix)]
334 #[test]
335 fn load_refuses_symlink_documents() {
336 let dir = tempfile::tempdir().unwrap();
337 let real = dir.path().join("real.json");
338 std::fs::write(&real, "{}").unwrap();
339 let link = dir.path().join("link.json");
340 std::os::unix::fs::symlink(&real, &link).unwrap();
341
342 let error = load_catalog_document("test", dir.path(), link.to_str().unwrap())
343 .expect_err("symlink document must be refused");
344 assert!(error.contains("symlink"), "{error}");
345 }
346
347 #[test]
348 fn load_refuses_unknown_format_documents() {
349 let dir = tempfile::tempdir().unwrap();
350 let doc = dir.path().join("catalog.json");
351 std::fs::write(&doc, r#"{"totally":"unknown"}"#).unwrap();
352
353 let error = load_catalog_document("test", dir.path(), doc.to_str().unwrap())
354 .expect_err("unknown format must be refused");
355 assert!(error.contains("could not be parsed"), "{error}");
356 }
357 }
358
358 lines RUST