Skip to main content

gravityfile_ops/archive/
mod.rs

1//! Archive extraction and creation.
2//!
3//! Extraction decodes into a private staging directory beside the
4//! destination and is published only after the whole archive decoded and
5//! passed validation, so a malformed or malicious archive never leaves
6//! partial output behind.
7
8use std::fs::{self, File};
9use std::io::{Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12use thiserror::Error;
13use tokio_util::sync::CancellationToken;
14
15use crate::fs::{RenameError, rename_noreplace, resolves_within, staging_dir};
16
17mod create;
18mod extract;
19mod parser_budget;
20#[cfg(test)]
21mod tests;
22
23pub use create::{create_archive, create_archive_cancellable};
24
25/// Cumulative reads during ZIP metadata parsing, including retries and seeks.
26const MAX_ZIP_METADATA_READ_BYTES: u64 = 64 * 1024 * 1024;
27/// TAR headers, extension bodies, sparse maps and skipped padding.
28const MAX_TAR_METADATA_READ_BYTES: u64 = 64 * 1024 * 1024;
29const MAX_TAR_ENTRY_METADATA_READ_BYTES: u64 = 1024 * 1024;
30/// Memory the XZ decoder may allocate for its dictionary.
31const MAX_XZ_DECODER_MEMORY: u64 = 256 * 1024 * 1024;
32/// ZIP symlink targets are stored as entry content; cap what is read.
33const MAX_SYMLINK_TARGET_BYTES: u64 = 8 * 1024;
34
35/// Open a ZIP archive without reading all of it.
36///
37/// To find an archive that does not start its file, such as a
38/// self-extracting executable, zip scans everything before the central
39/// directory: the whole archive. An archive that starts with a ZIP header,
40/// as nearly all do, is opened at offset 0 instead, which reads only the
41/// central directory. Anything else still gets the scan.
42pub fn open_zip<R: Read + Seek>(mut reader: R) -> zip::result::ZipResult<zip::ZipArchive<R>> {
43    use zip::read::{ArchiveOffset, Config};
44    let mut signature = [0; 4];
45    let at_start = reader.read_exact(&mut signature).is_ok()
46        // A local file header, or the end record of an empty archive.
47        && matches!(&signature, b"PK\x03\x04" | b"PK\x05\x06");
48    reader.seek(SeekFrom::Start(0))?;
49    let archive_offset = if at_start {
50        ArchiveOffset::Known(0)
51    } else {
52        ArchiveOffset::Detect
53    };
54    zip::ZipArchive::with_config(Config { archive_offset }, reader)
55}
56
57/// Resource limits that reject decompression bombs.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct ExtractLimits {
60    /// Total bytes written across all entries.
61    pub max_total_bytes: u64,
62    /// Bytes written for any single entry.
63    pub max_entry_bytes: u64,
64    /// Number of entries.
65    pub max_entries: u64,
66    /// Declared uncompressed-to-compressed ratio of a ZIP entry.
67    pub max_ratio: f64,
68}
69
70impl Default for ExtractLimits {
71    fn default() -> Self {
72        Self {
73            max_total_bytes: 64 * 1024 * 1024 * 1024,
74            max_entry_bytes: 64 * 1024 * 1024 * 1024,
75            max_entries: 1_000_000,
76            max_ratio: 10_000.0,
77        }
78    }
79}
80
81/// Error from an archive operation.
82#[derive(Debug, Error)]
83pub enum ArchiveError {
84    #[error("Archive operation cancelled before publication")]
85    Cancelled,
86
87    #[error("I/O error: {0}")]
88    Io(#[from] std::io::Error),
89
90    #[error("ZIP error: {0}")]
91    Zip(#[from] zip::result::ZipError),
92
93    #[error("Unsupported archive format: {0}")]
94    UnsupportedFormat(String),
95
96    #[error("Archive not found: {0}")]
97    NotFound(PathBuf),
98
99    #[error("Destination already exists: {0}")]
100    DestinationExists(PathBuf),
101
102    /// Publication failed and some already-published entries could not be
103    /// moved back out of the destination.
104    #[error("Archive extraction failed: {source}; {count} published entries could not be withdrawn from {destination}", count = .left_behind.len())]
105    ExtractionRollback {
106        destination: PathBuf,
107        #[source]
108        source: Box<ArchiveError>,
109        left_behind: Vec<PathBuf>,
110    },
111
112    #[error("Potential decompression bomb detected: {0}")]
113    DecompressionBomb(String),
114}
115
116/// Result of an archive operation.
117pub type ArchiveResult<T> = Result<T, ArchiveError>;
118
119fn invalid_data(message: impl Into<String>) -> ArchiveError {
120    ArchiveError::Io(std::io::Error::new(
121        std::io::ErrorKind::InvalidData,
122        message.into(),
123    ))
124}
125
126/// Supported archive formats.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ArchiveFormat {
129    Zip,
130    Tar,
131    TarGz,
132    TarBz2,
133    TarXz,
134    TarZst,
135}
136
137impl ArchiveFormat {
138    /// Every supported format.
139    pub const ALL: [Self; 6] = [
140        Self::Zip,
141        Self::Tar,
142        Self::TarGz,
143        Self::TarBz2,
144        Self::TarXz,
145        Self::TarZst,
146    ];
147
148    /// Detect the format from a file name.
149    pub fn from_path(path: &Path) -> Option<Self> {
150        let name = path.file_name()?.to_str()?.to_lowercase();
151        Self::ALL.into_iter().find(|format| {
152            format
153                .extensions()
154                .iter()
155                .any(|extension| name.ends_with(extension))
156        })
157    }
158
159    /// Canonical extension, including the leading dot.
160    pub fn extension(&self) -> &'static str {
161        self.extensions()[0]
162    }
163
164    /// Recognized extensions; the first is canonical.
165    fn extensions(&self) -> &'static [&'static str] {
166        match self {
167            Self::Zip => &[".zip"],
168            Self::Tar => &[".tar"],
169            Self::TarGz => &[".tar.gz", ".tgz"],
170            Self::TarBz2 => &[".tar.bz2", ".tbz2", ".tbz"],
171            Self::TarXz => &[".tar.xz", ".txz"],
172            Self::TarZst => &[".tar.zst", ".tzst"],
173        }
174    }
175}
176
177/// Extract an archive into `destination`, creating it if needed.
178///
179/// An existing destination directory is merged into: entries that already
180/// exist (other than directories on both sides) are reported as conflicts
181/// before anything is changed. Returns every extracted path at its final
182/// location.
183///
184/// # Security
185///
186/// - Absolute paths and `..` components are rejected.
187/// - Symlinks may not resolve outside the destination, checked by resolving
188///   every link the way the kernel would after decoding.
189/// - Setuid/setgid bits and extended attributes are not restored.
190/// - Sizes, entry counts and ZIP compression ratios are bounded by
191///   [`ExtractLimits`], measured on bytes actually written.
192pub fn extract_archive(archive_path: &Path, destination: &Path) -> ArchiveResult<Vec<PathBuf>> {
193    extract_archive_with(
194        archive_path,
195        destination,
196        &ExtractLimits::default(),
197        &CancellationToken::new(),
198    )
199}
200
201/// [`extract_archive`] with cooperative cancellation up to publication.
202pub fn extract_archive_cancellable(
203    archive_path: &Path,
204    destination: &Path,
205    cancel: &CancellationToken,
206) -> ArchiveResult<Vec<PathBuf>> {
207    extract_archive_with(archive_path, destination, &ExtractLimits::default(), cancel)
208}
209
210/// [`extract_archive`] with explicit limits and cancellation.
211pub fn extract_archive_with(
212    archive_path: &Path,
213    destination: &Path,
214    limits: &ExtractLimits,
215    cancel: &CancellationToken,
216) -> ArchiveResult<Vec<PathBuf>> {
217    check_cancel(cancel)?;
218    let input = open_archive_input(archive_path)?;
219    let format = ArchiveFormat::from_path(archive_path).ok_or_else(|| {
220        ArchiveError::UnsupportedFormat(
221            archive_path
222                .extension()
223                .and_then(|extension| extension.to_str())
224                .unwrap_or("unknown")
225                .to_string(),
226        )
227    })?;
228    if let Ok(metadata) = fs::symlink_metadata(destination)
229        && !metadata.is_dir()
230    {
231        return Err(ArchiveError::Io(std::io::Error::new(
232            std::io::ErrorKind::AlreadyExists,
233            "extraction destination is not a directory",
234        )));
235    }
236    let parent = crate::fs::parent_or_current(destination);
237    fs::create_dir_all(parent)?;
238    let destination =
239        dunce::canonicalize(parent)?.join(destination.file_name().ok_or_else(|| {
240            std::io::Error::new(
241                std::io::ErrorKind::InvalidInput,
242                "extraction destination has no file name",
243            )
244        })?);
245
246    let staging = staging_dir(&destination, ".gravityfile-extract-")?;
247    let payload = staging.path().join("payload");
248    fs::create_dir(&payload)?;
249    let payload = dunce::canonicalize(&payload)?;
250
251    let decoded = extract::decode(format, input, &payload, limits, cancel)
252        .and_then(|entries| reject_escaping_links(&payload, cancel).map(|()| entries));
253    let entries = match decoded {
254        Ok(entries) => entries,
255        Err(_) if cancel.is_cancelled() => return Err(ArchiveError::Cancelled),
256        Err(error) => return Err(error),
257    };
258    check_cancel(cancel)?;
259    publish(&payload, &destination)?;
260    if let Err(error) = staging.close() {
261        tracing::warn!(%error, "extraction published; staging cleanup incomplete");
262    }
263    Ok(entries
264        .into_iter()
265        .filter_map(|path| Some(destination.join(path.strip_prefix(&payload).ok()?)))
266        .collect())
267}
268
269/// Kernel-accurate check that no decoded symlink resolves outside `root`.
270fn reject_escaping_links(root: &Path, cancel: &CancellationToken) -> ArchiveResult<()> {
271    let mut pending = vec![root.to_path_buf()];
272    while let Some(directory) = pending.pop() {
273        for entry in fs::read_dir(&directory)? {
274            check_cancel(cancel)?;
275            let entry = entry?;
276            let file_type = entry.file_type()?;
277            if file_type.is_dir() {
278                pending.push(entry.path());
279            } else if file_type.is_symlink() && !resolves_within(root, &entry.path())? {
280                let relative = entry
281                    .path()
282                    .strip_prefix(root)
283                    .unwrap_or(&entry.path())
284                    .to_path_buf();
285                return Err(invalid_data(format!(
286                    "Symlink escapes destination: {}",
287                    relative.display()
288                )));
289            }
290        }
291    }
292    Ok(())
293}
294
295/// Move the decoded tree into place without replacing anything.
296///
297/// A new destination is a single rename. Merging into an existing directory
298/// first checks every entry for conflicts, then publishes each non-existing
299/// subtree with one rename; on failure the published subtrees are moved back.
300fn publish(payload: &Path, destination: &Path) -> ArchiveResult<()> {
301    match rename_noreplace(payload, destination) {
302        Ok(()) => return Ok(()),
303        Err(RenameError::DestinationExists) => {}
304        Err(error) => return Err(std::io::Error::from(error).into()),
305    }
306    if !fs::symlink_metadata(destination)?.is_dir() {
307        return Err(ArchiveError::DestinationExists(destination.to_path_buf()));
308    }
309
310    // Plan: (staged, target) for every subtree absent from the destination.
311    let mut moves = Vec::new();
312    let mut pending = vec![(payload.to_path_buf(), destination.to_path_buf())];
313    while let Some((staged_dir, target_dir)) = pending.pop() {
314        for entry in fs::read_dir(&staged_dir)? {
315            let entry = entry?;
316            let staged = entry.path();
317            let target = target_dir.join(entry.file_name());
318            match fs::symlink_metadata(&target) {
319                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
320                    moves.push((staged, target));
321                }
322                Ok(existing) if existing.is_dir() && entry.file_type()?.is_dir() => {
323                    pending.push((staged, target));
324                }
325                Ok(_) => {
326                    return Err(ArchiveError::Io(std::io::Error::new(
327                        std::io::ErrorKind::AlreadyExists,
328                        format!("archive entry conflicts with '{}'", target.display()),
329                    )));
330                }
331                Err(error) => return Err(error.into()),
332            }
333        }
334    }
335
336    let mut published: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(moves.len());
337    for (staged, target) in moves {
338        if let Err(error) = rename_noreplace(&staged, &target) {
339            let left_behind: Vec<PathBuf> = published
340                .iter()
341                .rev()
342                .filter(|(staged, target)| rename_noreplace(target, staged).is_err())
343                .map(|(_, target)| target.clone())
344                .collect();
345            let error = ArchiveError::Io(error.into());
346            return Err(if left_behind.is_empty() {
347                error
348            } else {
349                ArchiveError::ExtractionRollback {
350                    destination: destination.to_path_buf(),
351                    source: Box::new(error),
352                    left_behind,
353                }
354            });
355        }
356        published.push((staged, target));
357    }
358    Ok(())
359}
360
361/// Reader wrapper that turns cancellation into an I/O error at the next read.
362struct ArchiveInput<R> {
363    reader: R,
364    cancel: CancellationToken,
365}
366
367impl<R> ArchiveInput<R> {
368    fn new(reader: R, cancel: &CancellationToken) -> Self {
369        Self {
370            reader,
371            cancel: cancel.clone(),
372        }
373    }
374
375    fn check(&self) -> std::io::Result<()> {
376        if self.cancel.is_cancelled() {
377            Err(std::io::Error::other("archive extraction cancelled"))
378        } else {
379            Ok(())
380        }
381    }
382}
383
384impl<R: Read> Read for ArchiveInput<R> {
385    fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
386        self.check()?;
387        self.reader.read(bytes)
388    }
389}
390
391impl<R: std::io::Seek> std::io::Seek for ArchiveInput<R> {
392    fn seek(&mut self, position: std::io::SeekFrom) -> std::io::Result<u64> {
393        self.check()?;
394        self.reader.seek(position)
395    }
396}
397
398/// Open once so path replacement cannot switch inputs between validation and
399/// decoding. Symlinks to regular archives are followed; FIFOs never block.
400fn open_archive_input(path: &Path) -> ArchiveResult<File> {
401    let mut options = fs::OpenOptions::new();
402    options.read(true);
403    #[cfg(unix)]
404    {
405        use std::os::unix::fs::OpenOptionsExt;
406        options.custom_flags(libc::O_NONBLOCK);
407    }
408    let file = options.open(path).map_err(|error| {
409        if error.kind() == std::io::ErrorKind::NotFound {
410            ArchiveError::NotFound(path.to_path_buf())
411        } else {
412            ArchiveError::Io(error)
413        }
414    })?;
415    if !file.metadata()?.is_file() {
416        return Err(ArchiveError::Io(std::io::Error::new(
417            std::io::ErrorKind::InvalidInput,
418            "Archive input must be a regular file",
419        )));
420    }
421    Ok(file)
422}
423
424fn check_cancel(cancel: &CancellationToken) -> ArchiveResult<()> {
425    if cancel.is_cancelled() {
426        Err(ArchiveError::Cancelled)
427    } else {
428        Ok(())
429    }
430}