返回 DeepSeek-TUI-2026
skill_install.rs
根目录 / crates / tui / tests / skill_install.rs
1 //! Integration tests for the community-skill installer (#140).
2 //!
3 //! These tests exercise the full validation pipeline against a tiny in-process
4 //! HTTP server, so the network gate, download cap, tarball validation, atomic
5 //! rename, and `.installed-from` marker all run end-to-end. The module is
6 //! pulled in via `#[path]` includes (matching `integration_mock_llm.rs`) so we
7 //! get access to private helpers without a separate library crate.
8
9 use std::io::Write;
10 use std::path::Path;
11
12 use flate2::Compression;
13 use flate2::write::GzEncoder;
14 use tempfile::TempDir;
15 use tiny_http::{Method, Response, Server};
16
17 // Pull the production source files into this test binary so the test can
18 // reach `install`'s public surface without a dedicated library crate.
19 //
20 // `install.rs` only references `crate::network_policy` so we just need that
21 // one helper module alongside `install` itself.
22 #[path = "../src/network_policy.rs"]
23 mod network_policy;
24
25 #[path = "../src/skills/install.rs"]
26 #[allow(dead_code)]
27 mod install;
28
29 use crate::install::{InstallOutcome, InstallSource, UpdateResult};
30 use crate::network_policy::{DecisionToml, NetworkPolicy};
31
32 /// Construct a gzipped tarball from `(path, body)` pairs. Permissions are set
33 /// to 0o644 so umask differences across platforms don't perturb the bytes.
34 fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
35 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
36 {
37 let mut builder = tar::Builder::new(&mut gz);
38 for (path, body) in entries {
39 let mut header = tar::Header::new_gnu();
40 header.set_size(body.len() as u64);
41 header.set_mode(0o644);
42 header.set_cksum();
43 builder
44 .append_data(&mut header, path, *body)
45 .expect("append_data");
46 }
47 builder.finish().expect("finish tar");
48 }
49 gz.finish().expect("finish gz")
50 }
51
52 fn skill_md(name: &str, description: &str) -> Vec<u8> {
53 format!(
54 "---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nThis is a test skill.\n"
55 )
56 .into_bytes()
57 }
58
59 fn allow_all_policy() -> NetworkPolicy {
60 NetworkPolicy {
61 default: DecisionToml::Allow,
62 allow: Vec::new(),
63 deny: Vec::new(),
64 audit: false,
65 }
66 }
67
68 fn deny_all_policy() -> NetworkPolicy {
69 NetworkPolicy {
70 default: DecisionToml::Deny,
71 allow: Vec::new(),
72 deny: Vec::new(),
73 audit: false,
74 }
75 }
76
77 fn prompt_all_policy() -> NetworkPolicy {
78 NetworkPolicy {
79 default: DecisionToml::Prompt,
80 allow: Vec::new(),
81 deny: Vec::new(),
82 audit: false,
83 }
84 }
85
86 /// Spawn a tiny HTTP server that serves `bytes` at any path with 200 OK and
87 /// returns the bound URL. The server replies to *every* request (we re-use it
88 /// across multiple installs in the same test).
89 fn spawn_tarball_server(
90 bytes: Vec<u8>,
91 ) -> (
92 String,
93 std::sync::mpsc::Sender<()>,
94 std::thread::JoinHandle<()>,
95 ) {
96 let server = Server::http("127.0.0.1:0").expect("bind ephemeral port");
97 let url = format!(
98 "http://{}/skill.tar.gz",
99 server.server_addr().to_ip().expect("ip addr")
100 );
101 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
102 let handle = std::thread::spawn(move || {
103 loop {
104 // Poll-style with a small recv timeout so we can break out cleanly.
105 match server.recv_timeout(std::time::Duration::from_millis(100)) {
106 Ok(Some(req)) => {
107 if req.method() != &Method::Get {
108 continue;
109 }
110 let response = Response::from_data(bytes.clone());
111 let _ = req.respond(response);
112 }
113 Ok(None) => {}
114 Err(_) => break,
115 }
116 if shutdown_rx.try_recv().is_ok() {
117 break;
118 }
119 }
120 });
121 (url, shutdown_tx, handle)
122 }
123
124 fn shutdown(tx: std::sync::mpsc::Sender<()>, handle: std::thread::JoinHandle<()>) {
125 let _ = tx.send(());
126 let _ = handle.join();
127 }
128
129 #[tokio::test]
130 async fn install_happy_path_writes_skill_and_marker() {
131 let tarball = make_tarball(&[
132 (
133 "test-skill-main/SKILL.md",
134 &skill_md("test-skill", "Test skill"),
135 ),
136 ("test-skill-main/notes.txt", b"hello world"),
137 ]);
138 let (url, tx, handle) = spawn_tarball_server(tarball);
139
140 let tmp = TempDir::new().unwrap();
141 let policy = allow_all_policy();
142
143 let outcome = install::install(
144 InstallSource::DirectUrl(url),
145 tmp.path(),
146 install::DEFAULT_MAX_SIZE_BYTES,
147 &policy,
148 false,
149 )
150 .await
151 .expect("install ok");
152
153 let installed = match outcome {
154 InstallOutcome::Installed(s) => s,
155 other => panic!("expected Installed, got {other:?}"),
156 };
157 assert_eq!(installed.name, "test-skill");
158
159 let installed_dir = tmp.path().join("test-skill");
160 assert!(installed_dir.is_dir(), "skill dir created");
161 assert!(installed_dir.join("SKILL.md").is_file(), "SKILL.md present");
162 assert!(
163 installed_dir.join("notes.txt").is_file(),
164 "extra file present"
165 );
166 assert!(
167 installed_dir.join(install::INSTALLED_FROM_MARKER).is_file(),
168 ".installed-from marker present"
169 );
170
171 shutdown(tx, handle);
172 }
173
174 #[tokio::test]
175 async fn install_rejects_path_traversal() {
176 // `tar::Builder::append_data` rejects `..` itself, so we craft the bad
177 // entry by writing the raw header bytes via `append`.
178 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
179 {
180 let mut builder = tar::Builder::new(&mut gz);
181 let body = skill_md("test-skill", "T");
182 let mut hdr = tar::Header::new_gnu();
183 hdr.set_size(body.len() as u64);
184 hdr.set_mode(0o644);
185 hdr.set_cksum();
186 builder
187 .append_data(&mut hdr, "test-skill-main/SKILL.md", body.as_slice())
188 .unwrap();
189
190 // Path-traversal entry. The `tar` crate's `set_path` rejects `..`
191 // itself, so we patch the raw 100-byte name field in the header.
192 let evil_body: &[u8] = b"not gonna happen";
193 let mut evil_hdr = tar::Header::new_gnu();
194 evil_hdr.set_size(evil_body.len() as u64);
195 evil_hdr.set_mode(0o644);
196 // Write a name with a `..` directly into the legacy "name" field.
197 let bytes = evil_hdr.as_old_mut();
198 let evil_name = b"../etc/passwd";
199 bytes.name[..evil_name.len()].copy_from_slice(evil_name);
200 evil_hdr.set_cksum();
201 builder.append(&evil_hdr, evil_body).unwrap();
202 builder.finish().unwrap();
203 }
204 let tarball = gz.finish().unwrap();
205 let (url, tx, handle) = spawn_tarball_server(tarball);
206
207 let tmp = TempDir::new().unwrap();
208 let policy = allow_all_policy();
209 let err = install::install(
210 InstallSource::DirectUrl(url),
211 tmp.path(),
212 install::DEFAULT_MAX_SIZE_BYTES,
213 &policy,
214 false,
215 )
216 .await
217 .expect_err("path traversal must be rejected");
218 let msg = format!("{err:#}");
219 assert!(
220 msg.contains("escapes destination"),
221 "expected path-traversal error, got: {msg}"
222 );
223
224 shutdown(tx, handle);
225 }
226
227 #[tokio::test]
228 async fn install_rejects_oversized_tarball() {
229 let big = vec![b'a'; 256 * 1024]; // 256 KiB per file
230 let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
231 entries.push((
232 "test-skill-main/SKILL.md".to_string(),
233 skill_md("test-skill", "T"),
234 ));
235 for i in 0..50 {
236 entries.push((format!("test-skill-main/big-{i}.bin"), big.clone()));
237 }
238 let entry_refs: Vec<(&str, &[u8])> = entries
239 .iter()
240 .map(|(p, b)| (p.as_str(), b.as_slice()))
241 .collect();
242 let tarball = make_tarball(&entry_refs);
243 let (url, tx, handle) = spawn_tarball_server(tarball);
244
245 let tmp = TempDir::new().unwrap();
246 let policy = allow_all_policy();
247 let small_cap = 1024 * 1024;
248 let err = install::install(
249 InstallSource::DirectUrl(url),
250 tmp.path(),
251 small_cap,
252 &policy,
253 false,
254 )
255 .await
256 .expect_err("oversized must be rejected");
257 let msg = format!("{err:#}");
258 assert!(
259 msg.contains("too large") || msg.contains("exceed"),
260 "expected size cap error, got: {msg}"
261 );
262
263 shutdown(tx, handle);
264 }
265
266 #[tokio::test]
267 async fn install_rejects_missing_skill_md() {
268 let tarball = make_tarball(&[("repo-main/README.md", b"not a skill")]);
269 let (url, tx, handle) = spawn_tarball_server(tarball);
270
271 let tmp = TempDir::new().unwrap();
272 let policy = allow_all_policy();
273 let err = install::install(
274 InstallSource::DirectUrl(url),
275 tmp.path(),
276 install::DEFAULT_MAX_SIZE_BYTES,
277 &policy,
278 false,
279 )
280 .await
281 .expect_err("missing SKILL.md must be rejected");
282 assert!(format!("{err:#}").contains("missing SKILL.md"), "{err:#}");
283
284 shutdown(tx, handle);
285 }
286
287 #[tokio::test]
288 async fn install_rejects_missing_required_frontmatter() {
289 let tarball = make_tarball(&[("repo-main/SKILL.md", b"---\nname: test\n---\nbody\n")]);
290 let (url, tx, handle) = spawn_tarball_server(tarball);
291
292 let tmp = TempDir::new().unwrap();
293 let policy = allow_all_policy();
294 let err = install::install(
295 InstallSource::DirectUrl(url),
296 tmp.path(),
297 install::DEFAULT_MAX_SIZE_BYTES,
298 &policy,
299 false,
300 )
301 .await
302 .expect_err("missing description must be rejected");
303 assert!(format!("{err:#}").contains("description"), "{err:#}");
304
305 shutdown(tx, handle);
306 }
307
308 #[tokio::test]
309 async fn install_idempotent_then_uninstall_then_reinstall() {
310 let tarball_bytes =
311 make_tarball(&[("repo-main/SKILL.md", &skill_md("idem-skill", "Idempotent"))]);
312 let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
313
314 let tmp = TempDir::new().unwrap();
315 let policy = allow_all_policy();
316
317 install::install(
318 InstallSource::DirectUrl(url.clone()),
319 tmp.path(),
320 install::DEFAULT_MAX_SIZE_BYTES,
321 &policy,
322 false,
323 )
324 .await
325 .expect("first install ok");
326
327 // Second install with `update = false` must reject.
328 let err = install::install(
329 InstallSource::DirectUrl(url.clone()),
330 tmp.path(),
331 install::DEFAULT_MAX_SIZE_BYTES,
332 &policy,
333 false,
334 )
335 .await
336 .expect_err("second install must reject");
337 let msg = format!("{err:#}");
338 assert!(
339 msg.contains("already installed"),
340 "expected already-installed error, got: {msg}"
341 );
342
343 // Uninstall then reinstall.
344 install::uninstall("idem-skill", tmp.path()).expect("uninstall ok");
345 assert!(!tmp.path().join("idem-skill").exists());
346
347 install::install(
348 InstallSource::DirectUrl(url),
349 tmp.path(),
350 install::DEFAULT_MAX_SIZE_BYTES,
351 &policy,
352 false,
353 )
354 .await
355 .expect("reinstall ok");
356
357 assert!(tmp.path().join("idem-skill").join("SKILL.md").is_file());
358 shutdown(tx, handle);
359 }
360
361 #[tokio::test]
362 async fn update_no_change_returns_nochange_without_overwriting() {
363 let tarball_bytes =
364 make_tarball(&[("repo-main/SKILL.md", &skill_md("upd-skill", "Update test"))]);
365 let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
366 let tmp = TempDir::new().unwrap();
367 let policy = allow_all_policy();
368
369 install::install(
370 InstallSource::DirectUrl(url.clone()),
371 tmp.path(),
372 install::DEFAULT_MAX_SIZE_BYTES,
373 &policy,
374 false,
375 )
376 .await
377 .unwrap();
378
379 // Patch the marker so update() re-fetches the same URL.
380 let marker_path = tmp
381 .path()
382 .join("upd-skill")
383 .join(install::INSTALLED_FROM_MARKER);
384 let marker_body = std::fs::read_to_string(&marker_path).unwrap();
385 let mut marker_json: serde_json::Value = serde_json::from_str(&marker_body).unwrap();
386 marker_json["spec"] = serde_json::Value::String(url);
387 std::fs::write(&marker_path, marker_json.to_string()).unwrap();
388
389 // Capture mtime so we can confirm SKILL.md wasn't rewritten.
390 let skill_md_path = tmp.path().join("upd-skill").join("SKILL.md");
391 let mtime_before = std::fs::metadata(&skill_md_path)
392 .unwrap()
393 .modified()
394 .unwrap();
395
396 let result = install::update(
397 "upd-skill",
398 tmp.path(),
399 install::DEFAULT_MAX_SIZE_BYTES,
400 &policy,
401 )
402 .await
403 .expect("update ok");
404 assert!(matches!(result, UpdateResult::NoChange));
405
406 let mtime_after = std::fs::metadata(&skill_md_path)
407 .unwrap()
408 .modified()
409 .unwrap();
410 assert_eq!(mtime_before, mtime_after, "SKILL.md must not be rewritten");
411 shutdown(tx, handle);
412 }
413
414 #[tokio::test]
415 async fn install_with_deny_policy_returns_network_denied() {
416 let tmp = TempDir::new().unwrap();
417 let policy = deny_all_policy();
418 let outcome = install::install(
419 InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
420 tmp.path(),
421 install::DEFAULT_MAX_SIZE_BYTES,
422 &policy,
423 false,
424 )
425 .await
426 .expect("policy outcome should be Ok");
427 match outcome {
428 InstallOutcome::NetworkDenied(host) => {
429 assert!(host.contains("example.invalid"), "got host {host}");
430 }
431 other => panic!("expected NetworkDenied, got {other:?}"),
432 }
433
434 // Verify the temp dir is untouched.
435 assert!(
436 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
437 "temp dir must be untouched"
438 );
439 }
440
441 #[tokio::test]
442 async fn install_with_prompt_policy_returns_needs_approval() {
443 let tmp = TempDir::new().unwrap();
444 let policy = prompt_all_policy();
445 let outcome = install::install(
446 InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
447 tmp.path(),
448 install::DEFAULT_MAX_SIZE_BYTES,
449 &policy,
450 false,
451 )
452 .await
453 .expect("policy outcome should be Ok");
454 match outcome {
455 InstallOutcome::NeedsApproval(host) => {
456 assert!(host.contains("example.invalid"), "got host {host}");
457 }
458 other => panic!("expected NeedsApproval, got {other:?}"),
459 }
460 assert!(
461 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
462 "temp dir must be untouched on prompt"
463 );
464 }
465
466 #[tokio::test]
467 async fn install_rejects_symlink_entry() {
468 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
469 {
470 let mut builder = tar::Builder::new(&mut gz);
471
472 let body = skill_md("link-skill", "x");
473 let mut hdr = tar::Header::new_gnu();
474 hdr.set_size(body.len() as u64);
475 hdr.set_mode(0o644);
476 hdr.set_cksum();
477 builder
478 .append_data(&mut hdr, "repo-main/SKILL.md", body.as_slice())
479 .unwrap();
480
481 let mut link_hdr = tar::Header::new_gnu();
482 link_hdr.set_entry_type(tar::EntryType::Symlink);
483 link_hdr.set_size(0);
484 link_hdr.set_mode(0o777);
485 builder
486 .append_link(&mut link_hdr, "repo-main/escape", Path::new("/etc/passwd"))
487 .unwrap();
488 builder.finish().unwrap();
489 }
490 let tarball = gz.finish().unwrap();
491 let (url, tx, handle) = spawn_tarball_server(tarball);
492
493 let tmp = TempDir::new().unwrap();
494 let policy = allow_all_policy();
495 let err = install::install(
496 InstallSource::DirectUrl(url),
497 tmp.path(),
498 install::DEFAULT_MAX_SIZE_BYTES,
499 &policy,
500 false,
501 )
502 .await
503 .expect_err("symlinks must be rejected");
504 assert!(format!("{err:#}").contains("symlink"), "{err:#}");
505
506 shutdown(tx, handle);
507 }
508
509 #[tokio::test]
510 async fn install_ignores_symlink_outside_selected_skill_root() {
511 let mut gz = GzEncoder::new(Vec::new(), Compression::default());
512 {
513 let mut builder = tar::Builder::new(&mut gz);
514
515 let mut link_hdr = tar::Header::new_gnu();
516 link_hdr.set_entry_type(tar::EntryType::Symlink);
517 link_hdr.set_size(0);
518 link_hdr.set_mode(0o777);
519 builder
520 .append_link(&mut link_hdr, "repo-main/AGENTS.md", Path::new("CLAUDE.md"))
521 .unwrap();
522
523 let body = skill_md("nested-skill", "Nested skill");
524 let mut hdr = tar::Header::new_gnu();
525 hdr.set_size(body.len() as u64);
526 hdr.set_mode(0o644);
527 hdr.set_cksum();
528 builder
529 .append_data(
530 &mut hdr,
531 "repo-main/skills/nested-skill/SKILL.md",
532 body.as_slice(),
533 )
534 .unwrap();
535
536 let notes = b"selected subtree only";
537 let mut notes_hdr = tar::Header::new_gnu();
538 notes_hdr.set_size(notes.len() as u64);
539 notes_hdr.set_mode(0o644);
540 notes_hdr.set_cksum();
541 builder
542 .append_data(
543 &mut notes_hdr,
544 "repo-main/skills/nested-skill/notes.txt",
545 notes.as_slice(),
546 )
547 .unwrap();
548
549 builder.finish().unwrap();
550 }
551 let tarball = gz.finish().unwrap();
552 let (url, tx, handle) = spawn_tarball_server(tarball);
553
554 let tmp = TempDir::new().unwrap();
555 let policy = allow_all_policy();
556 let outcome = install::install(
557 InstallSource::DirectUrl(url),
558 tmp.path(),
559 install::DEFAULT_MAX_SIZE_BYTES,
560 &policy,
561 false,
562 )
563 .await
564 .expect("repo-level symlink outside selected skill root should be ignored");
565 let installed = match outcome {
566 InstallOutcome::Installed(installed) => installed,
567 other => panic!("expected Installed, got {other:?}"),
568 };
569
570 assert_eq!(installed.name, "nested-skill");
571 assert!(installed.path.join("SKILL.md").exists());
572 assert!(installed.path.join("notes.txt").exists());
573 assert!(!installed.path.join("AGENTS.md").exists());
574
575 shutdown(tx, handle);
576 }
577
578 #[test]
579 fn uninstall_refuses_system_skill() {
580 let tmp = TempDir::new().unwrap();
581 let dir = tmp.path().join("system-skill");
582 std::fs::create_dir_all(&dir).unwrap();
583 let mut f = std::fs::File::create(dir.join("SKILL.md")).unwrap();
584 f.write_all(b"---\nname: system-skill\ndescription: x\n---\n")
585 .unwrap();
586 // No `.installed-from` marker — looks like a system skill.
587
588 let err = install::uninstall("system-skill", tmp.path()).expect_err("must refuse");
589 assert!(format!("{err:#}").contains("not installed via"));
590 assert!(dir.exists(), "directory must be left alone");
591 }
592
592 lines RUST