ailloli_ui_icon/
lib.rs

1//! Deterministic, UI-independent validation and rasterization of application SVG icons.
2//!
3//! Application icons have stricter size, square-shape, external-resource, and
4//! visible-alpha requirements than the legacy arbitrary-SVG adapter. Successful
5//! rasters are square premultiplied RGBA8 buffers. The process performs no network
6//! or filesystem I/O through this crate.
7//!
8//! # Examples
9//!
10//! ```
11//! use ailloli_ui_core::AppIcon;
12//! use ailloli_ui_icon::{rasterize_app_icon, validate_app_icon};
13//! const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#;
14//! let icon = AppIcon::from_static_svg(SVG, "assets/icon.svg");
15//! assert_eq!(validate_app_icon(&icon).unwrap().width, 10.0);
16//! assert_eq!(rasterize_app_icon(&icon, 32).unwrap().rgba.len(), 32 * 32 * 4);
17//! ```
18
19use ailloli_ui_core::{AppIcon, SvgSource};
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex, OnceLock};
22
23/// Maximum accepted application-icon source length: 4 MiB (4,194,304 bytes).
24///
25/// The boundary itself is accepted; only larger sources return
26/// [`IconError::TooLarge`].
27///
28/// # Examples
29///
30/// ```
31/// use ailloli_ui_icon::MAX_SVG_BYTES;
32/// assert_eq!(MAX_SVG_BYTES, 4_194_304);
33/// ```
34pub const MAX_SVG_BYTES: usize = 4 * 1024 * 1024;
35/// Maximum accepted SVG view-box and parsed-tree dimension, in SVG/CSS pixels.
36///
37/// Exactly 8,192 is accepted; larger width or height is rejected.
38///
39/// # Examples
40///
41/// ```
42/// use ailloli_ui_icon::MAX_SVG_DIMENSION;
43/// assert_eq!(MAX_SVG_DIMENSION, 8_192.0);
44/// ```
45pub const MAX_SVG_DIMENSION: f32 = 8192.0;
46/// Maximum relative width/height delta treated as numerically square.
47///
48/// Validation computes `abs(width - height) / max(width, height)` in `f64` and
49/// rejects only values strictly greater than `1e-6`.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_icon::SQUARE_RELATIVE_EPSILON;
55/// assert_eq!(SQUARE_RELATIVE_EPSILON, 0.000_001);
56/// ```
57pub const SQUARE_RELATIVE_EPSILON: f64 = 1.0e-6;
58
59/// Metadata produced after source, geometry, parse, and visible-alpha validation.
60///
61/// `width` and `height` are the parsed [`usvg`] tree size in SVG/CSS pixels,
62/// which may differ from raw view-box values due to root sizing rules. Public
63/// construction permits arbitrary strings, infinities, and NaNs; only values
64/// returned by [`validate_app_icon`] carry the validation guarantee.
65///
66/// # Examples
67///
68/// ```
69/// use ailloli_ui_core::AppIcon;
70/// use ailloli_ui_icon::validate_app_icon;
71/// const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path d="M0 0h2v2H0z"/></svg>"#;
72/// let validated = validate_app_icon(&AppIcon::from_static_svg(SVG, "icon.svg")).unwrap();
73/// assert_eq!((validated.width, validated.height), (2.0, 2.0));
74/// assert_eq!(validated.sha256.len(), 64);
75/// ```
76#[derive(Debug, Clone, PartialEq)]
77pub struct ValidatedIcon {
78    /// Lowercase 64-character SHA-256 digest of the exact source bytes.
79    pub sha256: String,
80    /// Parsed tree width in SVG/CSS pixels.
81    pub width: f32,
82    /// Parsed tree height in SVG/CSS pixels.
83    pub height: f32,
84}
85
86/// Square premultiplied RGBA8 raster output.
87///
88/// Pixels are row-major, four bytes per pixel in red, green, blue, alpha order.
89/// Constructor-produced length is `width * height * 4`, and both dimensions are
90/// equal and at least one. Public fields can bypass all of those invariants.
91/// Cloning shares the allocation through [`Arc`].
92///
93/// # Examples
94///
95/// ```
96/// use ailloli_ui_core::AppIcon;
97/// use ailloli_ui_icon::rasterize_app_icon;
98/// const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1" fill="red"/></svg>"#;
99/// let raster = rasterize_app_icon(&AppIcon::from_static_svg(SVG, "icon.svg"), 8).unwrap();
100/// assert_eq!((raster.width, raster.height, raster.rgba.len()), (8, 8, 256));
101/// ```
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct RasterizedIcon {
104    /// Raster width in pixels; constructor-produced values are `px.max(1)`.
105    pub width: u32,
106    /// Raster height in pixels, equal to [`Self::width`] for produced values.
107    pub height: u32,
108    /// Shared row-major premultiplied RGBA8 bytes.
109    pub rgba: Arc<[u8]>,
110}
111
112/// Application-icon validation and raster allocation failures.
113///
114/// XML/SVG/backend messages and rejected resource strings are included verbatim
115/// and can vary with dependency versions. Match variants for control flow.
116///
117/// # Examples
118///
119/// ```
120/// use ailloli_ui_icon::IconError;
121/// assert_eq!(IconError::MissingViewBox.to_string(), "application icon SVG must declare a positive viewBox");
122/// ```
123#[derive(Debug, thiserror::Error)]
124pub enum IconError {
125    /// Source length is strictly greater than [`MAX_SVG_BYTES`].
126    #[error("SVG source exceeds the 4 MiB application icon limit")]
127    TooLarge,
128    /// Source is not UTF-8, is malformed XML, or its root is not `<svg>`.
129    #[error("application icon SVG is not valid UTF-8/XML: {0}")]
130    InvalidXml(String),
131    /// View box is absent/malformed, not four numbers, or has nonpositive size.
132    #[error("application icon SVG must declare a positive viewBox")]
133    MissingViewBox,
134    /// Relative view-box width/height delta exceeds square tolerance.
135    #[error("application icon SVG viewBox must be square (got {0}x{1})")]
136    NonSquare(f64, f64),
137    /// A forbidden element, reference, or recognized external CSS value was found.
138    #[error("application icon SVG contains forbidden element or external resource `{0}`")]
139    ExternalResource(String),
140    /// Raw view box or parsed tree width/height exceeds 8,192 pixels.
141    #[error("application icon SVG dimensions exceed {MAX_SVG_DIMENSION}px")]
142    DimensionsTooLarge,
143    /// [`usvg`] rejected otherwise structurally prevalidated bytes.
144    #[error("application icon SVG could not be parsed: {0}")]
145    InvalidSvg(String),
146    /// A 64-by-64 validation preview contained no pixel with nonzero alpha.
147    #[error("application icon SVG rendered fully transparent")]
148    FullyTransparent,
149    /// Square RGBA pixmap allocation failed for the normalized side length.
150    #[error("failed to allocate a {0}px application icon raster")]
151    Allocation(u32),
152}
153
154/// Parses positive view-box dimensions and rejects recognized active resources.
155///
156/// XML element matching is exact (`script`, `image`, `foreignObject`). Nonempty
157/// `href`/`xlink:href` values must start with `#`. Every attribute is also scanned
158/// case-insensitively for exact substrings `url(http:`, `url(https:`, `url(file:`,
159/// or `@import`. This is a deliberately specific syntactic screen, followed by
160/// `usvg` parsing; it is not a general sanitizer for arbitrary SVG consumers.
161///
162/// # Errors
163///
164/// Returns [`IconError::InvalidXml`] for non-UTF-8, malformed XML, or a non-SVG
165/// root; [`IconError::ExternalResource`] for a recognized active element or
166/// external reference; and [`IconError::MissingViewBox`] when the view box is
167/// absent, malformed, not four values, or has a non-positive extent.
168fn parse_view_box(bytes: &[u8]) -> Result<(f64, f64), IconError> {
169    let text = std::str::from_utf8(bytes).map_err(|err| IconError::InvalidXml(err.to_string()))?;
170    let doc =
171        roxmltree::Document::parse(text).map_err(|err| IconError::InvalidXml(err.to_string()))?;
172    let root = doc.root_element();
173    if root.tag_name().name() != "svg" {
174        return Err(IconError::InvalidXml(
175            "root element is not <svg>".to_string(),
176        ));
177    }
178    for node in doc.descendants().filter(|node| node.is_element()) {
179        let name = node.tag_name().name();
180        if matches!(name, "script" | "image" | "foreignObject") {
181            return Err(IconError::ExternalResource(name.to_string()));
182        }
183        for attribute in node.attributes() {
184            let value = attribute.value().trim();
185            let lower = value.to_ascii_lowercase();
186            if matches!(attribute.name(), "href" | "xlink:href")
187                && !value.is_empty()
188                && !value.starts_with('#')
189            {
190                return Err(IconError::ExternalResource(value.to_string()));
191            }
192            if lower.contains("url(http:")
193                || lower.contains("url(https:")
194                || lower.contains("url(file:")
195                || lower.contains("@import")
196            {
197                return Err(IconError::ExternalResource(value.to_string()));
198            }
199        }
200    }
201    let values: Vec<f64> = root
202        .attribute("viewBox")
203        .or_else(|| root.attribute("viewbox"))
204        .ok_or(IconError::MissingViewBox)?
205        .split(|ch: char| ch.is_ascii_whitespace() || ch == ',')
206        .filter(|value| !value.is_empty())
207        .map(|value| value.parse::<f64>().map_err(|_| IconError::MissingViewBox))
208        .collect::<Result<_, _>>()?;
209    if values.len() != 4 || values[2] <= 0.0 || values[3] <= 0.0 {
210        return Err(IconError::MissingViewBox);
211    }
212    Ok((values[2], values[3]))
213}
214
215/// Validates source size, XML/resource structure, square geometry, and visibility.
216///
217/// Validation accepts at most 4 MiB, requires an exact `viewBox` or `viewbox`
218/// attribute with four comma/ASCII-whitespace-separated `f64` values and positive
219/// width/height, enforces relative squareness and both raw/parsed 8,192-pixel
220/// limits, parses through [`usvg`], then requires nonzero alpha in a 64-pixel
221/// preview. View-box origins may be any parseable numbers. The returned digest
222/// covers exact source bytes; the `AppIcon` source-path label is irrelevant.
223///
224/// # Errors
225///
226/// Returns the corresponding [`IconError`] at the first failed stage, including
227/// preview allocation. Error strings can embed parser text or rejected attributes.
228///
229/// # Examples
230///
231/// ```
232/// use ailloli_ui_core::AppIcon;
233/// use ailloli_ui_icon::{validate_app_icon, IconError};
234/// const GOOD: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"><circle cx="2" cy="2" r="2"/></svg>"#;
235/// let validated = validate_app_icon(&AppIcon::from_static_svg(GOOD, "good.svg")).unwrap();
236/// assert_eq!((validated.width, validated.height), (4.0, 4.0));
237/// const BAD: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 2"><rect width="4" height="2"/></svg>"#;
238/// assert!(matches!(
239///     validate_app_icon(&AppIcon::from_static_svg(BAD, "bad.svg")),
240///     Err(IconError::NonSquare(width, height)) if width == 4.0 && height == 2.0
241/// ));
242/// ```
243pub fn validate_app_icon(icon: &AppIcon) -> Result<ValidatedIcon, IconError> {
244    if icon.bytes().len() > MAX_SVG_BYTES {
245        return Err(IconError::TooLarge);
246    }
247    let (view_width, view_height) = parse_view_box(icon.bytes())?;
248    let relative_delta = (view_width - view_height).abs() / view_width.max(view_height);
249    if relative_delta > SQUARE_RELATIVE_EPSILON {
250        return Err(IconError::NonSquare(view_width, view_height));
251    }
252    if view_width > MAX_SVG_DIMENSION as f64 || view_height > MAX_SVG_DIMENSION as f64 {
253        return Err(IconError::DimensionsTooLarge);
254    }
255    let tree = usvg::Tree::from_data(icon.bytes(), &usvg::Options::default())
256        .map_err(|err| IconError::InvalidSvg(err.to_string()))?;
257    let size = tree.size();
258    if size.width() > MAX_SVG_DIMENSION || size.height() > MAX_SVG_DIMENSION {
259        return Err(IconError::DimensionsTooLarge);
260    }
261    let preview = rasterize_tree(&tree, 64)?;
262    if !preview.rgba.chunks_exact(4).any(|pixel| pixel[3] != 0) {
263        return Err(IconError::FullyTransparent);
264    }
265    Ok(ValidatedIcon {
266        sha256: icon.sha256(),
267        width: size.width(),
268        height: size.height(),
269    })
270}
271
272/// Uniformly scales and centers a parsed tree into a transparent square pixmap.
273///
274/// `px` clamps to one. Aspect ratio is preserved and unused space stays
275/// transparent. Output uses tiny-skia's premultiplied RGBA byte order. This
276/// helper does not check source policy, squareness, or visible alpha.
277///
278/// # Errors
279///
280/// Returns [`IconError::Allocation`] when tiny-skia cannot allocate the clamped
281/// square pixmap.
282fn rasterize_tree(tree: &usvg::Tree, px: u32) -> Result<RasterizedIcon, IconError> {
283    let px = px.max(1);
284    let mut pixmap = tiny_skia::Pixmap::new(px, px).ok_or(IconError::Allocation(px))?;
285    let size = tree.size();
286    let scale = px as f32 / size.width().max(size.height());
287    let offset_x = (px as f32 - size.width() * scale) * 0.5;
288    let offset_y = (px as f32 - size.height() * scale) * 0.5;
289    let transform =
290        tiny_skia::Transform::from_translate(offset_x, offset_y).post_scale(scale, scale);
291    resvg::render(tree, transform, &mut pixmap.as_mut());
292    Ok(RasterizedIcon {
293        width: px,
294        height: px,
295        rgba: Arc::from(pixmap.data().to_vec()),
296    })
297}
298
299/// Process-global, lazily initialized, unbounded digest-and-size raster cache.
300static RASTER_CACHE: OnceLock<Mutex<HashMap<(String, u32), RasterizedIcon>>> = OnceLock::new();
301
302/// Validates and rasterizes an app icon, caching by source digest and side length.
303///
304/// `px` clamps to one for both key and output. Validation (including a 64-pixel
305/// preview) runs before every cache lookup. On a miss, source bytes are parsed a
306/// second time for final rendering. Sequential cache hits clone the
307/// [`RasterizedIcon`] and share its pixel [`Arc`]; concurrent misses can render
308/// redundantly and return different allocations before one cached value wins.
309/// The process-global cache has no eviction or byte/count bound.
310///
311/// # Errors
312///
313/// Returns any [`validate_app_icon`] error, a second-stage [`IconError::InvalidSvg`],
314/// or [`IconError::Allocation`] for the requested normalized raster size.
315///
316/// # Panics
317///
318/// Panics with `"app icon cache poisoned"` if another thread poisons the global
319/// cache mutex. Allocation failures may also abort rather than return depending
320/// on the allocator.
321///
322/// # Examples
323///
324/// ```
325/// use std::sync::Arc;
326/// use ailloli_ui_core::AppIcon;
327/// use ailloli_ui_icon::rasterize_app_icon;
328/// const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>"#;
329/// let icon = AppIcon::from_static_svg(SVG, "icon.svg");
330/// let first = rasterize_app_icon(&icon, 0).unwrap();
331/// let second = rasterize_app_icon(&icon, 1).unwrap();
332/// assert_eq!((first.width, first.height), (1, 1));
333/// assert!(Arc::ptr_eq(&first.rgba, &second.rgba));
334/// ```
335pub fn rasterize_app_icon(icon: &AppIcon, px: u32) -> Result<RasterizedIcon, IconError> {
336    let validated = validate_app_icon(icon)?;
337    let key = (validated.sha256, px.max(1));
338    let cache = RASTER_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
339    if let Some(raster) = cache
340        .lock()
341        .expect("app icon cache poisoned")
342        .get(&key)
343        .cloned()
344    {
345        return Ok(raster);
346    }
347    let tree = usvg::Tree::from_data(icon.bytes(), &usvg::Options::default())
348        .map_err(|err| IconError::InvalidSvg(err.to_string()))?;
349    let raster = rasterize_tree(&tree, key.1)?;
350    cache
351        .lock()
352        .expect("app icon cache poisoned")
353        .insert(key, raster.clone());
354    Ok(raster)
355}
356
357/// Rasterizes an arbitrary legacy SVG source without application-icon validation.
358///
359/// This adapter bypasses source-byte, resource-syntax, square-shape, dimension,
360/// visible-alpha, and cache checks. It uses default [`usvg`] parsing and returns
361/// a newly allocated, square, premultiplied RGBA8 vector; `px == 0` becomes one.
362/// Invalid SVG or raster allocation failure returns `None` with no diagnostic.
363/// Consumers requiring the application-icon security/portability policy must use
364/// [`rasterize_app_icon`] instead.
365///
366/// # Examples
367///
368/// ```
369/// use ailloli_ui_core::SvgSource;
370/// use ailloli_ui_icon::rasterize_svg_source;
371/// let source = SvgSource::Static(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 1"><rect width="2" height="1"/></svg>"#);
372/// assert_eq!(rasterize_svg_source(&source, 3).unwrap().len(), 3 * 3 * 4);
373/// assert!(rasterize_svg_source(&SvgSource::Static(b"not svg"), 3).is_none());
374/// ```
375pub fn rasterize_svg_source(source: &SvgSource, px: u32) -> Option<Vec<u8>> {
376    let tree = usvg::Tree::from_data(source.as_bytes(), &usvg::Options::default()).ok()?;
377    rasterize_tree(&tree, px)
378        .ok()
379        .map(|raster| raster.rgba.to_vec())
380}
381
382#[cfg(test)]
383/// Unit tests for validation, tolerance, security filtering, visibility, and cache sharing.
384mod tests {
385    use super::*;
386
387    /// Wraps static fixture bytes with the conventional test source-path label.
388    fn icon(svg: &'static [u8]) -> AppIcon {
389        AppIcon::from_static_svg(svg, "src/assets/icons/icon.svg")
390    }
391
392    #[test]
393    fn validates_and_upscales_visible_square_svg() {
394        let source = icon(br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="#ff0000"/></svg>"##);
395        let validated = validate_app_icon(&source).unwrap();
396        assert_eq!((validated.width, validated.height), (10.0, 10.0));
397        let raster = rasterize_app_icon(&source, 256).unwrap();
398        assert_eq!(raster.rgba.len(), 256 * 256 * 4);
399        assert!(raster
400            .rgba
401            .chunks_exact(4)
402            .any(|pixel| pixel[0] > 200 && pixel[3] > 0));
403    }
404
405    #[test]
406    fn permits_only_numerical_square_tolerance() {
407        let source = icon(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 79.375001 79.374999"><rect width="100%" height="100%"/></svg>"#);
408        validate_app_icon(&source).unwrap();
409        let source = icon(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 99"><rect width="100" height="99"/></svg>"#);
410        assert!(matches!(
411            validate_app_icon(&source),
412            Err(IconError::NonSquare(..))
413        ));
414    }
415
416    #[test]
417    fn rejects_active_external_and_transparent_sources() {
418        for svg in [
419            br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><script/></svg>"#.as_slice(),
420            br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="https://example.test/a.png"/></svg>"#.as_slice(),
421            br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#.as_slice(),
422        ] {
423            assert!(validate_app_icon(&icon(svg)).is_err());
424        }
425    }
426
427    #[test]
428    fn raster_cache_reuses_the_same_pixel_allocation() {
429        let source = icon(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#);
430        let first = rasterize_app_icon(&source, 32).unwrap();
431        let second = rasterize_app_icon(&source, 32).unwrap();
432        assert!(Arc::ptr_eq(&first.rgba, &second.rgba));
433    }
434}