返回 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 validated = PluginManifest::validate_from_path(&staged_path.join("plugin.toml"))
72 .map_err(|error| anyhow::anyhow!("staged plugin.toml failed validation: {error}"))?;
73 let name = validated.manifest.plugin.name.clone();
74 validate_skill_name_segment(&name).map_err(|error| {
75 anyhow::anyhow!("[plugin].name is not a safe directory name: {error:#}")
76 })?;
77 Ok((name, validated.content_hash))
78 }
79
80 /// Copy a local bundle directory into staging. Symlinks anywhere in the
81 /// source are rejected; a stale `.installed-from` marker is never copied so
82 /// provenance always reflects *this* install.
83 pub(super) fn stage_local_copy(
84 source: &Path,
85 user_plugins_dir: &Path,
86 max_size: u64,
87 ) -> Result<StagedPlugin> {
88 // Validate the source first; this also rejects symlinked roots/manifests.
89 PluginManifest::validate_from_path(&source.join("plugin.toml"))
90 .map_err(|error| anyhow::anyhow!("source is not a valid plugin bundle: {error}"))?;
91 let canonical_source = source
92 .canonicalize()
93 .with_context(|| format!("failed to resolve {}", source.display()))?;
94 if let Ok(canonical_plugins) = user_plugins_dir.canonicalize()
95 && (canonical_source == canonical_plugins
96 || canonical_source.starts_with(&canonical_plugins))
97 {
98 bail!(
99 "cannot install a bundle from inside the user plugins directory {}; \
100 it is already in place",
101 canonical_plugins.display()
102 );
103 }
104
105 let staged_path = fresh_staging_dir(user_plugins_dir)?;
106 let result = (|| -> Result<StagedPlugin> {
107 let mut budget = CopyBudget::default();
108 copy_bundle_regular_files(&canonical_source, &staged_path, max_size, &mut budget)?;
109 let (name, content_hash) = validate_staged(&staged_path)?;
110 Ok(StagedPlugin {
111 name,
112 staged_path: staged_path.clone(),
113 content_hash,
114 })
115 })();
116 if result.is_err() {
117 let _ = fs::remove_dir_all(&staged_path);
118 }
119 result
120 }
121
122 #[derive(Default)]
123 struct CopyBudget {
124 files: usize,
125 bytes: u64,
126 }
127
128 fn copy_bundle_regular_files(
129 source: &Path,
130 dest: &Path,
131 max_size: u64,
132 budget: &mut CopyBudget,
133 ) -> Result<()> {
134 for entry in fs::read_dir(source)
135 .with_context(|| format!("failed to read bundle dir {}", source.display()))?
136 {
137 let entry = entry?;
138 let path = entry.path();
139 let metadata = fs::symlink_metadata(&path)?;
140 if metadata.file_type().is_symlink() {
141 return Err(PluginInstallError::SymlinkRejected.into());
142 }
143 let name = entry.file_name();
144 if name == std::ffi::OsStr::new(INSTALLED_FROM_MARKER) {
145 continue;
146 }
147 let target = dest.join(&name);
148 if metadata.is_dir() {
149 fs::create_dir(&target)
150 .with_context(|| format!("failed to create {}", target.display()))?;
151 copy_bundle_regular_files(&path, &target, max_size, budget)?;
152 } else if metadata.is_file() {
153 budget.files = budget.files.saturating_add(1);
154 if budget.files > MAX_BUNDLE_FILES {
155 bail!("bundle exceeds the {MAX_BUNDLE_FILES} file limit");
156 }
157 budget.bytes = budget.bytes.saturating_add(metadata.len());
158 if budget.bytes > max_size {
159 return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
160 }
161 fs::copy(&path, &target).with_context(|| {
162 format!("failed to copy {} to {}", path.display(), target.display())
163 })?;
164 }
165 }
166 Ok(())
167 }
168
168 lines RUST