use std::borrow::Cow;
use std::io::{self, Read};
pub const MAX_TREE_DEPTH: usize = 512;
pub const MAX_DECOMPRESSED_ENTRY: usize = 512 * 1024 * 1024;
pub fn bounded_inflate(
compressed: &[u8],
claimed_len: u64,
hard_cap: usize,
) -> io::Result<Vec<u8>> {
const MAX_RESERVE: u64 = 16 * 1024 * 1024;
let reserve = claimed_len.min(hard_cap as u64).min(MAX_RESERVE) as usize;
let mut out = Vec::with_capacity(reserve);
let mut reader = flate2::read::DeflateDecoder::new(compressed).take(hard_cap as u64 + 1);
reader.read_to_end(&mut out)?;
if out.len() > hard_cap {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"decompressed entry exceeds size limit",
));
}
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn time_seed_nanos() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(12345)
}
#[cfg(target_arch = "wasm32")]
pub fn time_seed_nanos() -> u64 {
(js_sys::Date::now() * 1_000_000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
pub fn time_now_secs() -> u32 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
pub fn time_now_secs() -> u32 {
(js_sys::Date::now() / 1000.0) as u32
}
pub fn decode_text<'a>(bytes: &'a [u8], hint_encoding: Option<&str>) -> Cow<'a, str> {
let (result, _encoding, malformed) = encoding_rs::UTF_8.decode(bytes);
if !malformed {
return result;
}
if let Some(name) = hint_encoding
&& let Some(encoding) = encoding_rs::Encoding::for_label(name.as_bytes())
{
let (result, _, _) = encoding.decode(bytes);
return result;
}
let (result, _, _) = encoding_rs::WINDOWS_1252.decode(bytes);
result
}
pub fn percent_decode(s: &str) -> Cow<'_, str> {
if !s.contains('%') {
return Cow::Borrowed(s);
}
percent_encoding::percent_decode_str(s).decode_utf8_lossy()
}
pub fn percent_decode_href(href: &str) -> Cow<'_, str> {
if !href.contains('%') {
return Cow::Borrowed(href);
}
match href.split_once('#') {
Some((path, fragment)) => Cow::Owned(format!(
"{}#{}",
percent_decode(path),
percent_decode(fragment)
)),
None => percent_decode(href),
}
}
pub fn extract_image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
if data.len() < 24 {
return None;
}
if data.len() >= 24 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47
{
let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
return Some((width, height));
}
if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
return extract_jpeg_dimensions(data);
}
if data.len() >= 10 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
let width = u16::from_le_bytes([data[6], data[7]]) as u32;
let height = u16::from_le_bytes([data[8], data[9]]) as u32;
return Some((width, height));
}
None
}
fn extract_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
let mut i = 2;
while i + 4 < data.len() {
if data[i] != 0xFF {
i += 1;
continue;
}
let marker = data[i + 1];
if matches!(
marker,
0xC0 | 0xC1
| 0xC2
| 0xC3
| 0xC5
| 0xC6
| 0xC7
| 0xC9
| 0xCA
| 0xCB
| 0xCD
| 0xCE
| 0xCF
) && i + 9 < data.len()
{
let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
return Some((width, height));
}
if i + 3 < data.len() {
let length = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
i += 2 + length;
} else {
break;
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaFormat {
Jpeg,
Png,
Gif,
Svg,
WebP,
Ttf,
Otf,
Binary,
}
impl MediaFormat {
pub fn mime_type(self) -> &'static str {
match self {
MediaFormat::Jpeg => "image/jpeg",
MediaFormat::Png => "image/png",
MediaFormat::Gif => "image/gif",
MediaFormat::Svg => "image/svg+xml",
MediaFormat::WebP => "image/webp",
MediaFormat::Ttf => "font/ttf",
MediaFormat::Otf => "font/otf",
MediaFormat::Binary => "application/octet-stream",
}
}
pub fn is_image(self) -> bool {
matches!(
self,
MediaFormat::Jpeg
| MediaFormat::Png
| MediaFormat::Gif
| MediaFormat::Svg
| MediaFormat::WebP
)
}
pub fn is_font(self) -> bool {
matches!(self, MediaFormat::Ttf | MediaFormat::Otf)
}
}
pub fn detect_media_format(path: &str, data: &[u8]) -> MediaFormat {
let path_lower = path.to_lowercase();
if path_lower.ends_with(".jpg") || path_lower.ends_with(".jpeg") {
return MediaFormat::Jpeg;
}
if path_lower.ends_with(".png") {
return MediaFormat::Png;
}
if path_lower.ends_with(".gif") {
return MediaFormat::Gif;
}
if path_lower.ends_with(".svg") {
return MediaFormat::Svg;
}
if path_lower.ends_with(".webp") {
return MediaFormat::WebP;
}
if path_lower.ends_with(".ttf") {
return MediaFormat::Ttf;
}
if path_lower.ends_with(".otf") {
return MediaFormat::Otf;
}
if data.len() >= 4 {
if data[0] == 0xFF && data[1] == 0xD8 {
return MediaFormat::Jpeg;
}
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return MediaFormat::Png;
}
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
return MediaFormat::Gif;
}
if data.len() >= 12
&& data[0] == 0x52
&& data[1] == 0x49
&& data[2] == 0x46
&& data[3] == 0x46
&& data[8] == 0x57
&& data[9] == 0x45
&& data[10] == 0x42
&& data[11] == 0x50
{
return MediaFormat::WebP;
}
}
MediaFormat::Binary
}
pub(crate) fn guess_media_type(path: &str) -> &'static str {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"xhtml" | "html" | "htm" => "application/xhtml+xml",
"css" => "text/css",
"js" => "application/javascript",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"ttf" => "font/ttf",
"otf" => "font/otf",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ncx" => "application/x-dtbncx+xml",
"opf" => "application/oebps-package+xml",
_ => "application/octet-stream",
}
}
pub fn strip_ebook_chars(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains(['\u{00AD}', '\u{200B}']) {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c != '\u{00AD}' && c != '\u{200B}' {
out.push(c);
}
}
std::borrow::Cow::Owned(out)
}
pub fn detect_mime_type(filename: &str, data: &[u8]) -> Option<&'static str> {
let format = detect_media_format(filename, data);
match format {
MediaFormat::Binary => None,
other => Some(other.mime_type()),
}
}
pub fn truncate_to_date(s: &str) -> String {
if let Some(t_pos) = s.find('T') {
s[..t_pos].to_string()
} else {
s.to_string()
}
}
pub fn extract_xml_encoding(bytes: &[u8]) -> Option<&str> {
let check_len = bytes.len().min(100);
let prefix = &bytes[..check_len];
let xml_start = prefix.windows(5).position(|w| w == b"<?xml")?;
let after_xml = &prefix[xml_start..];
let enc_pos = after_xml
.windows(9)
.position(|w| w.eq_ignore_ascii_case(b"encoding="))?;
let after_enc = &after_xml[enc_pos + 9..];
if after_enc.is_empty() {
return None;
}
let quote = after_enc[0];
if quote != b'"' && quote != b'\'' {
return None;
}
let value_start = 1;
let value_end = after_enc[value_start..].iter().position(|&b| b == quote)? + value_start;
std::str::from_utf8(&after_enc[value_start..value_end]).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bounded_inflate_rejects_oversize_output() {
use std::io::Write;
let raw = vec![0u8; 1 << 20]; let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best());
enc.write_all(&raw).unwrap();
let compressed = enc.finish().unwrap();
assert!(compressed.len() < 4096, "payload should compress tiny");
let err = bounded_inflate(&compressed, u64::MAX, 4096).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let out = bounded_inflate(&compressed, 0, 1 << 21).unwrap();
assert_eq!(out, raw);
}
#[test]
fn test_percent_decode_basic() {
assert!(matches!(percent_decode("plain.xhtml"), Cow::Borrowed(_)));
assert_eq!(percent_decode("my%20chapter.xhtml"), "my chapter.xhtml");
assert_eq!(percent_decode("100%.png"), "100%.png");
assert_eq!(percent_decode("bad%zzseq"), "bad%zzseq");
assert_eq!(percent_decode("caf%C3%A9.xhtml"), "café.xhtml");
}
#[test]
fn test_percent_decode_href_fragment_separate() {
assert!(matches!(
percent_decode_href("ch1.xhtml#sec1"),
Cow::Borrowed(_)
));
assert_eq!(
percent_decode_href("my%20chapter.xhtml#sec%201"),
"my chapter.xhtml#sec 1"
);
assert_eq!(percent_decode_href("a%23b.xhtml#frag"), "a#b.xhtml#frag");
assert_eq!(percent_decode_href("#note%201"), "#note 1");
}
#[test]
fn test_detect_media_format_by_extension() {
assert_eq!(detect_media_format("image.jpg", &[]), MediaFormat::Jpeg);
assert_eq!(detect_media_format("image.JPEG", &[]), MediaFormat::Jpeg);
assert_eq!(detect_media_format("image.png", &[]), MediaFormat::Png);
assert_eq!(detect_media_format("image.gif", &[]), MediaFormat::Gif);
assert_eq!(detect_media_format("image.svg", &[]), MediaFormat::Svg);
assert_eq!(detect_media_format("image.webp", &[]), MediaFormat::WebP);
assert_eq!(detect_media_format("font.ttf", &[]), MediaFormat::Ttf);
assert_eq!(detect_media_format("font.otf", &[]), MediaFormat::Otf);
assert_eq!(detect_media_format("unknown", &[]), MediaFormat::Binary);
}
#[test]
fn test_detect_media_format_by_magic_bytes() {
let jpeg_data = [0xFF, 0xD8, 0xFF, 0xE0];
assert_eq!(
detect_media_format("unknown", &jpeg_data),
MediaFormat::Jpeg
);
let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(detect_media_format("unknown", &png_data), MediaFormat::Png);
let gif_data = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
assert_eq!(detect_media_format("unknown", &gif_data), MediaFormat::Gif);
}
#[test]
fn test_media_format_mime_type() {
assert_eq!(MediaFormat::Jpeg.mime_type(), "image/jpeg");
assert_eq!(MediaFormat::Png.mime_type(), "image/png");
assert_eq!(MediaFormat::Gif.mime_type(), "image/gif");
assert_eq!(MediaFormat::Svg.mime_type(), "image/svg+xml");
assert_eq!(MediaFormat::Ttf.mime_type(), "font/ttf");
assert_eq!(MediaFormat::Binary.mime_type(), "application/octet-stream");
}
#[test]
fn test_media_format_classification() {
assert!(MediaFormat::Jpeg.is_image());
assert!(MediaFormat::Png.is_image());
assert!(!MediaFormat::Ttf.is_image());
assert!(!MediaFormat::Binary.is_image());
assert!(MediaFormat::Ttf.is_font());
assert!(MediaFormat::Otf.is_font());
assert!(!MediaFormat::Jpeg.is_font());
}
#[test]
fn test_detect_mime_type() {
assert_eq!(detect_mime_type("image.jpg", &[]), Some("image/jpeg"));
assert_eq!(detect_mime_type("image.png", &[]), Some("image/png"));
assert_eq!(detect_mime_type("unknown", &[]), None);
}
#[test]
fn test_truncate_to_date() {
assert_eq!(truncate_to_date("2022-05-26T16:26:51Z"), "2022-05-26");
assert_eq!(truncate_to_date("2022-05-26"), "2022-05-26");
assert_eq!(truncate_to_date("2022-05-26T16:26:51+00:00"), "2022-05-26");
}
}