返回 CodeWhale
mod.rs
根目录 / crates / tui / src / plugins / install / mod.rs
1 //! Plugin install on-ramp (#5182).
2 //!
3 //! Fetches a plugin bundle from a local directory, a `github:owner/repo`
4 //! archive, or a direct tarball URL, and places it under the user plugins
5 //! root (`~/.codewhale/plugins/<name>/`). This module deliberately mirrors
6 //! [`crate::skills::install`]: the download, network-gating, traversal
7 //! rejection, and marker machinery is *reused* from there (`fetch_tarball`,
8 //! `is_safe_path`, `write_installed_from_v2`, `INSTALLED_FROM_MARKER`), while
9 //! the scan/extract step is plugin-shaped (a bundle is rooted at the single
10 //! `plugin.toml` in the tree, not at a `SKILL.md`).
11 //!
12 //! # Hard rules
13 //!
14 //! * Everything is staged in a private `.staging-*` sibling first. The
15 //! destination is only created (via atomic rename) once the bundle clears
16 //! every check — half-installed plugins never appear on disk.
17 //! * The fetched tree must contain **exactly one** `plugin.toml`; that file's
18 //! directory becomes the bundle root. Zero (not a plugin) or more than one
19 //! (ambiguous mono-repo) are both rejected.
20 //! * Path traversal (`..`, absolute paths) and symlinks/hard links inside the
21 //! selected bundle subtree are rejected. Entries outside the subtree are
22 //! never extracted.
23 //! * The manifest `[plugin].name` must be a single path-safe segment; it
24 //! becomes the destination directory name.
25 //! * Overwriting a bundle that lacks the `.installed-from` marker is refused
26 //! — hand-placed bundles are never clobbered. `update` swaps atomically
27 //! only when the upstream bytes changed; a changed bundle automatically
28 //! invalidates the hash-bound trust receipt at the next discovery.
29 //! * Installed bits land **disabled and untrusted**; trust/enablement is the
30 //! existing registry flow, not this module's concern.
31
32 //!
33 //! # Module map
34 //!
35 //! This module owns the source spec, the result types, and the three
36 //! verbs. The pipeline stages each live next door:
37 //!
38 //! * [`stage`] — copy a local bundle into a private `.staging-*` sibling
39 //! (symlink, file-count, and size rejection; manifest validation).
40 //! * [`tarball`] — the two-pass archive reader: scan for the single
41 //! `plugin.toml` under the size cap, then extract just that subtree.
42 //! * [`place`] — atomic rename into `<name>/`, marker write, and the
43 //! containment guards shared with discovery.
44 //!
45 //! Fetching is not ours: remote bytes come from
46 //! [`crate::skills::install::fetch_tarball`], network gating included.
47
48 use std::fs;
49 use std::path::{Path, PathBuf};
50
51 use anyhow::{Context, Result, bail};
52 use thiserror::Error;
53
54 use crate::network_policy::NetworkPolicy;
55 use crate::skills::install::{
56 self as skill_install, FetchOutcome, InstallSource, InstalledFromMarker, fetch_tarball,
57 sha256_hex, source_spec_string,
58 };
59
60 mod place;
61 mod stage;
62 mod tarball;
63
64 #[cfg(test)]
65 mod tests;
66
67 use place::{ensure_target_within_plugins_dir, finalize_install, plugin_target_path};
68 use stage::stage_local_copy;
69 use tarball::stage_tarball;
70
71 /// Marker file shared with the skill installer. Its presence means "this
72 /// bundle was placed by `/plugin install`" and enables update/uninstall.
73 pub use crate::skills::install::INSTALLED_FROM_MARKER;
74
75 /// Default per-bundle size cap. Mirrors the skill installer; the runtime
76 /// staging budget in `registry.rs` stays the outer bound.
77 pub const DEFAULT_MAX_SIZE_BYTES: u64 = skill_install::DEFAULT_MAX_SIZE_BYTES;
78
79 // ─────────────────────────────────────────────────────────────────────────────
80 // Source parsing
81 // ─────────────────────────────────────────────────────────────────────────────
82
83 /// Where a plugin bundle is installed from. See [`PluginInstallSource::parse`].
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub enum PluginInstallSource {
86 /// Local bundle directory (copied, never executed). Parsed from a plain
87 /// path or an explicit `path:<dir>` spec (the marker round-trip form).
88 LocalPath(PathBuf),
89 /// `github:owner/repo` or a direct `http(s)://…` tarball URL, downloaded
90 /// through the shared skill-install machinery. There is no registry
91 /// index in v1.
92 Remote(InstallSource),
93 }
94
95 impl PluginInstallSource {
96 /// Parse a user-supplied spec.
97 ///
98 /// * `github:owner/repo`, `https://…` → [`PluginInstallSource::Remote`]
99 /// (via [`InstallSource::parse`]; registry names are unreachable here)
100 /// * `path:<dir>` or any other value → [`PluginInstallSource::LocalPath`]
101 pub fn parse(spec: &str) -> Result<Self> {
102 let trimmed = spec.trim();
103 if trimmed.is_empty() {
104 bail!("install source must not be empty");
105 }
106 if let Some(path) = trimmed.strip_prefix("path:") {
107 return Self::local(path);
108 }
109 if trimmed.starts_with("github:")
110 || trimmed.starts_with("https://")
111 || trimmed.starts_with("http://")
112 {
113 let source = InstallSource::parse(trimmed)?;
114 return match source {
115 InstallSource::GitHubRepo(_) | InstallSource::DirectUrl(_) => {
116 Ok(Self::Remote(source))
117 }
118 InstallSource::Registry(_) => {
119 unreachable!("prefixed specs never parse as a registry name")
120 }
121 };
122 }
123 Self::local(trimmed)
124 }
125
126 fn local(spec: &str) -> Result<Self> {
127 let trimmed = spec.trim();
128 if trimmed.is_empty() {
129 bail!("local install path must not be empty");
130 }
131 Ok(Self::LocalPath(PathBuf::from(trimmed)))
132 }
133 }
134
135 /// Serialize a source for the `.installed-from` marker. Must round-trip
136 /// through [`PluginInstallSource::parse`].
137 fn plugin_spec_string(source: &PluginInstallSource, canonical_source: Option<&Path>) -> String {
138 match source {
139 PluginInstallSource::LocalPath(_) => {
140 let path = canonical_source.expect("local installs record the canonical source");
141 format!("path:{}", path.display())
142 }
143 PluginInstallSource::Remote(remote) => source_spec_string(remote),
144 }
145 }
146
147 // ─────────────────────────────────────────────────────────────────────────────
148 // Outcome / result types
149 // ─────────────────────────────────────────────────────────────────────────────
150
151 /// Outcome of an install attempt. Same shape as the skill installer's so the
152 /// caller can drop `NeedsApproval`/`NetworkDenied` into its approval flow.
153 #[derive(Debug)]
154 pub enum PluginInstallOutcome {
155 /// The bundle was installed (atomic rename + marker write succeeded).
156 Installed(InstalledPlugin),
157 /// The download host requires user approval; nothing touched disk.
158 NeedsApproval(String),
159 /// The download host is denied by network policy.
160 NetworkDenied(String),
161 }
162
163 /// Metadata for a successfully installed plugin bundle.
164 #[derive(Debug, Clone)]
165 pub struct InstalledPlugin {
166 /// Plugin name from `[plugin].name`; also the destination directory name.
167 pub name: String,
168 /// Final on-disk path: `<user_plugins_dir>/<name>/`.
169 pub path: PathBuf,
170 /// Whole-bundle content hash of the staged tree (pre-marker). Informational;
171 /// trust receipts always bind to the discovery-time hash.
172 pub content_hash: String,
173 /// SHA-256 over the downloaded tarball bytes (empty for local copies).
174 /// Used by [`update`] to detect upstream changes without re-extracting.
175 pub source_checksum: String,
176 }
177
178 /// Result of an [`update`] call.
179 #[derive(Debug)]
180 pub enum PluginUpdateResult {
181 /// Upstream tarball is byte-identical to the recorded checksum; no action.
182 NoChange,
183 /// Upstream changed and the on-disk bundle was atomically replaced.
184 Updated(InstalledPlugin),
185 /// Network policy requires approval for the download host.
186 NeedsApproval(String),
187 /// Network policy denied the download host.
188 NetworkDenied(String),
189 }
190
191 /// Install-time errors, kept as an enum so tests can pattern-match without
192 /// parsing strings.
193 #[derive(Debug, Error)]
194 pub enum PluginInstallError {
195 #[error("entry escapes destination directory: {0}")]
196 PathTraversal(String),
197 #[error("bundle is too large; uncompressed total would exceed {limit} bytes")]
198 OversizedBundle { limit: u64 },
199 #[error(
200 "archive must contain exactly one plugin.toml root; found {0} (install a single plugin bundle, not a mono-repo)"
201 )]
202 PluginTomlRoots(usize),
203 #[error("symlinks and hard links are not allowed in plugin bundles")]
204 SymlinkRejected,
205 #[error("plugin '{0}' is already installed; use /plugin update or uninstall it first")]
206 AlreadyInstalled(String),
207 #[error(
208 "plugin '{0}' was not installed via /plugin install (no .installed-from marker); refusing to touch the hand-placed bundle"
209 )]
210 NotInstalledHere(String),
211 }
212
213 // ─────────────────────────────────────────────────────────────────────────────
214 // Public API
215 // ─────────────────────────────────────────────────────────────────────────────
216
217 /// Install a plugin bundle into `user_plugins_dir`.
218 ///
219 /// Steps: resolve source → (remote only) network-gate and download under the
220 /// size cap → stage into a `.staging-*` sibling, enforcing traversal/symlink/
221 /// size rules and the single-`plugin.toml` requirement → validate the staged
222 /// manifest → `name_conflict` check → atomic rename into `<name>/` → write
223 /// `.installed-from` last.
224 ///
225 /// `update = false` rejects an existing destination. `update = true` (only
226 /// called from [`update`]) requires the marker and replaces atomically with a
227 /// backup-restore on failure.
228 ///
229 /// `name_conflict` is consulted with the validated manifest name before the
230 /// rename; returning `Some(message)` aborts the install. It lets the caller
231 /// reject names already claimed by builtin/workspace bundles.
232 pub async fn install(
233 source: PluginInstallSource,
234 user_plugins_dir: &Path,
235 max_size: u64,
236 network: &NetworkPolicy,
237 update: bool,
238 name_conflict: &dyn Fn(&str) -> Option<String>,
239 ) -> Result<PluginInstallOutcome> {
240 match &source {
241 PluginInstallSource::LocalPath(path) => {
242 let staged = stage_local_copy(path, user_plugins_dir, max_size)?;
243 if let Some(conflict) = name_conflict(&staged.name) {
244 let _ = fs::remove_dir_all(&staged.staged_path);
245 bail!(conflict);
246 }
247 let canonical = path
248 .canonicalize()
249 .with_context(|| format!("failed to resolve {}", path.display()))?;
250 finalize_install(
251 staged,
252 &plugin_spec_string(&source, Some(&canonical)),
253 None,
254 "",
255 user_plugins_dir,
256 update,
257 )
258 }
259 PluginInstallSource::Remote(remote) => {
260 let (bytes, url) = match fetch_tarball(remote, network, max_size).await? {
261 FetchOutcome::Bytes { bytes, url } => (bytes, url),
262 FetchOutcome::NeedsApproval(host) => {
263 return Ok(PluginInstallOutcome::NeedsApproval(host));
264 }
265 FetchOutcome::Denied(host) => {
266 return Ok(PluginInstallOutcome::NetworkDenied(host));
267 }
268 };
269 install_remote_bytes(
270 remote,
271 &bytes,
272 &url,
273 user_plugins_dir,
274 max_size,
275 update,
276 name_conflict,
277 )
278 }
279 }
280 }
281
282 /// Stage and finalize an already-downloaded remote tarball. Kept separate
283 /// from [`install`] so [`update`] can compare the checksum of the bytes it
284 /// already fetched instead of downloading twice.
285 fn install_remote_bytes(
286 remote: &InstallSource,
287 bytes: &[u8],
288 url: &str,
289 user_plugins_dir: &Path,
290 max_size: u64,
291 update: bool,
292 name_conflict: &dyn Fn(&str) -> Option<String>,
293 ) -> Result<PluginInstallOutcome> {
294 let checksum = sha256_hex(bytes);
295 let staged = stage_tarball(bytes, user_plugins_dir, max_size)?;
296 if let Some(conflict) = name_conflict(&staged.name) {
297 let _ = fs::remove_dir_all(&staged.staged_path);
298 bail!(conflict);
299 }
300 finalize_install(
301 staged,
302 &source_spec_string(remote),
303 Some(url),
304 &checksum,
305 user_plugins_dir,
306 update,
307 )
308 }
309
310 /// Re-fetch a previously installed plugin and atomically replace it if the
311 /// upstream tarball changed. The replaced bundle carries new content, so the
312 /// existing hash-bound trust receipt stops matching at the next discovery —
313 /// re-review is forced by the registry, not by this function.
314 ///
315 /// Bundles installed from a local path cannot be re-downloaded; reinstall
316 /// them with `/plugin install <path>` instead.
317 pub async fn update(
318 name: &str,
319 user_plugins_dir: &Path,
320 max_size: u64,
321 network: &NetworkPolicy,
322 ) -> Result<PluginUpdateResult> {
323 let target = plugin_target_path(name, user_plugins_dir)?;
324 if target.exists() {
325 ensure_target_within_plugins_dir(&target, user_plugins_dir)?;
326 }
327 let marker_path = target.join(INSTALLED_FROM_MARKER);
328 if !marker_path.exists() {
329 return Err(PluginInstallError::NotInstalledHere(name.to_string()).into());
330 }
331 let marker_body = fs::read_to_string(&marker_path)
332 .with_context(|| format!("failed to read {}", marker_path.display()))?;
333 let marker: InstalledFromMarker = serde_json::from_str(&marker_body)
334 .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?;
335 let source = PluginInstallSource::parse(&marker.spec)?;
336 let PluginInstallSource::Remote(remote) = source else {
337 bail!(
338 "plugin '{name}' was installed from a local path ({}) and cannot be updated from the network; \
339 reinstall it with /plugin install <path>",
340 marker.spec
341 );
342 };
343
344 let (bytes, url) = match fetch_tarball(&remote, network, max_size).await? {
345 FetchOutcome::Bytes { bytes, url } => (bytes, url),
346 FetchOutcome::NeedsApproval(host) => {
347 return Ok(PluginUpdateResult::NeedsApproval(host));
348 }
349 FetchOutcome::Denied(host) => return Ok(PluginUpdateResult::NetworkDenied(host)),
350 };
351 if sha256_hex(&bytes) == marker.source_checksum() {
352 return Ok(PluginUpdateResult::NoChange);
353 }
354
355 let outcome = install_remote_bytes(
356 &remote,
357 &bytes,
358 &url,
359 user_plugins_dir,
360 max_size,
361 true,
362 &|_| None,
363 )?;
364 match outcome {
365 PluginInstallOutcome::Installed(installed) => Ok(PluginUpdateResult::Updated(installed)),
366 PluginInstallOutcome::NeedsApproval(host) => Ok(PluginUpdateResult::NeedsApproval(host)),
367 PluginInstallOutcome::NetworkDenied(host) => Ok(PluginUpdateResult::NetworkDenied(host)),
368 }
369 }
370
371 /// Remove a plugin installed via `/plugin install`.
372 ///
373 /// Refuses to touch any directory that doesn't carry the `.installed-from`
374 /// marker — that's our cue that it's hand-placed and not ours to delete.
375 /// Callers must require the bundle to be disabled first (the mutation
376 /// controller does) and prune the registry state entry afterwards.
377 pub fn uninstall(name: &str, user_plugins_dir: &Path) -> Result<()> {
378 let target = plugin_target_path(name, user_plugins_dir)?;
379 if !target.exists() {
380 bail!("plugin '{name}' is not installed at {}", target.display());
381 }
382 ensure_target_within_plugins_dir(&target, user_plugins_dir)?;
383 if !target.join(INSTALLED_FROM_MARKER).exists() {
384 return Err(PluginInstallError::NotInstalledHere(name.to_string()).into());
385 }
386 fs::remove_dir_all(&target)
387 .with_context(|| format!("failed to remove {}", target.display()))?;
388 Ok(())
389 }
390
390 lines RUST