tauri_plugin_hot_update/
extract.rs1use std::cell::Cell;
28use std::fs;
29use std::io::{self, Read};
30use std::path::{Component, Path, PathBuf};
31use std::rc::Rc;
32
33use flate2::read::GzDecoder;
34
35pub const MAX_FILES: usize = 10_000;
39
40pub const MAX_UNCOMPRESSED_BYTES: u64 = 500 * 1024 * 1024;
44
45const STREAM_SLACK_BYTES: u64 = 32 * 1024 * 1024;
50
51#[derive(Debug, thiserror::Error)]
54pub enum ExtractError {
55 #[error("archive read/write failed: {0}")]
56 Io(#[from] io::Error),
57 #[error("archive entry {path:?} has forbidden type {entry_type} (only plain files and directories are allowed)")]
58 ForbiddenEntryType {
59 path: String,
60 entry_type: &'static str,
61 },
62 #[error("archive entry path {path:?} is unsafe (absolute, traversing, or non-normal)")]
63 UnsafePath { path: String },
64 #[error("archive exceeds the {limit}-entry cap")]
65 TooManyEntries { limit: usize },
66 #[error("archive exceeds the {limit}-byte uncompressed cap")]
67 TooLarge { limit: u64 },
68}
69
70pub(crate) fn extract_tar_gz(archive: &Path, target: &Path) -> Result<(), ExtractError> {
73 let limits = Limits {
74 max_entries: MAX_FILES,
75 max_total_bytes: MAX_UNCOMPRESSED_BYTES,
76 };
77 extract_with_limits(archive, target, limits)
78}
79
80#[derive(Debug, Clone, Copy)]
83struct Limits {
84 max_entries: usize,
85 max_total_bytes: u64,
86}
87
88fn extract_with_limits(archive: &Path, target: &Path, limits: Limits) -> Result<(), ExtractError> {
89 let stream_cap = limits.max_total_bytes.saturating_add(STREAM_SLACK_BYTES);
90 let exceeded = Rc::new(Cell::new(false));
91 let reader = CappedReader {
92 inner: GzDecoder::new(fs::File::open(archive)?),
93 remaining: stream_cap,
94 exceeded: Rc::clone(&exceeded),
95 };
96 let mut tar = tar::Archive::new(reader);
97
98 let mut entry_count: usize = 0;
99 let mut total_bytes: u64 = 0;
100 let mut entries = tar.entries()?;
101 loop {
102 let entry = match entries.next() {
103 Some(Ok(entry)) => entry,
104 Some(Err(e)) => return Err(map_stream_error(e, &exceeded, limits)),
105 None => break,
106 };
107
108 entry_count += 1;
109 if entry_count > limits.max_entries {
110 return Err(ExtractError::TooManyEntries {
111 limit: limits.max_entries,
112 });
113 }
114
115 let raw_path = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
116 let entry_type = entry.header().entry_type();
117 let kind = match entry_type {
118 tar::EntryType::Regular => EntryKind::File,
119 tar::EntryType::Directory => EntryKind::Dir,
120 other => {
121 return Err(ExtractError::ForbiddenEntryType {
122 path: raw_path,
123 entry_type: describe_entry_type(other),
124 })
125 }
126 };
127
128 let dest = safe_join(target, &raw_path)?;
129 match kind {
130 EntryKind::Dir => fs::create_dir_all(&dest)?,
131 EntryKind::File => {
132 total_bytes = total_bytes.saturating_add(entry.header().size()?);
134 if total_bytes > limits.max_total_bytes {
135 return Err(ExtractError::TooLarge {
136 limit: limits.max_total_bytes,
137 });
138 }
139 if let Some(parent) = dest.parent() {
140 fs::create_dir_all(parent)?;
141 }
142 let mut entry = entry;
143 let mut file = fs::File::create(&dest)?;
144 match io::copy(&mut entry, &mut file) {
145 Ok(_) => {}
146 Err(e) => return Err(map_stream_error(e, &exceeded, limits)),
147 }
148 file.sync_all()?;
149 }
150 }
151 }
152 Ok(())
153}
154
155enum EntryKind {
156 File,
157 Dir,
158}
159
160fn map_stream_error(e: io::Error, exceeded: &Cell<bool>, limits: Limits) -> ExtractError {
163 if exceeded.get() {
164 ExtractError::TooLarge {
165 limit: limits.max_total_bytes,
166 }
167 } else {
168 ExtractError::Io(e)
169 }
170}
171
172fn describe_entry_type(t: tar::EntryType) -> &'static str {
173 use tar::EntryType::*;
174 match t {
175 Symlink => "symlink",
176 Link => "hardlink",
177 Char => "char device",
178 Block => "block device",
179 Fifo => "fifo",
180 Continuous => "contiguous file",
181 GNUSparse => "sparse file",
182 XGlobalHeader => "pax global header",
183 XHeader => "pax header",
184 _ => "unsupported",
185 }
186}
187
188fn safe_join(target: &Path, raw: &str) -> Result<PathBuf, ExtractError> {
194 let unsafe_path = || ExtractError::UnsafePath { path: raw.into() };
195 if raw.is_empty() || raw.contains('\\') || raw.contains(':') {
198 return Err(unsafe_path());
199 }
200 let mut out = target.to_path_buf();
201 let mut pushed = false;
202 for component in Path::new(raw).components() {
203 match component {
204 Component::Normal(part) => {
205 out.push(part);
206 pushed = true;
207 }
208 _ => return Err(unsafe_path()),
209 }
210 }
211 if !pushed {
212 return Err(unsafe_path());
213 }
214 Ok(out)
215}
216
217struct CappedReader<R: Read> {
220 inner: R,
221 remaining: u64,
222 exceeded: Rc<Cell<bool>>,
223}
224
225impl<R: Read> Read for CappedReader<R> {
226 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
227 if self.remaining == 0 {
228 self.exceeded.set(true);
229 return Err(io::Error::other("decompressed stream exceeds the size cap"));
230 }
231 let allowed = usize::try_from(self.remaining.min(buf.len() as u64)).unwrap_or(buf.len());
232 let n = self.inner.read(&mut buf[..allowed])?;
233 self.remaining -= n as u64;
234 Ok(n)
235 }
236}
237
238#[cfg(test)]
239mod tests;