Skip to main content

dig_urn_resolver/
images.rs

1//! Branded error IMAGES for the `<img src>` path ([`crate::wasm::DigNetwork::resolve_image_url`]).
2//!
3//! An `<img>` tag cannot render the HTML error documents in [`crate::pages`] (it
4//! would show a broken-image icon), so the image path degrades to a branded, square
5//! **SVG** per failure — icon + short label + `DIG NETWORK` wordmark, in the DIG
6//! dark brand. Each is a static `const`-built string embedded in the crate and
7//! returned as a `data:image/svg+xml;base64,…` URI: NO external fetch (CSP-safe,
8//! offline-safe), and — critically — for an integrity failure the image is a STATIC
9//! placeholder, so no tampered/unverified byte can ever become the `<img>` content.
10//!
11//! The SVGs are intentional (icon + label + wordmark), legible when scaled down to a
12//! typical NFT thumbnail, and reuse the same palette as [`crate::pages`].
13
14use crate::error::ResolveError;
15use crate::resolver::ResolveOutcome;
16use base64::Engine;
17
18/// The DIG brand font stack (system fonts — no external font fetch).
19const FONT: &str = "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif";
20
21/// Which branded error image to show.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ErrorImage {
24    /// Served bytes failed verification (tampered / decoy). Red security treatment.
25    Integrity,
26    /// No node + gateway reachable. Blue, retryable, with a connect-a-node hint.
27    Unreachable,
28    /// The resource does not exist.
29    NotFound,
30    /// The input was not a resolvable DIG URN (bad URN, or rootless over the gateway).
31    InvalidUrn,
32    /// Any other failure.
33    Generic,
34}
35
36/// The branded image for a non-success [`ResolveOutcome`] (only ever called for a
37/// non-success variant).
38pub fn for_outcome(outcome: &ResolveOutcome) -> ErrorImage {
39    match outcome {
40        ResolveOutcome::Success(_) => ErrorImage::Generic, // never used for Success
41        ResolveOutcome::IntegrityFailure => ErrorImage::Integrity,
42        ResolveOutcome::Unreachable => ErrorImage::Unreachable,
43    }
44}
45
46/// The branded image for a hard [`ResolveError`].
47pub fn for_error(err: &ResolveError) -> ErrorImage {
48    match err {
49        ResolveError::Parse(_) | ResolveError::RootRequired => ErrorImage::InvalidUrn,
50        ResolveError::NotFound => ErrorImage::NotFound,
51        // Transport never escapes `resolve` (it becomes `Unreachable`), but map it
52        // sensibly for completeness.
53        ResolveError::Transport(_) => ErrorImage::Unreachable,
54        ResolveError::VerifyFailed(_) | ResolveError::DecryptFailed => ErrorImage::Integrity,
55        ResolveError::Rpc(_) => ErrorImage::Generic,
56    }
57}
58
59/// Per-image treatment: accent colour, background glow, the icon glyph symbol, the
60/// title, and an optional subtitle.
61fn treatment(
62    kind: ErrorImage,
63) -> (
64    &'static str,
65    &'static str,
66    &'static str,
67    &'static str,
68    &'static str,
69) {
70    match kind {
71        // (accent, glow, glyph, title, subtitle)
72        ErrorImage::Integrity => (
73            "#e5484d",
74            "#3a1416",
75            "!",
76            "Verification failed",
77            "Content could not be trusted",
78        ),
79        ErrorImage::Unreachable => (
80            "#3b6cf6",
81            "#14213a",
82            "\u{21bb}",
83            "Network unreachable",
84            "Connect a DIG node",
85        ),
86        ErrorImage::NotFound => ("#7f8db3", "#141a2e", "?", "Content not found", ""),
87        ErrorImage::InvalidUrn => ("#7f8db3", "#141a2e", "!", "Invalid DIG URN", ""),
88        ErrorImage::Generic => ("#7f8db3", "#141a2e", "!", "Something went wrong", ""),
89    }
90}
91
92/// The PRERENDERED PNG bytes for an error image (512×512), embedded at build time.
93///
94/// These are rasterized ONCE at authoring from [`svg`] (via `resvg`, see
95/// `examples/render_error_images.rs`) and committed under `assets/` — there is NO
96/// runtime SVG-raster dependency, and a raster PNG renders deterministically in any
97/// `<img>` (some CSP policies block `data:image/svg+xml`). Each is a STATIC
98/// placeholder per failure kind, so it can never carry tampered/unverified content.
99pub fn png(kind: ErrorImage) -> &'static [u8] {
100    match kind {
101        ErrorImage::Integrity => include_bytes!("../assets/error-integrity.png"),
102        ErrorImage::Unreachable => include_bytes!("../assets/error-unreachable.png"),
103        ErrorImage::NotFound => include_bytes!("../assets/error-not-found.png"),
104        ErrorImage::InvalidUrn => include_bytes!("../assets/error-invalid-urn.png"),
105        ErrorImage::Generic => include_bytes!("../assets/error-generic.png"),
106    }
107}
108
109/// The branded image as a `data:image/png;base64,…` URI, ready for an `<img src>`.
110pub fn data_uri(kind: ErrorImage) -> String {
111    let b64 = base64::engine::general_purpose::STANDARD.encode(png(kind));
112    format!("data:image/png;base64,{b64}")
113}
114
115/// Render the branded, square SVG for a given error image (viewBox 512×512) — the
116/// DESIGN SOURCE the committed PNGs are rasterized from (see [`png`]).
117pub fn svg(kind: ErrorImage) -> String {
118    let (accent, glow, glyph, title, subtitle) = treatment(kind);
119    let subtitle_el = if subtitle.is_empty() {
120        String::new()
121    } else {
122        format!(
123            "<text x='256' y='372' text-anchor='middle' font-family='{FONT}' font-size='26' fill='#aeb7d4'>{subtitle}</text>"
124        )
125    };
126    format!(
127        r##"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512' width='512' height='512' role='img' aria-label='DIG Network: {title}'>
128  <defs><radialGradient id='bg' cx='50%' cy='0%' r='120%'>
129    <stop offset='0%' stop-color='{glow}'/><stop offset='60%' stop-color='#0a0f1e'/><stop offset='100%' stop-color='#070a14'/>
130  </radialGradient></defs>
131  <rect width='512' height='512' fill='url(#bg)'/>
132  <circle cx='256' cy='190' r='68' fill='none' stroke='{accent}' stroke-width='8'/>
133  <text x='256' y='226' text-anchor='middle' font-family='{FONT}' font-size='84' font-weight='700' fill='{accent}'>{glyph}</text>
134  <text x='256' y='320' text-anchor='middle' font-family='{FONT}' font-size='40' font-weight='700' fill='#ffffff'>{title}</text>
135  {subtitle_el}
136  <text x='256' y='452' text-anchor='middle' font-family='{FONT}' font-size='22' font-weight='700' letter-spacing='6' fill='{accent}'>DIG NETWORK</text>
137  <text x='256' y='478' text-anchor='middle' font-family='{FONT}' font-size='16' letter-spacing='2' fill='#7f8db3'>dig.net</text>
138</svg>"##
139    )
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use base64::Engine;
146
147    const PNG_MAGIC: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
148
149    #[test]
150    fn every_kind_embeds_a_png_data_uri() {
151        for kind in [
152            ErrorImage::Integrity,
153            ErrorImage::Unreachable,
154            ErrorImage::NotFound,
155            ErrorImage::InvalidUrn,
156            ErrorImage::Generic,
157        ] {
158            // The embedded bytes are real PNGs (magic bytes).
159            assert!(png(kind).starts_with(PNG_MAGIC), "PNG magic present");
160            let uri = data_uri(kind);
161            assert!(uri.starts_with("data:image/png;base64,"));
162            // The decoded payload round-trips back to the PNG bytes.
163            let b64 = uri.trim_start_matches("data:image/png;base64,");
164            let decoded = base64::engine::general_purpose::STANDARD
165                .decode(b64)
166                .unwrap();
167            assert!(decoded.starts_with(PNG_MAGIC));
168        }
169    }
170
171    #[test]
172    fn svg_design_source_is_labelled_and_square() {
173        for kind in [
174            ErrorImage::Integrity,
175            ErrorImage::Unreachable,
176            ErrorImage::NotFound,
177            ErrorImage::InvalidUrn,
178            ErrorImage::Generic,
179        ] {
180            let svg = svg(kind);
181            assert!(svg.starts_with("<svg"));
182            assert!(svg.contains("DIG NETWORK"), "wordmark present");
183            assert!(svg.contains("viewBox='0 0 512 512'"), "square + scalable");
184        }
185    }
186
187    #[test]
188    fn labels_match_outcome() {
189        assert!(svg(ErrorImage::Integrity).contains("Verification failed"));
190        assert!(svg(ErrorImage::Unreachable).contains("Network unreachable"));
191        assert!(svg(ErrorImage::Unreachable).contains("Connect a DIG node"));
192        assert!(svg(ErrorImage::NotFound).contains("Content not found"));
193        assert!(svg(ErrorImage::InvalidUrn).contains("Invalid DIG URN"));
194        assert!(svg(ErrorImage::Generic).contains("Something went wrong"));
195    }
196
197    #[test]
198    fn outcome_and_error_mapping() {
199        assert_eq!(
200            for_outcome(&ResolveOutcome::IntegrityFailure),
201            ErrorImage::Integrity
202        );
203        assert_eq!(
204            for_outcome(&ResolveOutcome::Unreachable),
205            ErrorImage::Unreachable
206        );
207        assert_eq!(for_error(&ResolveError::NotFound), ErrorImage::NotFound);
208        assert_eq!(
209            for_error(&ResolveError::Parse("x".into())),
210            ErrorImage::InvalidUrn
211        );
212        assert_eq!(
213            for_error(&ResolveError::RootRequired),
214            ErrorImage::InvalidUrn
215        );
216        assert_eq!(
217            for_error(&ResolveError::Rpc("x".into())),
218            ErrorImage::Generic
219        );
220        assert_eq!(
221            for_error(&ResolveError::DecryptFailed),
222            ErrorImage::Integrity
223        );
224    }
225
226    #[test]
227    fn integrity_image_is_static_never_carries_bytes() {
228        // The integrity image is a constant embedded PNG — it can NEVER contain any
229        // (tampered) resource bytes, so no unverified byte reaches the <img>.
230        let uri = data_uri(ErrorImage::Integrity);
231        assert!(!uri.contains("TAMPERED_SECRET_PAYLOAD"));
232        // Identical regardless of any surrounding context — it is a pure constant.
233        assert_eq!(uri, data_uri(ErrorImage::Integrity));
234        assert_eq!(png(ErrorImage::Integrity), png(ErrorImage::Integrity));
235    }
236}