返回 CodeWhale
stage.rs
根目录 / crates / tui / src / plugins / install / stage.rs
1 //! Stage a bundle into a private `.staging-*` sibling of the plugins root.
2 //!
3 //! Nothing here ever touches the destination directory: staging either
4 //! produces a validated [`StagedPlugin`] or removes its own residue. The
5 //! local-copy path additionally rejects symlinks anywhere in the source and
6 //! never copies a stale `.installed-from` marker, so provenance always
7 //! reflects *this* install.
8
9 use std::fs;
10 use std::path::{Path, PathBuf};
11
12 use anyhow::{Context, Result, bail};
13
14 use crate::plugins::manifest::PluginManifest;
15 use crate::skills::install::validate_skill_name_segment;
16
17 use super::{INSTALLED_FROM_MARKER, PluginInstallError};
18
19 /// File count cap for local copies, mirroring the registry staging budget.
20 const MAX_BUNDLE_FILES: usize = 4_096;
21
22 #[derive(Debug)]
23 pub(super) struct StagedPlugin {
24 pub(super) name: String,
25 pub(super) staged_path: PathBuf,
26 pub(super) content_hash: String,
27 }
28
29 pub(super) fn fresh_staging_dir(user_plugins_dir: &Path) -> Result<PathBuf> {
30 ensure_plugins_dir(user_plugins_dir)?;
31 // A crashed stage can leave residue that discovery will surface as an
32 // untrusted, disabled bundle; the next install attempt cleans it up by
33 // using a fresh uuid path and never reuses the stale one.
34 let staged_path = user_plugins_dir.join(format!(".staging-{}", uuid::Uuid::new_v4().simple()));
35 fs::create_dir(&staged_path)
36 .with_context(|| format!("failed to create staging dir {}", staged_path.display()))?;
37 Ok(staged_path)
38 }
39
40 /// Create the user plugins root when missing. The persisted plugin state
41 /// (`state.json`) lives in this same directory, so it must satisfy the
42 /// registry's owner-only contract the first time `/plugin install` brings it
43 /// into existence — a pre-existing directory is left untouched (trust reports
44 /// unsafe permissions fail-closed rather than silently repairing them).
45 #[cfg(unix)]
46 fn ensure_plugins_dir(user_plugins_dir: &Path) -> Result<()> {
47 use std::os::unix::fs::DirBuilderExt as _;
48
49 let mut builder = fs::DirBuilder::new();
50 builder.recursive(true).mode(0o700);
51 builder.create(user_plugins_dir).with_context(|| {
52 format!(
53 "failed to create user plugins directory {}",
54 user_plugins_dir.display()
55 )
56 })
57 }
58
59 #[cfg(not(unix))]
60 fn ensure_plugins_dir(user_plugins_dir: &Path) -> Result<()> {
61 fs::create_dir_all(user_plugins_dir).with_context(|| {
62 format!(
63 "failed to create user plugins directory {}",
64 user_plugins_dir.display()
65 )
66 })
67 }
68
69 /// Validate the staged tree and return the manifest name + content hash.
70 pub(super) fn validate_staged(staged_path: &Path) -> Result<(String, String)> {
71 let manifest_path = crate::plugins::agent_plugin::resolve_manifest_path(staged_path)
72 .ok_or_else(|| {
73 anyhow::anyhow!("staged bundle has no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml")
74 })?;
75 let validated = PluginManifest::validate_from_path(&manifest_path)
76 .map_err(|error| anyhow::anyhow!("staged plugin manifest failed validation: {error}"))?;
77 let name = validated.manifest.plugin.name.clone();
78 validate_skill_name_segment(&name).map_err(|error| {
79 anyhow::anyhow!("[plugin].name is not a safe directory name: {error:#}")
80 })?;
81 Ok((name, validated.content_hash))
82 }
83
84 /// Copy a local bundle directory into staging. Symlinks anywhere in the
85 /// source are rejected; a stale `.installed-from` marker is never copied so
86 /// provenance always reflects *this* install.
87 pub(super) fn stage_local_copy(
88 source: &Path,
89 user_plugins_dir: &Path,
90 max_size: u64,
91 ) -> Result<StagedPlugin> {
92 // Validate the source first; this also rejects symlinked roots/manifests.
93 let manifest_path =
94 crate::plugins::agent_plugin::resolve_manifest_path(source).ok_or_else(|| {
95 anyhow::anyhow!(
96 "source is not a valid plugin bundle: no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml"
97 )
98 })?;
99 PluginManifest::validate_from_path(&manifest_path)
100 .map_err(|error| anyhow::anyhow!("source is not a valid plugin bundle: {error}"))?;
101 let canonical_source = source
102 .canonicalize()
103 .with_context(|| format!("failed to resolve {}", source.display()))?;
104 if let Ok(canonical_plugins) = user_plugins_dir.canonicalize()
105 && (canonical_source == canonical_plugins
106 || canonical_source.starts_with(&canonical_plugins))
107 {
108 bail!(
109 "cannot install a bundle from inside the user plugins directory {}; \
110 it is already in place",
111 canonical_plugins.display()
112 );
113 }
114
115 let staged_path = fresh_staging_dir(user_plugins_dir)?;
116 let result = (|| -> Result<StagedPlugin> {
117 let mut budget = CopyBudget::default();
118 copy_bundle_regular_files(&canonical_source, &staged_path, max_size, &mut budget)?;
119 let (name, content_hash) = validate_staged(&staged_path)?;
120 Ok(StagedPlugin {
121 name,
122 staged_path: staged_path.clone(),
123 content_hash,
124 })
125 })();
126 if result.is_err() {
127 let _ = fs::remove_dir_all(&staged_path);
128 }
129 result
130 }
131
132 #[derive(Default)]
133 struct CopyBudget {
134 files: usize,
135 bytes: u64,
136 }
137
138 fn copy_bundle_regular_files(
139 source: &Path,
140 dest: &Path,
141 max_size: u64,
142 budget: &mut CopyBudget,
143 ) -> Result<()> {
144 for entry in fs::read_dir(source)
145 .with_context(|| format!("failed to read bundle dir {}", source.display()))?
146 {
147 let entry = entry?;
148 let path = entry.path();
149 let metadata = fs::symlink_metadata(&path)?;
150 if metadata.file_type().is_symlink() {
151 return Err(PluginInstallError::SymlinkRejected.into());
152 }
153 let name = entry.file_name();
154 if name == std::ffi::OsStr::new(INSTALLED_FROM_MARKER) {
155 continue;
156 }
157 let target = dest.join(&name);
158 if metadata.is_dir() {
159 fs::create_dir(&target)
160 .with_context(|| format!("failed to create {}", target.display()))?;
161 copy_bundle_regular_files(&path, &target, max_size, budget)?;
162 } else if metadata.is_file() {
163 budget.files = budget.files.saturating_add(1);
164 if budget.files > MAX_BUNDLE_FILES {
165 bail!("bundle exceeds the {MAX_BUNDLE_FILES} file limit");
166 }
167 budget.bytes = budget.bytes.saturating_add(metadata.len());
168 if budget.bytes > max_size {
169 return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
170 }
171 fs::copy(&path, &target).with_context(|| {
172 format!("failed to copy {} to {}", path.display(), target.display())
173 })?;
174 }
175 }
176 Ok(())
177 }
178
178 lines RUST