Skip to main content

tauri_plugin_hot_update/
extract.rs

1//! Hardened tar.gz extraction for downloaded bundles.
2//!
3//! The archive is attacker-controlled until its sha256 matches the signed
4//! manifest — and even then, defense in depth: this extractor assumes the
5//! archive is hostile. Hardening (WP2 handoff + design doc):
6//!
7//! - **Symlinks and hardlinks are rejected**, not skipped. The assets
8//!   provider's `fs::read` follows symlinks, so a symlinked entry could
9//!   exfiltrate container files (auth data) into the webview. Zip-slip
10//!   guarding alone is insufficient.
11//! - Only plain files and directories are extracted; every other entry type
12//!   (devices, FIFOs, sparse files, unknown extensions) is a hard error.
13//! - Entry paths must be strictly relative-normal: no absolute paths, no
14//!   `..` or `.` components, no Windows prefixes. Anything else is refused
15//!   before any I/O happens for that entry.
16//! - Zip-bomb caps: a file-count cap and a total-uncompressed-bytes cap
17//!   (checked against each entry's declared size *before* reading its data),
18//!   plus a raw cap on bytes pulled through the gzip decoder — so even tar
19//!   metadata the `tar` crate buffers internally (e.g. GNU long names)
20//!   cannot expand without bound.
21//! - Nothing exotic is preserved: files land with default permissions,
22//!   ownership, and mtimes. Web assets need none of it.
23//! - Each extracted file is fsync'd. A torn bundle after a power loss would
24//!   otherwise fail its trial boot and permanently blacklist a *good*
25//!   archive hash; durability here is what keeps that update deliverable.
26
27use 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
35/// Maximum number of entries (files + directories) in a bundle archive.
36/// A web dist is typically well under a thousand entries; 10k leaves room
37/// while bounding inode/dirent abuse.
38pub const MAX_FILES: usize = 10_000;
39
40/// Maximum total uncompressed payload bytes across all entries. Bundles are
41/// frontend dists (a few MB compressed); 500 MB bounds decompression bombs
42/// without ever constraining a legitimate release.
43pub const MAX_UNCOMPRESSED_BYTES: u64 = 500 * 1024 * 1024;
44
45/// Slack on top of [`MAX_UNCOMPRESSED_BYTES`] for the raw decompressed
46/// stream cap: tar framing overhead (512-byte headers + padding per entry,
47/// long-name metadata). 32 MB covers [`MAX_FILES`] entries several times
48/// over.
49const STREAM_SLACK_BYTES: u64 = 32 * 1024 * 1024;
50
51/// Why extraction was refused or failed. Every variant is a hard stop; the
52/// caller discards the partial output directory.
53#[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
70/// Extract `archive` (tar.gz) into the existing directory `target`,
71/// enforcing the production caps ([`MAX_FILES`], [`MAX_UNCOMPRESSED_BYTES`]).
72pub(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/// Caps, separated from the constants so tests can exercise the enforcement
81/// logic with small values. Production always uses the documented constants.
82#[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                // Budget from the declared size BEFORE reading any data.
133                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
160/// A stream-cap hit surfaces from the tar crate as a wrapped I/O error;
161/// translate it back into the typed cap error.
162fn 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
188/// Join an entry path onto the target dir, refusing anything that is not a
189/// plain chain of normal components. Same discipline as the serving layer's
190/// `safe_join` — but here the input is attacker-controlled bytes, not a
191/// tauri-normalized `AssetKey`, so absolute paths and `..`/`.` components
192/// are live threats, not belt-and-braces.
193fn safe_join(target: &Path, raw: &str) -> Result<PathBuf, ExtractError> {
194    let unsafe_path = || ExtractError::UnsafePath { path: raw.into() };
195    // Windows-style separators and drive letters never appear in honest
196    // unix-built archives; reject rather than interpret.
197    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
217/// Hard cap on bytes read through the gzip decoder. Flips `exceeded` and
218/// errors once the cap is crossed, no matter what the tar crate is buffering.
219struct 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;