use std::collections::HashMap;
use std::io::{Cursor, Read};
use zip::ZipArchive;
#[derive(Clone)]
pub struct EpubArchive {
files: HashMap<String, Vec<u8>>,
files_lower: HashMap<String, String>,
}
impl EpubArchive {
pub fn open(path: &str) -> Result<Self, String> {
let bytes =
std::fs::read(path).map_err(|e| format!("Failed to read EPUB file {}: {}", path, e))?;
Self::from_bytes(&bytes)
}
pub fn empty() -> Self {
Self {
files: HashMap::new(),
files_lower: HashMap::new(),
}
}
pub fn insert(&mut self, path: impl Into<String>, data: Vec<u8>) {
let key = path.into();
self.files_lower.insert(key.to_lowercase(), key.clone());
self.files.insert(key, data);
}
pub fn files(&self) -> &HashMap<String, Vec<u8>> {
&self.files
}
pub fn get_opf_path(&self) -> Result<String, String> {
let container_xml = self.read_string("META-INF/container.xml")?;
crate::opf::parse_container_xml(&container_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(".jpg") || lower.ends_with(".jpeg") {
"image/jpeg"
} else if lower.ends_with(".png") {
"image/png"
} 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")
|| lower.ends_with(".otf")
|| lower.ends_with(".woff")
|| lower.ends_with(".woff2")
{
"font/otf"
} else if lower.ends_with(".ncx") {
"application/x-dtbncx+xml"
} else {
"application/octet-stream"
}
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
let cursor = Cursor::new(bytes);
let mut zip =
ZipArchive::new(cursor).map_err(|e| format!("Failed to parse ZIP archive: {}", e))?;
let mut files = HashMap::new();
let mut files_lower = HashMap::new();
let mut total_decompressed_bytes: u64 = 0;
const MAX_TOTAL_SIZE: u64 = 500 * 1024 * 1024; const MAX_ENTRY_SIZE: u64 = 200 * 1024 * 1024;
for i in 0..zip.len() {
let mut file = zip
.by_index(i)
.map_err(|e| format!("Failed to read file index {}: {}", i, e))?;
let name = file.name().to_string();
if name.ends_with('/') {
continue;
}
if file.size() > MAX_ENTRY_SIZE {
return Err(format!(
"ZIP entry '{}' exceeds maximum allowed uncompressed size of 200MB",
name
));
}
let mut content = Vec::new();
let mut chunk = [0u8; 8192];
let mut entry_bytes_read: u64 = 0;
loop {
let n = file
.read(&mut chunk)
.map_err(|e| format!("Failed to read entry content {}: {}", name, e))?;
if n == 0 {
break;
}
entry_bytes_read += n as u64;
total_decompressed_bytes += n as u64;
if entry_bytes_read > MAX_ENTRY_SIZE {
return Err(format!(
"ZIP entry '{}' decompressed size exceeded 200MB limit",
name
));
}
if total_decompressed_bytes > MAX_TOTAL_SIZE {
return Err(
"ZIP archive total decompressed size exceeded 500MB safety limit"
.to_string(),
);
}
content.extend_from_slice(&chunk[..n]);
}
let normalized = normalize_path(&name);
files_lower.insert(normalized.to_lowercase(), normalized.clone());
files.insert(normalized, content);
}
Ok(Self { files, files_lower })
}
pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>, String> {
self.read_bytes_ref(path).map(|bytes| bytes.to_vec())
}
pub fn read_bytes_ref(&self, path: &str) -> Result<&[u8], String> {
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());
}
let lower = clean_no_frag.to_lowercase();
if let Some(orig_key) = self.files_lower.get(&lower) {
if let Some(data) = self.files.get(orig_key) {
return Ok(data.as_slice());
}
}
Err(format!("File not found in archive: {}", path))
}
pub fn read_string(&self, path: &str) -> Result<String, String> {
let bytes = self.read_bytes_ref(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_lower.contains_key(&clean_no_frag.to_lowercase())
}
pub fn list_files(&self) -> Vec<String> {
let mut paths: Vec<String> = self.files.keys().cloned().collect();
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"
);
}
}