use std::collections::HashMap;
use std::io::Cursor;
use std::sync::{Arc, Mutex};
use usvg::ImageKind;
use super::ConvertError;
pub const MAX_SOURCE_BYTES: usize = 10 * 1024 * 1024;
pub const MAX_SOURCE_PIXELS: u64 = 40_000_000;
pub const MAX_SVG_BYTES: usize = 512 * 1024;
const REPO_STATIC_PREFIX: &str = "static/";
const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Source {
Repo(String),
Store(String),
}
impl Source {
pub fn parse_href(href: &str) -> Result<Option<Self>, ConvertError> {
let href = href.trim();
if href.is_empty() || href.starts_with('#') {
return Ok(None);
}
let refuse = |why: &str| {
ConvertError::source(format!(
"picture '{}' {why} — a picture is a store path (sandbox/photos/a.jpg) or a repository file under static/ (repo://static/logo.svg)",
shorten(href)
))
};
if href.starts_with("data:") {
return Err(refuse("is a data URI; store the file and name its path"));
}
if let Some((scheme, rest)) = href.split_once("://") {
return match scheme {
"repo" if rest.starts_with(REPO_STATIC_PREFIX) && clean_path(rest) => Ok(Some(Source::Repo(rest.to_string()))),
"repo" => Err(refuse("names a repository file outside static/")),
"zebfs" if clean_path(rest) => Ok(Some(Source::Store(rest.to_string()))),
"zebfs" => Err(refuse("must stay inside the store: no `..`, no absolute path")),
_ => Err(refuse("is a URL; fetch it with http.response.fetch --response-type bytes, fs.file.put it, then name the store path")),
};
}
if href.starts_with("//") {
return Err(refuse("is a URL"));
}
let path = href.trim_start_matches("./");
if !clean_path(path) {
return Err(refuse("must stay inside the store: no `..`, no absolute path"));
}
Ok(Some(Source::Store(path.to_string())))
}
pub fn is_svg(&self) -> bool {
let path = match self {
Source::Repo(p) | Source::Store(p) => p,
};
path.rsplit('.').next().is_some_and(|ext| ext.eq_ignore_ascii_case("svg"))
}
pub fn describe(&self) -> String {
match self {
Source::Repo(p) => format!("repo file {p}"),
Source::Store(p) => format!("store object {p}"),
}
}
}
fn shorten(href: &str) -> String {
if href.chars().count() > 60 { format!("{}…", href.chars().take(60).collect::<String>()) } else { href.to_string() }
}
fn clean_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.contains('\\')
&& !path.contains(':')
&& path.split('/').all(|seg| !seg.is_empty() && seg != "..")
}
pub fn image_hrefs(doc: &roxmltree::Document<'_>) -> Vec<String> {
doc.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "image")
.filter_map(|n| n.attribute("href").or_else(|| n.attribute((XLINK_NS, "href"))).map(str::to_string))
.collect()
}
pub trait SourceStore: Send + Sync {
fn read(&self, source: &Source) -> Result<Vec<u8>, ConvertError>;
}
pub struct MemoryStore(pub HashMap<Source, Vec<u8>>);
impl SourceStore for MemoryStore {
fn read(&self, source: &Source) -> Result<Vec<u8>, ConvertError> {
self.0.get(source).cloned().ok_or_else(|| ConvertError::source(format!("{} not found", source.describe())))
}
}
#[derive(Debug, Clone)]
pub enum Asset {
Raster { bytes: Arc<Vec<u8>>, format: image::ImageFormat, width: u32, height: u32 },
Svg { bytes: Arc<Vec<u8>> },
}
pub struct Resolver {
store: Arc<dyn SourceStore>,
cache: Mutex<HashMap<Source, Asset>>,
}
impl Resolver {
pub fn new(store: Arc<dyn SourceStore>) -> Self {
Self { store, cache: Mutex::new(HashMap::new()) }
}
pub fn prefetch(&self, source: &Source) -> Result<Asset, ConvertError> {
if let Some(hit) = self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(source) {
return Ok(hit.clone());
}
let bytes = self.store.read(source)?;
if bytes.len() > MAX_SOURCE_BYTES {
return Err(ConvertError::source(format!(
"{} is {} bytes; the limit is {MAX_SOURCE_BYTES}",
source.describe(),
bytes.len()
)));
}
let asset = if source.is_svg() || looks_like_svg(&bytes) {
if bytes.len() > MAX_SVG_BYTES {
return Err(ConvertError::source(format!(
"{} is {} bytes; an SVG picture may be {MAX_SVG_BYTES}",
source.describe(),
bytes.len()
)));
}
Asset::Svg { bytes: Arc::new(bytes) }
} else {
let reader = image::ImageReader::new(Cursor::new(&bytes))
.with_guessed_format()
.map_err(|e| ConvertError::source(format!("{}: {e}", source.describe())))?;
let format = reader
.format()
.ok_or_else(|| ConvertError::source(format!("{} is not a PNG, JPEG, WebP, GIF or SVG", source.describe())))?;
if !matches!(
format,
image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP | image::ImageFormat::Gif
) {
return Err(ConvertError::source(format!(
"{} is {format:?}; use PNG, JPEG, WebP, GIF or SVG",
source.describe()
)));
}
let (width, height) =
reader.into_dimensions().map_err(|e| ConvertError::source(format!("{}: {e}", source.describe())))?;
if u64::from(width) * u64::from(height) > MAX_SOURCE_PIXELS {
return Err(ConvertError::source(format!(
"{} is {width}×{height}; a raster may have {MAX_SOURCE_PIXELS} pixels",
source.describe()
)));
}
Asset::Raster { bytes: Arc::new(bytes), format, width, height }
};
self.cache.lock().unwrap_or_else(|e| e.into_inner()).insert(source.clone(), asset.clone());
Ok(asset)
}
pub fn image_kind(&self, href: &str, opt: &usvg::Options) -> Option<ImageKind> {
let source = Source::parse_href(href).ok().flatten()?;
let asset = self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(&source).cloned()?;
match asset {
Asset::Raster { bytes, format, .. } => Some(match format {
image::ImageFormat::Png => ImageKind::PNG(bytes),
image::ImageFormat::Jpeg => ImageKind::JPEG(bytes),
image::ImageFormat::WebP => ImageKind::WEBP(bytes),
image::ImageFormat::Gif => ImageKind::GIF(bytes),
_ => return None,
}),
Asset::Svg { bytes } => {
let sub = usvg::Options {
fontdb: opt.fontdb.clone(),
font_family: opt.font_family.clone(),
image_href_resolver: usvg::ImageHrefResolver {
resolve_data: Box::new(|_, _, _| None),
resolve_string: Box::new(|_, _| None),
},
..Default::default()
};
usvg::Tree::from_data(&bytes, &sub).ok().map(ImageKind::SVG)
}
}
}
pub fn options<'a>(&'a self, base: usvg::Options<'a>) -> usvg::Options<'a> {
usvg::Options {
image_href_resolver: usvg::ImageHrefResolver {
resolve_data: Box::new(|_, _, _| None),
resolve_string: Box::new(move |href, opt| self.image_kind(href, opt)),
},
..base
}
}
}
pub fn strip_safe_doctype(svg: &str) -> Result<std::borrow::Cow<'_, str>, ConvertError> {
let start = match svg.find("<!DOCTYPE") {
Some(i) => i,
None => return Ok(std::borrow::Cow::Borrowed(svg)),
};
if svg[..start].contains("<svg") {
return Ok(std::borrow::Cow::Borrowed(svg));
}
let mut quote: Option<char> = None;
for (offset, ch) in svg[start..].char_indices() {
match (quote, ch) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"') | (None, '\'') => quote = Some(ch),
(None, '[') => {
return Err(ConvertError::source(
"the svg declares an internal DTD subset, which is where XML entities live; remove the <!DOCTYPE …[…]> block (a plain SVG 1.1 doctype is fine)",
));
}
(None, '>') => {
let mut out = String::with_capacity(svg.len());
out.push_str(&svg[..start]);
out.push_str(&svg[start + offset + 1..]);
return Ok(std::borrow::Cow::Owned(out));
}
_ => {}
}
}
Err(ConvertError::source("the svg has an unterminated <!DOCTYPE"))
}
pub fn looks_like_svg(bytes: &[u8]) -> bool {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(8192)]);
let head = head.trim_start();
head.starts_with('<') && head.contains("<svg")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_doctype_is_cut_out_and_one_with_an_internal_subset_is_refused() {
let illustrator = "<?xml version=\"1.0\"?>\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n<svg xmlns=\"http://www.w3.org/2000/svg\"/>";
let out = strip_safe_doctype(illustrator).unwrap();
assert!(!out.contains("DOCTYPE") && out.contains("<svg"), "{out}");
let tricky = "<!DOCTYPE svg PUBLIC \"a>b\" \"c>d\"><svg/>";
assert_eq!(strip_safe_doctype(tricky).unwrap(), "<svg/>");
for bad in [
"<!DOCTYPE svg [<!ENTITY x SYSTEM \"file:///etc/passwd\">]><svg/>",
"<!DOCTYPE svg [ ]><svg/>",
] {
assert!(strip_safe_doctype(bad).unwrap_err().message.contains("internal DTD subset"), "{bad}");
}
assert_eq!(strip_safe_doctype("<svg/>").unwrap(), "<svg/>");
let inner = "<svg><text><!DOCTYPE svg [x]></text></svg>";
assert_eq!(strip_safe_doctype(inner).unwrap(), inner);
}
#[test]
fn the_four_ways_a_real_svg_file_opens_are_all_recognised() {
for good in [
"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
"<?xml version=\"1.0\"?><svg/>",
"<!-- Generator: Illustrator --><svg/>",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\"><svg/>",
"\n <svg/>",
] {
assert!(looks_like_svg(good.as_bytes()), "{good}");
}
for bad in ["{\"svg\": \"x\"}", "plain text", "<html><body/></html>", "\u{89}PNG\r\n"] {
assert!(!looks_like_svg(bad.as_bytes()), "{bad}");
}
}
#[test]
fn a_store_path_a_zebfs_href_and_a_repo_static_file_parse_and_the_rest_is_refused() {
assert_eq!(Source::parse_href("sandbox/photos/a.jpg").unwrap(), Some(Source::Store("sandbox/photos/a.jpg".into())));
assert_eq!(Source::parse_href("./generated/a.png").unwrap(), Some(Source::Store("generated/a.png".into())));
assert_eq!(Source::parse_href("zebfs://up/b.jpg").unwrap(), Some(Source::Store("up/b.jpg".into())));
assert_eq!(Source::parse_href("repo://static/pwa/logo.svg").unwrap(), Some(Source::Repo("static/pwa/logo.svg".into())));
assert_eq!(Source::parse_href("#grad").unwrap(), None);
for (bad, needle) in [
("https://x.test/a.png", "URL"),
("data:image/png;base64,AAAA", "data URI"),
("../secret.png", "inside the store"),
("/etc/passwd", "inside the store"),
("zebfs://../x", "inside the store"),
("repo://pages/home.tsx", "outside static/"),
("file:///etc/passwd", "URL"),
] {
let err = Source::parse_href(bad).unwrap_err();
assert!(err.message.contains(needle), "{bad}: {}", err.message);
}
}
#[test]
fn image_hrefs_are_read_off_the_tree_including_xlink() {
let doc = roxmltree::Document::parse(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image href="a.png"/><g><image xlink:href="b.jpg"/></g><use href="#x"/></svg>"##,
)
.unwrap();
assert_eq!(image_hrefs(&doc), vec!["a.png".to_string(), "b.jpg".to_string()]);
}
#[test]
fn the_resolver_caps_and_types_what_it_reads() {
let png = super::super::test_support::solid_png(4, 4, [255, 0, 0]);
let mut map = HashMap::new();
map.insert(Source::Store("a.png".into()), png);
map.insert(Source::Store("big.bin".into()), vec![0u8; MAX_SOURCE_BYTES + 1]);
map.insert(Source::Store("junk.png".into()), b"not an image".to_vec());
map.insert(Source::Repo("static/l.svg".into()), b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'><rect width='4' height='4'/></svg>".to_vec());
let resolver = Resolver::new(Arc::new(MemoryStore(map)));
assert!(matches!(resolver.prefetch(&Source::Store("a.png".into())).unwrap(), Asset::Raster { width: 4, height: 4, .. }));
assert!(resolver.prefetch(&Source::Store("big.bin".into())).unwrap_err().message.contains("limit"));
assert!(resolver.prefetch(&Source::Store("junk.png".into())).unwrap_err().message.contains("not a PNG"));
assert!(matches!(resolver.prefetch(&Source::Repo("static/l.svg".into())).unwrap(), Asset::Svg { .. }));
let fonts = super::super::FontSet::bundled();
let opt = fonts.options();
assert!(matches!(resolver.image_kind("a.png", &opt), Some(ImageKind::PNG(_))));
assert!(matches!(resolver.image_kind("zebfs://a.png", &opt), Some(ImageKind::PNG(_))));
assert!(matches!(resolver.image_kind("repo://static/l.svg", &opt), Some(ImageKind::SVG(_))));
assert!(resolver.image_kind("never-prefetched.png", &opt).is_none());
assert!(resolver.image_kind("https://x.test/a.png", &opt).is_none());
}
}