use crate::error::EbookError;
use ahash::AHashMap;
use parking_lot::Mutex;
use std::io::{Cursor, Read, Seek};
use std::path::Path;
use std::sync::Arc;
use zip::ZipArchive;
type LazyZipSource = Option<Arc<Mutex<ZipArchive<Cursor<Vec<u8>>>>>>;
#[derive(Clone)]
pub struct EpubArchive {
files: AHashMap<String, Vec<u8>>,
lazy_source: LazyZipSource,
lazy_index: AHashMap<String, usize>,
}
impl EpubArchive {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, EbookError> {
let path_ref = path.as_ref();
let file = std::fs::File::open(path_ref).map_err(|e| {
EbookError::Io(format!(
"Failed to open EPUB file {}: {}",
path_ref.display(),
e
))
})?;
Self::from_reader(file)
}
pub fn empty() -> Self {
Self {
files: AHashMap::new(),
lazy_source: None,
lazy_index: AHashMap::new(),
}
}
pub fn insert(&mut self, path: impl Into<String>, data: Vec<u8>) {
let key = normalize_path(&path.into());
self.files.insert(key, data);
}
pub fn remove(&mut self, path: &str) -> Option<Vec<u8>> {
let key = normalize_path(path);
self.files.remove(&key)
}
pub fn files(&self) -> &AHashMap<String, Vec<u8>> {
&self.files
}
pub fn get_opf_path(&self) -> Result<String, EbookError> {
let container_xml = self.read_string("META-INF/container.xml")?;
crate::opf::parse_container_xml(&container_xml).map_err(EbookError::Xml)
}
pub fn get_mime_type(path: &str) -> &'static str {
let lower = path.to_lowercase();
if lower.ends_with(".xhtml") || lower.ends_with(".html") || lower.ends_with(".htm") {
"application/xhtml+xml"
} else if lower.ends_with(".css") {
"text/css"
} else if lower.ends_with(".png") {
"image/png"
} else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
"image/jpeg"
} else if lower.ends_with(".gif") {
"image/gif"
} else if lower.ends_with(".svg") {
"image/svg+xml"
} else if lower.ends_with(".webp") {
"image/webp"
} else if lower.ends_with(".ttf") {
"font/ttf"
} else if lower.ends_with(".otf") {
"font/otf"
} else if lower.ends_with(".woff") {
"font/woff"
} else if lower.ends_with(".woff2") {
"font/woff2"
} else if lower.ends_with(".js") {
"application/javascript"
} else if lower.ends_with(".json") {
"application/json"
} else if lower.ends_with(".smil") {
"application/smil+xml"
} else {
"application/octet-stream"
}
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, EbookError> {
Self::from_reader(Cursor::new(bytes))
}
pub fn from_reader<R: Read + Seek>(mut reader: R) -> Result<Self, EbookError> {
let mut raw_bytes = Vec::new();
reader
.seek(std::io::SeekFrom::Start(0))
.map_err(|e| EbookError::Io(format!("Failed to seek reader: {}", e)))?;
reader
.read_to_end(&mut raw_bytes)
.map_err(|e| EbookError::Io(format!("Failed to read archive bytes: {}", e)))?;
let compressed_len = raw_bytes.len() as u64;
let mut zip = ZipArchive::new(Cursor::new(raw_bytes))
.map_err(|e| EbookError::Zip(format!("Failed to parse ZIP archive: {}", e)))?;
let entry_count = zip.len();
const MAX_ZIP_ENTRIES: usize = 50_000;
if entry_count > MAX_ZIP_ENTRIES {
return Err(EbookError::InvalidFormat(format!(
"ZIP archive exceeds maximum entry limit ({} > {})",
entry_count, MAX_ZIP_ENTRIES
)));
}
let mut total_uncompressed_estimate: u64 = 0;
for i in 0..entry_count {
if let Ok(file) = zip.by_index_raw(i) {
total_uncompressed_estimate =
total_uncompressed_estimate.saturating_add(file.size());
}
}
const MAX_DECOMPRESSION_RATIO: u64 = 100;
if compressed_len > 0 && total_uncompressed_estimate > 20 * 1024 * 1024 {
if total_uncompressed_estimate / compressed_len > MAX_DECOMPRESSION_RATIO {
return Err(EbookError::InvalidFormat(
"Zip bomb detected: uncompressed ratio exceeds 100:1 safety limit".to_string(),
));
}
}
const MAX_EAGER_TOTAL_SIZE: u64 = 256 * 1024 * 1024; let is_giant_archive = total_uncompressed_estimate > MAX_EAGER_TOTAL_SIZE;
let mut files = AHashMap::new();
let mut lazy_index = AHashMap::new();
if is_giant_archive {
let mut cumulative_metadata_size: usize = 0;
const MAX_LAZY_METADATA_BUDGET: usize = 64 * 1024 * 1024; const MAX_SINGLE_XML_ENTRY: u64 = 16 * 1024 * 1024;
for i in 0..entry_count {
let mut file = zip.by_index(i).map_err(|e| {
EbookError::Zip(format!("Failed to read entry index {}: {}", i, e))
})?;
let name = file.name().to_string();
if name.ends_with('/') {
continue;
}
let norm = normalize_path(&name);
lazy_index.insert(norm.clone(), i);
let lower = norm.to_lowercase();
if lower.ends_with(".xml")
|| lower.ends_with(".opf")
|| lower.ends_with(".ncx")
|| lower.contains("container.xml")
{
let mut content = Vec::new();
if file
.by_ref()
.take(MAX_SINGLE_XML_ENTRY)
.read_to_end(&mut content)
.is_ok()
{
cumulative_metadata_size =
cumulative_metadata_size.saturating_add(content.len());
if cumulative_metadata_size > MAX_LAZY_METADATA_BUDGET {
return Err(EbookError::InvalidFormat(
"Archive metadata exceeds aggregate safety budget (possible decompression bomb)".to_string(),
));
}
files.insert(norm, content);
}
}
}
Ok(Self {
files,
lazy_source: Some(Arc::new(Mutex::new(zip))),
lazy_index,
})
} else {
let mut total_decompressed: usize = 0;
const MAX_EAGER_BUDGET: usize = 300 * 1024 * 1024;
const MAX_SINGLE_FILE_SIZE: u64 = 64 * 1024 * 1024;
for i in 0..entry_count {
let mut file = zip.by_index(i).map_err(|e| {
EbookError::Zip(format!("Failed to read file index {}: {}", i, e))
})?;
let name = file.name().to_string();
if name.ends_with('/') {
continue;
}
let mut content = Vec::new();
file.by_ref()
.take(MAX_SINGLE_FILE_SIZE)
.read_to_end(&mut content)
.map_err(|e| {
EbookError::Io(format!("Failed to read entry content {}: {}", name, e))
})?;
total_decompressed = total_decompressed.saturating_add(content.len());
if total_decompressed > MAX_EAGER_BUDGET {
return Err(EbookError::InvalidFormat(
"Cumulative uncompressed archive size exceeds memory safety limit"
.to_string(),
));
}
let normalized = normalize_path(&name);
files.insert(normalized, content);
}
Ok(Self {
files,
lazy_source: None,
lazy_index,
})
}
}
pub fn is_lazy(&self) -> bool {
self.lazy_source.is_some()
}
pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>, EbookError> {
let clean = normalize_path(path);
let clean_no_frag = clean.split('#').next().unwrap_or(&clean);
if let Some(data) = self.files.get(clean_no_frag) {
return Ok(data.clone());
}
if let Some((_, data)) = self
.files
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(clean_no_frag))
{
return Ok(data.clone());
}
if let Some(ref lazy_arc) = self.lazy_source {
let entry_idx = self
.lazy_index
.get(clean_no_frag)
.or_else(|| {
self.lazy_index
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(clean_no_frag))
.map(|(_, idx)| idx)
})
.copied();
if let Some(idx) = entry_idx {
let mut zip = lazy_arc.lock();
let mut file = zip.by_index(idx).map_err(|e| {
EbookError::Zip(format!("Failed to decompress lazy entry {}: {}", path, e))
})?;
const MAX_LAZY_ENTRY_SIZE: u64 = 256 * 1024 * 1024; let alloc_capacity = (file.size() as usize).min(32 * 1024 * 1024);
let mut data = Vec::with_capacity(alloc_capacity);
file.by_ref()
.take(MAX_LAZY_ENTRY_SIZE)
.read_to_end(&mut data)
.map_err(|e| {
EbookError::Io(format!("Failed to read lazy entry content: {}", e))
})?;
return Ok(data);
}
}
Err(EbookError::NotFound(format!(
"File not found in archive: {}",
path
)))
}
pub fn read_bytes_ref(&self, path: &str) -> Result<&[u8], EbookError> {
let clean = normalize_path(path);
let clean_no_frag = clean.split('#').next().unwrap_or(&clean);
if let Some(data) = self.files.get(clean_no_frag) {
return Ok(data.as_slice());
}
if let Some((_, data)) = self
.files
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(clean_no_frag))
{
return Ok(data.as_slice());
}
Err(EbookError::NotFound(format!(
"File not found in eager archive buffer: {}",
path
)))
}
pub fn read_string(&self, path: &str) -> Result<String, EbookError> {
let bytes = self.read_bytes(path)?;
if let Ok(s) = simdutf8::basic::from_utf8(&bytes) {
Ok(s.to_string())
} else {
Ok(String::from_utf8_lossy(&bytes).to_string())
}
}
pub fn contains(&self, path: &str) -> bool {
let clean = normalize_path(path);
let clean_no_frag = clean.split('#').next().unwrap_or(&clean);
self.files.contains_key(clean_no_frag)
|| self
.files
.keys()
.any(|k| k.eq_ignore_ascii_case(clean_no_frag))
|| self.lazy_index.contains_key(clean_no_frag)
|| self
.lazy_index
.keys()
.any(|k| k.eq_ignore_ascii_case(clean_no_frag))
}
pub fn list_files(&self) -> Vec<String> {
let mut paths: Vec<String> = self.files.keys().cloned().collect();
paths.extend(self.lazy_index.keys().cloned());
paths.sort();
paths.dedup();
paths
}
}
pub fn normalize_path(path: &str) -> String {
let clean = path.replace('\\', "/");
let mut parts = Vec::new();
for part in clean.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop();
}
_ => parts.push(part),
}
}
parts.join("/")
}
pub fn resolve_relative_path(base_dir: &str, relative: &str) -> String {
let rel_no_frag = relative.split('#').next().unwrap_or(relative);
let rel_no_query = rel_no_frag.split('?').next().unwrap_or(rel_no_frag);
let decoded = percent_encoding::percent_decode_str(rel_no_query)
.decode_utf8_lossy()
.to_string();
let rel_clean = decoded.replace('\\', "/");
if rel_clean.starts_with('/') {
return normalize_path(&rel_clean);
}
let combined = if base_dir.is_empty() {
rel_clean
} else {
format!("{}/{}", base_dir, rel_clean)
};
normalize_path(&combined)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HttpRangeRequest {
pub url: String,
pub start: u64,
pub end: Option<u64>,
}
impl HttpRangeRequest {
pub fn new(url: &str, start: u64, end: Option<u64>) -> Self {
Self {
url: url.to_string(),
start,
end,
}
}
pub fn to_range_header(&self) -> (String, String) {
let val = match self.end {
Some(end_byte) => format!("bytes={}-{}", self.start, end_byte),
None => format!("bytes={}-", self.start),
};
("Range".to_string(), val)
}
pub fn parse_range_header(header_val: &str) -> Option<(u64, Option<u64>)> {
let clean = header_val.trim();
let spec = clean.strip_prefix("bytes=")?;
let mut parts = spec.split('-');
let start = parts.next()?.parse::<u64>().ok()?;
let end = parts.next().and_then(|s| s.parse::<u64>().ok());
Some((start, end))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_and_resolve() {
assert_eq!(
normalize_path("OEBPS/../OEBPS/ch1.xhtml"),
"OEBPS/ch1.xhtml"
);
assert_eq!(
resolve_relative_path("OEBPS/Text", "../Images/cover.jpg"),
"OEBPS/Images/cover.jpg"
);
}
}