Skip to main content

ebook_rs/
archive.rs

1use crate::error::EbookError;
2use ahash::AHashMap;
3use parking_lot::Mutex;
4use std::io::{Cursor, Read, Seek};
5use std::path::Path;
6use std::sync::Arc;
7use zip::ZipArchive;
8
9type LazyZipSource = Option<Arc<Mutex<ZipArchive<Cursor<Vec<u8>>>>>>;
10
11/// Represents an EPUB archive (ZIP container) in memory or from file with lazy decompression for giant archives (>500MB).
12#[derive(Clone)]
13pub struct EpubArchive {
14    files: AHashMap<String, Vec<u8>>,
15    lazy_source: LazyZipSource,
16    lazy_index: AHashMap<String, usize>,
17}
18
19impl EpubArchive {
20    /// Open an `EpubArchive` from a filesystem path.
21    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, EbookError> {
22        let path_ref = path.as_ref();
23        let file = std::fs::File::open(path_ref).map_err(|e| {
24            EbookError::Io(format!(
25                "Failed to open EPUB file {}: {}",
26                path_ref.display(),
27                e
28            ))
29        })?;
30        Self::from_reader(file)
31    }
32
33    /// Create an empty `EpubArchive` instance.
34    pub fn empty() -> Self {
35        Self {
36            files: AHashMap::new(),
37            lazy_source: None,
38            lazy_index: AHashMap::new(),
39        }
40    }
41
42    /// Insert or update a file entry in the archive.
43    pub fn insert(&mut self, path: impl Into<String>, data: Vec<u8>) {
44        let key = normalize_path(&path.into());
45        self.files.insert(key, data);
46    }
47
48    /// Remove a file entry from the archive.
49    pub fn remove(&mut self, path: &str) -> Option<Vec<u8>> {
50        let key = normalize_path(path);
51        self.files.remove(&key)
52    }
53
54    /// Access reference to underlying files map in the archive.
55    pub fn files(&self) -> &AHashMap<String, Vec<u8>> {
56        &self.files
57    }
58
59    /// Retrieve `.opf` package document path from `META-INF/container.xml`.
60    pub fn get_opf_path(&self) -> Result<String, EbookError> {
61        let container_xml = self.read_string("META-INF/container.xml")?;
62        crate::opf::parse_container_xml(&container_xml).map_err(EbookError::Xml)
63    }
64
65    /// Helper to detect MIME type from entry file extension.
66    pub fn get_mime_type(path: &str) -> &'static str {
67        let lower = path.to_lowercase();
68        if lower.ends_with(".xhtml") || lower.ends_with(".html") || lower.ends_with(".htm") {
69            "application/xhtml+xml"
70        } else if lower.ends_with(".css") {
71            "text/css"
72        } else if lower.ends_with(".png") {
73            "image/png"
74        } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
75            "image/jpeg"
76        } else if lower.ends_with(".gif") {
77            "image/gif"
78        } else if lower.ends_with(".svg") {
79            "image/svg+xml"
80        } else if lower.ends_with(".webp") {
81            "image/webp"
82        } else if lower.ends_with(".ttf") {
83            "font/ttf"
84        } else if lower.ends_with(".otf") {
85            "font/otf"
86        } else if lower.ends_with(".woff") {
87            "font/woff"
88        } else if lower.ends_with(".woff2") {
89            "font/woff2"
90        } else if lower.ends_with(".js") {
91            "application/javascript"
92        } else if lower.ends_with(".json") {
93            "application/json"
94        } else if lower.ends_with(".smil") {
95            "application/smil+xml"
96        } else {
97            "application/octet-stream"
98        }
99    }
100
101    /// Create an `EpubArchive` from raw ZIP byte data with Zip Bomb protection and lazy decompression.
102    pub fn from_bytes(bytes: &[u8]) -> Result<Self, EbookError> {
103        Self::from_reader(Cursor::new(bytes))
104    }
105
106    /// Construct `EpubArchive` from any `Read + Seek` source.
107    /// If total uncompressed size exceeds 500MB, seamlessly switches to lazy on-demand decompression mode.
108    pub fn from_reader<R: Read + Seek>(mut reader: R) -> Result<Self, EbookError> {
109        let mut raw_bytes = Vec::new();
110        reader
111            .seek(std::io::SeekFrom::Start(0))
112            .map_err(|e| EbookError::Io(format!("Failed to seek reader: {}", e)))?;
113        reader
114            .read_to_end(&mut raw_bytes)
115            .map_err(|e| EbookError::Io(format!("Failed to read archive bytes: {}", e)))?;
116
117        let compressed_len = raw_bytes.len() as u64;
118        let mut zip = ZipArchive::new(Cursor::new(raw_bytes))
119            .map_err(|e| EbookError::Zip(format!("Failed to parse ZIP archive: {}", e)))?;
120
121        let entry_count = zip.len();
122        const MAX_ZIP_ENTRIES: usize = 50_000;
123        if entry_count > MAX_ZIP_ENTRIES {
124            return Err(EbookError::InvalidFormat(format!(
125                "ZIP archive exceeds maximum entry limit ({} > {})",
126                entry_count, MAX_ZIP_ENTRIES
127            )));
128        }
129
130        let mut total_uncompressed_estimate: u64 = 0;
131        for i in 0..entry_count {
132            if let Ok(file) = zip.by_index_raw(i) {
133                total_uncompressed_estimate =
134                    total_uncompressed_estimate.saturating_add(file.size());
135            }
136        }
137
138        // Decompression ratio protection (e.g. 100:1 ratio check against zip-bombs)
139        const MAX_DECOMPRESSION_RATIO: u64 = 100;
140        if compressed_len > 0 && total_uncompressed_estimate > 20 * 1024 * 1024 {
141            if total_uncompressed_estimate / compressed_len > MAX_DECOMPRESSION_RATIO {
142                return Err(EbookError::InvalidFormat(
143                    "Zip bomb detected: uncompressed ratio exceeds 100:1 safety limit".to_string(),
144                ));
145            }
146        }
147
148        const MAX_EAGER_TOTAL_SIZE: u64 = 256 * 1024 * 1024; // 256 MB threshold for eager loading
149        let is_giant_archive = total_uncompressed_estimate > MAX_EAGER_TOTAL_SIZE;
150
151        let mut files = AHashMap::new();
152        let mut lazy_index = AHashMap::new();
153
154        if is_giant_archive {
155            // Lazy Mode: Index all entries and eagerly load only structural XML documents with strict cumulative safety cap
156            let mut cumulative_metadata_size: usize = 0;
157            const MAX_LAZY_METADATA_BUDGET: usize = 64 * 1024 * 1024; // 64 MB total across all metadata XMLs
158            const MAX_SINGLE_XML_ENTRY: u64 = 16 * 1024 * 1024; // 16 MB max per metadata file
159
160            for i in 0..entry_count {
161                let mut file = zip.by_index(i).map_err(|e| {
162                    EbookError::Zip(format!("Failed to read entry index {}: {}", i, e))
163                })?;
164                let name = file.name().to_string();
165                if name.ends_with('/') {
166                    continue;
167                }
168                let norm = normalize_path(&name);
169                lazy_index.insert(norm.clone(), i);
170
171                // Eagerly parse critical metadata files only
172                let lower = norm.to_lowercase();
173                if lower.ends_with(".xml")
174                    || lower.ends_with(".opf")
175                    || lower.ends_with(".ncx")
176                    || lower.contains("container.xml")
177                {
178                    let mut content = Vec::new();
179                    if file
180                        .by_ref()
181                        .take(MAX_SINGLE_XML_ENTRY)
182                        .read_to_end(&mut content)
183                        .is_ok()
184                    {
185                        cumulative_metadata_size =
186                            cumulative_metadata_size.saturating_add(content.len());
187                        if cumulative_metadata_size > MAX_LAZY_METADATA_BUDGET {
188                            return Err(EbookError::InvalidFormat(
189                                "Archive metadata exceeds aggregate safety budget (possible decompression bomb)".to_string(),
190                            ));
191                        }
192                        files.insert(norm, content);
193                    }
194                }
195            }
196
197            Ok(Self {
198                files,
199                lazy_source: Some(Arc::new(Mutex::new(zip))),
200                lazy_index,
201            })
202        } else {
203            // Eager Mode: Load and decompress everything into memory with safe budget
204            let mut total_decompressed: usize = 0;
205            const MAX_EAGER_BUDGET: usize = 300 * 1024 * 1024;
206            const MAX_SINGLE_FILE_SIZE: u64 = 64 * 1024 * 1024;
207
208            for i in 0..entry_count {
209                let mut file = zip.by_index(i).map_err(|e| {
210                    EbookError::Zip(format!("Failed to read file index {}: {}", i, e))
211                })?;
212                let name = file.name().to_string();
213                if name.ends_with('/') {
214                    continue;
215                }
216
217                let mut content = Vec::new();
218                file.by_ref()
219                    .take(MAX_SINGLE_FILE_SIZE)
220                    .read_to_end(&mut content)
221                    .map_err(|e| {
222                        EbookError::Io(format!("Failed to read entry content {}: {}", name, e))
223                    })?;
224
225                total_decompressed = total_decompressed.saturating_add(content.len());
226                if total_decompressed > MAX_EAGER_BUDGET {
227                    return Err(EbookError::InvalidFormat(
228                        "Cumulative uncompressed archive size exceeds memory safety limit"
229                            .to_string(),
230                    ));
231                }
232
233                let normalized = normalize_path(&name);
234                files.insert(normalized, content);
235            }
236
237            Ok(Self {
238                files,
239                lazy_source: None,
240                lazy_index,
241            })
242        }
243    }
244
245    /// Whether archive operates in lazy on-demand decompression mode.
246    pub fn is_lazy(&self) -> bool {
247        self.lazy_source.is_some()
248    }
249
250    /// Read raw bytes of a file in the archive.
251    pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>, EbookError> {
252        let clean = normalize_path(path);
253        let clean_no_frag = clean.split('#').next().unwrap_or(&clean);
254
255        if let Some(data) = self.files.get(clean_no_frag) {
256            return Ok(data.clone());
257        }
258
259        if let Some((_, data)) = self
260            .files
261            .iter()
262            .find(|(k, _)| k.eq_ignore_ascii_case(clean_no_frag))
263        {
264            return Ok(data.clone());
265        }
266
267        // Check lazy source if in lazy mode
268        if let Some(ref lazy_arc) = self.lazy_source {
269            let entry_idx = self
270                .lazy_index
271                .get(clean_no_frag)
272                .or_else(|| {
273                    self.lazy_index
274                        .iter()
275                        .find(|(k, _)| k.eq_ignore_ascii_case(clean_no_frag))
276                        .map(|(_, idx)| idx)
277                })
278                .copied();
279
280            if let Some(idx) = entry_idx {
281                let mut zip = lazy_arc.lock();
282                let mut file = zip.by_index(idx).map_err(|e| {
283                    EbookError::Zip(format!("Failed to decompress lazy entry {}: {}", path, e))
284                })?;
285                const MAX_LAZY_ENTRY_SIZE: u64 = 256 * 1024 * 1024; // 256 MB per entry limit
286                let alloc_capacity = (file.size() as usize).min(32 * 1024 * 1024);
287                let mut data = Vec::with_capacity(alloc_capacity);
288                file.by_ref()
289                    .take(MAX_LAZY_ENTRY_SIZE)
290                    .read_to_end(&mut data)
291                    .map_err(|e| {
292                        EbookError::Io(format!("Failed to read lazy entry content: {}", e))
293                    })?;
294                return Ok(data);
295            }
296        }
297
298        Err(EbookError::NotFound(format!(
299            "File not found in archive: {}",
300            path
301        )))
302    }
303
304    /// Zero-copy reference getter for eagerly loaded raw file bytes in the archive.
305    pub fn read_bytes_ref(&self, path: &str) -> Result<&[u8], EbookError> {
306        let clean = normalize_path(path);
307        let clean_no_frag = clean.split('#').next().unwrap_or(&clean);
308        if let Some(data) = self.files.get(clean_no_frag) {
309            return Ok(data.as_slice());
310        }
311        if let Some((_, data)) = self
312            .files
313            .iter()
314            .find(|(k, _)| k.eq_ignore_ascii_case(clean_no_frag))
315        {
316            return Ok(data.as_slice());
317        }
318        Err(EbookError::NotFound(format!(
319            "File not found in eager archive buffer: {}",
320            path
321        )))
322    }
323
324    /// Read text content of a file in the archive with SIMD UTF-8 / UTF-16 / legacy decoding.
325    pub fn read_string(&self, path: &str) -> Result<String, EbookError> {
326        let bytes = self.read_bytes(path)?;
327        if let Ok(s) = simdutf8::basic::from_utf8(&bytes) {
328            Ok(s.to_string())
329        } else if bytes.starts_with(&[0xFE, 0xFF]) {
330            let u16_data: Vec<u16> = bytes[2..]
331                .as_chunks::<2>()
332                .0
333                .iter()
334                .map(|c| u16::from_be_bytes([c[0], c[1]]))
335                .collect();
336            Ok(String::from_utf16_lossy(&u16_data))
337        } else if bytes.starts_with(&[0xFF, 0xFE]) {
338            let u16_data: Vec<u16> = bytes[2..]
339                .as_chunks::<2>()
340                .0
341                .iter()
342                .map(|c| u16::from_le_bytes([c[0], c[1]]))
343                .collect();
344            Ok(String::from_utf16_lossy(&u16_data))
345        } else if bytes.windows(2).any(|w| w == b"<\0" || w == b"\0<") {
346            let is_le = bytes.windows(2).any(|w| w == b"<\0");
347            let u16_data: Vec<u16> = bytes
348                .as_chunks::<2>()
349                .0
350                .iter()
351                .map(|c| {
352                    if is_le {
353                        u16::from_le_bytes([c[0], c[1]])
354                    } else {
355                        u16::from_be_bytes([c[0], c[1]])
356                    }
357                })
358                .collect();
359            Ok(String::from_utf16_lossy(&u16_data))
360        } else {
361            Ok(crate::dom::decode_bytes_with_encoding(&bytes, None))
362        }
363    }
364
365    /// Check if a file exists in the archive.
366    pub fn contains(&self, path: &str) -> bool {
367        let clean = normalize_path(path);
368        let clean_no_frag = clean.split('#').next().unwrap_or(&clean);
369        self.files.contains_key(clean_no_frag)
370            || self
371                .files
372                .keys()
373                .any(|k| k.eq_ignore_ascii_case(clean_no_frag))
374            || self.lazy_index.contains_key(clean_no_frag)
375            || self
376                .lazy_index
377                .keys()
378                .any(|k| k.eq_ignore_ascii_case(clean_no_frag))
379    }
380
381    /// List all unique file entry paths inside the archive.
382    pub fn list_files(&self) -> Vec<String> {
383        let mut paths: Vec<String> = self.files.keys().cloned().collect();
384        paths.extend(self.lazy_index.keys().cloned());
385        paths.sort();
386        paths.dedup();
387        paths
388    }
389}
390
391/// Helper function to normalize ZIP entry paths.
392pub fn normalize_path(path: &str) -> String {
393    let clean = path.replace('\\', "/");
394    let mut parts = Vec::new();
395    for part in clean.split('/') {
396        match part {
397            "" | "." => {}
398            ".." => {
399                parts.pop();
400            }
401            _ => parts.push(part),
402        }
403    }
404    parts.join("/")
405}
406
407/// Helper function to resolve relative paths against a base directory.
408pub fn resolve_relative_path(base_dir: &str, relative: &str) -> String {
409    // 1. Strip URL fragment identifier (#fragment)
410    let rel_no_frag = relative.split('#').next().unwrap_or(relative);
411    // 1b. Strip URL query string (?v=2, ?cache=...) — CSS versioned hrefs
412    let rel_no_query = rel_no_frag.split('?').next().unwrap_or(rel_no_frag);
413    // 2. Percent-decode URI path (%20, non-ASCII)
414    let decoded = percent_encoding::percent_decode_str(rel_no_query)
415        .decode_utf8_lossy()
416        .to_string();
417
418    let rel_clean = decoded.replace('\\', "/");
419    if rel_clean.starts_with('/') {
420        return normalize_path(&rel_clean);
421    }
422    let combined = if base_dir.is_empty() {
423        rel_clean
424    } else {
425        format!("{}/{}", base_dir, rel_clean)
426    };
427    normalize_path(&combined)
428}
429
430/// Helper for HTTP Range request generation and byte-slice streaming (`bytes=start-end`).
431#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
432pub struct HttpRangeRequest {
433    pub url: String,
434    pub start: u64,
435    pub end: Option<u64>,
436}
437
438impl HttpRangeRequest {
439    /// Create a new HTTP Range byte slice request.
440    pub fn new(url: &str, start: u64, end: Option<u64>) -> Self {
441        Self {
442            url: url.to_string(),
443            start,
444            end,
445        }
446    }
447
448    /// Generate standard HTTP Range header tuple ("Range", "bytes=start-end").
449    pub fn to_range_header(&self) -> (String, String) {
450        let val = match self.end {
451            Some(end_byte) => format!("bytes={}-{}", self.start, end_byte),
452            None => format!("bytes={}-", self.start),
453        };
454        ("Range".to_string(), val)
455    }
456
457    /// Parse an incoming HTTP Range header string (e.g. "bytes=0-1024").
458    pub fn parse_range_header(header_val: &str) -> Option<(u64, Option<u64>)> {
459        let clean = header_val.trim();
460        let spec = clean.strip_prefix("bytes=")?;
461        let mut parts = spec.split('-');
462        let start = parts.next()?.parse::<u64>().ok()?;
463        let end = parts.next().and_then(|s| s.parse::<u64>().ok());
464        Some((start, end))
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_normalize_and_resolve() {
474        assert_eq!(
475            normalize_path("OEBPS/../OEBPS/ch1.xhtml"),
476            "OEBPS/ch1.xhtml"
477        );
478        assert_eq!(
479            resolve_relative_path("OEBPS/Text", "../Images/cover.jpg"),
480            "OEBPS/Images/cover.jpg"
481        );
482    }
483}