zagens_runtime/skills/install/types.rs
1use std::path::PathBuf;
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5use thiserror::Error;
6
7pub fn default_cache_skills_dir() -> PathBuf {
8 dirs::home_dir().map_or_else(
9 || PathBuf::from("/tmp/deepseek/cache/skills"),
10 |_| zagens_config::user_data_path_or_relative("cache/skills"),
11 )
12}
13
14/// Default registry. Falls back to a community-curated `index.json` hosted on
15/// GitHub raw; users can override via `[skills] registry_url` in config.toml.
16pub const DEFAULT_REGISTRY_URL: &str =
17 "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
18
19/// Default per-skill size cap (5 MiB). Honored at unpack time so a malicious
20/// gzip bomb can't blow up RAM.
21pub const DEFAULT_MAX_SIZE_BYTES: u64 = 5 * 1024 * 1024;
22
23/// File written under each installed skill so [`update`] / [`uninstall`] can
24/// recover the original [`InstallSource`] without re-parsing user input.
25pub const INSTALLED_FROM_MARKER: &str = ".installed-from";
26
27/// File written under each trusted skill. Currently advisory (the install path
28/// never auto-runs anything) — the runtime tool-invocation gate consults this
29/// marker before executing scripts that ship with the skill.
30pub const TRUSTED_MARKER: &str = ".trusted";
31
32// ─────────────────────────────────────────────────────────────────────────────
33// Source parsing
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// Where a skill is being installed from. See [`InstallSource::parse`] for the
37/// accepted spec syntax.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum InstallSource {
40 /// `github:owner/repo`. Resolved to
41 /// `https://github.com/<owner>/<repo>/archive/refs/heads/main.tar.gz`
42 /// with a `master.tar.gz` fallback on 404.
43 GitHubRepo(String),
44 /// Raw `http(s)://…` tarball URL. Used as-is.
45 DirectUrl(String),
46 /// Curated registry lookup key. Looked up via the configured `registry_url`.
47 Registry(String),
48}
49
50impl InstallSource {
51 /// Parse a user-supplied spec. Empty / whitespace-only input is rejected.
52 ///
53 /// * `github:owner/repo` → [`InstallSource::GitHubRepo`]
54 /// * `https://github.com/owner/repo[.git]` (no path past the repo) →
55 /// [`InstallSource::GitHubRepo`]
56 /// * any other `http://` or `https://` prefix → [`InstallSource::DirectUrl`]
57 /// * anything else → [`InstallSource::Registry`]
58 pub fn parse(spec: &str) -> Result<Self> {
59 let trimmed = spec.trim();
60 if trimmed.is_empty() {
61 bail!("install source must not be empty");
62 }
63 if let Some(rest) = trimmed.strip_prefix("github:") {
64 let rest = rest.trim();
65 // Reject obviously bogus values up front. We intentionally accept
66 // case-insensitive owner/repo so `github:Hmbown/Foo` works.
67 let (owner, repo) = rest.split_once('/').with_context(|| {
68 format!("github source must be 'github:owner/repo' (got {spec})")
69 })?;
70 let owner = owner.trim();
71 let repo = repo.trim().trim_end_matches('/');
72 if owner.is_empty() || repo.is_empty() {
73 bail!("github source must be 'github:owner/repo' (got {spec})");
74 }
75 if owner.contains('/') || repo.contains('/') {
76 bail!("github source must be 'github:owner/repo' (got {spec})");
77 }
78 return Ok(Self::GitHubRepo(format!("{owner}/{repo}")));
79 }
80 if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
81 if let Some(repo) = parse_github_browser_url(trimmed) {
82 return Ok(Self::GitHubRepo(repo));
83 }
84 return Ok(Self::DirectUrl(trimmed.to_string()));
85 }
86 Ok(Self::Registry(trimmed.to_string()))
87 }
88}
89
90/// Detect bare `https://github.com/<owner>/<repo>` URLs (with or without a
91/// trailing `.git`) and return `owner/repo`. Returns `None` for any URL that
92/// already points at a specific archive / blob / tree path — those are real
93/// direct URLs and the caller fetches them as-is.
94pub(super) fn parse_github_browser_url(url: &str) -> Option<String> {
95 let after_scheme = url
96 .strip_prefix("https://")
97 .or_else(|| url.strip_prefix("http://"))?;
98 let (host, rest) = after_scheme.split_once('/')?;
99 if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com") {
100 return None;
101 }
102 let trimmed = rest.trim_end_matches('/');
103 let mut parts = trimmed.splitn(3, '/');
104 let owner = parts.next()?.trim();
105 let repo = parts.next()?.trim().trim_end_matches(".git");
106 if owner.is_empty() || repo.is_empty() {
107 return None;
108 }
109 // If there is a third segment, the URL points at a sub-resource
110 // (`/archive/...`, `/blob/...`, `/tree/...`). Treat that as a real direct
111 // URL — the user explicitly wants whatever lives at that path.
112 if parts.next().is_some() {
113 return None;
114 }
115 Some(format!("{owner}/{repo}"))
116}
117/// Outcome of an install attempt.
118#[derive(Debug)]
119pub enum InstallOutcome {
120 /// The skill was installed (or already present and idempotent).
121 Installed(InstalledSkill),
122 /// The host requires user approval before the install can proceed. The
123 /// caller should surface this through whatever approval pathway it has and
124 /// retry once approved (typically by adding the host to the policy's
125 /// allow list).
126 NeedsApproval(String),
127 /// The host is denied by network policy. The install is aborted.
128 NetworkDenied(String),
129}
130
131/// Metadata for a successfully installed skill.
132#[derive(Debug, Clone)]
133pub struct InstalledSkill {
134 /// Skill name (taken from SKILL.md frontmatter).
135 pub name: String,
136 /// Final on-disk path: `<skills_dir>/<name>/`.
137 pub path: PathBuf,
138 /// SHA-256 over the downloaded tarball bytes. Used by [`update`] to detect
139 /// upstream changes without re-extracting; also surfaced for telemetry /
140 /// future signature-verification work.
141 #[allow(dead_code)]
142 pub source_checksum: String,
143}
144
145/// Result of an [`update`] call.
146#[derive(Debug)]
147pub enum UpdateResult {
148 /// Upstream tarball is byte-identical to the on-disk checksum; no action.
149 NoChange,
150 /// Upstream changed and the on-disk install was atomically replaced.
151 Updated(InstalledSkill),
152 /// Network policy short-circuited the update. Same semantics as
153 /// [`InstallOutcome::NeedsApproval`].
154 NeedsApproval(String),
155 /// Network policy denied the update.
156 NetworkDenied(String),
157}
158
159/// Errors that can happen during install. Most variants are flattened into
160/// `anyhow::Error` at the public boundary; this enum is used internally so
161/// tests can pattern-match without parsing strings.
162#[derive(Debug, Error)]
163pub enum InstallError {
164 #[error("entry escapes destination directory: {0}")]
165 PathTraversal(String),
166 #[error("entry is too large; uncompressed total would exceed {limit} bytes")]
167 OversizedTarball { limit: u64 },
168 #[error("missing SKILL.md in archive")]
169 MissingSkillMd,
170 #[error("SKILL.md frontmatter missing required field: {0}")]
171 MissingFrontmatterField(&'static str),
172 #[error("symlinks are not allowed in skill tarballs")]
173 SymlinkRejected,
174 #[error("skill '{0}' is already installed; use update or remove it first")]
175 AlreadyInstalled(String),
176 #[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
177 NotInstalledHere(String),
178}
179#[derive(Debug)]
180pub enum SkillSyncOutcome {
181 /// Skill downloaded and written to the cache directory.
182 Downloaded { name: String, path: PathBuf },
183 /// Cached bytes match the upstream ETag / SHA-256; nothing written.
184 Fresh { name: String },
185 /// Skill download failed; the error is non-fatal so the sync continues.
186 Failed { name: String, reason: String },
187 /// Network policy blocked the download host.
188 Denied { name: String, host: String },
189 /// Network policy requires user approval for the download host.
190 NeedsApproval { name: String, host: String },
191}
192
193/// Overall result of [`sync_registry`].
194#[derive(Debug)]
195pub enum SyncResult {
196 /// Sync completed. `outcomes` contains one entry per skill in the index.
197 Done { outcomes: Vec<SkillSyncOutcome> },
198 /// The registry fetch was blocked by network policy.
199 RegistryDenied(String),
200 /// The registry fetch requires user approval.
201 RegistryNeedsApproval(String),
202}
203/// Curated-registry document. The shape is intentionally minimal so adding
204/// optional metadata later (homepage, version, signature) is forward-compatible.
205#[derive(Debug, Clone, Deserialize)]
206pub struct RegistryDocument {
207 /// Map of skill name → entry.
208 #[serde(default)]
209 pub skills: std::collections::BTreeMap<String, RegistryEntry>,
210}
211
212/// One row in the curated registry. `description` is optional so old indices
213/// keep parsing.
214#[derive(Debug, Clone, Deserialize)]
215pub struct RegistryEntry {
216 /// Source spec (e.g. `github:owner/repo`).
217 pub source: String,
218 /// Optional human-readable description.
219 #[serde(default)]
220 pub description: Option<String>,
221}
222
223/// Successful registry fetch result. Same shape as [`InstallOutcome`] for the
224/// network-policy outcomes so the caller can drop directly into approval flow.
225#[derive(Debug)]
226pub enum RegistryFetchResult {
227 Loaded(RegistryDocument),
228 NeedsApproval(String),
229 Denied(String),
230}
231
232pub(super) enum UrlResolution {
233 Resolved(Vec<String>),
234 NeedsApproval(String),
235 Denied(String),
236}
237
238pub(super) enum DownloadOutcome {
239 Bytes { bytes: Vec<u8>, url: String },
240 NeedsApproval(String),
241 Denied(String),
242}
243pub(super) enum DownloadAttempt {
244 Bytes(Vec<u8>),
245 NotFound(reqwest::StatusCode),
246}