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#[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 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 pub fn empty() -> Self {
35 Self {
36 files: AHashMap::new(),
37 lazy_source: None,
38 lazy_index: AHashMap::new(),
39 }
40 }
41
42 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 pub fn remove(&mut self, path: &str) -> Option<Vec<u8>> {
50 let key = normalize_path(path);
51 self.files.remove(&key)
52 }
53
54 pub fn files(&self) -> &AHashMap<String, Vec<u8>> {
56 &self.files
57 }
58
59 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 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 pub fn from_bytes(bytes: &[u8]) -> Result<Self, EbookError> {
103 Self::from_reader(Cursor::new(bytes))
104 }
105
106 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 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; 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 let mut cumulative_metadata_size: usize = 0;
157 const MAX_LAZY_METADATA_BUDGET: usize = 64 * 1024 * 1024; const MAX_SINGLE_XML_ENTRY: u64 = 16 * 1024 * 1024; 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 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 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 pub fn is_lazy(&self) -> bool {
247 self.lazy_source.is_some()
248 }
249
250 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 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; 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 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 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 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 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
391pub 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
407pub fn resolve_relative_path(base_dir: &str, relative: &str) -> String {
409 let rel_no_frag = relative.split('#').next().unwrap_or(relative);
411 let rel_no_query = rel_no_frag.split('?').next().unwrap_or(rel_no_frag);
413 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#[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 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 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 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}