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