返回 CodeWhale
install.rs
根目录 / crates / tui / src / skills / install.rs
1 //! Community-skill installer (#140).
2 //!
3 //! Pulls user-authored skills from GitHub or direct tarball URLs, validates them
4 //! against a path-traversal- and size-bounded extractor, and writes them into
5 //! `<skills_dir>/<name>/`. No backend service, no auto-execution: every install
6 //! is gated by the per-domain [`crate::network_policy::NetworkPolicy`] and
7 //! validation rejects any tarball entry that escapes the destination directory.
8 //!
9 //! Public surface:
10 //!
11 //! * [`InstallSource`] — `github:owner/repo`, raw URL, or curated registry
12 //! name. Parsed from a single string with [`InstallSource::parse`].
13 //! * [`install`] / [`update`] / [`uninstall`] — async install, atomic update,
14 //! and clean uninstall. All three preserve a `.installed-from` marker so the
15 //! bundled `skill-creator` (which lacks the marker) is never touched.
16 //! * [`InstallOutcome`] — `Installed` / `NeedsApproval(host)` /
17 //! `NetworkDenied(host)`. The `NeedsApproval` variant is returned without
18 //! side effects so the caller (slash-command, runtime API, etc.) can route
19 //! through its own approval flow.
20 //!
21 //! # Hard rules
22 //!
23 //! * Validation extracts to a temp directory first. The destination path is
24 //! only created (via atomic rename) once the tarball clears every check.
25 //! Half-installed skills can never appear on disk.
26 //! * Path traversal rejection covers both `..` segments and absolute paths.
27 //! Symlinks inside the selected skill subtree are rejected — there's no use
28 //! case for them in a SKILL.md bundle and they're a notorious foothold for
29 //! escape. Multi-skill repository archives may contain unrelated symlinks
30 //! outside that selected subtree; those entries are ignored and never
31 //! extracted.
32 //! * No `+x` is granted on extracted files. The optional `/skill trust <name>`
33 //! command writes a `.trusted` marker; tool-execution gating is a separate
34 //! concern that lives next to the tool registry.
35 //! * Claude Code plugin archives that contain multiple skills are rejected with
36 //! an explicit migration message. Codewhale can install individual
37 //! `SKILL.md` bundles, including `.claude/skills/<name>/SKILL.md`, but it
38 //! does not execute `plugin.json` plugin runtimes or custom command bundles.
39
40 use std::fs;
41 use std::io::{Read, Write};
42 use std::path::{Component, Path, PathBuf};
43
44 use anyhow::{Context, Result, bail};
45 use flate2::read::GzDecoder;
46 use futures_util::stream::{self, StreamExt};
47 use serde::{Deserialize, Serialize};
48 use sha2::{Digest, Sha256};
49 use thiserror::Error;
50
51 use crate::network_policy::{Decision, NetworkPolicy, host_from_url};
52
53 fn reqwest_client() -> reqwest::Client {
54 codewhale_release::platform_http_client_builder()
55 .build()
56 .expect("build platform HTTP client")
57 }
58
59 /// Cache directory for registry-synced skills.
60 ///
61 /// Lives at `~/.codewhale/cache/skills/` so it's separate from user-installed
62 /// skills and can be blown away without losing anything irreplaceable.
63 pub fn default_cache_skills_dir() -> PathBuf {
64 crate::config::effective_home_dir().map_or_else(
65 || PathBuf::from("/tmp/codewhale/cache/skills"),
66 |p| p.join(".codewhale").join("cache").join("skills"),
67 )
68 }
69
70 /// Default registry. Falls back to a community-curated `index.json` hosted on
71 /// GitHub raw; users can override via `[skills] registry_url` in config.toml.
72 pub const DEFAULT_REGISTRY_URL: &str =
73 "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
74
75 /// Default per-skill size cap (5 MiB). Honored at unpack time so a malicious
76 /// gzip bomb can't blow up RAM.
77 pub const DEFAULT_MAX_SIZE_BYTES: u64 = 5 * 1024 * 1024;
78 const SYNC_REGISTRY_CONCURRENCY: usize = 8;
79
80 /// File written under each installed skill so [`update`] / [`uninstall`] can
81 /// recover the original [`InstallSource`] without re-parsing user input.
82 pub const INSTALLED_FROM_MARKER: &str = ".installed-from";
83
84 /// File written under each trusted skill. Currently advisory (the install path
85 /// never auto-runs anything) — the runtime tool-invocation gate consults this
86 /// marker before executing scripts that ship with the skill.
87 pub const TRUSTED_MARKER: &str = ".trusted";
88
89 // ─────────────────────────────────────────────────────────────────────────────
90 // Source parsing
91 // ─────────────────────────────────────────────────────────────────────────────
92
93 /// Where a skill is being installed from. See [`InstallSource::parse`] for the
94 /// accepted spec syntax.
95 #[derive(Debug, Clone, PartialEq, Eq)]
96 pub enum InstallSource {
97 /// `github:owner/repo`. Resolved to
98 /// `https://github.com/<owner>/<repo>/archive/refs/heads/main.tar.gz`
99 /// with a `master.tar.gz` fallback on 404.
100 GitHubRepo(String),
101 /// Raw `http(s)://…` tarball URL. Used as-is.
102 DirectUrl(String),
103 /// Curated registry lookup key. Looked up via the configured `registry_url`.
104 Registry(String),
105 }
106
107 impl InstallSource {
108 /// Parse a user-supplied spec. Empty / whitespace-only input is rejected.
109 ///
110 /// * `github:owner/repo` → [`InstallSource::GitHubRepo`]
111 /// * `https://github.com/owner/repo[.git]` (no path past the repo) →
112 /// [`InstallSource::GitHubRepo`]
113 /// * any other `http://` or `https://` prefix → [`InstallSource::DirectUrl`]
114 /// * anything else → [`InstallSource::Registry`]
115 pub fn parse(spec: &str) -> Result<Self> {
116 let trimmed = spec.trim();
117 if trimmed.is_empty() {
118 bail!("install source must not be empty");
119 }
120 if let Some(rest) = trimmed.strip_prefix("github:") {
121 let rest = rest.trim();
122 // Reject obviously bogus values up front. We intentionally accept
123 // case-insensitive owner/repo so `github:Hmbown/Foo` works.
124 let (owner, repo) = rest.split_once('/').with_context(|| {
125 format!("github source must be 'github:owner/repo' (got {spec})")
126 })?;
127 let owner = owner.trim();
128 let repo = repo.trim().trim_end_matches('/');
129 if owner.is_empty() || repo.is_empty() {
130 bail!("github source must be 'github:owner/repo' (got {spec})");
131 }
132 if owner.contains('/') || repo.contains('/') {
133 bail!("github source must be 'github:owner/repo' (got {spec})");
134 }
135 return Ok(Self::GitHubRepo(format!("{owner}/{repo}")));
136 }
137 if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
138 if let Some(repo) = parse_github_browser_url(trimmed) {
139 return Ok(Self::GitHubRepo(repo));
140 }
141 return Ok(Self::DirectUrl(trimmed.to_string()));
142 }
143 Ok(Self::Registry(trimmed.to_string()))
144 }
145 }
146
147 /// Detect bare `https://github.com/<owner>/<repo>` URLs (with or without a
148 /// trailing `.git`) and return `owner/repo`. Returns `None` for any URL that
149 /// already points at a specific archive / blob / tree path — those are real
150 /// direct URLs and the caller fetches them as-is.
151 fn parse_github_browser_url(url: &str) -> Option<String> {
152 let after_scheme = url
153 .strip_prefix("https://")
154 .or_else(|| url.strip_prefix("http://"))?;
155 let (host, rest) = after_scheme.split_once('/')?;
156 if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com") {
157 return None;
158 }
159 let trimmed = rest.trim_end_matches('/');
160 let mut parts = trimmed.splitn(3, '/');
161 let owner = parts.next()?.trim();
162 let repo = parts.next()?.trim().trim_end_matches(".git");
163 if owner.is_empty() || repo.is_empty() {
164 return None;
165 }
166 // If there is a third segment, the URL points at a sub-resource
167 // (`/archive/...`, `/blob/...`, `/tree/...`). Treat that as a real direct
168 // URL — the user explicitly wants whatever lives at that path.
169 if parts.next().is_some() {
170 return None;
171 }
172 Some(format!("{owner}/{repo}"))
173 }
174
175 // ─────────────────────────────────────────────────────────────────────────────
176 // Outcome / result types
177 // ─────────────────────────────────────────────────────────────────────────────
178
179 /// Outcome of an install attempt.
180 #[derive(Debug)]
181 pub enum InstallOutcome {
182 /// The skill was installed (or already present and idempotent).
183 Installed(InstalledSkill),
184 /// The host requires user approval before the install can proceed. The
185 /// caller should surface this through whatever approval pathway it has and
186 /// retry once approved (typically by adding the host to the policy's
187 /// allow list).
188 NeedsApproval(String),
189 /// The host is denied by network policy. The install is aborted.
190 NetworkDenied(String),
191 }
192
193 /// Metadata for a successfully installed skill.
194 #[derive(Debug, Clone)]
195 pub struct InstalledSkill {
196 /// Skill name (taken from SKILL.md frontmatter).
197 pub name: String,
198 /// Final on-disk path: `<skills_dir>/<name>/`.
199 pub path: PathBuf,
200 /// SHA-256 over the downloaded tarball bytes. Used by [`update`] to detect
201 /// upstream changes without re-extracting; also surfaced for telemetry /
202 /// future signature-verification work.
203 #[expect(dead_code)]
204 pub source_checksum: String,
205 }
206
207 /// Result of an [`update`] call.
208 #[derive(Debug)]
209 pub enum UpdateResult {
210 /// Upstream tarball is byte-identical to the on-disk checksum; no action.
211 NoChange,
212 /// Upstream changed and the on-disk install was atomically replaced.
213 Updated(InstalledSkill),
214 /// Network policy short-circuited the update. Same semantics as
215 /// [`InstallOutcome::NeedsApproval`].
216 NeedsApproval(String),
217 /// Network policy denied the update.
218 NetworkDenied(String),
219 }
220
221 /// Errors that can happen during install. Most variants are flattened into
222 /// `anyhow::Error` at the public boundary; this enum is used internally so
223 /// tests can pattern-match without parsing strings.
224 #[derive(Debug, Error)]
225 pub enum InstallError {
226 #[error("entry escapes destination directory: {0}")]
227 PathTraversal(String),
228 #[error("entry is too large; uncompressed total would exceed {limit} bytes")]
229 OversizedTarball { limit: u64 },
230 #[error("missing SKILL.md in archive")]
231 MissingSkillMd,
232 #[error("SKILL.md frontmatter missing required field: {0}")]
233 MissingFrontmatterField(&'static str),
234 #[error("symlinks are not allowed in skill tarballs")]
235 SymlinkRejected,
236 #[error(
237 "Claude Code plugin archive contains multiple SKILL.md entries; Codewhale installs one SKILL.md bundle at a time and does not run plugin.json/custom-command runtimes. Install or migrate an individual skills/<name> directory instead"
238 )]
239 ClaudePluginBundle,
240 #[error("skill '{0}' is already installed; use update or remove it first")]
241 AlreadyInstalled(String),
242 #[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
243 NotInstalledHere(String),
244 }
245
246 // ─────────────────────────────────────────────────────────────────────────────
247 // Public API
248 // ─────────────────────────────────────────────────────────────────────────────
249
250 /// Install a community skill into `skills_dir`.
251 ///
252 /// Steps:
253 ///
254 /// 1. Resolve `source` to one or more candidate URLs (GitHub adds a
255 /// `master` fallback after `main`).
256 /// 2. Consult `network` for the host. `Allow` proceeds; `Deny` returns
257 /// [`InstallOutcome::NetworkDenied`]; `Prompt` returns
258 /// [`InstallOutcome::NeedsApproval`] without touching disk.
259 /// 3. Stream the tarball into a tempfile (capped at `max_size`).
260 /// 4. Validate the archive (path-traversal, size, no symlinks in the selected
261 /// skill subtree, SKILL.md present with required frontmatter fields) into a
262 /// sibling `<name>.tmp/` directory.
263 /// 5. Atomic-rename `<name>.tmp/` → `<name>/`.
264 /// 6. Write `.installed-from` and return [`InstalledSkill`].
265 ///
266 /// `update = false` rejects an existing destination. Pass `update = true`
267 /// from [`update`] to allow replacement.
268 ///
269 /// Convenience wrapper over [`install_with_registry`] that uses the bundled
270 /// [`DEFAULT_REGISTRY_URL`]. Public for downstream consumers (tests, runtime
271 /// API) even though the slash-command path always goes through
272 /// [`install_with_registry`] so the user's configured registry wins.
273 #[cfg_attr(not(test), expect(dead_code))]
274 #[cfg_attr(test, allow(dead_code))]
275 pub async fn install(
276 source: InstallSource,
277 skills_dir: &Path,
278 max_size: u64,
279 network: &NetworkPolicy,
280 update: bool,
281 ) -> Result<InstallOutcome> {
282 install_with_registry(
283 source,
284 skills_dir,
285 max_size,
286 network,
287 update,
288 DEFAULT_REGISTRY_URL,
289 )
290 .await
291 }
292
293 /// Same as [`install`] but lets the caller override the registry URL. Useful
294 /// for tests; the slash-command path always uses the configured registry.
295 pub async fn install_with_registry(
296 source: InstallSource,
297 skills_dir: &Path,
298 max_size: u64,
299 network: &NetworkPolicy,
300 update: bool,
301 registry_url: &str,
302 ) -> Result<InstallOutcome> {
303 let urls = candidate_urls(&source, network, registry_url).await?;
304 let urls = match urls {
305 UrlResolution::Resolved(urls) => urls,
306 UrlResolution::NeedsApproval(host) => return Ok(InstallOutcome::NeedsApproval(host)),
307 UrlResolution::Denied(host) => return Ok(InstallOutcome::NetworkDenied(host)),
308 };
309
310 // Try each URL in order — GitHub returns 404 for `main` on master-only
311 // repos, and we don't want to fail the install on that.
312 let (bytes, source_url) = match download_first_success(&urls, network, max_size).await? {
313 DownloadOutcome::Bytes { bytes, url } => (bytes, url),
314 DownloadOutcome::NeedsApproval(host) => return Ok(InstallOutcome::NeedsApproval(host)),
315 DownloadOutcome::Denied(host) => return Ok(InstallOutcome::NetworkDenied(host)),
316 };
317
318 // Compute a checksum before unpacking so [`update`] can detect upstream
319 // no-op changes without redoing the extract.
320 let checksum = sha256_hex(&bytes);
321
322 let staged = stage_tarball(&bytes, skills_dir, max_size)?;
323
324 // Move the staged dir into its final location. If `update` is set and the
325 // destination exists, replace it; otherwise reject.
326 // Keep any backup until content digest + marker write succeed so a failed
327 // finalize can restore the previous install.
328 let final_path = skills_dir.join(&staged.skill_name);
329 let mut backup_path: Option<PathBuf> = None;
330 if tokio::fs::try_exists(&final_path).await.unwrap_or(false) {
331 if !update {
332 // Clean up the staging dir before returning the error.
333 let _ = tokio::fs::remove_dir_all(&staged.staged_path).await;
334 return Err(InstallError::AlreadyInstalled(staged.skill_name).into());
335 }
336 // Same ownership gate as plugins/install/place.rs: an update may only
337 // replace a tree this installer created. The tarball's top-level name
338 // is not proof of ownership — without the marker we would delete a
339 // user-authored or system skill that happened to share the name.
340 if let Err(err) = reject_unmarked_update(&final_path, &staged.skill_name) {
341 let _ = tokio::fs::remove_dir_all(&staged.staged_path).await;
342 return Err(err.into());
343 }
344 let backup = skills_dir.join(format!("{}.bak", staged.skill_name));
345 if tokio::fs::try_exists(&backup).await.unwrap_or(false) {
346 tokio::fs::remove_dir_all(&backup).await.ok();
347 }
348 tokio::fs::rename(&final_path, &backup)
349 .await
350 .with_context(|| {
351 format!(
352 "failed to backup existing skill at {}",
353 final_path.display()
354 )
355 })?;
356 if let Err(err) = tokio::fs::rename(&staged.staged_path, &final_path).await {
357 tokio::fs::rename(&backup, &final_path).await.ok();
358 return Err(err).context("failed to install staged skill");
359 }
360 backup_path = Some(backup);
361 } else {
362 if let Some(parent) = final_path.parent() {
363 tokio::fs::create_dir_all(parent).await.with_context(|| {
364 format!("failed to create skills directory {}", parent.display())
365 })?;
366 }
367 tokio::fs::rename(&staged.staged_path, &final_path)
368 .await
369 .context("failed to install staged skill")?;
370 }
371
372 // Write the marker last so a partial install never leaves a stale
373 // .installed-from on disk. Prefer v2 with package content digest.
374 let spec = source_spec_string(&source);
375 let content_digest = match super::package_digest::compute_package_digest(&final_path) {
376 Ok(digest) => digest,
377 Err(err) => {
378 let _ = tokio::fs::remove_dir_all(&final_path).await;
379 if let Some(backup) = backup_path.take() {
380 let _ = tokio::fs::rename(&backup, &final_path).await;
381 }
382 return Err(anyhow::anyhow!(
383 "installed package failed content digest validation: {err}"
384 ));
385 }
386 };
387 if let Err(err) = write_installed_from_v2(
388 &final_path,
389 &spec,
390 Some(&source_url),
391 &checksum,
392 &content_digest,
393 &staged.skill_name,
394 ) {
395 let _ = tokio::fs::remove_dir_all(&final_path).await;
396 if let Some(backup) = backup_path.take() {
397 let _ = tokio::fs::rename(&backup, &final_path).await;
398 }
399 return Err(err);
400 }
401 if let Some(backup) = backup_path {
402 tokio::fs::remove_dir_all(&backup).await.ok();
403 }
404
405 Ok(InstallOutcome::Installed(InstalledSkill {
406 name: staged.skill_name,
407 path: final_path,
408 source_checksum: checksum,
409 }))
410 }
411
412 /// Re-fetch a previously installed skill and replace it on disk if the
413 /// upstream tarball changed.
414 ///
415 /// Reads `.installed-from` to recover the original [`InstallSource`], so
416 /// a skill installed via `/skill install github:foo/bar` can be updated via
417 /// `/skill update bar` without the user re-typing the spec.
418 ///
419 /// Convenience wrapper over [`update_with_registry`].
420 #[cfg_attr(not(test), expect(dead_code))]
421 #[cfg_attr(test, allow(dead_code))]
422 pub async fn update(
423 name: &str,
424 skills_dir: &Path,
425 max_size: u64,
426 network: &NetworkPolicy,
427 ) -> Result<UpdateResult> {
428 update_with_registry(name, skills_dir, max_size, network, DEFAULT_REGISTRY_URL).await
429 }
430
431 /// Same as [`update`] but lets the caller override the registry URL.
432 pub async fn update_with_registry(
433 name: &str,
434 skills_dir: &Path,
435 max_size: u64,
436 network: &NetworkPolicy,
437 registry_url: &str,
438 ) -> Result<UpdateResult> {
439 let target = skill_target_path(name, skills_dir)?;
440 if tokio::fs::try_exists(&target).await.unwrap_or(false) {
441 ensure_target_within_skills_dir(&target, skills_dir)?;
442 }
443 let marker_path = target.join(INSTALLED_FROM_MARKER);
444 if !tokio::fs::try_exists(&marker_path).await.unwrap_or(false) {
445 return Err(InstallError::NotInstalledHere(name.to_string()).into());
446 }
447 let marker_body = tokio::fs::read_to_string(&marker_path)
448 .await
449 .with_context(|| format!("failed to read {}", marker_path.display()))?;
450 let marker: InstalledFromMarker = serde_json::from_str(&marker_body)
451 .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?;
452 if !is_registry_updatable_spec(&marker.spec) {
453 bail!(
454 "skill '{name}' was imported locally (spec '{}') and cannot be updated from a registry; \
455 re-import or remove it first",
456 marker.spec
457 );
458 }
459
460 // Re-resolve the URL, taking the existing checksum as a short-circuit hint:
461 // we still hit the network so the user gets a useful "no upstream change"
462 // signal, but we skip the unpack step if the bytes match.
463 let source = InstallSource::parse(&marker.spec)?;
464 let urls = match candidate_urls(&source, network, registry_url).await? {
465 UrlResolution::Resolved(urls) => urls,
466 UrlResolution::NeedsApproval(host) => return Ok(UpdateResult::NeedsApproval(host)),
467 UrlResolution::Denied(host) => return Ok(UpdateResult::NetworkDenied(host)),
468 };
469 let (bytes, _url) = match download_first_success(&urls, network, max_size).await? {
470 DownloadOutcome::Bytes { bytes, url } => (bytes, url),
471 DownloadOutcome::NeedsApproval(host) => return Ok(UpdateResult::NeedsApproval(host)),
472 DownloadOutcome::Denied(host) => return Ok(UpdateResult::NetworkDenied(host)),
473 };
474
475 let checksum = sha256_hex(&bytes);
476 if checksum == marker.source_checksum() {
477 return Ok(UpdateResult::NoChange);
478 }
479
480 // Bytes changed — fall back to the regular install path with `update = true`
481 // so we get the same atomic-replace semantics. Content updates must not
482 // inherit a previous trust marker.
483 let trust_path = target.join(TRUSTED_MARKER);
484 let had_trust = tokio::fs::try_exists(&trust_path).await.unwrap_or(false);
485 let outcome =
486 install_with_registry(source, skills_dir, max_size, network, true, registry_url).await?;
487 match &outcome {
488 InstallOutcome::Installed(installed) => {
489 if had_trust {
490 let _ = tokio::fs::remove_file(installed.path.join(TRUSTED_MARKER)).await;
491 }
492 }
493 InstallOutcome::NeedsApproval(_) | InstallOutcome::NetworkDenied(_) => {}
494 }
495 match outcome {
496 InstallOutcome::Installed(installed) => Ok(UpdateResult::Updated(installed)),
497 InstallOutcome::NeedsApproval(host) => Ok(UpdateResult::NeedsApproval(host)),
498 InstallOutcome::NetworkDenied(host) => Ok(UpdateResult::NetworkDenied(host)),
499 }
500 }
501
502 /// Remove a community-installed skill.
503 ///
504 /// Refuses to touch any directory that doesn't carry the `.installed-from`
505 /// marker — that's our cue that it's user-owned and not a system skill.
506 pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> {
507 let target = skill_target_path(name, skills_dir)?;
508 if !target.exists() {
509 bail!("skill '{name}' is not installed at {}", target.display());
510 }
511 ensure_target_within_skills_dir(&target, skills_dir)?;
512 if !target.join(INSTALLED_FROM_MARKER).exists() {
513 return Err(InstallError::NotInstalledHere(name.to_string()).into());
514 }
515 fs::remove_dir_all(&target)
516 .with_context(|| format!("failed to remove {}", target.display()))?;
517 Ok(())
518 }
519
520 /// Mark a community-installed skill as trusted, binding the marker to the
521 /// current package content digest (schema v2).
522 ///
523 /// Refuses to mark system skills (no `.installed-from`) so the bundled
524 /// `skill-creator` doesn't accidentally inherit elevated tool privileges.
525 #[cfg(test)]
526 pub fn trust(name: &str, skills_dir: &Path) -> Result<()> {
527 let target = skill_target_path(name, skills_dir)?;
528 if !target.exists() {
529 bail!("skill '{name}' is not installed at {}", target.display());
530 }
531 ensure_target_within_skills_dir(&target, skills_dir)?;
532 if !target.join(INSTALLED_FROM_MARKER).exists() {
533 return Err(InstallError::NotInstalledHere(name.to_string()).into());
534 }
535 let content_digest = super::package_digest::compute_package_digest(&target)
536 .with_context(|| format!("cannot compute content digest for {}", target.display()))?;
537 write_trust_v2(&target, &content_digest)?;
538 Ok(())
539 }
540
541 /// Fetch the curated registry and return the parsed entries.
542 ///
543 /// Honours `network` (skipping the call entirely on Deny / Prompt).
544 pub async fn fetch_registry(
545 network: &NetworkPolicy,
546 registry_url: &str,
547 ) -> Result<RegistryFetchResult> {
548 let host = match host_from_url(registry_url) {
549 Some(host) => host,
550 None => bail!("invalid registry url: {registry_url}"),
551 };
552 match network.decide(&host) {
553 Decision::Allow => {}
554 Decision::Deny => return Ok(RegistryFetchResult::Denied(host)),
555 Decision::Prompt => return Ok(RegistryFetchResult::NeedsApproval(host)),
556 }
557 let body = reqwest_client()
558 .get(registry_url)
559 .send()
560 .await
561 .with_context(|| format!("failed to fetch registry {registry_url}"))?
562 .error_for_status()
563 .with_context(|| format!("registry {registry_url} returned an error status"))?
564 .text()
565 .await
566 .with_context(|| format!("failed to read registry body from {registry_url}"))?;
567 let parsed: RegistryDocument = serde_json::from_str(&body)
568 .with_context(|| format!("failed to parse registry json from {registry_url}"))?;
569 Ok(RegistryFetchResult::Loaded(parsed))
570 }
571
572 // ─────────────────────────────────────────────────────────────────────────────
573 // Registry sync (issue #433)
574 // ─────────────────────────────────────────────────────────────────────────────
575
576 /// Outcome of a single skill entry during [`sync_registry`].
577 #[derive(Debug, Clone)]
578 pub enum SkillSyncOutcome {
579 /// Skill downloaded and written to the cache directory.
580 Downloaded { name: String, path: PathBuf },
581 /// Cached bytes match the upstream ETag / SHA-256; nothing written.
582 Fresh { name: String },
583 /// Skill download failed; the error is non-fatal so the sync continues.
584 Failed { name: String, reason: String },
585 /// Network policy blocked the download host.
586 Denied { name: String, host: String },
587 /// Network policy requires user approval for the download host.
588 NeedsApproval { name: String, host: String },
589 }
590
591 /// Overall result of [`sync_registry`].
592 #[derive(Debug)]
593 pub enum SyncResult {
594 /// Sync completed. `outcomes` contains one entry per skill in the index.
595 Done { outcomes: Vec<SkillSyncOutcome> },
596 /// The registry fetch was blocked by network policy.
597 RegistryDenied(String),
598 /// The registry fetch requires user approval.
599 RegistryNeedsApproval(String),
600 }
601
602 /// Freshness metadata written alongside each cached skill so subsequent syncs
603 /// can skip unchanged content.
604 #[derive(Debug, Serialize, Deserialize)]
605 struct CacheMeta {
606 /// ETag returned by the server for the primary asset, if any.
607 #[serde(default)]
608 etag: Option<String>,
609 /// SHA-256 hex digest of the downloaded bytes.
610 sha256: String,
611 /// Source URL the asset was fetched from.
612 url: String,
613 }
614
615 /// Sync the remote registry to the local cache.
616 ///
617 /// For every skill listed in `index.json` this function:
618 ///
619 /// 1. Resolves the download URL (same logic as `install`).
620 /// 2. Checks the cached [`CacheMeta`] (etag + sha256) for freshness; skips
621 /// the download if unchanged.
622 /// 3. Downloads SKILL.md (and any companion files if the source is a tarball)
623 /// into `<cache_dir>/<name>/`.
624 /// 4. Writes updated [`CacheMeta`] so the next sync is fast.
625 ///
626 /// Failures per-skill are non-fatal: [`SkillSyncOutcome::Failed`] is recorded
627 /// and the sync continues. The caller decides how to surface per-skill errors.
628 pub async fn sync_registry(
629 network: &NetworkPolicy,
630 registry_url: &str,
631 cache_dir: &Path,
632 max_size: u64,
633 ) -> Result<SyncResult> {
634 let doc = match fetch_registry(network, registry_url).await? {
635 RegistryFetchResult::Loaded(doc) => doc,
636 RegistryFetchResult::Denied(host) => return Ok(SyncResult::RegistryDenied(host)),
637 RegistryFetchResult::NeedsApproval(host) => {
638 return Ok(SyncResult::RegistryNeedsApproval(host));
639 }
640 };
641
642 let outcomes = stream::iter(doc.skills.iter())
643 .map(|(name, entry)| sync_one_skill(name, entry, network, cache_dir, max_size))
644 .buffered(SYNC_REGISTRY_CONCURRENCY)
645 .collect()
646 .await;
647
648 Ok(SyncResult::Done { outcomes })
649 }
650
651 /// Sync a single skill entry from the registry into the cache directory.
652 async fn sync_one_skill(
653 name: &str,
654 entry: &RegistryEntry,
655 network: &NetworkPolicy,
656 cache_dir: &Path,
657 max_size: u64,
658 ) -> SkillSyncOutcome {
659 // Resolve the source to a concrete URL list.
660 let source = match InstallSource::parse(&entry.source) {
661 Ok(s) => s,
662 Err(err) => {
663 return SkillSyncOutcome::Failed {
664 name: name.to_string(),
665 reason: format!("invalid source spec '{}': {err:#}", entry.source),
666 };
667 }
668 };
669
670 // Registry sources in index.json must not point back at another registry.
671 if matches!(source, InstallSource::Registry(_)) {
672 return SkillSyncOutcome::Failed {
673 name: name.to_string(),
674 reason: format!("registry entry for '{name}' must not point to another registry entry"),
675 };
676 }
677
678 let urls = match &source {
679 InstallSource::GitHubRepo(repo) => vec![
680 format!("https://github.com/{repo}/archive/refs/heads/main.tar.gz"),
681 format!("https://github.com/{repo}/archive/refs/heads/master.tar.gz"),
682 ],
683 InstallSource::DirectUrl(url) => vec![url.clone()],
684 InstallSource::Registry(_) => unreachable!("guarded above"),
685 };
686
687 // Check the first downloadable URL against any cached meta.
688 let skill_cache_dir = cache_dir.join(name);
689 let meta_path = skill_cache_dir.join(".cache-meta.json");
690
691 // Try each candidate URL in order.
692 for url in &urls {
693 let host = match host_from_url(url) {
694 Some(h) => h,
695 None => continue,
696 };
697 match network.decide(&host) {
698 Decision::Allow => {}
699 Decision::Deny => {
700 return SkillSyncOutcome::Denied {
701 name: name.to_string(),
702 host,
703 };
704 }
705 Decision::Prompt => {
706 return SkillSyncOutcome::NeedsApproval {
707 name: name.to_string(),
708 host,
709 };
710 }
711 }
712
713 // Perform a HEAD request (or conditional GET) for freshness. We use a
714 // simple GET with If-None-Match when we have an ETag, falling back to
715 // an unconditional GET for servers that don't support ETags.
716 let existing_meta: Option<CacheMeta> = tokio::fs::read_to_string(&meta_path)
717 .await
718 .ok()
719 .and_then(|s| serde_json::from_str(&s).ok());
720
721 // Build the request — add If-None-Match if we have a cached ETag.
722 let client = reqwest_client();
723 let mut req = client.get(url);
724 if let Some(ref meta) = existing_meta
725 && let Some(ref etag) = meta.etag
726 {
727 req = req.header("If-None-Match", etag);
728 }
729
730 let resp = match req.send().await {
731 Ok(r) => r,
732 Err(err) => {
733 // Network error — try the next candidate URL.
734 let _ = err;
735 continue;
736 }
737 };
738
739 let status = resp.status();
740
741 // 304 Not Modified: cached copy is still fresh.
742 if status == reqwest::StatusCode::NOT_MODIFIED {
743 return SkillSyncOutcome::Fresh {
744 name: name.to_string(),
745 };
746 }
747
748 if status == reqwest::StatusCode::NOT_FOUND {
749 // Try next URL (main → master fallback).
750 continue;
751 }
752
753 if !status.is_success() {
754 return SkillSyncOutcome::Failed {
755 name: name.to_string(),
756 reason: format!("GET {url} returned HTTP {status}"),
757 };
758 }
759
760 // Capture ETag before consuming the response body.
761 let etag = resp
762 .headers()
763 .get(reqwest::header::ETAG)
764 .and_then(|v| v.to_str().ok())
765 .map(|s| s.to_string());
766
767 let compressed_cap = max_size.saturating_mul(4);
768 let bytes = match resp.bytes().await {
769 Ok(b) => b,
770 Err(err) => {
771 return SkillSyncOutcome::Failed {
772 name: name.to_string(),
773 reason: format!("failed to read body from {url}: {err:#}"),
774 };
775 }
776 };
777 if bytes.len() as u64 > compressed_cap {
778 return SkillSyncOutcome::Failed {
779 name: name.to_string(),
780 reason: format!(
781 "download from {url} exceeds compressed size cap ({compressed_cap} bytes)"
782 ),
783 };
784 }
785
786 // Compute SHA-256 of the downloaded bytes.
787 let sha256 = sha256_hex(&bytes);
788
789 // Short-circuit: if the hash matches the cached one, we're fresh even
790 // without a 304 (some CDNs strip ETags on redirects).
791 if let Some(ref meta) = existing_meta
792 && meta.sha256 == sha256
793 && meta.url == *url
794 {
795 return SkillSyncOutcome::Fresh {
796 name: name.to_string(),
797 };
798 }
799
800 // Determine whether this is a tarball or a plain SKILL.md.
801 // Heuristic: the URL ends with `.tar.gz` or `.tgz`, or the content
802 // starts with the gzip magic bytes (0x1f 0x8b).
803 let is_tarball =
804 url.ends_with(".tar.gz") || url.ends_with(".tgz") || bytes.starts_with(&[0x1f, 0x8b]);
805
806 let final_path: PathBuf = if is_tarball {
807 // Extract into a temp staging dir, then rename atomically.
808 let staged = match stage_tarball(&bytes, cache_dir, max_size) {
809 Ok(s) => s,
810 Err(err) => {
811 return SkillSyncOutcome::Failed {
812 name: name.to_string(),
813 reason: format!("tarball extraction failed: {err:#}"),
814 };
815 }
816 };
817 // Move staged dir into its final location, replacing any prior cache.
818 let dest = cache_dir.join(name);
819 if tokio::fs::try_exists(&dest).await.unwrap_or(false) {
820 let _ = tokio::fs::remove_dir_all(&dest).await;
821 }
822 if let Err(err) = tokio::fs::rename(&staged.staged_path, &dest).await {
823 let _ = tokio::fs::remove_dir_all(&staged.staged_path).await;
824 return SkillSyncOutcome::Failed {
825 name: name.to_string(),
826 reason: format!("failed to move staged skill into cache: {err:#}"),
827 };
828 }
829 dest
830 } else {
831 // Plain SKILL.md (or other companion text file). Write directly.
832 if let Err(err) = tokio::fs::create_dir_all(&skill_cache_dir).await {
833 return SkillSyncOutcome::Failed {
834 name: name.to_string(),
835 reason: format!("failed to create cache dir: {err:#}"),
836 };
837 }
838 let skill_md_path = skill_cache_dir.join("SKILL.md");
839 if let Err(err) = tokio::fs::write(&skill_md_path, &bytes).await {
840 return SkillSyncOutcome::Failed {
841 name: name.to_string(),
842 reason: format!("failed to write SKILL.md to cache: {err:#}"),
843 };
844 }
845 skill_cache_dir.clone()
846 };
847
848 // Write the updated freshness metadata.
849 let meta = CacheMeta {
850 etag,
851 sha256,
852 url: url.clone(),
853 };
854 let meta_json = serde_json::to_string(&meta).unwrap_or_default();
855 let _ = tokio::fs::write(final_path.join(".cache-meta.json"), meta_json).await;
856
857 return SkillSyncOutcome::Downloaded {
858 name: name.to_string(),
859 path: final_path,
860 };
861 }
862
863 // All candidate URLs exhausted without a successful response.
864 SkillSyncOutcome::Failed {
865 name: name.to_string(),
866 reason: format!(
867 "all candidate URLs for '{}' failed or were not found",
868 entry.source
869 ),
870 }
871 }
872
873 // ─────────────────────────────────────────────────────────────────────────────
874 // Internal helpers
875 // ─────────────────────────────────────────────────────────────────────────────
876
877 #[derive(Debug, Deserialize)]
878 pub(crate) struct InstalledFromMarker {
879 pub(crate) spec: String,
880 /// v1 download checksum field.
881 #[serde(default)]
882 checksum: String,
883 #[serde(default)]
884 source_checksum: Option<String>,
885 #[serde(default)]
886 #[expect(dead_code)]
887 schema_version: Option<u32>,
888 #[serde(default)]
889 #[expect(dead_code)]
890 content_digest: Option<String>,
891 }
892
893 impl InstalledFromMarker {
894 pub(crate) fn source_checksum(&self) -> &str {
895 self.source_checksum
896 .as_deref()
897 .filter(|s| !s.is_empty())
898 .unwrap_or(self.checksum.as_str())
899 }
900 }
901
902 /// Remote/registry update is only valid for install specs that are not local imports.
903 #[must_use]
904 pub fn is_registry_updatable_spec(spec: &str) -> bool {
905 let spec = spec.trim();
906 !spec.is_empty() && !spec.starts_with("import:")
907 }
908
909 /// Write schema-v2 `.installed-from` metadata (last step of a successful install).
910 pub fn write_installed_from_v2(
911 skill_dir: &Path,
912 spec: &str,
913 url: Option<&str>,
914 source_checksum: &str,
915 content_digest: &str,
916 installed_name: &str,
917 ) -> Result<()> {
918 let body = serde_json::json!({
919 "schema_version": 2,
920 "spec": spec,
921 "url": url,
922 "source_checksum": source_checksum,
923 "content_digest": content_digest,
924 "installed_name": installed_name,
925 "registry_version": null,
926 });
927 fs::write(skill_dir.join(INSTALLED_FROM_MARKER), body.to_string()).with_context(|| {
928 format!(
929 "failed to write {} for {}",
930 INSTALLED_FROM_MARKER,
931 skill_dir.display()
932 )
933 })?;
934 Ok(())
935 }
936
937 /// Write schema-v2 `.trusted` bound to a package content digest.
938 pub fn write_trust_v2(skill_dir: &Path, content_digest: &str) -> Result<()> {
939 let body = serde_json::json!({
940 "schema_version": 2,
941 "content_digest": content_digest,
942 });
943 fs::write(skill_dir.join(TRUSTED_MARKER), body.to_string()).with_context(|| {
944 format!(
945 "failed to write {} for {}",
946 TRUSTED_MARKER,
947 skill_dir.display()
948 )
949 })?;
950 Ok(())
951 }
952
953 /// Curated-registry document. The shape is intentionally minimal so adding
954 /// optional metadata later (homepage, version, signature) is forward-compatible.
955 #[derive(Debug, Clone, Deserialize)]
956 pub struct RegistryDocument {
957 /// Map of skill name → entry.
958 #[serde(default)]
959 pub skills: std::collections::BTreeMap<String, RegistryEntry>,
960 }
961
962 /// One row in the curated registry. Descriptive matching metadata is optional
963 /// so old indices keep parsing and new registries can publish it gradually.
964 #[derive(Debug, Clone, Deserialize)]
965 pub struct RegistryEntry {
966 /// Source spec (e.g. `github:owner/repo`).
967 pub source: String,
968 /// Optional human-readable description.
969 #[serde(default)]
970 pub description: Option<String>,
971 /// Task phrases that should rank this skill above description fallbacks.
972 #[serde(default)]
973 pub keywords: Vec<String>,
974 /// Relevant web domains, optionally written as full URLs by the registry.
975 #[serde(default)]
976 pub domains: Vec<String>,
977 }
978
979 /// Successful registry fetch result. Same shape as [`InstallOutcome`] for the
980 /// network-policy outcomes so the caller can drop directly into approval flow.
981 #[derive(Debug)]
982 pub enum RegistryFetchResult {
983 Loaded(RegistryDocument),
984 NeedsApproval(String),
985 Denied(String),
986 }
987
988 enum UrlResolution {
989 Resolved(Vec<String>),
990 NeedsApproval(String),
991 Denied(String),
992 }
993
994 enum DownloadOutcome {
995 Bytes { bytes: Vec<u8>, url: String },
996 NeedsApproval(String),
997 Denied(String),
998 }
999
1000 /// Outcome of [`fetch_tarball`], shared with the plugin installer (#5182) so
1001 /// both route `Prompt`/`Deny` hosts through their own approval flows instead
1002 /// of growing a second download path.
1003 #[derive(Debug)]
1004 pub(crate) enum FetchOutcome {
1005 Bytes { bytes: Vec<u8>, url: String },
1006 NeedsApproval(String),
1007 Denied(String),
1008 }
1009
1010 /// Resolve a *remote* [`InstallSource`] (GitHub repo or direct tarball URL)
1011 /// and download the first reachable candidate under the network policy.
1012 /// Registry sources are rejected: skill registry resolution stays inside
1013 /// [`candidate_urls`], and the plugin install on-ramp has no registry index.
1014 pub(crate) async fn fetch_tarball(
1015 source: &InstallSource,
1016 network: &NetworkPolicy,
1017 max_size: u64,
1018 ) -> Result<FetchOutcome> {
1019 let urls = match source {
1020 InstallSource::GitHubRepo(repo) => vec![
1021 format!("https://github.com/{repo}/archive/refs/heads/main.tar.gz"),
1022 format!("https://github.com/{repo}/archive/refs/heads/master.tar.gz"),
1023 ],
1024 InstallSource::DirectUrl(url) => vec![url.clone()],
1025 InstallSource::Registry(name) => {
1026 bail!("registry source '{name}' cannot be fetched as a plain tarball")
1027 }
1028 };
1029 Ok(
1030 match download_first_success(&urls, network, max_size).await? {
1031 DownloadOutcome::Bytes { bytes, url } => FetchOutcome::Bytes { bytes, url },
1032 DownloadOutcome::NeedsApproval(host) => FetchOutcome::NeedsApproval(host),
1033 DownloadOutcome::Denied(host) => FetchOutcome::Denied(host),
1034 },
1035 )
1036 }
1037
1038 /// Resolve the source spec into one or more candidate URLs to try in order.
1039 async fn candidate_urls(
1040 source: &InstallSource,
1041 network: &NetworkPolicy,
1042 registry_url: &str,
1043 ) -> Result<UrlResolution> {
1044 match source {
1045 InstallSource::GitHubRepo(repo) => {
1046 // GitHub's archive endpoint lives on `codeload.github.com` after
1047 // the redirect, but the public URL we hit is `github.com`. Both
1048 // typically appear in user allow lists; we check the canonical
1049 // host.
1050 Ok(UrlResolution::Resolved(vec![
1051 format!("https://github.com/{repo}/archive/refs/heads/main.tar.gz"),
1052 format!("https://github.com/{repo}/archive/refs/heads/master.tar.gz"),
1053 ]))
1054 }
1055 InstallSource::DirectUrl(url) => Ok(UrlResolution::Resolved(vec![url.clone()])),
1056 InstallSource::Registry(name) => {
1057 match fetch_registry(network, registry_url).await? {
1058 RegistryFetchResult::Loaded(doc) => {
1059 let entry = doc
1060 .skills
1061 .get(name)
1062 .with_context(|| format!("skill '{name}' not found in registry"))?
1063 .clone();
1064 let inner = InstallSource::parse(&entry.source).with_context(|| {
1065 format!(
1066 "registry entry for '{name}' has invalid source: {}",
1067 entry.source
1068 )
1069 })?;
1070 // Recurse only one level — registry pointing at registry is
1071 // disallowed to avoid cycles.
1072 if matches!(inner, InstallSource::Registry(_)) {
1073 bail!("registry entry for '{name}' must not point to another registry");
1074 }
1075 // Reuse this function for the inner source so GitHub fallback
1076 // still applies.
1077 Box::pin(candidate_urls(&inner, network, registry_url)).await
1078 }
1079 RegistryFetchResult::NeedsApproval(host) => Ok(UrlResolution::NeedsApproval(host)),
1080 RegistryFetchResult::Denied(host) => Ok(UrlResolution::Denied(host)),
1081 }
1082 }
1083 }
1084 }
1085
1086 /// Download the first URL whose host the policy allows and which returns 2xx.
1087 /// Returns `NeedsApproval` if every candidate hit `Prompt`, or `Denied` if every
1088 /// candidate was denied.
1089 async fn download_first_success(
1090 urls: &[String],
1091 network: &NetworkPolicy,
1092 max_size: u64,
1093 ) -> Result<DownloadOutcome> {
1094 let mut last_status: Option<reqwest::StatusCode> = None;
1095 let mut prompt_host: Option<String> = None;
1096 let mut denied_host: Option<String> = None;
1097 for url in urls {
1098 let host = match host_from_url(url) {
1099 Some(h) => h,
1100 None => bail!("invalid download url: {url}"),
1101 };
1102 match network.decide(&host) {
1103 Decision::Allow => {}
1104 Decision::Deny => {
1105 denied_host.get_or_insert(host);
1106 continue;
1107 }
1108 Decision::Prompt => {
1109 prompt_host.get_or_insert(host);
1110 continue;
1111 }
1112 }
1113 match download_with_cap(url, max_size).await? {
1114 DownloadAttempt::Bytes(bytes) => {
1115 return Ok(DownloadOutcome::Bytes {
1116 bytes,
1117 url: url.clone(),
1118 });
1119 }
1120 DownloadAttempt::NotFound(status) => {
1121 last_status = Some(status);
1122 continue;
1123 }
1124 }
1125 }
1126 if let Some(host) = denied_host {
1127 return Ok(DownloadOutcome::Denied(host));
1128 }
1129 if let Some(host) = prompt_host {
1130 return Ok(DownloadOutcome::NeedsApproval(host));
1131 }
1132 bail!(
1133 "failed to download skill (last status: {})",
1134 last_status
1135 .map(|s| s.to_string())
1136 .unwrap_or_else(|| "unknown".to_string())
1137 );
1138 }
1139
1140 enum DownloadAttempt {
1141 Bytes(Vec<u8>),
1142 NotFound(reqwest::StatusCode),
1143 }
1144
1145 /// Stream a URL into memory with a size cap. Aborts on the first read that
1146 /// would push the buffer over `max_size * 4` (the *4 accounts for compression;
1147 /// the unpack step still enforces `max_size` on the *uncompressed* bytes).
1148 async fn download_with_cap(url: &str, max_size: u64) -> Result<DownloadAttempt> {
1149 let resp = reqwest_client()
1150 .get(url)
1151 .send()
1152 .await
1153 .with_context(|| format!("failed to GET {url}"))?;
1154 let status = resp.status();
1155 if !status.is_success() {
1156 if status == reqwest::StatusCode::NOT_FOUND {
1157 return Ok(DownloadAttempt::NotFound(status));
1158 }
1159 bail!("download {url} returned {status}");
1160 }
1161 // Soft cap on the *compressed* download — well above max_size to allow
1162 // for highly compressible payloads but still bounded.
1163 let compressed_cap = max_size.saturating_mul(4);
1164 let bytes = resp
1165 .bytes()
1166 .await
1167 .with_context(|| format!("failed to read body of {url}"))?;
1168 if (bytes.len() as u64) > compressed_cap {
1169 bail!("download {url} exceeds compressed size cap of {compressed_cap} bytes");
1170 }
1171 Ok(DownloadAttempt::Bytes(bytes.to_vec()))
1172 }
1173
1174 struct StagedSkill {
1175 skill_name: String,
1176 staged_path: PathBuf,
1177 }
1178
1179 /// Validate a tarball and extract it into `<skills_dir>/<name>.tmp/`.
1180 fn stage_tarball(bytes: &[u8], skills_dir: &Path, max_size: u64) -> Result<StagedSkill> {
1181 fs::create_dir_all(skills_dir)
1182 .with_context(|| format!("failed to create skills directory {}", skills_dir.display()))?;
1183
1184 // Two passes: first determine the skill name (and therefore the staged
1185 // dir) by finding the SKILL.md, then extract under that staged dir.
1186 // Both passes share the same archive bytes; we reset by wrapping fresh
1187 // decoders.
1188
1189 let scan = scan_tarball(bytes, max_size)?;
1190
1191 // Prepare staged directory. Use a `.tmp` suffix so a crashed install
1192 // never collides with a real name; remove any leftover from a prior
1193 // failed attempt.
1194 let staged_path = skills_dir.join(format!("{}.tmp", scan.skill_name));
1195 if staged_path.exists() {
1196 fs::remove_dir_all(&staged_path).with_context(|| {
1197 format!(
1198 "failed to clean stale staging dir {}",
1199 staged_path.display()
1200 )
1201 })?;
1202 }
1203 fs::create_dir_all(&staged_path)
1204 .with_context(|| format!("failed to create staging dir {}", staged_path.display()))?;
1205
1206 // Second pass — extract.
1207 let result = extract_into(&scan, bytes, &staged_path, max_size);
1208 if let Err(err) = result {
1209 // Cleanup on failure so a half-staged directory doesn't survive.
1210 let _ = fs::remove_dir_all(&staged_path);
1211 return Err(err);
1212 }
1213
1214 Ok(StagedSkill {
1215 skill_name: scan.skill_name,
1216 staged_path,
1217 })
1218 }
1219
1220 struct TarballScan {
1221 /// Skill name from SKILL.md frontmatter.
1222 skill_name: String,
1223 /// Archive prefix to strip from each entry (e.g. `repo-main/`). May be empty.
1224 prefix: String,
1225 /// Sub-directory inside `prefix` that the SKILL.md lives in (`""` if root,
1226 /// or `skills/<name>` for repos that bundle multiple skills).
1227 skill_root: String,
1228 }
1229
1230 /// First pass: locate SKILL.md, validate frontmatter, compute total size,
1231 /// reject path-traversal entries and symlinks inside the selected install
1232 /// subtree. We do not write anything in this pass; that's the second pass's job.
1233 fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
1234 let cursor = std::io::Cursor::new(bytes);
1235 let gz = GzDecoder::new(cursor);
1236 let mut archive = tar::Archive::new(gz);
1237
1238 let mut total_size: u64 = 0;
1239 let mut prefix: Option<String> = None;
1240 let mut skill_md_relative: Option<(SkillMdCandidate, Vec<u8>)> = None;
1241 let mut skill_md_candidate_count: usize = 0;
1242 let mut has_claude_plugin_manifest = false;
1243 let mut link_paths: Vec<String> = Vec::new();
1244
1245 for entry in archive
1246 .entries()
1247 .context("failed to read tar entries (corrupt archive?)")?
1248 {
1249 let mut entry = entry.context("failed to read tar entry")?;
1250 let header = entry.header().clone();
1251 let entry_type = header.entry_type();
1252 let path = entry
1253 .path()
1254 .context("tar entry has invalid path")?
1255 .to_path_buf();
1256 let path_str = path.to_string_lossy().into_owned();
1257 if !is_safe_path(&path) {
1258 return Err(InstallError::PathTraversal(path_str).into());
1259 }
1260 if is_claude_plugin_manifest_path(&path) {
1261 has_claude_plugin_manifest = true;
1262 }
1263
1264 // Track total size against `max_size` (uncompressed). We honor `header
1265 // .size` rather than streaming-read every file; tar archives are
1266 // self-describing so this is reliable for non-malicious inputs and
1267 // catches the gzip-bomb case.
1268 if let Ok(size) = header.size() {
1269 total_size = total_size.saturating_add(size);
1270 if total_size > max_size {
1271 return Err(InstallError::OversizedTarball { limit: max_size }.into());
1272 }
1273 }
1274
1275 // Detect prefix from the first entry. GitHub archives wrap everything
1276 // in `<repo>-<branch>/`; direct tarballs may have no prefix. We treat
1277 // the first path component as the prefix iff the archive has more than
1278 // one entry under it, but for SKILL.md detection we just strip the
1279 // first component if every entry shares it.
1280 if prefix.is_none() {
1281 if let Some(Component::Normal(first)) = path.components().next() {
1282 let candidate = first.to_string_lossy().into_owned();
1283 // Only treat the first component as a prefix if it's a
1284 // directory-like (no extension and the path has more
1285 // components). Otherwise leave prefix empty.
1286 if path.components().count() > 1 {
1287 prefix = Some(candidate);
1288 } else {
1289 prefix = Some(String::new());
1290 }
1291 } else {
1292 prefix = Some(String::new());
1293 }
1294 }
1295
1296 if entry_type.is_symlink() || entry_type.is_hard_link() {
1297 link_paths.push(path_str);
1298 continue;
1299 }
1300
1301 // SKILL.md detection. Match the same workflow layouts that runtime
1302 // discovery understands:
1303 // * `<prefix>/SKILL.md`
1304 // * `<prefix>/*/skills/<name>/SKILL.md`
1305 // * `<prefix>/<name>/SKILL.md`
1306 if entry_type.is_file() {
1307 let stripped = strip_prefix(&path_str, prefix.as_deref().unwrap_or(""));
1308 if let Some(candidate) = skill_md_candidate(&stripped) {
1309 skill_md_candidate_count += 1;
1310 let mut buf = Vec::new();
1311 entry
1312 .read_to_end(&mut buf)
1313 .context("failed to read SKILL.md from archive")?;
1314 // Prefer the most explicit match: repo-root SKILL.md first,
1315 // then known skill-directory layouts, then a single nested
1316 // `<name>/SKILL.md` repository.
1317 let replace = skill_md_relative
1318 .as_ref()
1319 .is_none_or(|(current, _)| candidate.rank < current.rank);
1320 if replace {
1321 skill_md_relative = Some((candidate, buf));
1322 }
1323 }
1324 }
1325 }
1326
1327 let prefix = prefix.unwrap_or_default();
1328 if has_claude_plugin_manifest && skill_md_candidate_count > 1 {
1329 return Err(InstallError::ClaudePluginBundle.into());
1330 }
1331 let (skill_md, skill_md_bytes) = skill_md_relative
1332 .ok_or(InstallError::MissingSkillMd)
1333 .map_err(anyhow::Error::from)?;
1334
1335 for link_path in link_paths {
1336 if is_within_selected_root(&link_path, &prefix, &skill_md.skill_root) {
1337 return Err(InstallError::SymlinkRejected.into());
1338 }
1339 }
1340
1341 // Parse frontmatter to extract the skill name. We reuse the same parser
1342 // shape as `SkillRegistry::parse_skill` but inline it here so we don't
1343 // depend on the discovery module's private function.
1344 let name = parse_frontmatter_name(&skill_md_bytes)?;
1345
1346 Ok(TarballScan {
1347 skill_name: name,
1348 prefix,
1349 skill_root: skill_md.skill_root,
1350 })
1351 }
1352
1353 struct SkillMdCandidate {
1354 rank: u8,
1355 skill_root: String,
1356 }
1357
1358 fn skill_md_candidate(stripped_path: &str) -> Option<SkillMdCandidate> {
1359 if stripped_path.eq_ignore_ascii_case("SKILL.md") {
1360 return Some(SkillMdCandidate {
1361 rank: 0,
1362 skill_root: String::new(),
1363 });
1364 }
1365
1366 let parts: Vec<&str> = stripped_path.split('/').collect();
1367 if parts
1368 .last()
1369 .is_none_or(|last| !last.eq_ignore_ascii_case("SKILL.md"))
1370 {
1371 return None;
1372 }
1373
1374 // Common workflow-pack layouts:
1375 // `skills/<name>/SKILL.md`, `.agents/skills/<name>/SKILL.md`,
1376 // `.claude/skills/<name>/SKILL.md`, and nested package layouts such as
1377 // `packages/foo/skills/<name>/SKILL.md`.
1378 if parts.len() >= 3 {
1379 let container = parts[parts.len() - 3];
1380 let name = parts[parts.len() - 2];
1381 if container.eq_ignore_ascii_case("skills") && !name.is_empty() {
1382 return Some(SkillMdCandidate {
1383 rank: 1,
1384 skill_root: parts[..parts.len() - 1].join("/"),
1385 });
1386 }
1387 }
1388
1389 // Single-skill repos sometimes keep their root tidy with
1390 // `<skill-name>/SKILL.md` plus sibling docs at repo root.
1391 if parts.len() == 2 && !parts[0].is_empty() {
1392 return Some(SkillMdCandidate {
1393 rank: 2,
1394 skill_root: parts[0].to_string(),
1395 });
1396 }
1397
1398 None
1399 }
1400
1401 fn is_claude_plugin_manifest_path(path: &Path) -> bool {
1402 let parts: Vec<String> = path
1403 .components()
1404 .filter_map(|component| match component {
1405 Component::Normal(part) => Some(part.to_string_lossy().to_string()),
1406 _ => None,
1407 })
1408 .collect();
1409
1410 parts.windows(2).any(|window| {
1411 window[0].eq_ignore_ascii_case(".claude-plugin")
1412 && window[1].eq_ignore_ascii_case("plugin.json")
1413 })
1414 }
1415
1416 fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
1417 let cursor = std::io::Cursor::new(bytes);
1418 let gz = GzDecoder::new(cursor);
1419 let mut archive = tar::Archive::new(gz);
1420
1421 let mut total_size: u64 = 0;
1422 let prefix_with_root = if scan.skill_root.is_empty() {
1423 scan.prefix.clone()
1424 } else if scan.prefix.is_empty() {
1425 scan.skill_root.clone()
1426 } else {
1427 format!("{}/{}", scan.prefix, scan.skill_root)
1428 };
1429
1430 for entry in archive
1431 .entries()
1432 .context("failed to read tar entries (corrupt archive?)")?
1433 {
1434 let mut entry = entry.context("failed to read tar entry")?;
1435 let header = entry.header().clone();
1436 let entry_type = header.entry_type();
1437 let path = entry
1438 .path()
1439 .context("tar entry has invalid path")?
1440 .to_path_buf();
1441 let path_str = path.to_string_lossy().into_owned();
1442 if !is_safe_path(&path) {
1443 return Err(InstallError::PathTraversal(path_str).into());
1444 }
1445
1446 // Only extract entries that live under our skill root. For simple
1447 // tarballs (`SKILL.md` at root) that's everything; for multi-skill
1448 // repos it's the `skills/<name>/` slice.
1449 let stripped = strip_prefix(&path_str, &prefix_with_root).into_owned();
1450 if stripped.is_empty() && entry_type.is_dir() {
1451 // The root directory itself — already created.
1452 continue;
1453 }
1454 if stripped == path_str && !prefix_with_root.is_empty() {
1455 // Nothing to strip => entry is outside our subtree, skip.
1456 continue;
1457 }
1458 // Defense-in-depth: re-validate the stripped path.
1459 let stripped_path = Path::new(&stripped);
1460 if !is_safe_path(stripped_path) {
1461 return Err(InstallError::PathTraversal(stripped).into());
1462 }
1463 if entry_type.is_symlink() || entry_type.is_hard_link() {
1464 return Err(InstallError::SymlinkRejected.into());
1465 }
1466
1467 let target = dest.join(stripped_path);
1468 // Final paranoia check: ensure the resolved target stays under dest.
1469 // We can't canonicalize (target doesn't exist yet), so we walk
1470 // components one more time after composing.
1471 let target_components: Vec<_> = target.components().collect();
1472 let dest_components: Vec<_> = dest.components().collect();
1473 if !target_components.starts_with(dest_components.as_slice()) {
1474 return Err(InstallError::PathTraversal(stripped).into());
1475 }
1476
1477 if entry_type.is_dir() {
1478 fs::create_dir_all(&target)
1479 .with_context(|| format!("failed to create dir {}", target.display()))?;
1480 continue;
1481 }
1482 if entry_type.is_file() {
1483 if let Some(parent) = target.parent() {
1484 fs::create_dir_all(parent)
1485 .with_context(|| format!("failed to create dir {}", parent.display()))?;
1486 }
1487 // Read into a buffer so we can enforce `max_size`. Files inside
1488 // a SKILL bundle are small; copying through a buffer is fine.
1489 let mut buf = Vec::new();
1490 entry
1491 .read_to_end(&mut buf)
1492 .with_context(|| format!("failed to read {}", path.display()))?;
1493 total_size = total_size.saturating_add(buf.len() as u64);
1494 if total_size > max_size {
1495 return Err(InstallError::OversizedTarball { limit: max_size }.into());
1496 }
1497 let mut out = fs::OpenOptions::new()
1498 .create_new(true)
1499 .write(true)
1500 .open(&target)
1501 .with_context(|| format!("failed to create {}", target.display()))?;
1502 out.write_all(&buf)
1503 .with_context(|| format!("failed to write {}", target.display()))?;
1504 }
1505 }
1506 Ok(())
1507 }
1508
1509 fn selected_root(prefix: &str, skill_root: &str) -> String {
1510 if skill_root.is_empty() {
1511 prefix.to_string()
1512 } else if prefix.is_empty() {
1513 skill_root.to_string()
1514 } else {
1515 format!("{prefix}/{skill_root}")
1516 }
1517 }
1518
1519 fn is_within_selected_root(path: &str, prefix: &str, skill_root: &str) -> bool {
1520 let root = selected_root(prefix, skill_root);
1521 if root.is_empty() {
1522 return true;
1523 }
1524 path == root || path.starts_with(&format!("{root}/"))
1525 }
1526
1527 /// Ensure a tar path has no `..` segments and is not absolute.
1528 pub(crate) fn is_safe_path(path: &Path) -> bool {
1529 if path.is_absolute() {
1530 return false;
1531 }
1532 for component in path.components() {
1533 match component {
1534 Component::ParentDir => return false,
1535 Component::Prefix(_) | Component::RootDir => return false,
1536 _ => {}
1537 }
1538 }
1539 true
1540 }
1541
1542 fn skill_target_path(name: &str, skills_dir: &Path) -> Result<PathBuf> {
1543 let name = validate_skill_name_segment(name)?;
1544 Ok(skills_dir.join(name))
1545 }
1546
1547 pub(crate) fn validate_skill_name_segment(name: &str) -> Result<&str> {
1548 if name.is_empty() || name.trim() != name || name.chars().any(char::is_whitespace) {
1549 bail!("skill name must be a single path-safe segment (got '{name}')");
1550 }
1551 if name == "." || name == ".." || name.contains('/') || name.contains('\\') {
1552 bail!("skill name must be a single path-safe segment (got '{name}')");
1553 }
1554 let mut components = Path::new(name).components();
1555 if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
1556 bail!("skill name must be a single path-safe segment (got '{name}')");
1557 }
1558 Ok(name)
1559 }
1560
1561 fn ensure_target_within_skills_dir(target: &Path, skills_dir: &Path) -> Result<()> {
1562 let skills_dir = fs::canonicalize(skills_dir)
1563 .with_context(|| format!("failed to resolve {}", skills_dir.display()))?;
1564 let target = fs::canonicalize(target)
1565 .with_context(|| format!("failed to resolve {}", target.display()))?;
1566 if !target.starts_with(&skills_dir) {
1567 bail!(
1568 "skill path {} escapes skills directory {}",
1569 target.display(),
1570 skills_dir.display()
1571 );
1572 }
1573 Ok(())
1574 }
1575
1576 /// Strip a leading directory prefix (e.g. `repo-main/`) from a tarball path.
1577 fn strip_prefix<'a>(path: &'a str, prefix: &str) -> std::borrow::Cow<'a, str> {
1578 if prefix.is_empty() {
1579 return std::borrow::Cow::Borrowed(path);
1580 }
1581 let with_slash = format!("{prefix}/");
1582 if let Some(rest) = path.strip_prefix(&with_slash) {
1583 std::borrow::Cow::Owned(rest.to_string())
1584 } else if path == prefix {
1585 std::borrow::Cow::Borrowed("")
1586 } else {
1587 std::borrow::Cow::Borrowed(path)
1588 }
1589 }
1590
1591 /// Extract `name:` and ensure `description:` exist in the SKILL.md frontmatter.
1592 /// Also verifies the leading `---` fence so we reject malformed files early.
1593 fn parse_frontmatter_name(bytes: &[u8]) -> Result<String> {
1594 let content = std::str::from_utf8(bytes).context("SKILL.md is not valid UTF-8")?;
1595 let trimmed = content.trim_start();
1596 if !trimmed.starts_with("---") {
1597 bail!("SKILL.md is missing the leading '---' frontmatter fence");
1598 }
1599 let after_open = &trimmed[3..];
1600 let close = after_open.find("---").ok_or_else(|| {
1601 anyhow::anyhow!("SKILL.md is missing the closing '---' frontmatter fence")
1602 })?;
1603 let frontmatter = &after_open[..close];
1604
1605 let mut name: Option<String> = None;
1606 let mut has_description = false;
1607 for raw in frontmatter.lines() {
1608 let line = raw.trim();
1609 if line.is_empty() || line.starts_with('#') {
1610 continue;
1611 }
1612 if let Some((key, value)) = line.split_once(':') {
1613 let key = key.trim().to_ascii_lowercase();
1614 let value = value.trim().to_string();
1615 match key.as_str() {
1616 "name" if !value.is_empty() => name = Some(value),
1617 "description" if !value.is_empty() => has_description = true,
1618 _ => {}
1619 }
1620 }
1621 }
1622
1623 let name = name.ok_or(InstallError::MissingFrontmatterField("name"))?;
1624 if !has_description {
1625 return Err(InstallError::MissingFrontmatterField("description").into());
1626 }
1627 if validate_skill_name_segment(&name).is_err() {
1628 bail!("SKILL.md `name` must be a single path-safe segment (got '{name}')");
1629 }
1630 Ok(name)
1631 }
1632
1633 fn reject_unmarked_update(final_path: &Path, name: &str) -> std::result::Result<(), InstallError> {
1634 if final_path.join(INSTALLED_FROM_MARKER).exists() {
1635 Ok(())
1636 } else {
1637 Err(InstallError::NotInstalledHere(name.to_string()))
1638 }
1639 }
1640
1641 pub(crate) fn source_spec_string(source: &InstallSource) -> String {
1642 match source {
1643 InstallSource::GitHubRepo(repo) => format!("github:{repo}"),
1644 InstallSource::DirectUrl(url) => url.clone(),
1645 InstallSource::Registry(name) => name.clone(),
1646 }
1647 }
1648
1649 pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
1650 hex_bytes(Sha256::digest(bytes))
1651 }
1652
1653 fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
1654 let bytes = bytes.as_ref();
1655 let mut out = String::with_capacity(bytes.len() * 2);
1656 for byte in bytes {
1657 use std::fmt::Write as _;
1658 let _ = write!(&mut out, "{byte:02x}");
1659 }
1660 out
1661 }
1662
1663 // ─────────────────────────────────────────────────────────────────────────────
1664 // Tests
1665 // ─────────────────────────────────────────────────────────────────────────────
1666
1667 #[cfg(test)]
1668 mod tests {
1669 use super::*;
1670
1671 #[test]
1672 fn parse_github_source() {
1673 let s = InstallSource::parse("github:Hmbown/test-skill").unwrap();
1674 assert_eq!(
1675 s,
1676 InstallSource::GitHubRepo("Hmbown/test-skill".to_string())
1677 );
1678 }
1679
1680 #[test]
1681 fn parse_github_source_rejects_missing_repo() {
1682 let err = InstallSource::parse("github:Hmbown").unwrap_err();
1683 assert!(err.to_string().contains("github source must"), "got: {err}");
1684 }
1685
1686 #[test]
1687 fn parse_github_source_rejects_extra_slashes() {
1688 let err = InstallSource::parse("github:Hmbown/repo/extra").unwrap_err();
1689 assert!(err.to_string().contains("github source must"), "got: {err}");
1690 }
1691
1692 #[test]
1693 fn parse_direct_url_source() {
1694 let s = InstallSource::parse("https://example.com/skill.tar.gz").unwrap();
1695 assert_eq!(
1696 s,
1697 InstallSource::DirectUrl("https://example.com/skill.tar.gz".to_string())
1698 );
1699 let s = InstallSource::parse("http://example.com/skill.tar.gz").unwrap();
1700 assert_eq!(
1701 s,
1702 InstallSource::DirectUrl("http://example.com/skill.tar.gz".to_string())
1703 );
1704 }
1705
1706 #[test]
1707 fn parse_github_browser_url_routes_to_github_repo() {
1708 // Regression for #269: `https://github.com/<owner>/<repo>` was being
1709 // parsed as a DirectUrl, so the installer downloaded the HTML repo
1710 // page and tried to gzip-decode HTML ("invalid gzip header").
1711 for spec in [
1712 "https://github.com/obra/superpowers",
1713 "https://github.com/obra/superpowers/",
1714 "https://github.com/obra/superpowers.git",
1715 "https://github.com/obra/superpowers.git/",
1716 "https://www.github.com/obra/superpowers",
1717 "http://github.com/obra/superpowers",
1718 " https://github.com/obra/superpowers ",
1719 ] {
1720 let parsed = InstallSource::parse(spec)
1721 .unwrap_or_else(|err| panic!("parse({spec}) failed: {err}"));
1722 assert_eq!(
1723 parsed,
1724 InstallSource::GitHubRepo("obra/superpowers".to_string()),
1725 "spec {spec} must route to GitHubRepo",
1726 );
1727 }
1728 }
1729
1730 #[test]
1731 fn parse_github_archive_url_stays_direct() {
1732 // URLs that point at a specific subresource (archive tarball, blob,
1733 // tree) are real direct URLs — the user picked that exact path.
1734 for spec in [
1735 "https://github.com/obra/superpowers/archive/refs/heads/main.tar.gz",
1736 "https://github.com/obra/superpowers/blob/main/README.md",
1737 "https://github.com/obra/superpowers/tree/main",
1738 ] {
1739 let parsed = InstallSource::parse(spec).unwrap();
1740 assert!(
1741 matches!(parsed, InstallSource::DirectUrl(_)),
1742 "spec {spec} must stay DirectUrl, got {parsed:?}",
1743 );
1744 }
1745 }
1746
1747 #[test]
1748 fn parse_registry_source() {
1749 let s = InstallSource::parse("my-skill").unwrap();
1750 assert_eq!(s, InstallSource::Registry("my-skill".to_string()));
1751 }
1752
1753 #[test]
1754 fn parse_rejects_empty() {
1755 assert!(InstallSource::parse("").is_err());
1756 assert!(InstallSource::parse(" ").is_err());
1757 }
1758
1759 #[test]
1760 fn is_safe_path_rejects_traversal() {
1761 assert!(!is_safe_path(Path::new("../etc/passwd")));
1762 assert!(!is_safe_path(Path::new("foo/../bar")));
1763 assert!(!is_safe_path(Path::new("/etc/passwd")));
1764 assert!(is_safe_path(Path::new("foo/bar/baz")));
1765 assert!(is_safe_path(Path::new("SKILL.md")));
1766 }
1767
1768 #[test]
1769 fn parse_frontmatter_extracts_name() {
1770 let body = b"---\nname: hello\ndescription: greeter\n---\nbody\n";
1771 assert_eq!(parse_frontmatter_name(body).unwrap(), "hello");
1772 }
1773
1774 #[test]
1775 fn parse_frontmatter_missing_name_fails() {
1776 let body = b"---\ndescription: x\n---\n";
1777 let err = parse_frontmatter_name(body).unwrap_err();
1778 assert!(format!("{err}").contains("name"));
1779 }
1780
1781 #[test]
1782 fn parse_frontmatter_missing_description_fails() {
1783 let body = b"---\nname: x\n---\n";
1784 let err = parse_frontmatter_name(body).unwrap_err();
1785 assert!(format!("{err}").contains("description"));
1786 }
1787
1788 #[test]
1789 fn parse_frontmatter_rejects_unsafe_name() {
1790 let body = b"---\nname: ../evil\ndescription: x\n---\n";
1791 assert!(parse_frontmatter_name(body).is_err());
1792
1793 let body = b"---\nname: a name with spaces\ndescription: x\n---\n";
1794 assert!(parse_frontmatter_name(body).is_err());
1795
1796 let body = b"---\nname: tab\tname\ndescription: x\n---\n";
1797 assert!(parse_frontmatter_name(body).is_err());
1798 }
1799
1800 #[test]
1801 fn parse_frontmatter_requires_opening_fence() {
1802 let body = b"name: hello\ndescription: x\n";
1803 assert!(parse_frontmatter_name(body).is_err());
1804 }
1805
1806 #[test]
1807 fn update_refuses_to_replace_a_directory_without_an_install_marker() {
1808 let tmp = tempfile::tempdir().expect("tempdir");
1809 let dest = tmp.path().join("hand-authored");
1810 std::fs::create_dir_all(&dest).expect("dest");
1811 std::fs::write(dest.join("SKILL.md"), "---\nname: hand-authored\n---\n").expect("skill md");
1812 assert!(!dest.join(INSTALLED_FROM_MARKER).exists());
1813 let err = reject_unmarked_update(&dest, "hand-authored").unwrap_err();
1814 assert!(matches!(err, InstallError::NotInstalledHere(name) if name == "hand-authored"));
1815 assert!(dest.join("SKILL.md").exists(), "unmarked tree must survive");
1816 }
1817
1818 #[test]
1819 fn user_skill_names_must_be_single_safe_segments() {
1820 for bad in [
1821 "",
1822 "../evil",
1823 "/tmp/evil",
1824 "two words",
1825 "two\twords",
1826 "evil/name",
1827 "evil\\name",
1828 ".",
1829 "..",
1830 " leading",
1831 "trailing ",
1832 ] {
1833 assert!(
1834 validate_skill_name_segment(bad).is_err(),
1835 "expected {bad:?} to be rejected"
1836 );
1837 }
1838 assert_eq!(
1839 validate_skill_name_segment("safe-name_1").unwrap(),
1840 "safe-name_1"
1841 );
1842 }
1843
1844 #[test]
1845 fn uninstall_and_trust_reject_unsafe_skill_names_before_path_join() {
1846 let tmp = tempfile::tempdir().expect("tempdir");
1847 let skills_dir = tmp.path().join("skills");
1848 std::fs::create_dir_all(&skills_dir).expect("skills dir");
1849
1850 for bad in [
1851 "../evil",
1852 "/tmp/evil",
1853 "evil/name",
1854 "evil\\name",
1855 "two words",
1856 ] {
1857 assert!(uninstall(bad, &skills_dir).is_err());
1858 assert!(trust(bad, &skills_dir).is_err());
1859 }
1860 }
1861
1862 #[cfg(unix)]
1863 #[test]
1864 fn uninstall_rejects_symlink_target_escaping_skills_dir() {
1865 let tmp = tempfile::tempdir().expect("tempdir");
1866 let skills_dir = tmp.path().join("skills");
1867 let outside = tmp.path().join("outside");
1868 std::fs::create_dir_all(&skills_dir).expect("skills dir");
1869 std::fs::create_dir_all(&outside).expect("outside dir");
1870 std::fs::write(outside.join(INSTALLED_FROM_MARKER), "{}").expect("marker");
1871 std::os::unix::fs::symlink(&outside, skills_dir.join("linked")).expect("symlink");
1872
1873 let err = uninstall("linked", &skills_dir).unwrap_err();
1874 assert!(err.to_string().contains("escapes skills directory"));
1875 assert!(outside.exists());
1876 }
1877
1878 #[test]
1879 fn strip_prefix_handles_all_cases() {
1880 assert_eq!(strip_prefix("foo/bar", "foo"), "bar");
1881 assert_eq!(strip_prefix("foo", "foo"), "");
1882 assert_eq!(strip_prefix("baz/bar", "foo"), "baz/bar");
1883 assert_eq!(strip_prefix("foo/bar", ""), "foo/bar");
1884 }
1885
1886 #[test]
1887 fn source_spec_string_roundtrips() {
1888 assert_eq!(
1889 source_spec_string(&InstallSource::GitHubRepo("a/b".into())),
1890 "github:a/b"
1891 );
1892 assert_eq!(
1893 source_spec_string(&InstallSource::DirectUrl("https://x".into())),
1894 "https://x"
1895 );
1896 assert_eq!(
1897 source_spec_string(&InstallSource::Registry("x".into())),
1898 "x"
1899 );
1900 }
1901 }
1902
1902 lines RUST