use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use percent_encoding::percent_decode_str;
use super::has_data_scheme;
use crate::image::{fetch_remote, normalize_svg};
const MAX_EMBED_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImageHandling {
#[default]
Link,
Embed,
Sealed { fetch_remote: bool },
}
pub(super) struct ImageResolver {
handling: ImageHandling,
source_dir: Option<PathBuf>,
canon_dir: Option<PathBuf>,
approved_outside: Vec<PathBuf>,
fetched: Mutex<HashMap<String, Option<String>>>,
left_out: AtomicUsize,
}
impl ImageResolver {
pub(super) fn new(
handling: ImageHandling,
source_dir: Option<&Path>,
approved_outside: &[PathBuf],
) -> Self {
Self {
handling,
source_dir: source_dir.map(Path::to_path_buf),
canon_dir: source_dir.and_then(|dir| dir.canonicalize().ok()),
approved_outside: approved_outside.to_vec(),
fetched: Mutex::new(HashMap::new()),
left_out: AtomicUsize::new(0),
}
}
pub(super) fn left_out(&self) -> usize {
self.left_out.load(Ordering::Relaxed)
}
pub(super) fn resolve<'u>(&self, src: &'u str) -> Option<Cow<'u, str>> {
match self.handling {
ImageHandling::Link => Some(Cow::Borrowed(src)),
ImageHandling::Embed => {
Some(self.embed_local(src).map_or(Cow::Borrowed(src), Cow::Owned))
}
ImageHandling::Sealed { fetch_remote } => {
let sealed = if has_data_scheme(src) {
raster_data_uri(src).map(Cow::Owned)
} else if is_http_url(src) {
fetch_remote
.then(|| self.fetch(src.trim()))
.flatten()
.map(Cow::Owned)
} else {
self.embed_local(src).map(Cow::Owned)
};
if sealed.is_none() {
self.left_out.fetch_add(1, Ordering::Relaxed);
}
sealed
}
}
}
fn embed_local(&self, src: &str) -> Option<String> {
let dir = self.source_dir.as_deref()?;
let image = local_image(src, dir, self.canon_dir.as_deref())?;
if !image.inside && !self.approved_outside.contains(&image.canonical) {
return None;
}
file_data_uri(&image.canonical)
}
fn fetch(&self, url: &str) -> Option<String> {
let mut fetched = self.fetched.lock().unwrap_or_else(|e| e.into_inner());
fetched
.entry(url.to_owned())
.or_insert_with(|| fetch_remote(url).ok().and_then(|b| bytes_data_uri(&b)))
.clone()
}
}
pub(super) struct LocalImage {
pub(super) canonical: PathBuf,
pub(super) inside: bool,
}
pub(super) fn local_image(
src: &str,
source_dir: &Path,
canon_dir: Option<&Path>,
) -> Option<LocalImage> {
let canonical = source_dir.join(local_path(src)?).canonicalize().ok()?;
if !canonical.is_file() {
return None;
}
mime_from_extension(&canonical)?;
let inside = canon_dir.is_some_and(|dir| canonical.starts_with(dir));
Some(LocalImage { canonical, inside })
}
fn local_path(src: &str) -> Option<PathBuf> {
let src = src.trim();
let path = match src.get(..7) {
Some(prefix) if prefix.eq_ignore_ascii_case("file://") => {
let rest = &src[7..];
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
match rest.as_bytes() {
[b'/', drive, b':', ..] if cfg!(windows) && drive.is_ascii_alphabetic() => {
&rest[1..]
}
[b'/', ..] => rest,
_ => return None,
}
}
_ => {
let scheme_end = src.find([':', '/', '?', '#']);
if scheme_end.is_some_and(|i| i > 1 && src.as_bytes()[i] == b':') {
return None;
}
src
}
};
let path = path.split(['?', '#']).next().unwrap_or_default();
let decoded = percent_decode_str(path).decode_utf8().ok()?;
let slashes = ['/', '\\'];
if decoded.is_empty()
|| decoded
.strip_prefix(slashes)
.is_some_and(|rest| rest.starts_with(slashes))
{
return None;
}
Some(PathBuf::from(decoded.as_ref()))
}
fn file_data_uri(path: &Path) -> Option<String> {
if std::fs::metadata(path).ok()?.len() > MAX_EMBED_BYTES {
return None;
}
bytes_data_uri(&std::fs::read(path).ok()?)
}
fn bytes_data_uri(bytes: &[u8]) -> Option<String> {
match raster_mime(bytes) {
Some(mime) => Some(data_uri(mime, bytes)),
None => svg_data_uri(std::str::from_utf8(bytes).ok()?),
}
}
fn raster_mime(bytes: &[u8]) -> Option<&'static str> {
use image::ImageFormat;
Some(match image::guess_format(bytes).ok()? {
ImageFormat::Png => "image/png",
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Gif => "image/gif",
ImageFormat::WebP => "image/webp",
ImageFormat::Bmp => "image/bmp",
_ => return None,
})
}
fn raster_data_uri(src: &str) -> Option<String> {
let bytes = decode_data_uri(src)?;
Some(data_uri(raster_mime(&bytes)?, &bytes))
}
fn decode_data_uri(src: &str) -> Option<Vec<u8>> {
let (header, payload) = src.trim().split_once(',')?;
if header.to_ascii_lowercase().ends_with(";base64") {
let compact: String = payload
.chars()
.filter(|c| !c.is_ascii_whitespace())
.collect();
BASE64.decode(compact).ok()
} else {
Some(percent_decode_str(payload).collect())
}
}
const SVG_MIME: &str = "image/svg+xml";
fn svg_data_uri(svg: &str) -> Option<String> {
Some(data_uri(SVG_MIME, normalize_svg(svg).ok()?.as_bytes()))
}
fn data_uri(mime: &str, bytes: &[u8]) -> String {
format!("data:{mime};base64,{}", BASE64.encode(bytes))
}
fn mime_from_extension(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => SVG_MIME,
_ => return None,
})
}
fn is_http_url(src: &str) -> bool {
let src = src.trim_start().to_ascii_lowercase();
src.starts_with("http://") || src.starts_with("https://")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_path_reads_paths_and_file_urls_as_a_browser_does() {
let p = |s: &str| local_path(s).map(|p| p.to_string_lossy().into_owned());
assert_eq!(p("img/a%20b.png").as_deref(), Some("img/a b.png"));
assert_eq!(p("../x.png?v=2#top").as_deref(), Some("../x.png"));
assert_eq!(p("/abs/x.png").as_deref(), Some("/abs/x.png"));
assert_eq!(p("file:///abs/x.png").as_deref(), Some("/abs/x.png"));
assert_eq!(
p("FILE://localhost/abs/x.png").as_deref(),
Some("/abs/x.png")
);
let drive_url = if cfg!(windows) {
"C:/x.png"
} else {
"/C:/x.png"
};
assert_eq!(p("file:///C:/x.png").as_deref(), Some(drive_url));
assert_eq!(p("C:/x.png").as_deref(), Some("C:/x.png"));
assert_eq!(p("https://example.com/x.png"), None);
assert_eq!(p("ftp://example.com/x.png"), None);
assert_eq!(p("#frag"), None);
}
#[test]
fn local_path_refuses_references_to_another_host() {
for src in [
"//host/share/x.png",
r"\\host\share\x.png",
r"/\host/x.png",
"%2F%2Fhost/x.png",
"file://host/share/x.png",
"file:////host/share/x.png",
] {
assert_eq!(local_path(src), None, "{src}");
}
}
const PNG_SIGNATURE: [u8; 10] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
#[test]
fn data_uris_are_typed_by_content() {
let png = BASE64.encode(PNG_SIGNATURE);
assert_eq!(
raster_data_uri(&format!("data:image/gif;base64,{png}")),
Some(format!("data:image/png;base64,{png}"))
);
assert!(raster_data_uri(&format!(" DATA:image/png;BASE64,{png}")).is_some());
let svg = BASE64.encode(r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#);
assert!(raster_data_uri(&format!("data:image/png;base64,{svg}")).is_none());
assert!(raster_data_uri("data:image/png,%3Csvg%3E").is_none());
assert!(raster_data_uri("data:image/png;base64,not base64!").is_none());
}
#[test]
fn remote_bytes_are_typed_by_content() {
assert!(bytes_data_uri(&PNG_SIGNATURE)
.unwrap()
.starts_with("data:image/png;base64,"));
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4"/></svg>"#;
assert!(bytes_data_uri(svg)
.unwrap()
.starts_with("data:image/svg+xml;base64,"));
assert!(bytes_data_uri(b"<html>not an image</html>").is_none());
}
}