Skip to main content

eui_core/host/
policy.rs

1//! The policy rules every host applies before a client function, a URL open
2//! or a media load (`catalog/host.json` functions / urls / media). Pure;
3//! `fixtures/host-policy.json` locks them on every platform.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use super::contract::{DEFAULT_MEDIA_SCHEMES, DEFAULT_URL_SCHEMES};
11use super::js_trim;
12
13/// The catalog's built-in client functions: the basic 14 + round 1's 15
14/// core ones (`FUNCTION_NAMES`).
15pub const BUILTIN_FUNCTIONS: &[&str] = crate::generated::catalog::FUNCTION_NAMES;
16
17pub fn is_builtin_function(name: &str) -> bool {
18    BUILTIN_FUNCTIONS.contains(&name)
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum FunctionDecision {
24    Allow,
25    Ask,
26    Deny,
27    NotFound,
28}
29
30impl FunctionDecision {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            FunctionDecision::Allow => "allow",
34            FunctionDecision::Ask => "ask",
35            FunctionDecision::Deny => "deny",
36            FunctionDecision::NotFound => "not_found",
37        }
38    }
39
40    pub fn parse(s: &str) -> Option<FunctionDecision> {
41        Some(match s {
42            "allow" => FunctionDecision::Allow,
43            "ask" => FunctionDecision::Ask,
44            "deny" => FunctionDecision::Deny,
45            "not_found" => FunctionDecision::NotFound,
46            _ => return None,
47        })
48    }
49
50    fn rank(self) -> u8 {
51        match self {
52            FunctionDecision::Allow => 0,
53            FunctionDecision::Ask => 1,
54            FunctionDecision::Deny => 2,
55            FunctionDecision::NotFound => 3,
56        }
57    }
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
61pub struct FunctionPolicy {
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub allow: Option<Vec<String>>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub ask: Option<Vec<String>>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub deny: Option<Vec<String>>,
68    /// For a registered name no list matches; default `allow`.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub default: Option<FunctionDecision>,
71}
72
73/// `harness.*` matches `harness.toast`; anything else is exact.
74pub fn matches_pattern(pattern: &str, name: &str) -> bool {
75    match pattern.strip_suffix('*') {
76        Some(prefix) => name.starts_with(prefix),
77        None => pattern == name,
78    }
79}
80
81/// The gate: built-ins always run; an unregistered name is `not_found`; then
82/// deny wins, then allow, then ask, then the policy's default.
83pub fn decide_function(policy: Option<&FunctionPolicy>, name: &str, registered: bool) -> FunctionDecision {
84    if is_builtin_function(name) {
85        return FunctionDecision::Allow;
86    }
87    if !registered {
88        return FunctionDecision::NotFound;
89    }
90    let empty = FunctionPolicy::default();
91    let p = policy.unwrap_or(&empty);
92    let any = |list: &Option<Vec<String>>| list.iter().flatten().any(|pattern| matches_pattern(pattern, name));
93    if any(&p.deny) {
94        return FunctionDecision::Deny;
95    }
96    if any(&p.allow) {
97        return FunctionDecision::Allow;
98    }
99    if any(&p.ask) {
100        return FunctionDecision::Ask;
101    }
102    p.default.unwrap_or(FunctionDecision::Allow)
103}
104
105/// Two policies stacked (a package's allowlist under the host's own): the
106/// stricter decision wins.
107pub fn combine_decisions(a: FunctionDecision, b: FunctionDecision) -> FunctionDecision {
108    if a.rank() >= b.rank() {
109        a
110    } else {
111        b
112    }
113}
114
115/// A declarative package's `functions` list as a policy: listed = allow,
116/// anything else = deny.
117pub fn package_policy(functions: Option<&[String]>) -> FunctionPolicy {
118    FunctionPolicy { allow: Some(functions.map(<[String]>::to_vec).unwrap_or_default()), ask: None, deny: None, default: Some(FunctionDecision::Deny) }
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122pub struct UrlPolicy {
123    /// The schemes allowed; default [`DEFAULT_URL_SCHEMES`] (https, http,
124    /// mailto, tel). `["https"]` = https only.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub schemes: Option<Vec<String>>,
127    /// http(s) hosts allowed: exact or `*.example.com`; unset = any.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub hosts: Option<Vec<String>>,
130    /// VAPP-107: private, loopback and link-local hosts
131    /// ([`is_private_host`]) are DENIED for http(s) unless listed here:
132    /// exact, `*.suffix`, or `*` for every private address. A `hosts`
133    /// allowlist does not opt one in.
134    #[serde(default, rename = "allowPrivate", skip_serializing_if = "Option::is_none")]
135    pub allow_private: Option<Vec<String>>,
136    #[serde(default, rename = "baseUrl", skip_serializing_if = "Option::is_none")]
137    pub base_url: Option<String>,
138    /// #EXP-1674: what `~/x` expands against (a host's own link root, e.g. an
139    /// app's team: `/t/acme`, or an absolute url). `None` = `~/x` is denied,
140    /// so it paints as text.
141    #[serde(default, rename = "linkBase", skip_serializing_if = "Option::is_none")]
142    pub link_base: Option<String>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct UrlDecision {
147    pub allowed: bool,
148    /// The absolute url when it parses.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub url: Option<String>,
151    /// `invalid` | `scheme` | `host` | `private`.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub reason: Option<String>,
154}
155
156/// `new URL(url)`, else `new URL(url, baseUrl)` (WHATWG both).
157fn absolute(url: &str, base_url: Option<&str>) -> Option<Url> {
158    if let Ok(u) = Url::parse(url) {
159        return Some(u);
160    }
161    let base = Url::parse(base_url?).ok()?;
162    base.join(url).ok()
163}
164
165fn host_matches(pattern: &str, host: &str) -> bool {
166    let p = pattern.to_lowercase();
167    match p.strip_prefix('*') {
168        Some(suffix) if p.starts_with("*.") => host.ends_with(suffix) && host.len() > suffix.len(),
169        _ => host == p,
170    }
171}
172
173/// The dotted-decimal IPv4 a WHATWG-parsed hostname is (the parser has
174/// already normalized `0x7f.1`, `2130706433` and the like), else `None`.
175fn ipv4(host: &str) -> Option<[u8; 4]> {
176    let mut out = [0u8; 4];
177    let mut n = 0;
178    for part in host.split('.') {
179        if n == 4 || part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) {
180            return None;
181        }
182        out[n] = part.parse().ok()?;
183        n += 1;
184    }
185    (n == 4).then_some(out)
186}
187
188/// A WHATWG-serialized IPv6 (`[...]`, lower-case hex, `::` once) as its
189/// eight 16-bit pieces, else `None`.
190fn ipv6(host: &str) -> Option<[u16; 8]> {
191    let text = host.strip_prefix('[')?.strip_suffix(']')?;
192    let pieces = |part: &str| -> Option<Vec<u16>> {
193        if part.is_empty() {
194            return Some(Vec::new());
195        }
196        part.split(':').map(|p| if (1..=4).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { u16::from_str_radix(p, 16).ok() } else { None }).collect()
197    };
198    let mut out = [0u16; 8];
199    match text.split_once("::") {
200        None => {
201            let all = pieces(text)?;
202            if all.len() != 8 {
203                return None;
204            }
205            out.copy_from_slice(&all);
206        }
207        Some((head, tail)) => {
208            if tail.contains("::") {
209                return None;
210            }
211            let (head, tail) = (pieces(head)?, pieces(tail)?);
212            if head.len() + tail.len() > 7 {
213                return None;
214            }
215            out[..head.len()].copy_from_slice(&head);
216            out[8 - tail.len()..].copy_from_slice(&tail);
217        }
218    }
219    Some(out)
220}
221
222fn private_v4([a, b, _, _]: [u8; 4]) -> bool {
223    // 0.0.0.0/8 ("this" network), 10/8, 100.64/10 (CGNAT, release train
224    // 2026-10-23: a carrier's or a tailnet's internal range), 127/8
225    // (loopback), 169.254/16 (link-local), 172.16/12, 192.168/16.
226    a == 0 || a == 10 || (a == 100 && b & 0xc0 == 64) || a == 127 || (a == 169 && b == 254) || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 168)
227}
228
229/// The IPv4 two 16-bit pieces carry.
230fn v4_of(hi: u16, lo: u16) -> [u8; 4] {
231    [(hi >> 8) as u8, (hi & 0xff) as u8, (lo >> 8) as u8, (lo & 0xff) as u8]
232}
233
234/// VAPP-107: is a parsed, lower-cased hostname one the URL and media policy
235/// deny by default (`catalog/host.json` urls.privateHosts)? `localhost` and
236/// `*.localhost`, `*.local` (mDNS), the IPv4 blocks 0/8, 10/8, 127/8,
237/// 169.254/16, 172.16/12, 192.168/16, 100.64/10, and IPv6 `::`, `::1`,
238/// fe80::/10, fc00::/7 plus a private IPv4 embedded IPv4-mapped
239/// (`::ffff:a.b.c.d`), IPv4-compatible (`::a.b.c.d`), NAT64
240/// (`64:ff9b::a.b.c.d`) or 6to4 (`2002:AABB:CCDD::`). LITERALS only:
241/// the policy never resolves names (a public name that resolves privately
242/// is the host network's job).
243pub fn is_private_host(hostname: &str) -> bool {
244    let lower = hostname.to_lowercase();
245    let host = lower.strip_suffix('.').unwrap_or(&lower);
246    if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") {
247        return true;
248    }
249    if let Some(v4) = ipv4(host) {
250        return private_v4(v4);
251    }
252    let Some(v6) = ipv6(host) else { return false };
253    if v6.iter().all(|p| *p == 0) {
254        return true;
255    }
256    if v6[..7].iter().all(|p| *p == 0) && v6[7] == 1 {
257        return true;
258    }
259    if v6[0] & 0xffc0 == 0xfe80 || v6[0] & 0xfe00 == 0xfc00 {
260        return true;
261    }
262    // Release train 2026-10-23: every form that embeds an IPv4 reaches it
263    // (mapped ::ffff:0:0/96, compatible ::/96, NAT64 64:ff9b::/96, 6to4 2002::/16).
264    let mapped = v6[..5].iter().all(|p| *p == 0) && (v6[5] == 0xffff || v6[5] == 0);
265    let nat64 = v6[0] == 0x64 && v6[1] == 0xff9b && v6[2..6].iter().all(|p| *p == 0);
266    if mapped || nat64 {
267        return private_v4(v4_of(v6[6], v6[7]));
268    }
269    if v6[0] == 0x2002 {
270        return private_v4(v4_of(v6[1], v6[2]));
271    }
272    false
273}
274
275fn private_allowed(allow: Option<&Vec<String>>, host: &str) -> bool {
276    allow.iter().flat_map(|l| l.iter()).any(|p| p == "*" || host_matches(p, host))
277}
278
279/// #EXP-1674: `~/x` against the host's `link_base`; `None` = denied: no base,
280/// `~//`, or a `.` or `..` path segment. Checked on what the URL parser will
281/// read (it drops tab and newline, takes `\\` as `/` and `%2e` as `.`), so a
282/// spelling never climbs out of the base.
283pub fn expand_link_base(url: &str, link_base: Option<&str>) -> Option<String> {
284    let link_base = link_base.filter(|base| !base.is_empty())?;
285    let clean: String = url.chars().filter(|c| !matches!(c, '\t' | '\n' | '\r')).collect();
286    if !clean.starts_with("~/") || clean.starts_with("~//") || clean.starts_with("~/\\") {
287        return None;
288    }
289    let rest = &clean[2..];
290    let path = &rest[..rest.find(['?', '#']).unwrap_or(rest.len())];
291    let dots = path.split(['/', '\\']).any(|segment| {
292        let segment = segment.replace("%2e", ".").replace("%2E", ".");
293        segment == "." || segment == ".."
294    });
295    if dots {
296        return None;
297    }
298    Some(format!("{}/{rest}", link_base.trim_end_matches('/')))
299}
300
301/// The decision for a url: `~/x` expands against `link_base` first
302/// ([`expand_link_base`]); it must parse (relative against `base_url`), use
303/// one of `schemes`, and for http(s) match `hosts` when set and not be a
304/// private host unless `allow_private` covers it.
305pub fn decide_url(policy: Option<&UrlPolicy>, url: &str) -> UrlDecision {
306    let trimmed = js_trim(url);
307    let shorthand = trimmed.chars().filter(|c| !matches!(c, '\t' | '\n' | '\r')).take(2).eq("~/".chars());
308    let expanded = if shorthand { expand_link_base(trimmed, policy.and_then(|p| p.link_base.as_deref())) } else { Some(trimmed.to_string()) };
309    let Some(expanded) = expanded else {
310        return UrlDecision { allowed: false, url: None, reason: Some("invalid".into()) };
311    };
312    let Some(parsed) = absolute(&expanded, policy.and_then(|p| p.base_url.as_deref())) else {
313        return UrlDecision { allowed: false, url: None, reason: Some("invalid".into()) };
314    };
315    let scheme = parsed.scheme().to_lowercase();
316    let href = parsed.as_str().to_string();
317    let allowed_scheme = match policy.and_then(|p| p.schemes.as_ref()) {
318        Some(list) => list.contains(&scheme),
319        None => DEFAULT_URL_SCHEMES.contains(&scheme.as_str()),
320    };
321    if !allowed_scheme {
322        return UrlDecision { allowed: false, url: Some(href), reason: Some("scheme".into()) };
323    }
324    if scheme == "http" || scheme == "https" {
325        let host = parsed.host_str().unwrap_or("").to_lowercase();
326        if let Some(hosts) = policy.and_then(|p| p.hosts.as_ref()) {
327            if !hosts.iter().any(|h| host_matches(h, &host)) {
328                return UrlDecision { allowed: false, url: Some(href), reason: Some("host".into()) };
329            }
330        }
331        if is_private_host(&host) && !private_allowed(policy.and_then(|p| p.allow_private.as_ref()), &host) {
332            return UrlDecision { allowed: false, url: Some(href), reason: Some("private".into()) };
333        }
334    }
335    UrlDecision { allowed: true, url: Some(href), reason: None }
336}
337
338#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
339pub struct MediaRule {
340    pub prefix: String,
341    #[serde(default)]
342    pub headers: BTreeMap<String, String>,
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
346pub struct MediaOptions {
347    #[serde(default, rename = "baseUrl", skip_serializing_if = "Option::is_none")]
348    pub base_url: Option<String>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub rules: Option<Vec<MediaRule>>,
351    /// The schemes a src may use; default [`DEFAULT_MEDIA_SCHEMES`] (https,
352    /// http, data). `file` only when listed.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub schemes: Option<Vec<String>>,
355    /// http(s) hosts media may load from: exact or `*.example.com`; unset = any.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub hosts: Option<Vec<String>>,
358    /// VAPP-107: the private hosts media may load from ([`UrlPolicy::allow_private`]).
359    #[serde(default, rename = "allowPrivate", skip_serializing_if = "Option::is_none")]
360    pub allow_private: Option<Vec<String>>,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364pub struct MediaRequest {
365    pub url: String,
366    /// Sorted by name.
367    pub headers: BTreeMap<String, String>,
368}
369
370/// The image / media loader's request: the absolute url plus the headers of
371/// every rule whose prefix it starts with (later rules win per header).
372/// `None` when the url does not resolve or the media policy (schemes, hosts)
373/// denies it: nothing loads.
374pub fn media_request(url: &str, options: &MediaOptions) -> Option<MediaRequest> {
375    let policy = UrlPolicy {
376        schemes: Some(options.schemes.clone().unwrap_or_else(|| DEFAULT_MEDIA_SCHEMES.iter().map(|s| s.to_string()).collect())),
377        hosts: options.hosts.clone(),
378        allow_private: options.allow_private.clone(),
379        base_url: options.base_url.clone(),
380        link_base: None,
381    };
382    let decision = decide_url(Some(&policy), url);
383    if !decision.allowed {
384        return None;
385    }
386    let href = decision.url?;
387    let mut headers = BTreeMap::new();
388    for rule in options.rules.iter().flatten() {
389        if href.starts_with(&rule.prefix) {
390            headers.extend(rule.headers.iter().map(|(k, v)| (k.clone(), v.clone())));
391        }
392    }
393    Some(MediaRequest { url: href, headers })
394}
395
396/// #EXP-1674 (`catalog/host.json` urls): one piece of a host's `text_links`
397/// answer for a `hostLinks` Text.
398#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
399pub struct TextLink {
400    pub text: String,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub href: Option<String>,
403    /// #VAPP-258 (`catalog/host.json` markdown): a Markdown paints the
404    /// segment as an inline Chip (a `hostLinks` Text as its plain link).
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub chip: Option<TextLinkChip>,
407}
408
409/// #VAPP-258: a text link's inline chip: `kind` in
410/// [`super::contract::MARKDOWN_CHIP_KINDS`] (the host's meaning, one look),
411/// a non-empty `label`, an optional icon registry name and `ident`.
412#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
413pub struct TextLinkChip {
414    pub kind: String,
415    pub label: String,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub icon: Option<String>,
418    /// #VAPP-258: an identifier (`ABC-1`) painted mono before the label
419    /// (an empty one is none).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub ident: Option<String>,
422}
423
424impl TextLinkChip {
425    /// A known kind and a non-empty label (else the chip is dropped).
426    pub fn is_valid(&self) -> bool {
427        super::contract::MARKDOWN_CHIP_KINDS.contains(&self.kind.as_str()) && !self.label.is_empty()
428    }
429}
430
431/// A host's `text_links` answer ready to paint, or `None` for the plain text:
432/// the segments must concatenate back to `text`; an href the policy denies
433/// stays text (chip or not); no allowed link = plain. #VAPP-258: a chip
434/// survives only on an allowed href and when [`TextLinkChip::is_valid`].
435pub fn police_text_links(policy: Option<&UrlPolicy>, text: &str, segments: Vec<TextLink>) -> Option<Vec<TextLink>> {
436    if segments.iter().map(|segment| segment.text.as_str()).collect::<String>() != text {
437        return None;
438    }
439    let policed: Vec<TextLink> = segments
440        .into_iter()
441        .map(|segment| {
442            let href = segment.href.and_then(|href| safe_href(policy, &href));
443            let chip = segment.chip.filter(|chip| href.is_some() && chip.is_valid());
444            TextLink { text: segment.text, href, chip }
445        })
446        .collect();
447    policed.iter().any(|segment| segment.href.is_some()).then_some(policed)
448}
449
450/// The href a renderer may navigate to (Link, markdown links, FileUpload
451/// file urls), or `None` when the URL policy denies it.
452pub fn safe_href(policy: Option<&UrlPolicy>, url: &str) -> Option<String> {
453    if url.is_empty() {
454        return None;
455    }
456    let d = decide_url(policy, url);
457    if d.allowed {
458        d.url
459    } else {
460        None
461    }
462}
463
464/// Width × height from an image's header (PNG, JPEG, GIF, WebP, BMP) or an
465/// SVG's root `width`/`height`/`viewBox` without decoding it — what a
466/// loader checks against [`super::contract::MEDIA_MAX_PIXELS`] BEFORE
467/// decoding. `None` = not a header this reads (truncated bytes, another
468/// format). VAPP-103 rfix: an SVG has a size ([`svg_dimensions`]).
469pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
470    if is_svg(bytes) {
471        return svg_dimensions(bytes);
472    }
473    let be16 = |i: usize| bytes.get(i..i + 2).map(|b| u16::from_be_bytes([b[0], b[1]]) as u32);
474    let le16 = |i: usize| bytes.get(i..i + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as u32);
475    let be32 = |i: usize| bytes.get(i..i + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
476    let le32 = |i: usize| bytes.get(i..i + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
477    let le24 = |i: usize| bytes.get(i..i + 3).map(|b| u32::from_le_bytes([b[0], b[1], b[2], 0]));
478    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
479        return Some((be32(16)?, be32(20)?));
480    }
481    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
482        return Some((le16(6)?, le16(8)?));
483    }
484    if bytes.starts_with(b"BM") {
485        let h = le32(22)? as i32;
486        return Some((le32(18)?, h.unsigned_abs()));
487    }
488    if bytes.len() >= 30 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
489        return match &bytes[12..16] {
490            b"VP8 " => Some((le16(26)? & 0x3fff, le16(28)? & 0x3fff)),
491            b"VP8L" => {
492                let b = le32(21)?;
493                Some(((b & 0x3fff) + 1, ((b >> 14) & 0x3fff) + 1))
494            }
495            b"VP8X" => Some((le24(24)? + 1, le24(27)? + 1)),
496            _ => None,
497        };
498    }
499    if bytes.starts_with(b"\xff\xd8") {
500        let mut i = 2;
501        while i + 4 <= bytes.len() {
502            if bytes[i] != 0xff {
503                i += 1;
504                continue;
505            }
506            let marker = bytes[i + 1];
507            if marker == 0xff {
508                i += 1;
509                continue;
510            }
511            if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
512                i += 2;
513                continue;
514            }
515            let len = be16(i + 2)? as usize;
516            // SOF0..SOF15 except DHT (c4), JPG (c8), DAC (cc).
517            if (0xc0..=0xcf).contains(&marker) && marker != 0xc4 && marker != 0xc8 && marker != 0xcc {
518                return Some((be16(i + 7)?, be16(i + 5)?));
519            }
520            i += 2 + len;
521        }
522        return None;
523    }
524    None
525}
526
527/// An SVG by its first non-blank bytes (`<svg` or `<?xml`), like the
528/// loaders sniff it.
529fn is_svg(bytes: &[u8]) -> bool {
530    let head = &bytes[..bytes.len().min(256)];
531    let start = head.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(head.len());
532    let head = &head[start..];
533    let lower = |p: &[u8]| head.len() >= p.len() && head[..p.len()].eq_ignore_ascii_case(p);
534    lower(b"<svg") || lower(b"<?xml")
535}
536
537/// The size an SVG rasterizes at: its root's `width`/`height` (px, or
538/// pt/pc/in/cm/mm/em/ex converted at 96 dpi and a 16 px em; `%` = unset);
539/// a missing one follows the `viewBox` aspect, both missing = the
540/// `viewBox` size, no `viewBox` = 100 (usvg's default). `None` = no `<svg`
541/// root tag.
542pub fn svg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
543    let at = bytes.windows(4).position(|w| w == b"<svg")?;
544    let end = bytes[at..].iter().position(|b| *b == b'>').map_or(bytes.len(), |e| at + e);
545    let tag = String::from_utf8_lossy(&bytes[at + 4..end]);
546    let (mut width, mut height, mut view_box) = (None, None, None);
547    let t = tag.as_bytes();
548    let mut i = 0;
549    while i < t.len() {
550        if !(t[i].is_ascii_alphabetic() || t[i] == b'_' || t[i] == b':') || i == 0 || !t[i - 1].is_ascii_whitespace() {
551            i += 1;
552            continue;
553        }
554        let name_start = i;
555        while i < t.len() && (t[i].is_ascii_alphanumeric() || matches!(t[i], b'_' | b':' | b'-' | b'.')) {
556            i += 1;
557        }
558        let name = &tag[name_start..i];
559        while i < t.len() && t[i].is_ascii_whitespace() {
560            i += 1;
561        }
562        if t.get(i) != Some(&b'=') {
563            continue;
564        }
565        i += 1;
566        while i < t.len() && t[i].is_ascii_whitespace() {
567            i += 1;
568        }
569        let Some(&q) = t.get(i).filter(|q| **q == b'"' || **q == b'\'') else { continue };
570        let value_start = i + 1;
571        let value_end = t[value_start..].iter().position(|b| *b == q).map_or(t.len(), |e| value_start + e);
572        let value = &tag[value_start..value_end];
573        match name {
574            "width" => width = svg_length(value),
575            "height" => height = svg_length(value),
576            "viewBox" => view_box = svg_view_box(value),
577            _ => {}
578        }
579        i = value_end + 1;
580    }
581    let (w, h) = match (width, height, view_box) {
582        (Some(w), Some(h), _) => (w, h),
583        (Some(w), None, Some((vw, vh))) => (w, w * vh / vw),
584        (None, Some(h), Some((vw, vh))) => (h * vw / vh, h),
585        (Some(w), None, None) => (w, 100.0),
586        (None, Some(h), None) => (100.0, h),
587        (None, None, Some((vw, vh))) => (vw, vh),
588        (None, None, None) => (100.0, 100.0),
589    };
590    let px = |v: f64| if v.is_finite() { v.ceil().clamp(0.0, u32::MAX as f64) as u32 } else { u32::MAX };
591    Some((px(w), px(h)))
592}
593
594/// An SVG length in px (`None` = a percentage, unknown unit, negative or
595/// not a number).
596fn svg_length(value: &str) -> Option<f64> {
597    let v = value.trim();
598    let split = v.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))).unwrap_or(v.len());
599    // An exponent `e` followed by no digit belongs to the unit (`em`, `ex`).
600    let (mut num, mut unit) = v.split_at(split);
601    if let Some(stripped) = num.strip_suffix(['e', 'E']) {
602        num = stripped;
603        unit = &v[num.len()..];
604    }
605    let n: f64 = num.parse().ok()?;
606    let scale = match unit.trim() {
607        "" | "px" => 1.0,
608        "pt" => 4.0 / 3.0,
609        "pc" => 16.0,
610        "in" => 96.0,
611        "cm" => 96.0 / 2.54,
612        "mm" => 96.0 / 25.4,
613        "em" => 16.0,
614        "ex" => 8.0,
615        _ => return None,
616    };
617    (n.is_finite() && n >= 0.0).then_some(n * scale)
618}
619
620/// A `viewBox`'s width and height (`None` unless both are > 0).
621fn svg_view_box(value: &str) -> Option<(f64, f64)> {
622    let parts: Vec<f64> = value.split(|c: char| c.is_ascii_whitespace() || c == ',').filter(|p| !p.is_empty()).map(str::parse).collect::<Result<_, _>>().ok()?;
623    match parts[..] {
624        [_, _, w, h] if w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite() => Some((w, h)),
625        _ => None,
626    }
627}
628
629/// The frames an image decodes into: a GIF's image descriptors, an APNG's
630/// `acTL` count, an animated WebP's `ANMF` chunks; 1 otherwise (and for a
631/// truncated header, the frames found).
632pub fn image_frames(bytes: &[u8]) -> u32 {
633    let le32 = |i: usize| bytes.get(i..i + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
634    let be32 = |i: usize| bytes.get(i..i + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize);
635    let frames = if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
636        let skip_blocks = |mut i: usize| -> usize {
637            while let Some(&n) = bytes.get(i) {
638                i += 1 + n as usize;
639                if n == 0 {
640                    break;
641                }
642            }
643            i
644        };
645        let table = |packed: u8| if packed & 0x80 != 0 { 3usize << ((packed & 7) + 1) } else { 0 };
646        let mut i = 13 + bytes.get(10).map_or(0, |p| table(*p));
647        let mut count = 0u32;
648        loop {
649            match bytes.get(i) {
650                Some(0x2c) => {
651                    count = count.saturating_add(1);
652                    let Some(&packed) = bytes.get(i + 9) else { break };
653                    i = skip_blocks(i + 10 + table(packed) + 1);
654                }
655                Some(0x21) => i = skip_blocks(i + 2),
656                _ => break,
657            }
658        }
659        count
660    } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
661        let mut i = 8;
662        let mut count = 1;
663        while let (Some(len), Some(kind)) = (be32(i), bytes.get(i + 4..i + 8)) {
664            if kind == b"acTL" {
665                count = be32(i + 8).map_or(1, |n| n.min(u32::MAX as usize) as u32);
666                break;
667            }
668            if kind == b"IDAT" {
669                break;
670            }
671            i = match i.checked_add(12 + len) {
672                Some(next) => next,
673                None => break,
674            };
675        }
676        count
677    } else if bytes.len() >= 21 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0 {
678        let mut i = 12;
679        let mut count = 0u32;
680        while let (Some(kind), Some(size)) = (bytes.get(i..i + 4), le32(i + 4)) {
681            if kind == b"ANMF" {
682                count = count.saturating_add(1);
683            }
684            i = match i.checked_add(8 + size + (size & 1)) {
685                Some(next) => next,
686                None => break,
687            };
688        }
689        count
690    } else {
691        1
692    };
693    frames.max(1)
694}
695
696/// What a whole image's bytes say before any decode (VAPP-103): no known
697/// image format, a raster (PNG, JPEG, GIF, WebP, BMP) or SVG whose size
698/// cannot be read, or its size and [`image_frames`]. Every renderer's
699/// pixel cap reads this ONE answer (the natives through the FFI).
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum ImageHeader {
702    Unknown,
703    Unreadable,
704    Size { width: u32, height: u32, frames: u32 },
705}
706
707pub fn image_header(bytes: &[u8]) -> ImageHeader {
708    let raster = bytes.starts_with(b"\x89PNG\r\n\x1a\n")
709        || bytes.starts_with(b"GIF87a")
710        || bytes.starts_with(b"GIF89a")
711        || bytes.starts_with(b"BM")
712        || bytes.starts_with(b"\xff\xd8")
713        || (bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP");
714    if !raster && !is_svg(bytes) {
715        return ImageHeader::Unknown;
716    }
717    match image_dimensions(bytes) {
718        Some((width, height)) => ImageHeader::Size { width, height, frames: image_frames(bytes) },
719        None => ImageHeader::Unreadable,
720    }
721}
722
723/// VAPP-103 rfix: the pixel cap on a whole image before it decodes, under
724/// `max_bytes` / `max_pixels`: a raster format or an SVG whose size cannot
725/// be read is REFUSED (`media: image dimensions unreadable`), and width ×
726/// height × [`image_frames`] must fit `max_pixels` (`media is W×H × N
727/// frames, over … pixels`). Bytes of no known image format pass (the
728/// decoder refuses them).
729pub fn media_image_check(bytes: &[u8], max_bytes: u64, max_pixels: u64) -> Result<(), String> {
730    if bytes.len() as u64 > max_bytes {
731        return Err(format!("media is over {max_bytes} bytes"));
732    }
733    match image_header(bytes) {
734        ImageHeader::Unknown => Ok(()),
735        ImageHeader::Unreadable => Err("media: image dimensions unreadable".into()),
736        ImageHeader::Size { width: w, height: h, frames } if frames <= 1 => {
737            if w as u128 * h as u128 > max_pixels as u128 {
738                return Err(format!("media is {w}×{h}, over {max_pixels} pixels"));
739            }
740            Ok(())
741        }
742        ImageHeader::Size { width: w, height: h, frames } => {
743            if w as u128 * h as u128 * frames as u128 > max_pixels as u128 {
744                return Err(format!("media is {w}×{h} × {frames} frames, over {max_pixels} pixels"));
745            }
746            Ok(())
747        }
748    }
749}
750
751/// [`media_image_check`] under the contract's `media.limits`.
752pub fn media_image_within_limits(bytes: &[u8]) -> Result<(), String> {
753    use super::contract::{MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS};
754    media_image_check(bytes, MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS)
755}
756
757/// Whether a load fits `media.limits`: `bytes` read so far (or the
758/// Content-Length) and, once the header is in, the image's pixels.
759pub fn media_within_limits(bytes: u64, dimensions: Option<(u32, u32)>) -> Result<(), String> {
760    use super::contract::{MEDIA_MAX_BYTES, MEDIA_MAX_PIXELS};
761    if bytes > MEDIA_MAX_BYTES {
762        return Err(format!("media is over {MEDIA_MAX_BYTES} bytes"));
763    }
764    if let Some((w, h)) = dimensions {
765        if w as u64 * h as u64 > MEDIA_MAX_PIXELS {
766            return Err(format!("media is {w}×{h}, over {MEDIA_MAX_PIXELS} pixels"));
767        }
768    }
769    Ok(())
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    /// VAPP-103 rfix: SVGs have a size, frames multiply the pixels, and a
777    /// raster whose dimensions cannot be read is refused (it failed open).
778    #[test]
779    fn svg_sizes_animation_frames_and_unreadable_rasters_meet_the_pixel_cap() {
780        assert_eq!(svg_dimensions(br#"<svg xmlns="http://www.w3.org/2000/svg" width="20000" height="20000"/>"#), Some((20_000, 20_000)));
781        assert!(media_image_within_limits(br#"<svg width="20000" height="20000"></svg>"#).unwrap_err().contains("20000×20000"));
782        assert_eq!(svg_dimensions(br#"<?xml version="1.0"?><svg viewBox="0 0 300 150"/>"#), Some((300, 150)));
783        assert_eq!(svg_dimensions(br#"<svg width="100000" viewBox="0 0 1 1">"#), Some((100_000, 100_000)), "a missing height follows the viewBox aspect");
784        assert_eq!(svg_dimensions(br#"<svg width="2in" height="10em">"#), Some((192, 160)));
785        assert_eq!(svg_dimensions(br#"<svg width="50%">"#), Some((100, 100)));
786        assert_eq!(svg_dimensions(b"<svg>"), Some((100, 100)));
787        assert!(media_image_within_limits(br#"<svg width="24" height="24"/>"#).is_ok());
788        assert!(media_image_within_limits(b"<?xml version='1.0'?><note/>").unwrap_err().contains("unreadable"));
789        // A 1000×1000 GIF with 40 frames: 40 Mpx decoded.
790        let mut gif = b"GIF89a".to_vec();
791        gif.extend_from_slice(&1000u16.to_le_bytes());
792        gif.extend_from_slice(&1000u16.to_le_bytes());
793        gif.extend_from_slice(&[0, 0, 0]);
794        for _ in 0..40 {
795            gif.extend_from_slice(&[0x21, 0xf9, 4, 0, 0, 0, 0, 0]);
796            gif.extend_from_slice(&[0x2c, 0, 0, 0, 0, 0xe8, 3, 0xe8, 3, 0, 2, 2, 0x4c, 1, 0]);
797        }
798        gif.push(0x3b);
799        assert_eq!(image_frames(&gif), 40);
800        assert!(media_image_within_limits(&gif).unwrap_err().contains("40 frames"));
801        assert_eq!(image_frames(&gif[..gif.len() / 2]), 20, "a truncated GIF counts the frames it holds");
802        // An APNG announcing 100 frames of 1000×1000.
803        let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
804        png.extend_from_slice(&1000u32.to_be_bytes());
805        png.extend_from_slice(&1000u32.to_be_bytes());
806        png.extend_from_slice(&[8, 6, 0, 0, 0, 0, 0, 0, 0]);
807        png.extend_from_slice(b"\0\0\0\x08acTL");
808        png.extend_from_slice(&100u32.to_be_bytes());
809        png.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]);
810        assert_eq!(image_frames(&png), 100);
811        assert!(media_image_within_limits(&png).is_err());
812        // An animated WebP canvas of 2000×2000 with 10 ANMF chunks.
813        let mut webp = b"RIFF\0\0\0\0WEBPVP8X\x0a\0\0\0\x02\0\0\0".to_vec();
814        webp.extend_from_slice(&1999u32.to_le_bytes()[..3]);
815        webp.extend_from_slice(&1999u32.to_le_bytes()[..3]);
816        for _ in 0..10 {
817            webp.extend_from_slice(b"ANMF\x02\0\0\0ab");
818        }
819        assert_eq!(image_dimensions(&webp), Some((2000, 2000)));
820        assert_eq!(image_frames(&webp), 10);
821        assert!(media_image_within_limits(&webp).unwrap_err().contains("10 frames"));
822        // A raster header too short to read is refused, not passed.
823        assert!(media_image_within_limits(b"\x89PNG\r\n\x1a\n\0\0").unwrap_err().contains("unreadable"));
824        assert!(media_image_within_limits(b"\xff\xd8\xff\xe0").unwrap_err().contains("unreadable"));
825        assert!(media_image_within_limits(b"{\"not\": \"an image\"}").is_ok());
826        assert!(media_image_within_limits(b"GIF89a\x10\0\x20\0").is_ok());
827    }
828
829    #[test]
830    fn image_headers_give_dimensions_without_decoding() {
831        let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
832        png.extend_from_slice(&40_000u32.to_be_bytes());
833        png.extend_from_slice(&30_000u32.to_be_bytes());
834        assert_eq!(image_dimensions(&png), Some((40_000, 30_000)));
835        assert!(media_within_limits(1024, image_dimensions(&png)).is_err());
836        assert_eq!(image_dimensions(b"GIF89a\x10\0\x20\0"), Some((16, 32)));
837        // JPEG: SOI, an APP0 segment, then SOF0 (height 0x0100, width 0x0200).
838        let jpeg = b"\xff\xd8\xff\xe0\0\x04ab\xff\xc0\0\x11\x08\x01\x00\x02\x00\x03";
839        assert_eq!(image_dimensions(jpeg), Some((512, 256)));
840        assert_eq!(image_dimensions(b"<svg/>"), Some((100, 100)));
841        assert!(media_within_limits(crate::host::MEDIA_MAX_BYTES + 1, None).is_err());
842        assert!(media_within_limits(10, Some((100, 100))).is_ok());
843    }
844
845    /// VAPP-107: the literal forms the WHATWG parser leaves behind.
846    #[test]
847    fn private_hosts_are_literals_in_every_normalized_form() {
848        for h in ["localhost", "LOCALHOST.", "app.localhost", "printer.local", "127.0.0.1", "127.255.0.9", "10.1.2.3", "172.16.0.1", "172.31.9.9", "192.168.0.1", "169.254.169.254", "0.0.0.0", "[::1]", "[::]", "[fe80::1]", "[febf::1]", "[fc00::1]", "[fdab:1::2]", "[::ffff:7f00:1]", "[::ffff:c0a8:101]", "100.64.0.1", "100.127.255.255", "[::7f00:1]", "[64:ff9b::7f00:1]", "[2002:7f00:1::]", "[2002:a00:1::1]"] {
849            assert!(is_private_host(h), "{h} is private");
850        }
851        for h in ["exponential.at", "8.8.8.8", "172.32.0.1", "172.15.0.1", "11.0.0.1", "192.169.0.1", "[2001:db8::1]", "[::ffff:808:808]", "[fec0::1]", "localhost.example", "mylocal.example", "256.1.1.1", "1.2.3", "[::1", "[::ffff::1]", "100.63.255.255", "100.128.0.1", "[::808:808]", "[64:ff9b::808:808]", "[2002:808:808::]"] {
852            assert!(!is_private_host(h), "{h} is not private");
853        }
854        assert_eq!(decide_url(None, "http://[::FFFF:127.0.0.1]/").reason.as_deref(), Some("private"));
855        let opt = UrlPolicy { allow_private: Some(vec!["localhost".into()]), ..Default::default() };
856        assert!(decide_url(Some(&opt), "http://localhost:3000/").allowed);
857        assert_eq!(decide_url(Some(&opt), "http://127.0.0.1:3000/").reason.as_deref(), Some("private"));
858    }
859
860    #[test]
861    fn hrefs_pass_the_url_policy() {
862        assert_eq!(safe_href(None, "javascript:alert(1)"), None);
863        assert_eq!(safe_href(None, "https://exponential.at/x").as_deref(), Some("https://exponential.at/x"));
864        let hosts = UrlPolicy { hosts: Some(vec!["exponential.at".into()]), ..Default::default() };
865        assert_eq!(safe_href(Some(&hosts), "https://evil.example/"), None);
866    }
867}