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