#[cfg(feature = "remote-images")]
use anyhow::anyhow;
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
#[cfg(feature = "remote-images")]
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct ImageMeta {
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
pub ext: &'static str,
}
#[derive(Debug, Clone)]
pub struct RemoteImageOptions {
pub cache_enabled: bool,
pub cache_dir: Option<PathBuf>,
pub user_agent: Option<String>,
}
impl Default for RemoteImageOptions {
fn default() -> Self {
Self {
cache_enabled: true,
cache_dir: None,
user_agent: None,
}
}
}
pub fn configure_remote_images(options: RemoteImageOptions) {
set_remote_image_options(options);
}
#[cfg(feature = "remote-images")]
static REMOTE_IMAGE_OPTIONS: OnceLock<RemoteImageOptions> = OnceLock::new();
#[cfg(feature = "remote-images")]
fn set_remote_image_options(options: RemoteImageOptions) {
let _ = REMOTE_IMAGE_OPTIONS.set(options);
}
#[cfg(not(feature = "remote-images"))]
fn set_remote_image_options(_options: RemoteImageOptions) {}
#[cfg(feature = "remote-images")]
fn remote_image_options() -> RemoteImageOptions {
REMOTE_IMAGE_OPTIONS.get().cloned().unwrap_or_default()
}
pub fn load_any(base_dir: &Path, src: &str) -> Result<ImageMeta> {
if src.starts_with("data:") {
return load_data_uri(src);
}
if src.starts_with("http://") || src.starts_with("https://") {
return fetch_remote(src);
}
let path = if Path::new(src).is_absolute() {
std::path::PathBuf::from(src)
} else {
base_dir.join(src)
};
load(&path)
}
fn load_data_uri(src: &str) -> Result<ImageMeta> {
let (meta, data) = src
.split_once(',')
.ok_or_else(|| anyhow::anyhow!("invalid data URI: missing comma separator"))?;
let meta = meta
.strip_prefix("data:")
.ok_or_else(|| anyhow::anyhow!("invalid data URI: missing data: prefix"))?;
let mut parts = meta.split(';');
let mime = parts
.next()
.filter(|part| !part.is_empty())
.unwrap_or("text/plain");
let base64_encoded = parts.any(|part| part.eq_ignore_ascii_case("base64"));
let bytes = if base64_encoded {
decode_base64_data(data).context("decode base64 data URI")?
} else {
decode_percent_data(data).context("decode percent-encoded data URI")?
};
sniff(&bytes, &format!("data URI ({mime})"))
}
fn decode_base64_data(input: &str) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(input.len() * 3 / 4);
let mut buf = 0u32;
let mut bits = 0u8;
let mut seen_padding = false;
for b in input.bytes().filter(|b| !b.is_ascii_whitespace()) {
if b == b'=' {
seen_padding = true;
continue;
}
if seen_padding {
bail!("invalid base64 padding");
}
let val = base64_value(b)
.ok_or_else(|| anyhow::anyhow!("invalid base64 character `{}`", char::from(b)))?;
buf = (buf << 6) | val as u32;
bits += 6;
while bits >= 8 {
bits -= 8;
out.push(((buf >> bits) & 0xff) as u8);
if bits == 0 {
buf = 0;
} else {
buf &= (1 << bits) - 1;
}
}
}
if bits == 6 {
bail!("truncated base64 data");
}
Ok(out)
}
fn base64_value(b: u8) -> Option<u8> {
match b {
b'A'..=b'Z' => Some(b - b'A'),
b'a'..=b'z' => Some(b - b'a' + 26),
b'0'..=b'9' => Some(b - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
fn decode_percent_data(input: &str) -> Result<Vec<u8>> {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
bail!("truncated percent escape");
}
let hi = hex_value(bytes[i + 1]).ok_or_else(|| {
anyhow::anyhow!("invalid percent escape `{}`", char::from(bytes[i + 1]))
})?;
let lo = hex_value(bytes[i + 2]).ok_or_else(|| {
anyhow::anyhow!("invalid percent escape `{}`", char::from(bytes[i + 2]))
})?;
out.push((hi << 4) | lo);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
Ok(out)
}
fn hex_value(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub fn load_any_or_placeholder(base_dir: &Path, src: &str) -> ImageMeta {
match load_any(base_dir, src) {
Ok(meta) => meta,
Err(e) => {
let reason = format!("{:#}", e);
eprintln!("md2any: warning: image failed, substituting placeholder");
eprintln!(" src: {}", src);
eprintln!(" reason: {}", reason);
placeholder_meta(src, &reason)
}
}
}
pub fn placeholder_meta(src: &str, reason: &str) -> ImageMeta {
#[cfg(feature = "svg")]
{
if let Ok(meta) = svg_placeholder(src, reason) {
return meta;
}
}
#[cfg(not(feature = "svg"))]
let _ = (src, reason);
static_placeholder_meta()
}
#[cfg(feature = "svg")]
fn svg_placeholder(src: &str, reason: &str) -> Result<ImageMeta> {
let svg = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400" viewBox="0 0 600 400">
<rect width="600" height="400" fill="#fef2f2"/>
<rect x="4" y="4" width="592" height="392" fill="none" stroke="#dc2626" stroke-width="6"/>
<line x1="80" y1="80" x2="180" y2="180" stroke="#dc2626" stroke-width="8" stroke-linecap="round"/>
<line x1="180" y1="80" x2="80" y2="180" stroke="#dc2626" stroke-width="8" stroke-linecap="round"/>
<text x="300" y="220" font-family="DejaVu Sans" font-size="26" font-weight="bold" text-anchor="middle" fill="#991b1b">Image failed to load</text>
<text x="300" y="270" font-family="DejaVu Sans" font-size="14" text-anchor="middle" fill="#7f1d1d">{}</text>
<text x="300" y="320" font-family="DejaVu Sans" font-size="12" text-anchor="middle" fill="#7f1d1d">{}</text>
</svg>"##,
escape_xml(&truncate_for_display(src, 80)),
escape_xml(&truncate_for_display(reason, 100)),
);
rasterize_svg(svg.as_bytes(), "image-failed placeholder")
}
#[cfg(feature = "svg")]
fn truncate_for_display(s: &str, max_chars: usize) -> String {
let collapsed: String = s
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if collapsed.chars().count() <= max_chars {
return collapsed;
}
let head: String = collapsed
.chars()
.take(max_chars.saturating_sub(1))
.collect();
format!("{}…", head)
}
#[cfg(feature = "svg")]
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn static_placeholder_meta() -> ImageMeta {
use std::sync::OnceLock;
static BYTES: OnceLock<Vec<u8>> = OnceLock::new();
let bytes = BYTES.get_or_init(build_static_placeholder_png).clone();
ImageMeta {
bytes,
width: 320,
height: 200,
ext: "png",
}
}
fn build_static_placeholder_png() -> Vec<u8> {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
const W: u32 = 320;
const H: u32 = 200;
let mut raw: Vec<u8> = Vec::with_capacity(((W * 3) + 1) as usize * H as usize);
for y in 0..H {
raw.push(0); for x in 0..W {
let on_border = x < 4 || x >= W - 4 || y < 4 || y >= H - 4;
let on_diag1 = x.abs_diff(y * W / H) < 4;
let on_diag2 = x.abs_diff((H - 1 - y) * W / H) < 4;
let (r, g, b) = if on_border || on_diag1 || on_diag2 {
(0xdcu8, 0x26u8, 0x26u8)
} else {
(0xfeu8, 0xf2u8, 0xf2u8)
};
raw.push(r);
raw.push(g);
raw.push(b);
}
}
let mut compressed: Vec<u8> = Vec::new();
{
let mut enc = ZlibEncoder::new(&mut compressed, Compression::default());
enc.write_all(&raw).expect("zlib encode in-memory");
enc.finish().expect("zlib finish");
}
let mut png: Vec<u8> = Vec::with_capacity(compressed.len() + 64);
png.extend_from_slice(b"\x89PNG\r\n\x1a\n");
let mut ihdr_data = Vec::with_capacity(13);
ihdr_data.extend_from_slice(&W.to_be_bytes());
ihdr_data.extend_from_slice(&H.to_be_bytes());
ihdr_data.extend_from_slice(&[8, 2, 0, 0, 0]); write_png_chunk(&mut png, b"IHDR", &ihdr_data);
write_png_chunk(&mut png, b"IDAT", &compressed);
write_png_chunk(&mut png, b"IEND", &[]);
png
}
fn write_png_chunk(out: &mut Vec<u8>, tag: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(tag);
out.extend_from_slice(data);
let mut crc: u32 = 0xffff_ffff;
for &b in tag.iter().chain(data.iter()) {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xedb8_8320
} else {
crc >> 1
};
}
}
out.extend_from_slice(&(!crc).to_be_bytes());
}
#[cfg(feature = "remote-images")]
pub fn fetch_remote(url: &str) -> Result<ImageMeta> {
let options = remote_image_options();
let cache_path = remote_cache_path(url, &options);
if let Some(path) = cache_path.as_deref() {
if let Some(meta) = load_remote_cache(path, url) {
return Ok(meta);
}
}
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.user_agent(&remote_user_agent(&options))
.build();
let bytes = fetch_with_retries(&agent, url, 3)?;
let meta = sniff(&bytes, url)?;
if let Some(path) = cache_path.as_deref() {
write_cache(path, &bytes);
}
Ok(meta)
}
#[cfg(feature = "remote-images")]
fn fetch_with_retries(agent: &ureq::Agent, url: &str, attempts: usize) -> Result<Vec<u8>> {
use std::io::Read;
const CAP_BYTES: u64 = 20 * 1024 * 1024;
let mut last_err: anyhow::Error = anyhow!("fetch {}", url);
for attempt in 0..attempts {
match agent.get(url).call() {
Ok(resp) => {
let mut bytes: Vec<u8> = Vec::new();
resp.into_reader()
.take(CAP_BYTES + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("read {}", url))?;
if bytes.len() as u64 > CAP_BYTES {
bail!(
"fetch {}: payload exceeds {} MB cap (received >{} bytes); \
host an optimised copy or vendor the image locally",
url,
CAP_BYTES / (1024 * 1024),
CAP_BYTES
);
}
return Ok(bytes);
}
Err(ureq::Error::Status(code, resp)) => {
last_err = anyhow!("fetch {}: HTTP {}", url, code);
let retryable = matches!(code, 408 | 429 | 500 | 502 | 503 | 504);
if retryable && attempt + 1 < attempts {
std::thread::sleep(retry_delay(Some(&resp), attempt));
continue;
}
return Err(last_err);
}
Err(e) => {
last_err = anyhow!("fetch {}: {}", url, e);
if attempt + 1 < attempts {
std::thread::sleep(retry_delay(None, attempt));
continue;
}
return Err(last_err);
}
}
}
Err(last_err)
}
#[cfg(feature = "remote-images")]
fn write_cache(path: &Path, bytes: &[u8]) {
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = path.with_extension(format!("tmp.{}.{}", std::process::id(), nanos));
if fs::write(&tmp, bytes).is_ok() {
if fs::rename(&tmp, path).is_err() {
let _ = fs::write(path, bytes);
let _ = fs::remove_file(&tmp);
}
}
}
#[cfg(feature = "remote-images")]
fn remote_user_agent(options: &RemoteImageOptions) -> String {
options
.user_agent
.clone()
.unwrap_or_else(|| format!("md2any/{}; remote image fetch", env!("CARGO_PKG_VERSION")))
}
#[cfg(feature = "remote-images")]
fn retry_delay(resp: Option<&ureq::Response>, attempt: usize) -> std::time::Duration {
if let Some(header) = resp.and_then(|r| r.header("Retry-After")) {
if let Some(secs) = parse_retry_after(header) {
return std::time::Duration::from_secs(secs.min(30));
}
}
let millis = 300u64.saturating_mul(1 << attempt.min(5));
std::time::Duration::from_millis(millis.min(30_000))
}
#[cfg(feature = "remote-images")]
fn parse_retry_after(value: &str) -> Option<u64> {
let v = value.trim();
if let Ok(secs) = v.parse::<u64>() {
return Some(secs);
}
let target = parse_http_date(v)?;
let now = std::time::SystemTime::now();
target
.duration_since(now)
.ok()
.map(|d| d.as_secs())
.or(Some(0))
}
#[cfg(feature = "remote-images")]
fn parse_http_date(s: &str) -> Option<std::time::SystemTime> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() != 6 || !parts[5].eq_ignore_ascii_case("GMT") {
return None;
}
let day: u32 = parts[1].parse().ok()?;
let month = match parts[2] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
let year: i32 = parts[3].parse().ok()?;
let time: Vec<&str> = parts[4].split(':').collect();
if time.len() != 3 {
return None;
}
let hour: u32 = time[0].parse().ok()?;
let minute: u32 = time[1].parse().ok()?;
let second: u32 = time[2].parse().ok()?;
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u32;
let doy =
(153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day.saturating_sub(1);
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era as i64 * 146097 + doe as i64 - 719468;
let total_secs = days * 86400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64;
if total_secs < 0 {
return None;
}
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(total_secs as u64))
}
#[cfg(feature = "remote-images")]
fn load_remote_cache(path: &Path, url: &str) -> Option<ImageMeta> {
let bytes = std::fs::read(path).ok()?;
match sniff(&bytes, url) {
Ok(meta) => Some(meta),
Err(_) => {
let _ = std::fs::remove_file(path);
None
}
}
}
#[cfg(feature = "remote-images")]
fn remote_cache_path(url: &str, options: &RemoteImageOptions) -> Option<PathBuf> {
if !options.cache_enabled {
return None;
}
let root = options
.cache_dir
.clone()
.unwrap_or_else(default_remote_cache_dir);
let key = normalize_url_for_cache(url);
Some(root.join(format!("{:016x}.img", fnv1a64(key.as_bytes()))))
}
#[cfg(feature = "remote-images")]
fn normalize_url_for_cache(url: &str) -> String {
let trimmed = url.trim();
let no_frag = trimmed.split('#').next().unwrap_or(trimmed);
let (base, query) = match no_frag.split_once('?') {
Some((b, q)) => (b, Some(q)),
None => (no_frag, None),
};
let base_norm = base.trim_end_matches('/');
let (scheme_host, path) = match base_norm.find("://") {
Some(idx) => {
let after = &base_norm[idx + 3..];
match after.find('/') {
Some(slash) => (
base_norm[..idx + 3 + slash].to_ascii_lowercase(),
&after[slash..],
),
None => (base_norm.to_ascii_lowercase(), ""),
}
}
None => (String::new(), base_norm),
};
let mut out = scheme_host;
out.push_str(path);
if let Some(q) = query {
out.push('?');
out.push_str(q);
}
out
}
#[cfg(feature = "remote-images")]
fn default_remote_cache_dir() -> PathBuf {
platform_cache_dir()
.unwrap_or_else(|| std::env::temp_dir().join("md2any"))
.join("remote-images")
}
#[cfg(feature = "remote-images")]
pub fn remote_cache_status() -> (PathBuf, bool) {
let options = remote_image_options();
let platform_ok = platform_cache_dir().is_some();
let dir = options
.cache_dir
.clone()
.unwrap_or_else(default_remote_cache_dir);
(dir, platform_ok)
}
#[cfg(not(feature = "remote-images"))]
pub fn remote_cache_status() -> (PathBuf, bool) {
(PathBuf::new(), false)
}
#[cfg(all(feature = "remote-images", target_os = "windows"))]
fn platform_cache_dir() -> Option<PathBuf> {
std::env::var_os("LOCALAPPDATA")
.map(|p| PathBuf::from(p).join("md2any").join("Cache"))
.or_else(|| {
std::env::var_os("APPDATA").map(|p| PathBuf::from(p).join("md2any").join("Cache"))
})
}
#[cfg(all(feature = "remote-images", target_os = "macos"))]
fn platform_cache_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|p| {
PathBuf::from(p)
.join("Library")
.join("Caches")
.join("md2any")
})
}
#[cfg(all(
feature = "remote-images",
not(any(target_os = "windows", target_os = "macos"))
))]
fn platform_cache_dir() -> Option<PathBuf> {
std::env::var_os("XDG_CACHE_HOME")
.map(|p| PathBuf::from(p).join("md2any"))
.or_else(|| {
std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".cache").join("md2any"))
})
}
#[cfg(feature = "remote-images")]
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for b in bytes {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[cfg(not(feature = "remote-images"))]
pub fn fetch_remote(url: &str) -> Result<ImageMeta> {
bail!(
"remote image {} requested but md2any was built without the \
`remote-images` feature",
url
)
}
fn sniff(bytes: &[u8], origin: &str) -> Result<ImageMeta> {
if bytes.len() < 16 {
bail!("image too small: {}", origin);
}
if &bytes[..8] == b"\x89PNG\r\n\x1a\n" {
let (w, h) = parse_png_dims(bytes).with_context(|| format!("parse PNG {}", origin))?;
return Ok(ImageMeta {
bytes: bytes.to_vec(),
width: w,
height: h,
ext: "png",
});
}
if bytes[0] == 0xFF && bytes[1] == 0xD8 {
let (w, h) = parse_jpeg_dims(bytes).with_context(|| format!("parse JPEG {}", origin))?;
return Ok(ImageMeta {
bytes: bytes.to_vec(),
width: w,
height: h,
ext: "jpeg",
});
}
if looks_like_svg(bytes) {
return rasterize_svg(bytes, origin);
}
bail!(
"unsupported image format: {} (PNG, JPEG, and SVG supported)",
origin
)
}
fn looks_like_svg(bytes: &[u8]) -> bool {
let prefix = std::str::from_utf8(&bytes[..bytes.len().min(512)]).unwrap_or("");
let trimmed = prefix.trim_start();
trimmed.starts_with("<svg") || (trimmed.starts_with("<?xml") && prefix.contains("<svg"))
}
pub fn rasterize_svg_to_png(bytes: &[u8], origin: &str) -> Result<ImageMeta> {
rasterize_svg(bytes, origin)
}
#[cfg(feature = "svg")]
fn rasterize_svg(bytes: &[u8], origin: &str) -> Result<ImageMeta> {
let mut fontdb = usvg::fontdb::Database::new();
for ttf in crate::font::FONTS {
fontdb.load_font_data(ttf.to_vec());
}
fontdb.set_sans_serif_family("DejaVu Sans");
fontdb.set_monospace_family("DejaVu Sans Mono");
let opt = usvg::Options {
fontdb: std::sync::Arc::new(fontdb),
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(bytes, &opt)
.map_err(|e| anyhow::anyhow!("parse SVG {}: {}", origin, e))?;
let size = tree.size();
let scale = 192.0 / 96.0; let target_w = (size.width() * scale).ceil().max(1.0) as u32;
let target_h = (size.height() * scale).ceil().max(1.0) as u32;
let mut pixmap = tiny_skia::Pixmap::new(target_w, target_h)
.ok_or_else(|| anyhow::anyhow!("alloc {}x{} pixmap", target_w, target_h))?;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale as f32, scale as f32),
&mut pixmap.as_mut(),
);
let png = pixmap
.encode_png()
.map_err(|e| anyhow::anyhow!("encode PNG {}: {}", origin, e))?;
sniff(&png, origin)
}
#[cfg(not(feature = "svg"))]
fn rasterize_svg(_bytes: &[u8], origin: &str) -> Result<ImageMeta> {
bail!(
"SVG image {} requested but md2any was built without the `svg` feature",
origin
)
}
pub fn load(path: &Path) -> Result<ImageMeta> {
let bytes = std::fs::read(path).with_context(|| format!("read image {}", path.display()))?;
sniff(&bytes, &path.display().to_string())
}
fn parse_png_dims(b: &[u8]) -> Result<(u32, u32)> {
if b.len() < 24 {
bail!("truncated PNG header");
}
let w = u32::from_be_bytes([b[16], b[17], b[18], b[19]]);
let h = u32::from_be_bytes([b[20], b[21], b[22], b[23]]);
if w == 0 || h == 0 {
bail!("PNG reports zero dimensions");
}
Ok((w, h))
}
fn parse_jpeg_dims(b: &[u8]) -> Result<(u32, u32)> {
let mut i = 2;
while i + 1 < b.len() {
while i < b.len() && b[i] == 0xFF {
i += 1;
}
if i >= b.len() {
bail!("JPEG truncated before marker");
}
let marker = b[i];
i += 1;
if matches!(
marker,
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
) {
if i + 7 > b.len() {
bail!("JPEG SOF truncated");
}
let h = u16::from_be_bytes([b[i + 3], b[i + 4]]) as u32;
let w = u16::from_be_bytes([b[i + 5], b[i + 6]]) as u32;
if w == 0 || h == 0 {
bail!("JPEG reports zero dimensions");
}
return Ok((w, h));
}
if matches!(marker, 0xD0..=0xD9) {
continue;
}
if i + 2 > b.len() {
bail!("JPEG length truncated");
}
let seg_len = u16::from_be_bytes([b[i], b[i + 1]]) as usize;
if seg_len < 2 {
bail!("invalid JPEG segment length");
}
i += seg_len;
}
bail!("JPEG SOF not found")
}
#[cfg(all(test, feature = "remote-images"))]
mod tests {
use super::*;
#[test]
fn fnv1a64_known_vectors() {
assert_eq!(fnv1a64(b""), 0xcbf29ce484222325);
assert_eq!(fnv1a64(b"a"), 0xaf63dc4c8601ec8c);
assert_eq!(fnv1a64(b"foobar"), 0x85944171f73967e8);
}
#[test]
fn fnv1a64_collisions_unlikely_for_typical_input() {
let mut seen = std::collections::HashSet::new();
for i in 0..1_000 {
let url = format!("https://example.com/image-{}.png", i);
assert!(seen.insert(fnv1a64(url.as_bytes())));
}
}
#[test]
fn normalise_strips_fragment_and_trailing_slash() {
assert_eq!(
normalize_url_for_cache("https://example.com/foo/"),
normalize_url_for_cache("https://example.com/foo"),
);
assert_eq!(
normalize_url_for_cache("https://example.com/foo#anchor"),
normalize_url_for_cache("https://example.com/foo"),
);
assert_eq!(
normalize_url_for_cache(" https://example.com/foo "),
normalize_url_for_cache("https://example.com/foo"),
);
}
#[test]
fn normalise_lowercases_scheme_and_host_only() {
assert_eq!(
normalize_url_for_cache("HTTPS://Example.COM/Path?V=2"),
"https://example.com/Path?V=2",
);
}
#[test]
fn normalise_keeps_query_string() {
let a = normalize_url_for_cache("https://example.com/img.png?v=1");
let b = normalize_url_for_cache("https://example.com/img.png?v=2");
assert_ne!(a, b);
}
#[test]
fn parse_retry_after_seconds() {
assert_eq!(parse_retry_after("30"), Some(30));
assert_eq!(parse_retry_after(" 120 "), Some(120));
assert_eq!(parse_retry_after("0"), Some(0));
}
#[test]
fn parse_retry_after_garbage_is_none() {
assert_eq!(parse_retry_after("soon"), None);
assert_eq!(parse_retry_after(""), None);
}
#[test]
fn parse_retry_after_date_form() {
let delta = parse_retry_after("Wed, 31 Dec 2099 23:59:59 GMT").unwrap();
assert!(delta > 0);
}
#[test]
fn parse_retry_after_date_in_past_is_zero() {
assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), Some(0));
}
#[test]
fn retry_delay_caps_at_30s() {
let d = retry_delay(None, 100);
assert!(d.as_secs() <= 30);
}
}