Skip to main content

verso/library/
epub_guard.rs

1use std::path::Path;
2use thiserror::Error;
3
4pub struct Limits {
5    pub max_decompressed_bytes: u64,
6    pub max_entry_bytes: u64,
7    pub max_entries: usize,
8}
9impl Default for Limits {
10    fn default() -> Self {
11        Self {
12            max_decompressed_bytes: 256 * 1024 * 1024,
13            max_entry_bytes: 16 * 1024 * 1024,
14            max_entries: 10_000,
15        }
16    }
17}
18
19#[derive(Debug, Error)]
20pub enum GuardError {
21    #[error("path traversal attempt in zip entry: {0}")]
22    PathTraversal(String),
23    #[error("archive entry count {0} exceeds limit {1}")]
24    TooManyEntries(usize, usize),
25    #[error("entry {0} size {1} exceeds per-entry limit {2}")]
26    EntryTooLarge(String, u64, u64),
27    #[error("total decompressed size {0} exceeds limit {1}")]
28    TotalTooLarge(u64, u64),
29    #[error("symlink entry not allowed: {0}")]
30    Symlink(String),
31    #[error("io: {0}")]
32    Io(#[from] std::io::Error),
33    #[error("zip: {0}")]
34    Zip(#[from] zip::result::ZipError),
35}
36
37/// Validate an EPUB's ZIP structure without extracting to disk.
38pub fn validate_archive(path: &Path, limits: Limits) -> Result<(), GuardError> {
39    let file = std::fs::File::open(path)?;
40    let mut archive = zip::ZipArchive::new(file)?;
41
42    if archive.len() > limits.max_entries {
43        return Err(GuardError::TooManyEntries(
44            archive.len(),
45            limits.max_entries,
46        ));
47    }
48
49    let mut total: u64 = 0;
50    for i in 0..archive.len() {
51        let entry = archive.by_index(i)?;
52        let name = entry.name().to_string();
53
54        if name.contains("..") || name.starts_with('/') || name.starts_with('\\') {
55            return Err(GuardError::PathTraversal(name));
56        }
57        if is_symlink(&entry) {
58            return Err(GuardError::Symlink(name));
59        }
60        if entry.size() > limits.max_entry_bytes {
61            return Err(GuardError::EntryTooLarge(
62                name,
63                entry.size(),
64                limits.max_entry_bytes,
65            ));
66        }
67        total = total.saturating_add(entry.size());
68        if total > limits.max_decompressed_bytes {
69            return Err(GuardError::TotalTooLarge(
70                total,
71                limits.max_decompressed_bytes,
72            ));
73        }
74    }
75    Ok(())
76}
77
78fn is_symlink(entry: &zip::read::ZipFile<'_>) -> bool {
79    // zip 0.6 does not expose `is_symlink`; detect via unix mode bits.
80    // S_IFLNK = 0o120000.
81    entry
82        .unix_mode()
83        .map(|m| (m & 0o170000) == 0o120000)
84        .unwrap_or(false)
85}