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