From c98bd7a4024d36303d26b8b1de2823eb2557dd23 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 22:29:38 -0800 Subject: [PATCH 1/4] chore(quest): claim quest/m1/overlay-assets.md Quest-Claim: a97c81d77de75a2c From 8594ae5555abdc0025db94ce5f85eab5d58fcd75 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 23:02:54 -0800 Subject: [PATCH 2/4] feat(scene): overlays load their own images and fonts An overlay names images and fonts as data: URLs or files under the scene file's directory. The source reads and checks each before the overlay opens, and the overlay's document fetches from those bytes alone, so its first frame has them and it never touches the network. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 29 ++ README.md | 36 +- quest/m1/README.md | 1 - quest/m1/overlay-assets.md | 27 -- rs/moq-scene/Cargo.toml | 9 +- rs/moq-scene/src/collection.rs | 5 +- rs/moq-scene/src/compositor.rs | 37 +- rs/moq-scene/src/overlay.rs | 756 ++++++++++++++++++++++++++------- rs/moq-scene/src/source.rs | 15 +- 9 files changed, 706 insertions(+), 209 deletions(-) delete mode 100644 quest/m1/overlay-assets.md diff --git a/Cargo.lock b/Cargo.lock index 36db5085..a52d632e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -664,6 +670,7 @@ dependencies = [ "thread_local", "url", "web-time", + "wuff", ] [[package]] @@ -753,6 +760,15 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "bs58" version = "0.5.1" @@ -4184,6 +4200,7 @@ name = "moq-scene" version = "0.1.0" dependencies = [ "anyrender_vello", + "base64 0.23.1", "blitz-dom", "blitz-html", "blitz-paint", @@ -4202,6 +4219,7 @@ dependencies = [ "moq-video", "notosans", "parley", + "percent-encoding", "postcard", "rustix 1.1.5", "serde", @@ -9109,6 +9127,17 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wuff" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d" +dependencies = [ + "brotli-decompressor", + "bytes", + "flate2", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/README.md b/README.md index c041ca1a..b31d99c8 100644 --- a/README.md +++ b/README.md @@ -280,19 +280,35 @@ drawn by the studio itself (through [Blitz](https://github.com/DioxusLabs/blitz) rather than a browser, on every platform. Its CSS animations and transitions run on the program clock, so they land on the same frame in the program and in a recording or replay of it, whatever the studio's load. It runs no scripts -and loads nothing: no images, stylesheets, or fonts from anywhere. Its text is -set in the bundled Noto Sans, regular, bold, or italic, whatever `font-family` -names, so it looks the same on every machine. An overlay outside what -overlays draw fails to start, naming the first element, attribute, rule, or -property it refused: +and never touches the network. Its text is set in the bundled Noto Sans, +regular, bold, or italic, unless it loads a face of its own, and never in the +machine's fonts, so it looks the same on every machine. + +An overlay's own images and fonts, such as a logo or a brand face, come from +`data:` URLs or from files in the scene file's directory, named relative to +it (`url(brand/logo.png)`), never from the web or anywhere else on disk. The +studio reads them all as the overlay starts, so its first frame has them, and +reads them again only when the overlay is edited or reloaded. Images are PNG +or JPEG, at most 4096 pixels a side; fonts are TrueType, OpenType, WOFF, or +WOFF2, loaded with `@font-face`: + +```css +@font-face { font-family: Brand; src: url(brand/face.woff2); font-weight: bold } +.title { font-family: Brand; background: url(brand/logo.png) no-repeat } +``` + +An overlay outside what overlays draw fails to start, naming the first +element, attribute, rule, property, or asset it refused: - Elements: text and the boxes around it (`div`, `span`, `p`, headings, - lists, tables, and the like), plus `style`, `title`, and `meta`. Not - `script`, `img`, `link`, `iframe`, `svg`, `canvas`, or form controls. + lists, tables, and the like), `img`, plus `style`, `title`, and `meta`. Not + `script`, `link`, `iframe`, `svg`, `canvas`, or form controls. - Attributes: `id`, `class`, `style`, `lang`, `dir`, `title`, `hidden`, - table and list numbering ones, and `data-*` and `aria-*`. -- CSS rules: style rules, nested ones included, `@media`, and `@keyframes`. - Not `@import` or `@font-face`, nor any `url()` or `image-set()`. + table and list numbering ones, `data-*` and `aria-*`, and an `img`'s `src` + and `alt`. +- CSS rules: style rules, nested ones included, `@media`, `@keyframes`, and + `@font-face` with `font-family`, `src`, `font-weight`, `font-style`, and + `font-display`. Not `@import`, nor `local()` or `image-set()`. - CSS properties: box layout, flexbox, grid, positioning, margins and padding, colors, opacity, gradients, borders and their radii, `box-shadow`, outlines, transforms, `clip-path`, fonts and text layout, text decoration, diff --git a/quest/m1/README.md b/quest/m1/README.md index fab4c79f..b955fd20 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -25,7 +25,6 @@ than opening an m2 for them. - [Viewer-side composition](/quest/m1/compose/README.md) - sources as their own tracks plus a layout track, composed by viewers and by a server - [Overlays restart on air](/quest/m1/overlay-restart.md) - an overlay's CSS timeline restarts at each cut unless set to keep running -- [Overlay assets](/quest/m1/overlay-assets.md) - overlays use fonts and images from data: URLs or files beside the scene - [Hotkeys and editor preferences](/quest/m1/hotkeys.md) - system-wide hotkeys, arrow nudge, aspect lock, and persisted editor settings - [Isolated guest recordings](/quest/m1/guest-recording.md) - record-each-source also writes each guest's tracks to its own file - [Scene graph view](/quest/m1/scene-graph.md) - a node view of picture and audio scenes, their routing, volume, pan, and waveforms diff --git a/quest/m1/overlay-assets.md b/quest/m1/overlay-assets.md deleted file mode 100644 index 6f92fe65..00000000 --- a/quest/m1/overlay-assets.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] Overlay assets - -## Goal - -An overlay uses its own fonts and images, such as a logo or a brand face, -given as `data:` URLs or as files beside the scene file. Any other URL is -refused with an error naming it. - -## Plan - -From [moq-dev/studio#34](https://github.com/moq-dev/studio/pull/34). - -Decided 2026-11-04 with the maintainer (/quest-plan): - -- ✅ **Local files and `data:` URLs only.** With no network, what goes on air - is deterministic and private, and an asset can't fail or change mid-show. - Rejected: also fetching over https. - -Implementation: - -- Give Blitz a `NetProvider` that serves only `data:` URLs and files under - the scene file's directory, rejecting `..` escapes and symlinks out of it. -- Widen `overlay::check` (`rs/moq-scene/src/overlay.rs`) to accept - `@font-face` and `` / `background-image` that resolve that way. -- Load assets before the overlay first paints, so its first frame on program - has them. -- `README.md` documents where assets go. diff --git a/rs/moq-scene/Cargo.toml b/rs/moq-scene/Cargo.toml index 7d03c13e..8458e633 100644 --- a/rs/moq-scene/Cargo.toml +++ b/rs/moq-scene/Cargo.toml @@ -35,12 +35,15 @@ cef = ["dep:cef"] # Overlays: Blitz lays HTML and CSS out and paints it into a Vello scene, which # Vello renders on the compositor's device. Pinned to the beta whose Vello and # wgpu match moq-video's: the stable 0.2 line is on wgpu 26, which cannot share -# the device. Fonts and network access are left off, so an overlay loads nothing. +# the device. System fonts and network access are left off, so an overlay loads +# only its own assets; `woff` decodes the WOFF and WOFF2 fonts it loads. anyrender_vello = "0.15" -blitz-dom = { version = "=0.3.0-beta.4", default-features = false } +blitz-dom = { version = "=0.3.0-beta.4", default-features = false, features = ["woff"] } blitz-html = "=0.3.0-beta.4" blitz-paint = { version = "=0.3.0-beta.4", default-features = false } blitz-traits = "=0.3.0-beta.4" +# Decodes an overlay's base64 `data:` URLs. +base64 = "0.23" bytemuck = { version = "1", features = ["derive"] } bytes = { workspace = true } # Reads the declarations an overlay's CSS checks, as Stylo serializes them. @@ -61,6 +64,8 @@ moq-net = { workspace = true } # `render` is the compositor's whole input path: it turns every decoded or # captured frame into a texture on the studio's device. moq-video = { workspace = true, features = ["render"] } +# Decodes an overlay's `data:` URLs. +percent-encoding = "2" serde = { workspace = true } serde_with = { workspace = true } # Stylo, Blitz's style engine, which parses an overlay's CSS to check it. diff --git a/rs/moq-scene/src/collection.rs b/rs/moq-scene/src/collection.rs index 6d5e23f4..55da088f 100644 --- a/rs/moq-scene/src/collection.rs +++ b/rs/moq-scene/src/collection.rs @@ -1455,8 +1455,9 @@ pub enum Source { /// A graphic written as HTML and CSS without scripts, such as a title or /// lower third. Its CSS animations run on the program clock, so they land /// on the same frame in the program and everything made from it. It loads - /// nothing, and HTML or CSS outside what overlays draw fails the source, - /// naming it. + /// only images and fonts from `data:` URLs and files under the scene + /// file's directory, and HTML, CSS, or an asset outside what overlays draw + /// fails the source, naming it. Overlay { /// The document, its CSS in `
" + .into(), + width: 4, + height: 4, + }, + ); + let fill = Rect::fill(&collection.canvas); + collection.scenes[0].items = vec![Item::new("red", fill), Item::new("overlay", fill)]; + let program = drawn(collection, dir).await; + assert_eq!(pixel(&program, 3, 3), BLUE); + }) + .await; + } + /// An overlay's CSS animation runs on the program clock from when the /// overlay opens: a frame shows it where the program time says, however late /// that frame is drawn, even the first one to show it. diff --git a/rs/moq-scene/src/overlay.rs b/rs/moq-scene/src/overlay.rs index 7d66cebc..2e2d58cf 100644 --- a/rs/moq-scene/src/overlay.rs +++ b/rs/moq-scene/src/overlay.rs @@ -2,17 +2,22 @@ //! laid out by Blitz and painted by Vello into a texture on the compositor's //! device, on the program clock. //! -//! An overlay is untrusted, so it loads and runs nothing: Blitz has no script -//! engine, its document has no network or navigation provider, and [`check`] -//! refuses any element, attribute, CSS rule, or property outside the subset an -//! overlay draws, naming it. +//! An overlay is untrusted, so it runs nothing and loads only its own assets: +//! Blitz has no script engine or navigation provider, [`check`] refuses any +//! element, attribute, CSS rule, or property outside the subset an overlay +//! draws, naming it, and reads every image and font the overlay names up +//! front, from a `data:` URL or a file under the scene file's directory. Its +//! document fetches from those [`Assets`] alone, never the network. use std::cell::RefCell; -use std::collections::HashMap; -use std::sync::{Arc, mpsc}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; use blitz_dom::{BaseDocument, DocumentConfig, FontContext, Node, ns}; +use blitz_traits::net::{Bytes, NetHandler, NetProvider, Request as Fetch}; use blitz_traits::shell::{ColorScheme, Viewport}; +use cssparser::{ParseError, ParseErrorKind, Parser, Token}; use kio::clock::Instant; use moq_video::render::wgpu; use style::context::QuirksMode; @@ -20,6 +25,7 @@ use style::error_reporting::{ContextualParseError, ParseErrorReporter}; use style::properties::PropertyDeclarationBlock; use style::shared_lock::{SharedRwLock, SharedRwLockReadGuard, ToCssWithGuard}; use style::stylesheets::{AllowImportRules, CssRule, CssRuleType, Origin, StylesheetContents, UrlExtraData}; +use url::Url; use crate::Error; use crate::compositor::FORMAT; @@ -41,10 +47,10 @@ const PREMULTIPLY: &str = r#" } "#; -/// The elements an overlay may hold: text and the boxes around it. Anything -/// that loads a resource (`img`, `link`, `iframe`), runs code (`script`), -/// takes input (`input`, `button`), or draws outside CSS (`svg`, `canvas`) is -/// refused. +/// The elements an overlay may hold: text, the boxes around it, and images. +/// Anything that loads a document or stylesheet (`link`, `iframe`), runs code +/// (`script`), takes input (`input`, `button`), or draws outside CSS (`svg`, +/// `canvas`) is refused. const ELEMENTS: &[&str] = &[ "html", "head", @@ -114,6 +120,7 @@ const ELEMENTS: &[&str] = &[ "tr", "th", "td", + "img", ]; /// The attributes an overlay's elements may carry, besides `aria-*` and @@ -123,6 +130,16 @@ const ATTRIBUTES: &[&str] = &[ "reversed", "value", "datetime", ]; +/// The attributes an `` carries besides [`ATTRIBUTES`]: one image, sized +/// by CSS. +const IMAGE_ATTRIBUTES: &[&str] = &["src", "alt"]; + +/// The `@font-face` descriptors Blitz honors. It ignores the rest, such as +/// `unicode-range`, so a face would apply where its CSS says it does not. +/// `font-display` changes nothing, since every font loads before the first +/// paint. +const FONT_FACE: &[&str] = &["font-family", "src", "font-weight", "font-style", "font-display"]; + /// The CSS properties an overlay may set, as written, shorthands included: the /// ones Blitz lays out and paints through Vello. Missing are those it ignores /// or Vello cannot draw yet, such as `filter`, `backdrop-filter`, @@ -330,44 +347,349 @@ const PROPERTIES: &[&str] = &[ "transition-behavior", ]; -/// CSS functions that load a resource, which an overlay cannot. -const LOADS: &[&str] = &["url(", "image-set("]; +/// An overlay's HTML that passed [`check`], with every asset it loads. +pub(crate) struct Content { + html: String, + /// What its relative URLs resolve against: the scene file's directory. + base: Url, + /// Each image and font it loads, by the URL its document resolves to. + assets: HashMap, +} + +/// What an asset is meant to be, to check that its bytes are one Blitz draws. +#[derive(Clone, Copy)] +enum Kind { + Image, + Font, +} + +/// The first bytes of a font Blitz loads: TrueType, OpenType, WOFF, or WOFF2. +const FONT_MAGIC: &[&[u8]] = &[b"\0\x01\0\0", b"true", b"OTTO", b"wOFF", b"wOF2"]; /// Refuse `html` unless every element, attribute, CSS rule, and CSS property -/// in it is one an overlay draws, naming the first that is not. -pub(crate) fn check(html: &str) -> Result<(), Error> { - let document = document(html, 1, 1); - check_node(&document, document.root_node()) +/// in it is one an overlay draws, and every image and font it names loads +/// from a `data:` URL or a file under `dir`, the scene file's directory, +/// naming the first that does not. +pub(crate) fn check(html: &str, dir: &Path) -> Result { + let dir = std::path::absolute(dir) + .map_err(|err| Error::Overlay(format!("cannot find the scene file's directory: {err}")))?; + let base = Url::from_directory_path(&dir) + .map_err(|()| Error::Overlay(format!("cannot name `{}` as a URL", dir.display())))?; + // Blitz panics as it parses an element whose `src` or `href` is no URL. + let document = std::panic::catch_unwind(|| document(html, 1, 1, &base, None)) + .map_err(|_| Error::Overlay("an element has a `src` or `href` that is no URL".into()))?; + let mut loader = Loader { + dir, + content: Content { + html: html.into(), + base, + assets: HashMap::new(), + }, + }; + loader.check_node(&document, document.root_node())?; + Ok(loader.content) +} + +/// Walks an overlay's HTML and CSS for [`check`], reading each asset it names. +struct Loader { + dir: PathBuf, + content: Content, } -fn check_node(document: &BaseDocument, node: &Node) -> Result<(), Error> { - if let Some(element) = node.element_data() { - let name: &str = &element.name.local; - if element.name.ns != ns!(html) || !ELEMENTS.contains(&name) { - return Err(refused(format!("the `<{name}>` element"))); +impl Loader { + fn check_node(&mut self, document: &BaseDocument, node: &Node) -> Result<(), Error> { + if let Some(element) = node.element_data() { + let name: &str = &element.name.local; + if element.name.ns != ns!(html) || !ELEMENTS.contains(&name) { + return Err(refused(format!("the `<{name}>` element"))); + } + let image = name == "img"; + for attribute in element.attrs.iter() { + let attribute: &str = &attribute.name.local; + let inert = attribute.starts_with("aria-") || attribute.starts_with("data-"); + let allowed = ATTRIBUTES.contains(&attribute) || (image && IMAGE_ATTRIBUTES.contains(&attribute)); + if !inert && !allowed { + return Err(refused(format!("the `{attribute}` attribute on `<{name}>`"))); + } + } + if image { + let src = node.attr(blitz_dom::local_name!("src")).unwrap_or_default(); + if src.is_empty() { + return Err(Error::Overlay("an `` has no `src`".into())); + } + self.load(src, Kind::Image)?; + } + if name == "style" { + self.check_sheet(&node.text_content())?; + } + if let Some(style) = node.attr(blitz_dom::local_name!("style")) { + self.check_attribute(style)?; + } } - for attribute in element.attrs.iter() { - let attribute: &str = &attribute.name.local; - let inert = attribute.starts_with("aria-") || attribute.starts_with("data-"); - if !inert && !ATTRIBUTES.contains(&attribute) { - return Err(refused(format!("the `{attribute}` attribute on `<{name}>`"))); + for &child in &node.children { + if let Some(child) = document.get_node(child) { + self.check_node(document, child)?; } } - if name == "style" { - check_sheet(&node.text_content())?; + Ok(()) + } + + fn check_sheet(&mut self, css: &str) -> Result<(), Error> { + let lock = SharedRwLock::new(); + let reporter = Reporter::default(); + let sheet = StylesheetContents::from_str( + css, + url(), + Origin::Author, + &lock, + None, + Some(&reporter), + QuirksMode::NoQuirks, + AllowImportRules::No, + None, + ); + reporter.result()?; + let guard = lock.read(); + self.check_rules(&sheet.rules.read_with(&guard).0, &guard, true) + } + + /// Check `rules`, which are a sheet's own when `top`, where Blitz looks + /// for `@font-face`. + fn check_rules(&mut self, rules: &[CssRule], guard: &SharedRwLockReadGuard, top: bool) -> Result<(), Error> { + for rule in rules { + match rule { + CssRule::Style(style) => { + let style = style.read_with(guard); + self.check_block(style.block.read_with(guard))?; + if let Some(nested) = &style.rules { + self.check_rules(&nested.read_with(guard).0, guard, false)?; + } + } + CssRule::NestedDeclarations(nested) => { + self.check_block(nested.read_with(guard).block.read_with(guard))? + } + CssRule::Media(media) => self.check_rules(&media.rules.read_with(guard).0, guard, false)?, + CssRule::Keyframes(keyframes) => { + for keyframe in &keyframes.read_with(guard).keyframes { + self.check_block(keyframe.read_with(guard).block.read_with(guard))?; + } + } + CssRule::FontFace(_) if top => self.check_font_face(&rule.to_css_string(guard))?, + CssRule::FontFace(_) => { + return Err(refused("an `@font-face` rule inside another rule".into())); + } + other => { + let css = other.to_css_string(guard); + let name = css.split(|c: char| c.is_whitespace() || c == '{' || c == ';').next(); + return Err(refused(format!("the `{}` rule", name.unwrap_or_default()))); + } + } } - if let Some(style) = node.attr(blitz_dom::local_name!("style")) { - check_attribute(style)?; + Ok(()) + } + + fn check_attribute(&mut self, css: &str) -> Result<(), Error> { + let reporter = Reporter::default(); + let block = style::properties::parse_style_attribute( + css, + &url(), + Some(&reporter), + QuirksMode::NoQuirks, + CssRuleType::Style, + ); + reporter.result()?; + self.check_block(&block) + } + + /// Check a block's declarations by the names they serialize under, which + /// are shorthands wherever one covers them, so `border: 1px solid` is + /// checked as `border` rather than as the `border-image` it resets. + fn check_block(&mut self, block: &PropertyDeclarationBlock) -> Result<(), Error> { + let mut css = String::new(); + block.to_css(&mut css).expect("writing to a string"); + self.check_declarations(&css, PROPERTIES, "property", Kind::Image)?; + Ok(()) + } + + /// Check an `@font-face` rule, as Stylo serializes it, and read its fonts. + fn check_font_face(&mut self, css: &str) -> Result<(), Error> { + let body = css + .split_once('{') + .and_then(|(_, body)| body.rsplit_once('}')) + .ok_or_else(|| unreadable(css))? + .0; + let named = self.check_declarations(body, FONT_FACE, "`@font-face` descriptor", Kind::Font)?; + for needed in ["font-family", "src"] { + if !named.contains(needed) { + return Err(Error::Overlay(format!("an `@font-face` rule has no `{needed}`"))); + } } + Ok(()) } - for &child in &node.children { - if let Some(child) = document.get_node(child) { - check_node(document, child)?; + + /// Check that `css`, a run of declarations, names only what `allowed` + /// lists or custom properties, loading each `url()` in their values as a + /// `kind`. Returns the names. + fn check_declarations( + &mut self, + css: &str, + allowed: &[&str], + what: &str, + kind: Kind, + ) -> Result, Error> { + let mut named = HashSet::new(); + let mut parser = Parser::new(css); + while !parser.is_exhausted() { + let name = parser.expect_ident_cloned().map_err(|_| unreadable(css))?; + parser.expect_colon().map_err(|_| unreadable(css))?; + if !name.starts_with("--") && !allowed.contains(&name.as_ref()) { + return Err(refused(format!("the `{name}` {what}"))); + } + parser + .parse_until_after(cssparser::Delimiter::Semicolon, |value| { + self.load_value(&name, value, kind) + }) + .map_err(|err| match err.kind { + ParseErrorKind::Custom(err) => err, + ParseErrorKind::Basic(_) => unreadable(css), + })?; + named.insert(name.to_string()); } + Ok(named) + } + + /// Load each `url()` in `name`'s value as a `kind`, refusing the functions + /// that load something else. + fn load_value(&mut self, name: &str, input: &mut Parser, kind: Kind) -> Result<(), ParseError> { + while let Ok(token) = input.next() { + match token.clone() { + Token::UnquotedUrl(url) => self.load(&url, kind).map_err(ParseError::custom)?, + Token::Function(function) if function.eq_ignore_ascii_case("url") => { + let url = input.parse_nested_block(|input| Ok(input.expect_string()?.clone()))?; + self.load(&url, kind).map_err(ParseError::custom)?; + } + Token::Function(function) + if ["local", "image-set", "-webkit-image-set", "src"] + .iter() + .any(|loads| function.eq_ignore_ascii_case(loads)) => + { + let function = function.to_ascii_lowercase(); + return Err(ParseError::custom(Error::Overlay(format!( + "`{name}` uses `{function}()`, which an overlay does not load" + )))); + } + Token::Function(_) | Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => { + input.parse_nested_block(|input| self.load_value(name, input, kind))?; + } + _ => {} + } + } + Ok(()) + } + + /// Read the asset `raw` names, resolved as the overlay's document will, + /// and check that it is a `kind` Blitz draws. + fn load(&mut self, raw: &str, kind: Kind) -> Result<(), Error> { + let failed = |err: String| Error::Overlay(format!("cannot load `{}`: {err}", shorten(raw))); + let url = self.content.base.join(raw).map_err(|err| failed(err.to_string()))?; + let bytes = match self.content.assets.get(url.as_str()) { + Some(bytes) => bytes.clone(), + None => match url.scheme() { + "data" => data(&url), + "file" => self.file(&url), + _ => Err("an overlay loads only `data:` URLs and files under the scene file's directory".into()), + } + .map_err(failed)?, + }; + match kind { + Kind::Image => image(&bytes), + Kind::Font => match bytes.get(..4) { + Some(magic) if FONT_MAGIC.contains(&magic) => Ok(()), + _ => Err("it is not a TrueType, OpenType, WOFF, or WOFF2 font".into()), + }, + } + .map_err(failed)?; + self.content.assets.insert(url.into(), bytes); + Ok(()) + } + + /// Read the file `url` names, if it lies under the scene file's directory + /// once every symlink and `..` is resolved. + fn file(&self, url: &Url) -> Result { + let path = url.to_file_path().map_err(|()| "it names no local file".to_string())?; + let path = std::fs::canonicalize(path).map_err(|err| err.to_string())?; + let dir = + std::fs::canonicalize(&self.dir).map_err(|err| format!("cannot find the scene file's directory: {err}"))?; + if !path.starts_with(&dir) { + return Err("it is outside the scene file's directory".into()); + } + std::fs::read(&path).map(Bytes::from).map_err(|err| err.to_string()) + } +} + +/// The bytes a `data:` URL holds, decoded as the Fetch standard does. +fn data(url: &Url) -> Result { + use base64::Engine; + use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; + + const FORGIVING: GeneralPurpose = GeneralPurpose::new( + &base64::alphabet::STANDARD, + GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::Indifferent) + .with_decode_allow_trailing_bits(true), + ); + + let (meta, body) = url[url::Position::BeforePath..url::Position::AfterQuery] + .split_once(',') + .ok_or("it has no `,` before its data")?; + let body: Vec = percent_encoding::percent_decode_str(body).collect(); + let meta = meta.trim_ascii(); + let base64 = meta + .len() + .checked_sub("base64".len()) + .and_then(|at| meta.split_at_checked(at)) + .is_some_and(|(head, tail)| tail.eq_ignore_ascii_case("base64") && head.trim_end_matches(' ').ends_with(';')); + if !base64 { + return Ok(body.into()); + } + let body: Vec = body.into_iter().filter(|byte| !byte.is_ascii_whitespace()).collect(); + FORGIVING + .decode(body) + .map(Bytes::from) + .map_err(|err| format!("its base64 is malformed: {err}")) +} + +/// Check that `bytes` are a PNG or JPEG no larger than an overlay, so decoding +/// it holds no more than an overlay's own texture. +fn image(bytes: &[u8]) -> Result<(), String> { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .expect("reading from memory"); + if !matches!( + reader.format(), + Some(image::ImageFormat::Png | image::ImageFormat::Jpeg) + ) { + return Err("it is not a PNG or JPEG image".into()); + } + let (width, height) = reader.into_dimensions().map_err(|err| err.to_string())?; + let max = crate::browser::MAX_SIDE; + if width > max || height > max { + return Err(format!( + "it is {width}x{height}; each side must be at most {max} pixels" + )); } Ok(()) } +/// `url` as an error names it, a long `data:` URL cut short. +fn shorten(url: &str) -> String { + const MAX: usize = 64; + match url.char_indices().nth(MAX) { + Some((end, _)) => format!("{}...", &url[..end]), + None => url.into(), + } +} + /// Collects the first error Stylo reports, such as an unknown property or an /// invalid value. #[derive(Default)] @@ -390,121 +712,73 @@ impl Reporter { } } +/// The base [`check`]'s CSS parses against; [`Loader::load`] resolves each +/// `url()` itself, as written. fn url() -> UrlExtraData { - url::Url::parse("about:blank").expect("a valid url").into() + Url::parse("about:blank").expect("a valid url").into() } -fn check_sheet(css: &str) -> Result<(), Error> { - let lock = SharedRwLock::new(); - let reporter = Reporter::default(); - let sheet = StylesheetContents::from_str( - css, - url(), - Origin::Author, - &lock, - None, - Some(&reporter), - QuirksMode::NoQuirks, - AllowImportRules::No, - None, - ); - reporter.result()?; - let guard = lock.read(); - check_rules(&sheet.rules.read_with(&guard).0, &guard) +fn unreadable(css: &str) -> Error { + Error::Overlay(format!("cannot read back the declarations `{css}`")) } -fn check_rules(rules: &[CssRule], guard: &SharedRwLockReadGuard) -> Result<(), Error> { - for rule in rules { - match rule { - CssRule::Style(style) => { - let style = style.read_with(guard); - check_block(style.block.read_with(guard))?; - if let Some(nested) = &style.rules { - check_rules(&nested.read_with(guard).0, guard)?; - } - } - CssRule::NestedDeclarations(nested) => check_block(nested.read_with(guard).block.read_with(guard))?, - CssRule::Media(media) => check_rules(&media.rules.read_with(guard).0, guard)?, - CssRule::Keyframes(keyframes) => { - for keyframe in &keyframes.read_with(guard).keyframes { - check_block(keyframe.read_with(guard).block.read_with(guard))?; - } - } - other => { - let css = other.to_css_string(guard); - let name = css.split(|c: char| c.is_whitespace() || c == '{' || c == ';').next(); - return Err(refused(format!("the `{}` rule", name.unwrap_or_default()))); - } - } - } - Ok(()) +fn refused(what: String) -> Error { + Error::Overlay(format!("{what} is not supported in an overlay")) } -fn check_attribute(css: &str) -> Result<(), Error> { - let reporter = Reporter::default(); - let block = style::properties::parse_style_attribute( - css, - &url(), - Some(&reporter), - QuirksMode::NoQuirks, - CssRuleType::Style, - ); - reporter.result()?; - check_block(&block) +/// Serves an overlay's document the assets [`check`] read, and nothing else. +struct Assets { + content: Content, + /// The first URL the document fetched that [`check`] did not read. + missing: Mutex>, } -/// Check a block's declarations by the names they serialize under, which are -/// shorthands wherever one covers them, so `border: 1px solid` is checked as -/// `border` rather than as the `border-image` it resets. -fn check_block(block: &PropertyDeclarationBlock) -> Result<(), Error> { - let mut css = String::new(); - block.to_css(&mut css).expect("writing to a string"); - - let mut parser = cssparser::Parser::new(&css); - while !parser.is_exhausted() { - let name = parser.expect_ident_cloned().map_err(|_| unreadable(&css))?; - parser.expect_colon().map_err(|_| unreadable(&css))?; - let start = parser.position(); - parser - .parse_until_after(cssparser::Delimiter::Semicolon, |value| { - while value.next().is_ok() {} - Ok::<_, cssparser::ParseError<()>>(()) - }) - .map_err(|_| unreadable(&css))?; - let value = parser.slice_from(start).to_ascii_lowercase(); - - if !name.starts_with("--") && !PROPERTIES.contains(&name.as_ref()) { - return Err(refused(format!("the `{name}` property"))); - } - if let Some(load) = LOADS.iter().find(|load| value.contains(*load)) { - return Err(Error::Overlay(format!( - "`{name}` uses `{load}`, but an overlay loads nothing" - ))); +impl NetProvider for Assets { + fn fetch(&self, _document: usize, request: Fetch, handler: Box) { + let url = request.url.as_str(); + match self.content.assets.get(url) { + Some(bytes) => handler.bytes(url.into(), bytes.clone()), + None => { + self.missing.lock().unwrap().get_or_insert_with(|| shorten(url)); + } } } - Ok(()) } -fn unreadable(css: &str) -> Error { - Error::Overlay(format!("cannot read back the declarations `{css}`")) -} - -fn refused(what: String) -> Error { - Error::Overlay(format!("{what} is not supported in an overlay")) -} - -/// Parse `html` into a `width` by `height` document with no network access. -fn document(html: &str, width: u32, height: u32) -> BaseDocument { +/// Parse `html` into a `width` by `height` document whose relative URLs +/// resolve against `base`, fetching through `assets` or, without, nothing. +fn document(html: &str, width: u32, height: u32, base: &Url, assets: Option>) -> BaseDocument { let config = DocumentConfig { viewport: Some(Viewport::new(width, height, 1.0, ColorScheme::Light)), font_ctx: Some(fonts()), + base_url: Some(base.to_string()), + net_provider: assets.map(|assets| assets as Arc), ..Default::default() }; blitz_html::HtmlDocument::from_html(html, config).into_inner() } -/// Noto Sans, regular, bold, and italic, as every family: an overlay loads no -/// fonts, and leaves the machine's out so that it looks the same on any. +/// Lay `content` out at `width` by `height`, at the start of its timeline. +fn open(content: Content, width: u32, height: u32) -> (BaseDocument, Arc) { + let assets = Arc::new(Assets { + content, + missing: Mutex::default(), + }); + let Content { html, base, .. } = &assets.content; + let mut document = document(html, width, height, base, Some(assets.clone())); + // Stylo starts an animation at the time of the resolve that creates it, so + // this one starts every animation at the start of the timeline, however + // late the overlay is first drawn. Assets arrive as they are fetched, from + // memory, so each is in place by the first paint's resolve: fonts and + // `` ones by this resolve, and background images, which it fetches, + // by the next. + document.resolve(0.0); + (document, assets) +} + +/// Noto Sans, regular, bold, and italic, as every generic family and any +/// family an overlay loads no font for. The machine's fonts are left out, so +/// an overlay looks the same on any. fn fonts() -> FontContext { use parley::fontique::{Blob, Collection, CollectionOptions, GenericFamily, SourceCache}; @@ -557,7 +831,7 @@ pub(crate) struct Overlays { enum Request { Open { id: u64, - html: String, + content: Content, /// Where Vello paints, straight. paint: wgpu::TextureView, /// Where the paint lands premultiplied, for the compositor to sample. @@ -593,9 +867,9 @@ impl Overlays { }) } - /// Lay out `html` at `width` by `height`, its CSS timeline starting at - /// `start` on the program clock. The HTML has passed [`check`]. - pub fn open(&mut self, html: &str, width: u32, height: u32, start: Instant) -> Overlay { + /// Lay out `content` at `width` by `height`, its CSS timeline starting at + /// `start` on the program clock. + pub fn open(&mut self, content: Content, width: u32, height: u32, start: Instant) -> Overlay { let texture = |label, usage| { self.device.create_texture(&wgpu::TextureDescriptor { label: Some(label), @@ -621,7 +895,7 @@ impl Overlays { // A thread that stopped fails the next paint, which says so. let _ = self.requests.send(Request::Open { id, - html: html.to_string(), + content, paint: paint.create_view(&Default::default()), view: texture.create_view(&Default::default()), start, @@ -693,19 +967,16 @@ fn run( match request { Request::Open { id, - html, + content, paint, view, start, } => { let size = view.texture().size(); - let mut document = document(&html, size.width, size.height); - // Stylo starts an animation at the time of the resolve that - // creates it, so this one starts every animation at the start of - // the timeline, however late the overlay is first drawn. - document.resolve(0.0); + let (document, assets) = open(content, size.width, size.height); let document = Document { document, + assets, paint, view, premultiply: None, @@ -856,6 +1127,8 @@ impl Painter { /// An overlay's document on the overlay thread, and where it paints. struct Document { document: BaseDocument, + /// What it fetches from. + assets: Arc, /// Where Vello paints, straight. paint: wgpu::TextureView, /// Where the paint lands premultiplied. @@ -883,6 +1156,9 @@ impl Document { } self.document .resolve(now.saturating_duration_since(self.start).as_secs_f64()); + if let Some(url) = self.assets.missing.lock().unwrap().as_ref() { + return Err(format!("the overlay fetched `{url}`, which it did not name")); + } let size = self.paint.texture().size(); painter.scene.reset(); @@ -919,7 +1195,39 @@ mod tests { use super::*; fn refusal(html: &str) -> String { - check(html).unwrap_err().to_string() + refusal_in(html, Path::new(".")) + } + + /// Why `html` fails with its scene file in `dir`. + fn refusal_in(html: &str, dir: &Path) -> String { + match check(html, dir) { + Ok(_) => panic!("{html} passes"), + Err(err) => err.to_string(), + } + } + + /// A `width` by `height` PNG. + fn png(width: u32, height: u32) -> Vec { + let mut png = Vec::new(); + image::RgbaImage::new(width, height) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + png + } + + fn base64(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + /// The widths of `document`'s `tag` elements, in order. + fn widths(document: &BaseDocument, tag: &str) -> Vec { + document + .tree() + .iter() + .filter(|(_, node)| node.element_data().is_some_and(|element| &*element.name.local == tag)) + .map(|(_, node)| node.final_layout().size.width) + .collect() } #[test] @@ -940,15 +1248,15 @@ mod tests { @media (min-width: 1000px) { .third { font-size: 48px } }
Ada Lovelace Analyst
"#, + Path::new("."), ) .unwrap(); } #[test] - fn what_loads_or_runs_is_refused() { + fn what_runs_or_loads_from_elsewhere_is_refused() { for (html, named) in [ ("", "`