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