返回 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 fn symlink_tarball(link_path: &str, target: &str, manifest: &str) -> Vec<u8> {
32 let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
33 let mut builder = tar::Builder::new(encoder);
34 let body = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
35 let mut header = tar::Header::new_gnu();
36 header.set_size(body.len() as u64);
37 header.set_mode(0o644);
38 header.set_cksum();
39 builder
40 .append_data(&mut header, manifest, &body[..])
41 .unwrap();
42 let mut link_header = tar::Header::new_gnu();
43 link_header.set_entry_type(tar::EntryType::Symlink);
44 link_header.set_size(0);
45 link_header.set_mode(0o777);
46 link_header.set_cksum();
47 builder
48 .append_link(&mut link_header, link_path, target)
49 .unwrap();
50 let encoder = builder.into_inner().unwrap();
51 encoder.finish().unwrap()
52 }
53
54 /// Emit one raw ustar file entry with an arbitrary (possibly hostile) name.
55 /// `tar::Builder` refuses `..` and absolute paths on write, so adversarial
56 /// archives have to be assembled byte-by-byte.
57 fn raw_tar_file_entry(name: &[u8], body: &[u8]) -> Vec<u8> {
58 let mut header = [0_u8; 512];
59 header[..name.len()].copy_from_slice(name);
60 header[100..108].copy_from_slice(b"0000644\0");
61 header[108..116].copy_from_slice(b"0000000\0");
62 header[116..124].copy_from_slice(b"0000000\0");
63 let size = format!("{:011o}\0", body.len());
64 header[124..136].copy_from_slice(size.as_bytes());
65 header[136..148].copy_from_slice(b"00000000000\0");
66 header[148..156].copy_from_slice(b" ");
67 header[156] = b'0';
68 header[257..263].copy_from_slice(b"ustar\0");
69 header[263..265].copy_from_slice(b"00");
70 let checksum: u32 = header.iter().map(|byte| u32::from(*byte)).sum();
71 let checksum = format!("{checksum:06o}\0 ");
72 header[148..156].copy_from_slice(checksum.as_bytes());
73 let mut out = header.to_vec();
74 out.extend_from_slice(body);
75 let padding = (512 - body.len() % 512) % 512;
76 out.extend(std::iter::repeat_n(0, padding));
77 out
78 }
79
80 fn raw_tarball(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
81 use std::io::Write as _;
82
83 let mut tar_bytes = Vec::new();
84 for (name, body) in entries {
85 tar_bytes.extend(raw_tar_file_entry(name, body));
86 }
87 tar_bytes.extend(std::iter::repeat_n(0, 1024));
88 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
89 encoder.write_all(&tar_bytes).unwrap();
90 encoder.finish().unwrap()
91 }
92
93 fn allow_all() -> NetworkPolicy {
94 NetworkPolicy {
95 default: crate::network_policy::DecisionToml::Allow,
96 ..Default::default()
97 }
98 }
99
100 fn no_conflict() -> impl Fn(&str) -> Option<String> {
101 |_| None
102 }
103
104 // ── scan/extract rules ────────────────────────────────────────────────
105
106 #[test]
107 fn scan_rejects_path_traversal() {
108 let bytes = raw_tarball(&[(
109 b"repo-main/../evil/plugin.toml",
110 b"schema_version = 1\n[plugin]\nname = \"evil\"\n",
111 )]);
112 let err = scan_tarball(&bytes, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
113 assert!(
114 matches!(
115 err.downcast_ref::<PluginInstallError>(),
116 Some(PluginInstallError::PathTraversal(_))
117 ),
118 "got: {err:#}"
119 );
120 }
121
122 #[test]
123 fn scan_rejects_absolute_paths() {
124 let bytes = raw_tarball(&[(
125 b"/tmp/evil/plugin.toml",
126 b"schema_version = 1\n[plugin]\nname = \"evil\"\n",
127 )]);
128 assert!(scan_tarball(&bytes, DEFAULT_MAX_SIZE_BYTES).is_err());
129 }
130
131 #[test]
132 fn scan_enforces_size_cap() {
133 let body = vec![b'x'; 1024];
134 let bytes = tarball(&[
135 (
136 "repo-main/plugin.toml",
137 b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n",
138 ),
139 ("repo-main/blob.bin", &body),
140 ]);
141 let err = scan_tarball(&bytes, 512).unwrap_err();
142 assert!(
143 matches!(
144 err.downcast_ref::<PluginInstallError>(),
145 Some(PluginInstallError::OversizedBundle { .. })
146 ),
147 "got: {err:#}"
148 );
149 }
150
151 #[test]
152 fn scan_requires_exactly_one_plugin_toml_root() {
153 let zero = tarball(&[("repo-main/README.md", b"no manifest here")]);
154 let err = scan_tarball(&zero, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
155 assert!(
156 matches!(
157 err.downcast_ref::<PluginInstallError>(),
158 Some(PluginInstallError::PluginTomlRoots(0))
159 ),
160 "got: {err:#}"
161 );
162
163 let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
164 let two = tarball(&[
165 ("repo-main/plugin.toml", manifest),
166 ("repo-main/examples/other/plugin.toml", manifest),
167 ]);
168 let err = scan_tarball(&two, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
169 assert!(
170 matches!(
171 err.downcast_ref::<PluginInstallError>(),
172 Some(PluginInstallError::PluginTomlRoots(2))
173 ),
174 "got: {err:#}"
175 );
176 }
177
178 #[test]
179 fn extract_rejects_symlinks_inside_the_bundle_subtree() {
180 let bytes = symlink_tarball(
181 "repo-main/evil-link",
182 "/etc/passwd",
183 "repo-main/plugin.toml",
184 );
185 let tmp = tempfile::tempdir().unwrap();
186 let plugins = tmp.path().join("plugins");
187 let err = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES).unwrap_err();
188 assert!(
189 matches!(
190 err.downcast_ref::<PluginInstallError>(),
191 Some(PluginInstallError::SymlinkRejected)
192 ),
193 "got: {err:#}"
194 );
195 assert!(fs::read_dir(&plugins).unwrap().next().is_none());
196 }
197
198 #[test]
199 fn extract_ignores_entries_outside_the_bundle_subtree() {
200 let manifest = b"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n";
201 let bytes = tarball(&[
202 ("repo-main/bundles/demo/plugin.toml", manifest),
203 (
204 "repo-main/bundles/demo/skills/a/SKILL.md",
205 b"---\nname: a\ndescription: a\n---\n",
206 ),
207 ("repo-main/other/plugin.toml.bak", b"ignored"),
208 ("repo-main/README.md", b"repo docs stay behind"),
209 ]);
210 let tmp = tempfile::tempdir().unwrap();
211 let plugins = tmp.path().join("plugins");
212 let staged = stage_tarball(&bytes, &plugins, DEFAULT_MAX_SIZE_BYTES).unwrap();
213 assert_eq!(staged.name, "demo");
214 assert!(staged.staged_path.join("plugin.toml").exists());
215 assert!(staged.staged_path.join("skills/a/SKILL.md").exists());
216 assert!(!staged.staged_path.join("README.md").exists());
217 assert!(!staged.staged_path.join("other").exists());
218 fs::remove_dir_all(&staged.staged_path).unwrap();
219 }
220
221 // ── local copy rules ──────────────────────────────────────────────────
222
223 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
224 async fn install_from_local_path_copies_and_marks_the_bundle() {
225 let tmp = tempfile::tempdir().unwrap();
226 let plugins = tmp.path().join("plugins");
227 let source = write_bundle(tmp.path(), "src/demo", "demo");
228 fs::create_dir_all(source.join("skills/hello")).unwrap();
229 fs::write(
230 source.join("skills/hello/SKILL.md"),
231 "---\nname: hello\ndescription: hi\n---\nbody\n",
232 )
233 .unwrap();
234
235 let outcome = install(
236 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
237 &plugins,
238 DEFAULT_MAX_SIZE_BYTES,
239 &allow_all(),
240 false,
241 &no_conflict(),
242 )
243 .await
244 .unwrap();
245 let PluginInstallOutcome::Installed(installed) = outcome else {
246 panic!("expected install to succeed");
247 };
248 assert_eq!(installed.name, "demo");
249 assert_eq!(installed.path, plugins.join("demo"));
250 assert!(installed.path.join("plugin.toml").exists());
251 assert!(installed.path.join("skills/hello/SKILL.md").exists());
252 let marker: serde_json::Value = serde_json::from_str(
253 &fs::read_to_string(installed.path.join(INSTALLED_FROM_MARKER)).unwrap(),
254 )
255 .unwrap();
256 assert!(marker["spec"].as_str().unwrap().starts_with("path:"));
257 // Local copies must not inherit a stale provenance marker.
258 assert_ne!(marker["spec"].as_str().unwrap(), "path:");
259 }
260
261 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
262 async fn install_refuses_to_overwrite_a_hand_placed_bundle() {
263 let tmp = tempfile::tempdir().unwrap();
264 let plugins = tmp.path().join("plugins");
265 write_bundle(&plugins, "demo", "demo");
266 let source = write_bundle(tmp.path(), "src/demo", "demo");
267
268 let err = install(
269 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
270 &plugins,
271 DEFAULT_MAX_SIZE_BYTES,
272 &allow_all(),
273 false,
274 &no_conflict(),
275 )
276 .await
277 .unwrap_err();
278 assert!(
279 matches!(
280 err.downcast_ref::<PluginInstallError>(),
281 Some(PluginInstallError::NotInstalledHere(_))
282 ),
283 "hand-placed bundle must be protected, got: {err:#}"
284 );
285 assert!(
286 !plugins.join("demo/skills").exists(),
287 "no partial overwrite"
288 );
289
290 // A bundle that *was* installed here gets the AlreadyInstalled hint.
291 fs::write(plugins.join("demo").join(INSTALLED_FROM_MARKER), "{}").unwrap();
292 let err = install(
293 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
294 &plugins,
295 DEFAULT_MAX_SIZE_BYTES,
296 &allow_all(),
297 false,
298 &no_conflict(),
299 )
300 .await
301 .unwrap_err();
302 assert!(
303 matches!(
304 err.downcast_ref::<PluginInstallError>(),
305 Some(PluginInstallError::AlreadyInstalled(_))
306 ),
307 "got: {err:#}"
308 );
309 }
310
311 #[cfg(unix)]
312 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
313 async fn local_install_rejects_symlinks_in_the_source() {
314 let tmp = tempfile::tempdir().unwrap();
315 let plugins = tmp.path().join("plugins");
316 let source = write_bundle(tmp.path(), "src/demo", "demo");
317 std::os::unix::fs::symlink("/etc/passwd", source.join("linked")).unwrap();
318
319 let err = install(
320 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
321 &plugins,
322 DEFAULT_MAX_SIZE_BYTES,
323 &allow_all(),
324 false,
325 &no_conflict(),
326 )
327 .await
328 .unwrap_err();
329 // The bundle validator rejects symlinked content before any copy runs.
330 assert!(format!("{err:#}").contains("symbolic link"), "got: {err:#}");
331 assert!(!plugins.join("demo").exists());
332 }
333
334 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
335 async fn install_refuses_sources_inside_the_plugins_root() {
336 let tmp = tempfile::tempdir().unwrap();
337 let plugins = tmp.path().join("plugins");
338 let nested = write_bundle(&plugins, "demo", "demo");
339 let err = install(
340 PluginInstallSource::parse(nested.to_str().unwrap()).unwrap(),
341 &plugins,
342 DEFAULT_MAX_SIZE_BYTES,
343 &allow_all(),
344 false,
345 &no_conflict(),
346 )
347 .await
348 .unwrap_err();
349 assert!(format!("{err:#}").contains("inside the user plugins directory"));
350 }
351
352 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
353 async fn install_enforces_the_name_conflict_hook() {
354 let tmp = tempfile::tempdir().unwrap();
355 let plugins = tmp.path().join("plugins");
356 let source = write_bundle(tmp.path(), "src/demo", "demo");
357 let err = install(
358 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
359 &plugins,
360 DEFAULT_MAX_SIZE_BYTES,
361 &allow_all(),
362 false,
363 &|name| Some(format!("name '{name}' is shadowed by a builtin bundle")),
364 )
365 .await
366 .unwrap_err();
367 assert!(format!("{err:#}").contains("shadowed by a builtin bundle"));
368 assert!(!plugins.join("demo").exists());
369 // The staging dir must be cleaned up on the conflict path.
370 assert!(
371 !fs::read_dir(&plugins)
372 .map(|mut entries| entries.any(|entry| entry
373 .unwrap()
374 .file_name()
375 .to_string_lossy()
376 .starts_with(".staging-")))
377 .unwrap_or(false)
378 );
379 }
380
381 // ── update / uninstall ────────────────────────────────────────────────
382
383 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
384 async fn update_refuses_local_installs_and_missing_markers() {
385 let tmp = tempfile::tempdir().unwrap();
386 let plugins = tmp.path().join("plugins");
387 let source = write_bundle(tmp.path(), "src/demo", "demo");
388 install(
389 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
390 &plugins,
391 DEFAULT_MAX_SIZE_BYTES,
392 &allow_all(),
393 false,
394 &no_conflict(),
395 )
396 .await
397 .unwrap();
398
399 let err = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
400 .await
401 .unwrap_err();
402 assert!(format!("{err:#}").contains("local path"), "got: {err:#}");
403
404 write_bundle(&plugins, "hand", "hand");
405 let err = update("hand", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
406 .await
407 .unwrap_err();
408 assert!(
409 matches!(
410 err.downcast_ref::<PluginInstallError>(),
411 Some(PluginInstallError::NotInstalledHere(_))
412 ),
413 "got: {err:#}"
414 );
415 }
416
417 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
418 async fn uninstall_requires_the_marker_and_removes_the_bundle() {
419 let tmp = tempfile::tempdir().unwrap();
420 let plugins = tmp.path().join("plugins");
421 let source = write_bundle(tmp.path(), "src/demo", "demo");
422 install(
423 PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
424 &plugins,
425 DEFAULT_MAX_SIZE_BYTES,
426 &allow_all(),
427 false,
428 &no_conflict(),
429 )
430 .await
431 .unwrap();
432
433 uninstall("demo", &plugins).unwrap();
434 assert!(!plugins.join("demo").exists());
435
436 write_bundle(&plugins, "hand", "hand");
437 let err = uninstall("hand", &plugins).unwrap_err();
438 assert!(
439 matches!(
440 err.downcast_ref::<PluginInstallError>(),
441 Some(PluginInstallError::NotInstalledHere(_))
442 ),
443 "got: {err:#}"
444 );
445 assert!(plugins.join("hand").exists(), "hand-placed bundle survives");
446 assert!(uninstall("missing", &plugins).is_err());
447 }
448
449 #[cfg(unix)]
450 #[test]
451 fn uninstall_rejects_symlink_targets_escaping_the_plugins_root() {
452 let tmp = tempfile::tempdir().unwrap();
453 let plugins = tmp.path().join("plugins");
454 let outside = tmp.path().join("outside");
455 fs::create_dir_all(&plugins).unwrap();
456 fs::create_dir_all(&outside).unwrap();
457 fs::write(outside.join(INSTALLED_FROM_MARKER), "{}").unwrap();
458 std::os::unix::fs::symlink(&outside, plugins.join("linked")).unwrap();
459
460 let err = uninstall("linked", &plugins).unwrap_err();
461 assert!(format!("{err:#}").contains("escapes plugins directory"));
462 assert!(outside.exists());
463 }
464
465 // ── source parsing ────────────────────────────────────────────────────
466
467 #[test]
468 fn parse_routes_remote_and_local_specs() {
469 assert_eq!(
470 PluginInstallSource::parse("github:owner/repo").unwrap(),
471 PluginInstallSource::Remote(InstallSource::GitHubRepo("owner/repo".into()))
472 );
473 assert_eq!(
474 PluginInstallSource::parse("https://example.com/p.tar.gz").unwrap(),
475 PluginInstallSource::Remote(InstallSource::DirectUrl(
476 "https://example.com/p.tar.gz".into()
477 ))
478 );
479 assert_eq!(
480 PluginInstallSource::parse("./bundles/demo").unwrap(),
481 PluginInstallSource::LocalPath(PathBuf::from("./bundles/demo"))
482 );
483 assert_eq!(
484 PluginInstallSource::parse("path:/opt/demo").unwrap(),
485 PluginInstallSource::LocalPath(PathBuf::from("/opt/demo"))
486 );
487 assert!(PluginInstallSource::parse("").is_err());
488 assert!(PluginInstallSource::parse(" ").is_err());
489 assert!(PluginInstallSource::parse("path:").is_err());
490 }
491
492 // ── remote fetch against a loopback server ────────────────────────────
493
494 /// Serve each body once, in order, over plain loopback HTTP.
495 fn serve_bodies(bodies: Vec<Vec<u8>>) -> String {
496 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
497 let port = listener.local_addr().unwrap().port();
498 std::thread::spawn(move || {
499 for body in bodies {
500 let Ok((mut stream, _)) = listener.accept() else {
501 return;
502 };
503 // Consume the request headers before responding.
504 let mut request = Vec::new();
505 let mut buf = [0_u8; 1024];
506 loop {
507 use std::io::Read as _;
508 let read = stream.read(&mut buf).unwrap_or(0);
509 if read == 0 {
510 break;
511 }
512 request.extend_from_slice(&buf[..read]);
513 if request.windows(4).any(|window| window == b"\r\n\r\n") {
514 break;
515 }
516 }
517 use std::io::Write as _;
518 let head = format!(
519 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
520 body.len()
521 );
522 let _ = stream.write_all(head.as_bytes());
523 let _ = stream.write_all(&body);
524 let _ = stream.flush();
525 }
526 });
527 format!("http://127.0.0.1:{port}/plugin.tar.gz")
528 }
529
530 fn loopback_policy() -> NetworkPolicy {
531 NetworkPolicy {
532 allow: vec!["127.0.0.1".to_string()],
533 ..Default::default()
534 }
535 }
536
537 fn remote_bundle_bytes(name: &str, extra: &[u8]) -> Vec<u8> {
538 let manifest = format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n");
539 tarball(&[
540 ("repo-main/plugin.toml", manifest.as_bytes()),
541 ("repo-main/data.txt", extra),
542 ])
543 }
544
545 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
546 async fn update_is_a_digest_noop_until_the_upstream_changes() {
547 let tmp = tempfile::tempdir().unwrap();
548 let plugins = tmp.path().join("plugins");
549 let v1 = remote_bundle_bytes("demo", b"v1");
550 let v2 = remote_bundle_bytes("demo", b"v2-changed");
551 // install, update (same bytes → no-op), update (new bytes → swap).
552 let url = serve_bodies(vec![v1.clone(), v1.clone(), v2.clone()]);
553
554 let outcome = install(
555 PluginInstallSource::parse(&url).unwrap(),
556 &plugins,
557 DEFAULT_MAX_SIZE_BYTES,
558 &loopback_policy(),
559 false,
560 &no_conflict(),
561 )
562 .await
563 .unwrap();
564 let PluginInstallOutcome::Installed(installed) = outcome else {
565 panic!("expected install to succeed");
566 };
567 assert_eq!(installed.name, "demo");
568 assert_eq!(
569 fs::read(plugins.join("demo/data.txt")).unwrap(),
570 b"v1".to_vec()
571 );
572
573 let no_change = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
574 .await
575 .unwrap();
576 assert!(
577 matches!(no_change, PluginUpdateResult::NoChange),
578 "identical upstream bytes must be a digest no-op"
579 );
580 assert_eq!(
581 fs::read(plugins.join("demo/data.txt")).unwrap(),
582 b"v1".to_vec()
583 );
584
585 let changed = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &loopback_policy())
586 .await
587 .unwrap();
588 let PluginUpdateResult::Updated(updated) = changed else {
589 panic!("changed upstream bytes must swap the bundle");
590 };
591 assert_eq!(
592 fs::read(updated.path.join("data.txt")).unwrap(),
593 b"v2-changed".to_vec()
594 );
595 // The marker records the new checksum, so a following update against
596 // the same bytes would be a no-op again.
597 let marker: serde_json::Value = serde_json::from_str(
598 &fs::read_to_string(updated.path.join(INSTALLED_FROM_MARKER)).unwrap(),
599 )
600 .unwrap();
601 assert_eq!(marker["source_checksum"].as_str().unwrap(), sha256_hex(&v2));
602 }
603
604 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
605 async fn remote_install_surfaces_policy_gates_without_touching_disk() {
606 let tmp = tempfile::tempdir().unwrap();
607 let plugins = tmp.path().join("plugins");
608
609 // Default policy prompts for unknown hosts.
610 let outcome = install(
611 PluginInstallSource::parse("https://plugin.example.invalid/x.tar.gz").unwrap(),
612 &plugins,
613 DEFAULT_MAX_SIZE_BYTES,
614 &NetworkPolicy::default(),
615 false,
616 &no_conflict(),
617 )
618 .await
619 .unwrap();
620 assert!(
621 matches!(
622 outcome,
623 PluginInstallOutcome::NeedsApproval(ref host) if host == "plugin.example.invalid"
624 ),
625 "got: {outcome:?}"
626 );
627
628 let denied = NetworkPolicy {
629 deny: vec!["plugin.example.invalid".to_string()],
630 ..Default::default()
631 };
632 let outcome = install(
633 PluginInstallSource::parse("https://plugin.example.invalid/x.tar.gz").unwrap(),
634 &plugins,
635 DEFAULT_MAX_SIZE_BYTES,
636 &denied,
637 false,
638 &no_conflict(),
639 )
640 .await
641 .unwrap();
642 assert!(
643 matches!(outcome, PluginInstallOutcome::NetworkDenied(_)),
644 "got: {outcome:?}"
645 );
646 assert!(!plugins.join("demo").exists());
647 }
648
648 lines RUST