返回 CodeWhale
tests.rs
根目录 / crates / tui / src / plugins / install / tests.rs
1 use super::*;
2 // `scan_tarball` lives in the sibling stage-reader module and is not needed
3 // by the verbs, so it is not in `super`'s namespace.
4 use super::tarball::scan_tarball;
5
6 fn write_bundle(root: &Path, dir: &str, name: &str) -> PathBuf {
7 let bundle = root.join(dir);
8 fs::create_dir_all(&bundle).unwrap();
9 fs::write(
10 bundle.join("plugin.toml"),
11 format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n"),
12 )
13 .unwrap();
14 bundle
15 }
16
17 fn tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
18 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
19 let mut builder = tar::Builder::new(encoder);
20 for (path, body) in entries {
21 let mut header = tar::Header::new_gnu();
22 header.set_size(body.len() as u64);
23 header.set_mode(0o644);
24 header.set_cksum();
25 builder.append_data(&mut header, path, *body).unwrap();
26 }
27 let encoder = builder.into_inner().unwrap();
28 encoder.finish().unwrap()
29 }
30
31 #[cfg(unix)]
32 #[test]
33 fn remote_plugin_stage_preserves_only_owner_executable_intent() {
34 use std::os::unix::fs::PermissionsExt as _;
35
36 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
37 let mut builder = tar::Builder::new(encoder);
38 for (path, body, mode) in [
39 (
40 "repo/plugin.toml",
41 &b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n"[..],
42 0o644,
43 ),
44 ("repo/bin/server", &b"#!/bin/sh\nexit 0\n"[..], 0o6755),
45 ("repo/data.txt", &b"data"[..], 0o666),
46 ] {
47 let mut header = tar::Header::new_gnu();
48 header.set_size(body.len() as u64);
49 header.set_mode(mode);
50 header.set_cksum();
51 builder.append_data(&mut header, path, body).unwrap();
52 }
53 let bytes = builder.into_inner().unwrap().finish().unwrap();
54 let tmp = tempfile::tempdir().unwrap();
55 let staged = stage_tarball(&bytes, tmp.path(), DEFAULT_MAX_SIZE_BYTES, None).unwrap();
56 for (path, expected) in [("bin/server", 0o700), ("data.txt", 0o600)] {
57 assert_eq!(
58 fs::metadata(staged.staged_path.join(path))
59 .unwrap()
60 .permissions()
61 .mode()
62 & 0o7777,
63 expected,
64 "{path}"
65 );
66 }
67 }
68
69 fn symlink_tarball(link_path: &str, target: &str, manifest: &str) -> Vec<u8> {
70 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
71 let mut builder = tar::Builder::new(encoder);
72 let body = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
73 let mut header = tar::Header::new_gnu();
74 header.set_size(body.len() as u64);
75 header.set_mode(0o644);
76 header.set_cksum();
77 builder
78 .append_data(&mut header, manifest, &body[..])
79 .unwrap();
80 let mut link_header = tar::Header::new_gnu();
81 link_header.set_entry_type(tar::EntryType::Symlink);
82 link_header.set_size(0);
83 link_header.set_mode(0o777);
84 link_header.set_cksum();
85 builder
86 .append_link(&mut link_header, link_path, target)
87 .unwrap();
88 let encoder = builder.into_inner().unwrap();
89 encoder.finish().unwrap()
90 }
91
92 /// Emit one raw ustar file entry with an arbitrary (possibly hostile) name.
93 /// `tar::Builder` refuses `..` and absolute paths on write, so adversarial
94 /// archives have to be assembled byte-by-byte.
95 fn raw_tar_file_entry(name: &[u8], body: &[u8]) -> Vec<u8> {
96 let mut header = [0_u8; 512];
97 header[..name.len()].copy_from_slice(name);
98 header[100..108].copy_from_slice(b"0000644\0");
99 header[108..116].copy_from_slice(b"0000000\0");
100 header[116..124].copy_from_slice(b"0000000\0");
101 let size = format!("{:011o}\0", body.len());
102 header[124..136].copy_from_slice(size.as_bytes());
103 header[136..148].copy_from_slice(b"00000000000\0");
104 header[148..156].copy_from_slice(b" ");
105 header[156] = b'0';
106 header[257..263].copy_from_slice(b"ustar\0");
107 header[263..265].copy_from_slice(b"00");
108 let checksum: u32 = header.iter().map(|byte| u32::from(*byte)).sum();
109 let checksum = format!("{checksum:06o}\0 ");
110 header[148..156].copy_from_slice(checksum.as_bytes());
111 let mut out = header.to_vec();
112 out.extend_from_slice(body);
113 let padding = (512 - body.len() % 512) % 512;
114 out.extend(std::iter::repeat_n(0, padding));
115 out
116 }
117
118 fn raw_tarball(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
119 use std::io::Write as _;
120
121 let mut tar_bytes = Vec::new();
122 for (name, body) in entries {
123 tar_bytes.extend(raw_tar_file_entry(name, body));
124 }
125 tar_bytes.extend(std::iter::repeat_n(0, 1024));
126 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
127 encoder.write_all(&tar_bytes).unwrap();
128 encoder.finish().unwrap()
129 }
130
131 fn allow_all() -> NetworkPolicy {
132 NetworkPolicy {
133 default: crate::network_policy::DecisionToml::Allow,
134 ..Default::default()
135 }
136 }
137
138 fn no_conflict() -> impl Fn(&str) -> Option<String> {
139 |_| None
140 }
141
142 // ── scan/extract rules ────────────────────────────────────────────────
143
144 #[test]
145 fn scan_rejects_path_traversal() {
146 let bytes = raw_tarball(&[(
147 b"repo-main/../evil/plugin.toml",
148 b"schema_version = 1\n[plugin]\nname = \"evil\"\n",
149 )]);
150 let err = scan_tarball(&bytes, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
151 assert!(
152 matches!(
153 err.downcast_ref::<PluginInstallError>(),
154 Some(PluginInstallError::PathTraversal(_))
155 ),
156 "got: {err:#}"
157 );
158 }
159
160 #[test]
161 fn scan_rejects_absolute_paths() {
162 let bytes = raw_tarball(&[(
163 b"/tmp/evil/plugin.toml",
164 b"schema_version = 1\n[plugin]\nname = \"evil\"\n",
165 )]);
166 assert!(scan_tarball(&bytes, DEFAULT_MAX_SIZE_BYTES).is_err());
167 }
168
169 #[test]
170 fn scan_enforces_size_cap() {
171 let body = vec![b'x'; 1024];
172 let bytes = tarball(&[
173 (
174 "repo-main/plugin.toml",
175 b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
176 ),
177 ("repo-main/blob.bin", &body),
178 ]);
179 let err = scan_tarball(&bytes, 512).unwrap_err();
180 assert!(
181 matches!(
182 err.downcast_ref::<PluginInstallError>(),
183 Some(PluginInstallError::OversizedBundle { .. })
184 ),
185 "got: {err:#}"
186 );
187 }
188
189 #[test]
190 fn scan_requires_exactly_one_plugin_manifest_root() {
191 let zero = tarball(&[("repo-main/README.md", b"no manifest here")]);
192 let err = scan_tarball(&zero, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
193 assert!(
194 matches!(
195 err.downcast_ref::<PluginInstallError>(),
196 Some(PluginInstallError::PluginTomlRoots(0))
197 ),
198 "got: {err:#}"
199 );
200
201 let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
202 let two = tarball(&[
203 ("repo-main/plugin.toml", manifest),
204 ("repo-main/examples/other/plugin.toml", manifest),
205 ]);
206 let err = scan_tarball(&two, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
207 assert!(
208 matches!(
209 err.downcast_ref::<PluginInstallError>(),
210 Some(PluginInstallError::PluginTomlRoots(2))
211 ),
212 "got: {err:#}"
213 );
214 }
215
216 #[test]
217 fn stage_tarball_accepts_a_kimi_manifest_root() {
218 let manifest = br#"{
219 "name": "kimi-archive",
220 "version": "1.0.0",
221 "description": "Kimi archive fixture"
222 }"#;
223 let bytes = tarball(&[("repo-main/kimi.plugin.json", manifest)]);
224 let tmp = tempfile::tempdir().unwrap();
225 let plugins = tmp.path().join("plugins");
226
227 let staged = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES, None).unwrap();
228
229 assert_eq!(staged.name, "kimi-archive");
230 assert!(staged.staged_path.join("kimi.plugin.json").is_file());
231 }
232
233 #[test]
234 fn extract_rejects_symlinks_inside_the_bundle_subtree() {
235 let bytes = symlink_tarball(
236 "repo-main/evil-link",
237 "/etc/passwd",
238 "repo-main/plugin.toml",
239 );
240 let tmp = tempfile::tempdir().unwrap();
241 let plugins = tmp.path().join("plugins");
242 let err = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES, None).unwrap_err();
243 assert!(
244 matches!(
245 err.downcast_ref::<PluginInstallError>(),
246 Some(PluginInstallError::SymlinkRejected)
247 ),
248 "got: {err:#}"
249 );
250 assert!(fs::read_dir(&plugins).unwrap().next().is_none());
251 }
252
253 #[test]
254 fn extract_ignores_entries_outside_the_bundle_subtree() {
255 let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
256 let bytes = tarball(&[
257 ("repo-main/bundles/demo/plugin.toml", manifest),
258 (
259 "repo-main/bundles/demo/skills/a/SKILL.md",
260 b"---\nname: a\ndescription: a\n---\n",
261 ),
262 ("repo-main/other/plugin.toml.bak", b"ignored"),
263 ("repo-main/README.md", b"repo docs stay behind"),
264 ]);
265 let tmp = tempfile::tempdir().unwrap();
266 let plugins = tmp.path().join("plugins");
267 let staged = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES, None).unwrap();
268 assert_eq!(staged.name, "demo");
269 assert!(staged.staged_path.join("plugin.toml").exists());
270 assert!(staged.staged_path.join("skills/a/SKILL.md").exists());
271 assert!(!staged.staged_path.join("README.md").exists());
272 assert!(!staged.staged_path.join("other").exists());
273 fs::remove_dir_all(&staged.staged_path).unwrap();
274 }
275
276 // ── local copy rules ──────────────────────────────────────────────────
277
278 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
279 async fn install_from_local_path_copies_and_marks_the_bundle() {
280 let tmp = tempfile::tempdir().unwrap();
281 let plugins = tmp.path().join("plugins");
282 let source = write_bundle(tmp.path(), "src/demo", "demo");
283 fs::create_dir_all(source.join("skills/hello")).unwrap();
284 fs::write(
285 source.join("skills/hello/SKILL.md"),
286 "---\nname: hello\ndescription: hi\n---\nbody\n",
287 )
288 .unwrap();
289
290 let outcome = install(
291 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
292 &plugins,
293 DEFAULT_MAX_SIZE_BYTES,
294 &allow_all(),
295 false,
296 &no_conflict(),
297 )
298 .await
299 .unwrap();
300 let PluginInstallOutcome::Installed(installed) = outcome else {
301 panic!("expected install to succeed");
302 };
303 assert_eq!(installed.name, "demo");
304 assert_eq!(installed.path, plugins.join("demo"));
305 assert!(installed.path.join("plugin.toml").exists());
306 assert!(installed.path.join("skills/hello/SKILL.md").exists());
307 let marker: serde_json::Value = serde_json::from_str(
308 &fs::read_to_string(installed.path.join(INSTALLED_FROM_MARKER)).unwrap(),
309 )
310 .unwrap();
311 assert!(marker["spec"].as_str().unwrap().starts_with("path:"));
312 // Local copies must not inherit a stale provenance marker.
313 assert_ne!(marker["spec"].as_str().unwrap(), "path:");
314 }
315
316 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
317 async fn exact_local_install_rejects_changed_bytes_before_placement() {
318 let tmp = tempfile::tempdir().unwrap();
319 let plugins = tmp.path().join("plugins");
320 let source = write_bundle(tmp.path(), "src/exact-demo", "exact-demo");
321 let expected =
322 crate::plugins::manifest::PluginManifest::validate_from_path(&source.join("plugin.toml"))
323 .unwrap()
324 .content_hash;
325 fs::write(source.join("README.md"), "changed after review\n").unwrap();
326
327 let error = install_with_expected_content_hash(
328 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
329 &plugins,
330 DEFAULT_MAX_SIZE_BYTES,
331 &allow_all(),
332 &no_conflict(),
333 &expected,
334 )
335 .await
336 .unwrap_err();
337
338 assert!(format!("{error:#}").contains("source changed after review"));
339 assert!(!plugins.join("exact-demo").exists());
340 assert!(
341 fs::read_dir(&plugins).unwrap().all(|entry| !entry
342 .unwrap()
343 .file_name()
344 .to_string_lossy()
345 .starts_with(".staging-")),
346 "hash mismatch must clean its private staging directory"
347 );
348 }
349
350 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
351 async fn install_imports_official_kimi_datasource_shape() {
352 let tmp = tempfile::tempdir().unwrap();
353 let plugins = tmp.path().join("plugins");
354 let source = tmp.path().join("src/kimi-datasource");
355 fs::create_dir_all(source.join("bin")).unwrap();
356 fs::write(
357 source.join("kimi.plugin.json"),
358 r#"{
359 "name": "kimi-datasource",
360 "version": "3.3.0",
361 "description": "Kimi datasource",
362 "mcpServers": {
363 "data": {
364 "command": "node",
365 "args": ["./bin/kimi-datasource.mjs"],
366 "cwd": "./"
367 }
368 },
369 "interface": {
370 "displayName": "Kimi Datasource",
371 "shortDescription": "Data tools",
372 "developerName": "Moonshot AI"
373 }
374 }"#,
375 )
376 .unwrap();
377 fs::write(
378 source.join("SKILL.md"),
379 "---\nname: kimi-datasource\ndescription: data\n---\n",
380 )
381 .unwrap();
382 fs::write(source.join("bin/kimi-datasource.mjs"), "// fixture\n").unwrap();
383
384 let outcome = install(
385 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
386 &plugins,
387 DEFAULT_MAX_SIZE_BYTES,
388 &allow_all(),
389 false,
390 &no_conflict(),
391 )
392 .await
393 .unwrap();
394 let PluginInstallOutcome::Installed(installed) = outcome else {
395 panic!("expected Kimi plugin install to succeed");
396 };
397 assert_eq!(installed.name, "kimi-datasource");
398 assert!(installed.path.join("kimi.plugin.json").exists());
399 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
400 &installed.path.join("kimi.plugin.json"),
401 )
402 .unwrap();
403 assert_eq!(validated.inventory.skills, 1);
404 assert_eq!(validated.inventory.mcp_servers, 1);
405 assert_eq!(validated.inventory.stdio_mcp_servers, 1);
406 assert_eq!(
407 validated.manifest.plugin.display_name.as_deref(),
408 Some("Kimi Datasource")
409 );
410 }
411
412 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
413 async fn install_imports_managed_kimi_cu_shape_with_platform_and_tool_filters() {
414 let tmp = tempfile::tempdir().unwrap();
415 let plugins = tmp.path().join("plugins");
416 let source = tmp.path().join("src/kimi-cu");
417 fs::create_dir_all(source.join("skills/kimi-cu")).unwrap();
418 fs::create_dir_all(source.join("bin")).unwrap();
419 fs::write(
420 source.join("kimi.plugin.json"),
421 r#"{
422 "name": "kimi-cu",
423 "version": "0.5.4",
424 "description": "Computer-use wiring",
425 "license": "Proprietary",
426 "skills": "./skills/",
427 "mcpServers": {
428 "kimi-cu": {
429 "command": "sh",
430 "args": ["./bin/kimi-cu-mcp"],
431 "cwd": "./",
432 "enabledTools": ["computer_get_state", "computer_click"]
433 }
434 },
435 "interface": {
436 "displayName": "Kimi Computer Use",
437 "shortDescription": "Computer control",
438 "longDescription": "Requires the external Kimi runtime and permissions.",
439 "developerName": "Moonshot AI",
440 "iconUrl": "https://example.invalid/kimi-cu.png",
441 "category": "Developer Tools",
442 "hostKind": "local",
443 "platforms": ["macos"],
444 "mcpOverrides": {
445 "mac": {
446 "displayName": "Kimi Computer Use for macOS",
447 "iconUrl": "https://example.invalid/kimi-cu-mac.png"
448 }
449 }
450 }
451 }"#,
452 )
453 .unwrap();
454 fs::write(
455 source.join("skills/kimi-cu/SKILL.md"),
456 "---\nname: kimi-cu\ndescription: computer use\n---\n",
457 )
458 .unwrap();
459 fs::write(source.join("bin/kimi-cu-mcp"), "#!/bin/sh\n").unwrap();
460
461 let outcome = install(
462 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
463 &plugins,
464 DEFAULT_MAX_SIZE_BYTES,
465 &allow_all(),
466 false,
467 &no_conflict(),
468 )
469 .await
470 .unwrap();
471 let PluginInstallOutcome::Installed(installed) = outcome else {
472 panic!("expected managed Kimi CU plugin install to succeed");
473 };
474 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
475 &installed.path.join("kimi.plugin.json"),
476 )
477 .unwrap();
478 assert_eq!(validated.inventory.skills, 1);
479 assert_eq!(validated.inventory.mcp_servers, 1);
480 assert_eq!(validated.inventory.stdio_mcp_servers, 1);
481 assert_eq!(
482 validated.manifest.plugin.license.as_deref(),
483 Some("Proprietary")
484 );
485 assert_eq!(
486 validated
487 .manifest
488 .when
489 .as_ref()
490 .and_then(|when| when.os.as_ref()),
491 Some(&vec!["macos".to_string()])
492 );
493 assert_eq!(validated.applicable, cfg!(target_os = "macos"));
494 let server = validated
495 .manifest
496 .mcp_servers
497 .as_ref()
498 .and_then(|servers| servers.get("kimi-cu"))
499 .unwrap();
500 assert_eq!(
501 server.enabled_tools,
502 ["computer_get_state", "computer_click"]
503 );
504 }
505
506 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
507 async fn install_imports_official_kimi_webbridge_shape() {
508 let tmp = tempfile::tempdir().unwrap();
509 let plugins = tmp.path().join("plugins");
510 let source = tmp.path().join("src/kimi-webbridge");
511 fs::create_dir_all(source.join("skills/kimi-webbridge")).unwrap();
512 fs::write(
513 source.join("kimi.plugin.json"),
514 r#"{
515 "$schema": "https://kimi.com/schemas/kimi.plugin.schema.json",
516 "name": "kimi-webbridge",
517 "version": "1.11.3",
518 "description": "Control the real browser",
519 "keywords": ["browser", "automation"],
520 "author": "Moonshot AI",
521 "license": "Proprietary",
522 "skills": "./skills/",
523 "interface": {
524 "displayName": "Kimi WebBridge",
525 "shortDescription": "Browser control",
526 "longDescription": "Requires the local daemon and browser extension.",
527 "developerName": "Moonshot AI",
528 "websiteURL": "https://www.kimi.com/features/webbridge"
529 }
530 }"#,
531 )
532 .unwrap();
533 fs::write(
534 source.join("skills/kimi-webbridge/SKILL.md"),
535 "---\nname: kimi-webbridge\ndescription: browser\n---\n",
536 )
537 .unwrap();
538
539 let outcome = install(
540 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
541 &plugins,
542 DEFAULT_MAX_SIZE_BYTES,
543 &allow_all(),
544 false,
545 &no_conflict(),
546 )
547 .await
548 .unwrap();
549 let PluginInstallOutcome::Installed(installed) = outcome else {
550 panic!("expected Kimi plugin install to succeed");
551 };
552 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
553 &installed.path.join("kimi.plugin.json"),
554 )
555 .unwrap();
556 assert_eq!(validated.inventory.skills, 1);
557 assert_eq!(validated.inventory.mcp_servers, 0);
558 assert_eq!(
559 validated.manifest.plugin.author.as_deref(),
560 Some("Moonshot AI")
561 );
562 }
563
564 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
565 async fn kimi_import_rejects_unsupported_runtime_fields() {
566 let tmp = tempfile::tempdir().unwrap();
567 let plugins = tmp.path().join("plugins");
568 let source = tmp.path().join("src/kimi-hooks");
569 fs::create_dir_all(&source).unwrap();
570 fs::write(
571 source.join("kimi.plugin.json"),
572 r#"{
573 "name": "kimi-hooks",
574 "version": "1.0.0",
575 "hooks": [{"event":"PreToolUse","command":"node ./hook.mjs"}]
576 }"#,
577 )
578 .unwrap();
579
580 let error = install(
581 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
582 &plugins,
583 DEFAULT_MAX_SIZE_BYTES,
584 &allow_all(),
585 false,
586 &no_conflict(),
587 )
588 .await
589 .unwrap_err();
590 assert!(
591 format!("{error:#}").contains("unknown field `hooks`"),
592 "unsupported Kimi runtime fields must fail closed: {error:#}"
593 );
594 assert!(!plugins.join("kimi-hooks").exists());
595 }
596
597 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
598 async fn kimi_import_rejects_unknown_mcp_executable_fields() {
599 let tmp = tempfile::tempdir().unwrap();
600 let plugins = tmp.path().join("plugins");
601 let source = tmp.path().join("src/kimi-unknown-exec");
602 fs::create_dir_all(&source).unwrap();
603 fs::write(
604 source.join("kimi.plugin.json"),
605 r#"{
606 "name": "kimi-unknown-exec",
607 "version": "1.0.0",
608 "mcpServers": {
609 "unknown": {
610 "command": "node",
611 "args": ["server.mjs"],
612 "postInstallCommand": "node install.mjs"
613 }
614 }
615 }"#,
616 )
617 .unwrap();
618
619 let error = install(
620 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
621 &plugins,
622 DEFAULT_MAX_SIZE_BYTES,
623 &allow_all(),
624 false,
625 &no_conflict(),
626 )
627 .await
628 .unwrap_err();
629 assert!(
630 format!("{error:#}").contains("unknown field `postInstallCommand`"),
631 "unknown Kimi executable fields must fail closed: {error:#}"
632 );
633 assert!(!plugins.join("kimi-unknown-exec").exists());
634 }
635
636 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
637 async fn install_refuses_to_overwrite_a_hand_placed_bundle() {
638 let tmp = tempfile::tempdir().unwrap();
639 let plugins = tmp.path().join("plugins");
640 write_bundle(&plugins, "demo", "demo");
641 let source = write_bundle(tmp.path(), "src/demo", "demo");
642
643 let err = install(
644 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
645 &plugins,
646 DEFAULT_MAX_SIZE_BYTES,
647 &allow_all(),
648 false,
649 &no_conflict(),
650 )
651 .await
652 .unwrap_err();
653 assert!(
654 matches!(
655 err.downcast_ref::<PluginInstallError>(),
656 Some(PluginInstallError::NotInstalledHere(_))
657 ),
658 "hand-placed bundle must be protected, got: {err:#}"
659 );
660 assert!(
661 !plugins.join("demo/skills").exists(),
662 "no partial overwrite"
663 );
664
665 // A bundle that *was* installed here gets the AlreadyInstalled hint.
666 fs::write(plugins.join("demo").join(INSTALLED_FROM_MARKER), "{}").unwrap();
667 let err = install(
668 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
669 &plugins,
670 DEFAULT_MAX_SIZE_BYTES,
671 &allow_all(),
672 false,
673 &no_conflict(),
674 )
675 .await
676 .unwrap_err();
677 assert!(
678 matches!(
679 err.downcast_ref::<PluginInstallError>(),
680 Some(PluginInstallError::AlreadyInstalled(_))
681 ),
682 "got: {err:#}"
683 );
684 }
685
686 #[cfg(unix)]
687 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
688 async fn local_install_rejects_symlinks_in_the_source() {
689 let tmp = tempfile::tempdir().unwrap();
690 let plugins = tmp.path().join("plugins");
691 let source = write_bundle(tmp.path(), "src/demo", "demo");
692 std::os::unix::fs::symlink("/etc/passwd", source.join("linked")).unwrap();
693
694 let err = install(
695 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
696 &plugins,
697 DEFAULT_MAX_SIZE_BYTES,
698 &allow_all(),
699 false,
700 &no_conflict(),
701 )
702 .await
703 .unwrap_err();
704 // The bundle validator rejects symlinked content before any copy runs.
705 assert!(format!("{err:#}").contains("symbolic link"), "got: {err:#}");
706 assert!(!plugins.join("demo").exists());
707 }
708
709 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
710 async fn install_refuses_sources_inside_the_plugins_root() {
711 let tmp = tempfile::tempdir().unwrap();
712 let plugins = tmp.path().join("plugins");
713 let nested = write_bundle(&plugins, "demo", "demo");
714 let err = install(
715 PluginInstallSource::parse(nested.to_str().unwrap()).unwrap(),
716 &plugins,
717 DEFAULT_MAX_SIZE_BYTES,
718 &allow_all(),
719 false,
720 &no_conflict(),
721 )
722 .await
723 .unwrap_err();
724 assert!(format!("{err:#}").contains("inside the user plugins directory"));
725 }
726
727 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
728 async fn install_enforces_the_name_conflict_hook() {
729 let tmp = tempfile::tempdir().unwrap();
730 let plugins = tmp.path().join("plugins");
731 let source = write_bundle(tmp.path(), "src/demo", "demo");
732 let err = install(
733 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
734 &plugins,
735 DEFAULT_MAX_SIZE_BYTES,
736 &allow_all(),
737 false,
738 &|name| Some(format!("name '{name}' is shadowed by a builtin bundle")),
739 )
740 .await
741 .unwrap_err();
742 assert!(format!("{err:#}").contains("shadowed by a builtin bundle"));
743 assert!(err.downcast_ref::<PluginNameConflict>().is_some());
744 assert!(!plugins.join("demo").exists());
745 // The staging dir must be cleaned up on the conflict path.
746 assert!(
747 !fs::read_dir(&plugins)
748 .map(|mut entries| entries.any(|entry| entry
749 .unwrap()
750 .file_name()
751 .to_string_lossy()
752 .starts_with(".staging-")))
753 .unwrap_or(false)
754 );
755 }
756
757 // ── update / uninstall ────────────────────────────────────────────────
758
759 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
760 async fn update_refuses_local_installs_and_missing_markers() {
761 let tmp = tempfile::tempdir().unwrap();
762 let plugins = tmp.path().join("plugins");
763 let source = write_bundle(tmp.path(), "src/demo", "demo");
764 install(
765 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
766 &plugins,
767 DEFAULT_MAX_SIZE_BYTES,
768 &allow_all(),
769 false,
770 &no_conflict(),
771 )
772 .await
773 .unwrap();
774
775 let err = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
776 .await
777 .unwrap_err();
778 assert!(format!("{err:#}").contains("local path"), "got: {err:#}");
779
780 write_bundle(&plugins, "hand", "hand");
781 let err = update("hand", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
782 .await
783 .unwrap_err();
784 assert!(
785 matches!(
786 err.downcast_ref::<PluginInstallError>(),
787 Some(PluginInstallError::NotInstalledHere(_))
788 ),
789 "got: {err:#}"
790 );
791 }
792
793 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
794 async fn uninstall_requires_the_marker_and_removes_the_bundle() {
795 let tmp = tempfile::tempdir().unwrap();
796 let plugins = tmp.path().join("plugins");
797 let source = write_bundle(tmp.path(), "src/demo", "demo");
798 install(
799 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
800 &plugins,
801 DEFAULT_MAX_SIZE_BYTES,
802 &allow_all(),
803 false,
804 &no_conflict(),
805 )
806 .await
807 .unwrap();
808
809 uninstall("demo", &plugins).unwrap();
810 assert!(!plugins.join("demo").exists());
811
812 write_bundle(&plugins, "hand", "hand");
813 let err = uninstall("hand", &plugins).unwrap_err();
814 assert!(
815 matches!(
816 err.downcast_ref::<PluginInstallError>(),
817 Some(PluginInstallError::NotInstalledHere(_))
818 ),
819 "got: {err:#}"
820 );
821 assert!(plugins.join("hand").exists(), "hand-placed bundle survives");
822 assert!(uninstall("missing", &plugins).is_err());
823 }
824
825 #[cfg(unix)]
826 #[test]
827 fn uninstall_rejects_symlink_targets_escaping_the_plugins_root() {
828 let tmp = tempfile::tempdir().unwrap();
829 let plugins = tmp.path().join("plugins");
830 let outside = tmp.path().join("outside");
831 fs::create_dir_all(&plugins).unwrap();
832 fs::create_dir_all(&outside).unwrap();
833 fs::write(outside.join(INSTALLED_FROM_MARKER), "{}").unwrap();
834 std::os::unix::fs::symlink(&outside, plugins.join("linked")).unwrap();
835
836 let err = uninstall("linked", &plugins).unwrap_err();
837 assert!(format!("{err:#}").contains("escapes plugins directory"));
838 assert!(outside.exists());
839 }
840
841 // ── source parsing ────────────────────────────────────────────────────
842
843 #[test]
844 fn parse_routes_remote_and_local_specs() {
845 assert_eq!(
846 PluginInstallSource::parse("github:owner/repo").unwrap(),
847 PluginInstallSource::Remote(InstallSource::GitHubRepo("owner/repo".into()))
848 );
849 assert_eq!(
850 PluginInstallSource::parse("https://example.com/p.tar.gz").unwrap(),
851 PluginInstallSource::Remote(InstallSource::DirectUrl(
852 "https://example.com/p.tar.gz".into()
853 ))
854 );
855 assert_eq!(
856 PluginInstallSource::parse("./bundles/demo").unwrap(),
857 PluginInstallSource::LocalPath(PathBuf::from("./bundles/demo"))
858 );
859 assert_eq!(
860 PluginInstallSource::parse("path:/opt/demo").unwrap(),
861 PluginInstallSource::LocalPath(PathBuf::from("/opt/demo"))
862 );
863 assert!(PluginInstallSource::parse("").is_err());
864 assert!(PluginInstallSource::parse(" ").is_err());
865 assert!(PluginInstallSource::parse("path:").is_err());
866 }
867
868 // ── remote fetch against a loopback server ────────────────────────────
869
870 /// Serve each body once, in order, over plain loopback HTTP.
871 fn serve_bodies(bodies: Vec<Vec<u8>>) -> String {
872 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
873 let port = listener.local_addr().unwrap().port();
874 std::thread::spawn(move || {
875 for body in bodies {
876 let Ok((mut stream, _)) = listener.accept() else {
877 return;
878 };
879 // Consume the request headers before responding.
880 let mut request = Vec::new();
881 let mut buf = [0_u8; 1024];
882 loop {
883 use std::io::Read as _;
884 let read = stream.read(&mut buf).unwrap_or(0);
885 if read == 0 {
886 break;
887 }
888 request.extend_from_slice(&buf[..read]);
889 if request.windows(4).any(|window| window == b"\r\n\r\n") {
890 break;
891 }
892 }
893 use std::io::Write as _;
894 let head = format!(
895 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
896 body.len()
897 );
898 let _ = stream.write_all(head.as_bytes());
899 let _ = stream.write_all(&body);
900 let _ = stream.flush();
901 }
902 });
903 format!("http://127.0.0.1:{port}/plugin.tar.gz")
904 }
905
906 fn loopback_policy() -> NetworkPolicy {
907 NetworkPolicy {
908 allow: vec!["127.0.0.1".to_string()],
909 ..Default::default()
910 }
911 }
912
913 fn remote_bundle_bytes(name: &str, extra: &[u8]) -> Vec<u8> {
914 let manifest = format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n");
915 tarball(&[
916 ("repo-main/plugin.toml", manifest.as_bytes()),
917 ("repo-main/data.txt", extra),
918 ])
919 }
920
921 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
922 async fn marketplace_archive_selects_only_requested_bundle_and_updates_same_source() {
923 let tmp = tempfile::tempdir().unwrap();
924 let plugins = tmp.path().join("plugins");
925 let make_archive = |content: &[u8], name: &str| {
926 tarball(&[
927 (
928 "repo-main/plugins/demo/plugin.toml",
929 format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n")
930 .as_bytes(),
931 ),
932 ("repo-main/plugins/demo/data.txt", content),
933 (
934 "repo-main/plugins/other/plugin.toml",
935 b"schema_version = 1\n[plugin]\nname = \"other\"\nversion = \"1.0.0\"\n",
936 ),
937 ])
938 };
939 let v1 = make_archive(b"v1", "demo");
940 let v2 = make_archive(b"v2", "demo");
941 let url = format!(
942 "{}#path=plugins/demo",
943 serve_bodies(vec![v1.clone(), v1, v2, make_archive(b"renamed", "other")])
944 );
945 let outcome = install(
946 PluginInstallSource::parse(&url).unwrap(),
947 &plugins,
948 DEFAULT_MAX_SIZE_BYTES,
949 &loopback_policy(),
950 false,
951 &no_conflict(),
952 )
953 .await
954 .unwrap();
955 assert!(matches!(outcome, PluginInstallOutcome::Installed(_)));
956 assert!(!plugins.join("other").exists());
957 assert_eq!(fs::read(plugins.join("demo/data.txt")).unwrap(), b"v1");
958 assert!(matches!(
959 update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
960 .await
961 .unwrap(),
962 PluginUpdateResult::NoChange
963 ));
964 assert!(matches!(
965 update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
966 .await
967 .unwrap(),
968 PluginUpdateResult::Updated(_)
969 ));
970 assert_eq!(fs::read(plugins.join("demo/data.txt")).unwrap(), b"v2");
971 let marker: InstalledFromMarker =
972 serde_json::from_str(&fs::read_to_string(plugins.join("demo/.installed-from")).unwrap())
973 .unwrap();
974 assert_eq!(marker.spec, url);
975 assert!(
976 update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
977 .await
978 .is_err()
979 );
980 assert_eq!(fs::read(plugins.join("demo/data.txt")).unwrap(), b"v2");
981 assert!(!plugins.join("other").exists());
982 }
983
984 #[test]
985 fn archive_bundle_selector_rejects_traversal_missing_and_ambiguous_roots() {
986 for selector in [
987 "",
988 "/plugins/demo",
989 "../demo",
990 "plugins/../demo",
991 "plugins//demo",
992 "plugins/%2e%2e",
993 "plugins\\demo",
994 ] {
995 assert!(
996 PluginInstallSource::parse(&format!(
997 "https://example.test/repo.tar.gz#path={selector}"
998 ))
999 .is_err()
1000 );
1001 }
1002 let tmp = tempfile::tempdir().unwrap();
1003 let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
1004 let bytes = tarball(&[
1005 ("a/plugins/demo/plugin.toml", manifest),
1006 ("b/plugins/demo/plugin.toml", manifest),
1007 ]);
1008 for path in ["plugins/demo", "plugins/missing"] {
1009 assert!(stage_tarball(&bytes, tmp.path(), DEFAULT_MAX_SIZE_BYTES, Some(path)).is_err());
1010 }
1011 let linked = symlink_tarball(
1012 "repo/plugins/demo/link",
1013 "/tmp",
1014 "repo/plugins/demo/plugin.toml",
1015 );
1016 assert!(
1017 stage_tarball(
1018 &linked,
1019 tmp.path(),
1020 DEFAULT_MAX_SIZE_BYTES,
1021 Some("plugins/demo")
1022 )
1023 .is_err()
1024 );
1025 assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 0);
1026 }
1027
1028 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1029 async fn update_is_a_digest_noop_until_the_upstream_changes() {
1030 let tmp = tempfile::tempdir().unwrap();
1031 let plugins = tmp.path().join("plugins");
1032 let v1 = remote_bundle_bytes("demo", b"v1");
1033 let v2 = remote_bundle_bytes("demo", b"v2-changed");
1034 // install, update (same bytes → no-op), update (new bytes → swap).
1035 let url = serve_bodies(vec![v1.clone(), v1.clone(), v2.clone()]);
1036
1037 let outcome = install(
1038 PluginInstallSource::parse(&url).unwrap(),
1039 &plugins,
1040 DEFAULT_MAX_SIZE_BYTES,
1041 &loopback_policy(),
1042 false,
1043 &no_conflict(),
1044 )
1045 .await
1046 .unwrap();
1047 let PluginInstallOutcome::Installed(installed) = outcome else {
1048 panic!("expected install to succeed");
1049 };
1050 assert_eq!(installed.name, "demo");
1051 assert_eq!(
1052 fs::read(plugins.join("demo/data.txt")).unwrap(),
1053 b"v1".to_vec()
1054 );
1055
1056 let no_change = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
1057 .await
1058 .unwrap();
1059 assert!(
1060 matches!(no_change, PluginUpdateResult::NoChange),
1061 "identical upstream bytes must be a digest no-op"
1062 );
1063 assert_eq!(
1064 fs::read(plugins.join("demo/data.txt")).unwrap(),
1065 b"v1".to_vec()
1066 );
1067
1068 let changed = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
1069 .await
1070 .unwrap();
1071 let PluginUpdateResult::Updated(updated) = changed else {
1072 panic!("changed upstream bytes must swap the bundle");
1073 };
1074 assert_eq!(
1075 fs::read(updated.path.join("data.txt")).unwrap(),
1076 b"v2-changed".to_vec()
1077 );
1078 // The marker records the new checksum, so a following update against
1079 // the same bytes would be a no-op again.
1080 let marker: serde_json::Value = serde_json::from_str(
1081 &fs::read_to_string(updated.path.join(INSTALLED_FROM_MARKER)).unwrap(),
1082 )
1083 .unwrap();
1084 assert_eq!(marker["source_checksum"].as_str().unwrap(), sha256_hex(&v2));
1085 }
1086
1087 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1088 async fn remote_install_surfaces_policy_gates_without_touching_disk() {
1089 let tmp = tempfile::tempdir().unwrap();
1090 let plugins = tmp.path().join("plugins");
1091
1092 // Default policy prompts for unknown hosts.
1093 let outcome = install(
1094 PluginInstallSource::parse("https://plugin.example.invalid/x.tar.gz").unwrap(),
1095 &plugins,
1096 DEFAULT_MAX_SIZE_BYTES,
1097 &NetworkPolicy::default(),
1098 false,
1099 &no_conflict(),
1100 )
1101 .await
1102 .unwrap();
1103 assert!(
1104 matches!(
1105 outcome,
1106 PluginInstallOutcome::NeedsApproval(ref host) if host == "plugin.example.invalid"
1107 ),
1108 "got: {outcome:?}"
1109 );
1110
1111 let denied = NetworkPolicy {
1112 deny: vec!["plugin.example.invalid".to_string()],
1113 ..Default::default()
1114 };
1115 let outcome = install(
1116 PluginInstallSource::parse("https://plugin.example.invalid/x.tar.gz").unwrap(),
1117 &plugins,
1118 DEFAULT_MAX_SIZE_BYTES,
1119 &denied,
1120 false,
1121 &no_conflict(),
1122 )
1123 .await
1124 .unwrap();
1125 assert!(
1126 matches!(outcome, PluginInstallOutcome::NetworkDenied(_)),
1127 "got: {outcome:?}"
1128 );
1129 assert!(!plugins.join("demo").exists());
1130 }
1131
1132 /// Same manifest/MCP shape as the official Linear and GitHub marketplace
1133 /// bundles: metadata is nested, components and the flat MCP map are at root.
1134 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1135 async fn install_claude_bundle_keeps_root_components_and_review_hash() {
1136 let tmp = tempfile::tempdir().unwrap();
1137 let source = tmp.path().join("source");
1138 fs::create_dir_all(source.join(".claude-plugin")).unwrap();
1139 fs::create_dir_all(source.join("skills/triage")).unwrap();
1140 fs::write(
1141 source.join(".claude-plugin/plugin.json"),
1142 r#"{"name":"linear","description":"Issue tracking","author":{"name":"Linear"},"skills":"./skills/"}"#,
1143 )
1144 .unwrap();
1145 fs::write(
1146 source.join(".mcp.json"),
1147 r#"{"linear":{"type":"http","url":"https://mcp.linear.app/mcp"}}"#,
1148 )
1149 .unwrap();
1150 fs::write(
1151 source.join("skills/triage/SKILL.md"),
1152 "---\nname: triage\ndescription: Issue triage\n---\nRead issues.\n",
1153 )
1154 .unwrap();
1155 let plugins = tmp.path().join("plugins");
1156 let outcome = install(
1157 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
1158 &plugins,
1159 DEFAULT_MAX_SIZE_BYTES,
1160 &allow_all(),
1161 false,
1162 &no_conflict(),
1163 )
1164 .await
1165 .unwrap();
1166 let PluginInstallOutcome::Installed(installed) = outcome else {
1167 panic!("expected install")
1168 };
1169 let path = installed.path.join(".claude-plugin/plugin.json");
1170 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(&path).unwrap();
1171 assert_eq!(
1172 validated.canonical_root,
1173 installed.path.canonicalize().unwrap()
1174 );
1175 assert_eq!(validated.inventory.skills, 1);
1176 assert_eq!(validated.inventory.mcp_servers, 1);
1177 let server = &validated.manifest.mcp_servers.as_ref().unwrap()["linear"];
1178 assert_eq!(server.url.as_deref(), Some("https://mcp.linear.app/mcp"));
1179 assert!(server.command.is_none());
1180 assert!(
1181 server.transport.is_none(),
1182 "Streamable HTTP is the existing default HTTP transport"
1183 );
1184 fs::write(
1185 installed.path.join(".mcp.json"),
1186 r#"{"linear":{"type":"http","url":"https://changed.invalid/mcp"}}"#,
1187 )
1188 .unwrap();
1189 let changed = crate::plugins::manifest::PluginManifest::validate_from_path(&path).unwrap();
1190 assert_ne!(validated.content_hash, changed.content_hash);
1191 assert_ne!(validated.capability_hash, changed.capability_hash);
1192 }
1193
1194 #[test]
1195 fn claude_archive_stages_the_bundle_outside_metadata_directory() {
1196 let bytes = tarball(&[
1197 ("repo-main/.claude-plugin/plugin.json", br#"{"name":"github"}"#),
1198 ("repo-main/.mcp.json", br#"{"mcpServers":{"github":{"type":"http","url":"https://api.githubcopilot.com/mcp/","headers":{"Authorization":"Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}","X-Workspace":"${GITHUB_WORKSPACE}"}}}}"#),
1199 ("repo-main/skills/review/SKILL.md", b"---\nname: review\ndescription: Review\n---\nReview code.\n"),
1200 ]);
1201 let tmp = tempfile::tempdir().unwrap();
1202 let staged = stage_tarball(
1203 &bytes,
1204 &tmp.path().join("plugins"),
1205 DEFAULT_MAX_SIZE_BYTES,
1206 None,
1207 )
1208 .unwrap();
1209 assert_eq!(staged.name, "github");
1210 assert!(
1211 staged
1212 .staged_path
1213 .join(".claude-plugin/plugin.json")
1214 .is_file()
1215 );
1216 assert!(staged.staged_path.join(".mcp.json").is_file());
1217 assert!(staged.staged_path.join("skills/review/SKILL.md").is_file());
1218 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
1219 &staged.staged_path.join(".claude-plugin/plugin.json"),
1220 )
1221 .unwrap();
1222 let server = &validated.manifest.mcp_servers.as_ref().unwrap()["github"];
1223 assert!(server.headers.is_empty());
1224 assert_eq!(
1225 server.bearer_token_env_var.as_deref(),
1226 Some("GITHUB_PERSONAL_ACCESS_TOKEN")
1227 );
1228 assert_eq!(server.env_headers["X-Workspace"], "GITHUB_WORKSPACE");
1229 }
1230
1231 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1232 async fn claude_import_rejects_unsupported_components_without_partial_install() {
1233 for (file, body) in [
1234 ("hooks/hooks.json", "{}"),
1235 (".lsp.json", "{}"),
1236 (
1237 ".mcp.json",
1238 r#"{"demo":{"command":"node","unknownExecutableField":"run"}}"#,
1239 ),
1240 (
1241 ".mcp.json",
1242 r#"{"demo":{"type":"http","url":"https://example.com/mcp","headers":{"Authorization":"Bearer literal-secret"}}}"#,
1243 ),
1244 (
1245 ".mcp.json",
1246 r#"{"demo":{"type":"http","url":"https://example.com/mcp","headers":{"Authorization":"Bearer ${TOKEN}-suffix"}}}"#,
1247 ),
1248 (
1249 ".mcp.json",
1250 r#"{"demo":{"type":"http","url":"https://example.com/mcp","headers":{"Authorization":"${TOKEN} ${OTHER}"}}}"#,
1251 ),
1252 ] {
1253 let tmp = tempfile::tempdir().unwrap();
1254 let source = tmp.path().join("source");
1255 fs::create_dir_all(source.join(".claude-plugin")).unwrap();
1256 fs::write(
1257 source.join(".claude-plugin/plugin.json"),
1258 r#"{"name":"demo"}"#,
1259 )
1260 .unwrap();
1261 fs::create_dir_all(source.join(file).parent().unwrap()).unwrap();
1262 fs::write(source.join(file), body).unwrap();
1263 let plugins = tmp.path().join("plugins");
1264 assert!(
1265 install(
1266 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
1267 &plugins,
1268 DEFAULT_MAX_SIZE_BYTES,
1269 &allow_all(),
1270 false,
1271 &no_conflict()
1272 )
1273 .await
1274 .is_err(),
1275 "{file}"
1276 );
1277 assert!(!plugins.join("demo").exists());
1278 }
1279 }
1280
1281 #[cfg(unix)]
1282 #[test]
1283 fn claude_metadata_and_mcp_links_cannot_escape_bundle_validation() {
1284 use std::os::unix::fs::symlink;
1285 let tmp = tempfile::tempdir().unwrap();
1286 let root = tmp.path().join("bundle");
1287 let outside = tmp.path().join("outside");
1288 fs::create_dir_all(&root).unwrap();
1289 fs::create_dir_all(&outside).unwrap();
1290 fs::write(outside.join("plugin.json"), r#"{"name":"demo"}"#).unwrap();
1291 symlink(&outside, root.join(".claude-plugin")).unwrap();
1292 assert!(
1293 crate::plugins::manifest::PluginManifest::validate_from_path(
1294 &root.join(".claude-plugin/plugin.json")
1295 )
1296 .is_err()
1297 );
1298 fs::remove_file(root.join(".claude-plugin")).unwrap();
1299 fs::create_dir_all(root.join(".claude-plugin")).unwrap();
1300 fs::copy(
1301 outside.join("plugin.json"),
1302 root.join(".claude-plugin/plugin.json"),
1303 )
1304 .unwrap();
1305 fs::write(outside.join("mcp.json"), "{}").unwrap();
1306 symlink(outside.join("mcp.json"), root.join(".mcp.json")).unwrap();
1307 assert!(
1308 crate::plugins::manifest::PluginManifest::validate_from_path(
1309 &root.join(".claude-plugin/plugin.json")
1310 )
1311 .is_err()
1312 );
1313 }
1314
1314 lines RUST