返回 CodeWhale
tarball.rs
根目录 / crates / tui / src / plugins / install / tarball.rs
1 //! Two-pass tarball reader for remote bundles.
2 //!
3 //! Pass one ([`scan_tarball_for_bundle`]) writes nothing: it rejects traversal and
4 //! absolute paths, enforces the uncompressed size cap from the headers, and
5 //! locates the single bundle root — the directory holding the bundle's
6 //! manifest (`plugin.json`, `kimi.plugin.json`, or the legacy `plugin.toml`). Pass two
7 //! ([`extract_into`]) extracts only entries under that root, so a mono-repo's
8 //! symlinks elsewhere in the archive are never materialized.
9
10 use std::collections::BTreeSet;
11 use std::fs;
12 use std::io::{Read, Write};
13 use std::path::Path;
14
15 use anyhow::{Context, Result};
16 use flate2::read::GzDecoder;
17
18 use crate::skills::install::is_safe_path;
19
20 use super::PluginInstallError;
21 use super::stage::{StagedPlugin, fresh_staging_dir, validate_staged};
22
23 /// Validate a tarball and extract the manifest-rooted subtree into a
24 /// `.staging-*` sibling of the destination.
25 pub(super) fn stage_tarball(
26 bytes: &[u8],
27 user_plugins_dir: &Path,
28 max_size: u64,
29 bundle_path: Option<&str>,
30 ) -> Result<StagedPlugin> {
31 let scan = scan_tarball_for_bundle(bytes, max_size, bundle_path)?;
32 let staged_path = fresh_staging_dir(user_plugins_dir)?;
33 let result = extract_into(&scan, bytes, &staged_path, max_size)
34 .and_then(|()| validate_staged(&staged_path));
35 match result {
36 Ok((name, content_hash)) => Ok(StagedPlugin {
37 name,
38 staged_path,
39 content_hash,
40 }),
41 Err(error) => {
42 let _ = fs::remove_dir_all(&staged_path);
43 Err(error)
44 }
45 }
46 }
47
48 #[derive(Debug)]
49 pub(super) struct TarballScan {
50 /// Archive-relative directory containing the bundle's manifest (`""` when
51 /// the manifest sits at the archive root).
52 plugin_root: String,
53 }
54
55 /// First pass: validate entry paths, enforce the uncompressed size cap, and
56 /// locate the single bundle root. Nothing is written in this pass.
57 #[cfg(test)]
58 pub(super) fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
59 scan_tarball_for_bundle(bytes, max_size, None)
60 }
61
62 fn scan_tarball_for_bundle(
63 bytes: &[u8],
64 max_size: u64,
65 bundle_path: Option<&str>,
66 ) -> Result<TarballScan> {
67 let cursor = std::io::Cursor::new(bytes);
68 let gz = GzDecoder::new(cursor);
69 let mut archive = tar::Archive::new(gz);
70
71 let mut total_size: u64 = 0;
72 let mut manifest_paths: Vec<String> = Vec::new();
73
74 for entry in archive
75 .entries()
76 .context("failed to read tar entries (corrupt archive?)")?
77 {
78 let entry = entry.context("failed to read tar entry")?;
79 let header = entry.header().clone();
80 let path = entry
81 .path()
82 .context("tar entry has invalid path")?
83 .to_path_buf();
84 let path_str = path.to_string_lossy().into_owned();
85 if !is_safe_path(&path) {
86 return Err(PluginInstallError::PathTraversal(path_str).into());
87 }
88 if let Ok(size) = header.size() {
89 total_size = total_size.saturating_add(size);
90 if total_size > max_size {
91 return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
92 }
93 }
94 if header.entry_type().is_file()
95 && path.file_name().is_some_and(|name| {
96 name == std::ffi::OsStr::new(crate::plugins::agent_plugin::PLUGIN_JSON_NAME)
97 || name
98 == std::ffi::OsStr::new(crate::plugins::agent_plugin::KIMI_PLUGIN_JSON_NAME)
99 || name == std::ffi::OsStr::new(crate::plugins::agent_plugin::PLUGIN_TOML_NAME)
100 })
101 {
102 manifest_paths.push(path_str);
103 }
104 }
105
106 // A dual-published bundle carries `plugin.json` and `plugin.toml` in the
107 // same directory; that is one root, not two. Manifests in different
108 // directories stay an ambiguous mono-repo and are rejected.
109 let roots: BTreeSet<String> = manifest_paths
110 .iter()
111 .map(|manifest| {
112 crate::plugins::agent_plugin::plugin_root_for_manifest(Path::new(manifest))
113 .map(|root| root.to_string_lossy().into_owned())
114 .unwrap_or_default()
115 })
116 .filter(|root| {
117 bundle_path.is_none_or(|wanted| {
118 root == wanted
119 || root
120 .split_once('/')
121 .is_some_and(|(_, relative)| relative == wanted)
122 })
123 })
124 .collect();
125 if roots.len() != 1 {
126 return Err(PluginInstallError::PluginTomlRoots(roots.len()).into());
127 }
128 let plugin_root = roots.into_iter().next().unwrap_or_default();
129 Ok(TarballScan { plugin_root })
130 }
131
132 /// Second pass: extract only entries under the scanned bundle root.
133 fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
134 let cursor = std::io::Cursor::new(bytes);
135 let gz = GzDecoder::new(cursor);
136 let mut archive = tar::Archive::new(gz);
137 let mut total_size: u64 = 0;
138
139 for entry in archive
140 .entries()
141 .context("failed to read tar entries (corrupt archive?)")?
142 {
143 let mut entry = entry.context("failed to read tar entry")?;
144 let header = entry.header().clone();
145 let entry_type = header.entry_type();
146 let path = entry
147 .path()
148 .context("tar entry has invalid path")?
149 .to_path_buf();
150 let path_str = path.to_string_lossy().into_owned();
151 if !is_safe_path(&path) {
152 return Err(PluginInstallError::PathTraversal(path_str).into());
153 }
154
155 // Keep only the bundle subtree. Entries outside it (including any
156 // symlinks a mono-repo ships elsewhere) are ignored, never extracted.
157 let stripped = if scan.plugin_root.is_empty() {
158 path_str.clone()
159 } else if path_str == scan.plugin_root {
160 String::new()
161 } else if let Some(rest) = path_str.strip_prefix(&format!("{}/", scan.plugin_root)) {
162 rest.to_string()
163 } else {
164 continue;
165 };
166 if stripped.is_empty() {
167 // The bundle root directory itself — the staging dir already exists.
168 continue;
169 }
170 // Defense-in-depth: re-validate the stripped path.
171 let stripped_path = Path::new(&stripped);
172 if !is_safe_path(stripped_path) {
173 return Err(PluginInstallError::PathTraversal(stripped).into());
174 }
175 if entry_type.is_symlink() || entry_type.is_hard_link() {
176 return Err(PluginInstallError::SymlinkRejected.into());
177 }
178
179 let target = dest.join(stripped_path);
180 // Final paranoia check: the composed target must stay under dest.
181 let target_components: Vec<_> = target.components().collect();
182 let dest_components: Vec<_> = dest.components().collect();
183 if !target_components.starts_with(dest_components.as_slice()) {
184 return Err(PluginInstallError::PathTraversal(stripped).into());
185 }
186
187 if entry_type.is_dir() {
188 fs::create_dir_all(&target)
189 .with_context(|| format!("failed to create dir {}", target.display()))?;
190 continue;
191 }
192 if entry_type.is_file() {
193 if let Some(parent) = target.parent() {
194 fs::create_dir_all(parent)
195 .with_context(|| format!("failed to create dir {}", parent.display()))?;
196 }
197 let mut buf = Vec::new();
198 entry
199 .read_to_end(&mut buf)
200 .with_context(|| format!("failed to read {}", path.display()))?;
201 total_size = total_size.saturating_add(buf.len() as u64);
202 if total_size > max_size {
203 return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
204 }
205 let mut options = fs::OpenOptions::new();
206 options.create_new(true).write(true);
207 #[cfg(unix)]
208 {
209 use std::os::unix::fs::OpenOptionsExt as _;
210 options.mode(0o600);
211 }
212 let mut out = options
213 .open(&target)
214 .with_context(|| format!("failed to create {}", target.display()))?;
215 out.write_all(&buf)
216 .with_context(|| format!("failed to write {}", target.display()))?;
217 #[cfg(unix)]
218 {
219 use std::os::unix::fs::PermissionsExt as _;
220 let executable = header.mode().context("invalid archive file mode")? & 0o111 != 0;
221 let mode = if executable { 0o700 } else { 0o600 };
222 out.set_permissions(fs::Permissions::from_mode(mode))
223 .with_context(|| format!("failed to set mode for {}", target.display()))?;
224 }
225 }
226 }
227 Ok(())
228 }
229
229 lines RUST