Skip to main content

webserver_base/assets/
validate.rs

1//! Boot-time proof that every asset a page will reference actually resolves.
2//!
3//! Resolving a missing asset to its un-hashed path would produce a link that
4//! 404s for the visitor and nothing at all for us — the worst kind of failure,
5//! because it is invisible from the inside. So the check happens once, at
6//! start-up, and reports *every* miss at once.
7//!
8//! Start-up rather than per-request on purpose: a failed request means a visitor
9//! sees a broken page, while a failed boot means the deploy never cuts over and
10//! the previous container keeps serving. Same signal, no outage — and locally it
11//! surfaces the moment you run the server.
12//!
13//! This covers everything *declared*: site-wide and per-page stylesheets and
14//! scripts, the social image, and the generated icon set. Assets a handler looks
15//! up dynamically at request time cannot be enumerated here, and remain the
16//! project's responsibility.
17
18use std::fmt::Write as _;
19use std::path::{Path, PathBuf};
20
21use super::cache_buster::CacheBuster;
22use super::error::CacheBusterError;
23use super::generate::FAVICON_DIRECTORY;
24use super::icons::{self, ALLOWED_FAVICON_FILES, DERIVED, IconSource};
25
26/// Checks that every declared asset is in the manifest.
27///
28/// An absolute URL is skipped: a CDN stylesheet will never be in the manifest,
29/// and that is the one legitimate reason for a path to be absent.
30///
31/// # Errors
32///
33/// [`CacheBusterError::UnresolvedAssets`], naming every miss.
34pub fn validate_declared(
35    cache_buster: &CacheBuster,
36    declared: &[String],
37) -> Result<(), CacheBusterError> {
38    let missing: Vec<&String> = declared
39        .iter()
40        .filter(|path| !is_external(path))
41        .filter(|path| !cache_buster.is_hashed(path))
42        .collect();
43
44    if missing.is_empty() {
45        return Ok(());
46    }
47
48    let mut listed: String = String::new();
49    for path in &missing {
50        // Writing into a String cannot fail.
51        let _ = write!(listed, "\n  - {path}");
52    }
53
54    Err(CacheBusterError::UnresolvedAssets {
55        count: missing.len(),
56        missing: listed,
57    })
58}
59
60/// Checks the icon set: one source, and every derived file present and exactly
61/// the size it claims to be.
62///
63/// Returns which source the project authored, because the layout links an SVG
64/// icon only when there is one.
65///
66/// # Errors
67///
68/// [`CacheBusterError`] if the source is missing, ambiguous, or wrongly sized,
69/// or if a derived icon is absent or the wrong size.
70pub fn validate_icons(cache_buster: &CacheBuster) -> Result<IconSource, CacheBusterError> {
71    validate_favicon_directory(cache_buster.root())?;
72
73    let source: IconSource =
74        icons::resolve_source_in(cache_buster.root(), cache_buster.manifest())?;
75
76    for icon in &DERIVED {
77        let logical: String = format!("{FAVICON_DIRECTORY}/{}", icon.file_name);
78        let path: PathBuf = cache_buster.file(&logical);
79
80        if !path.is_file() {
81            return Err(CacheBusterError::IconMismatch {
82                path: logical,
83                expected: icon.size,
84                found: String::from("missing"),
85            });
86        }
87
88        // Only the PNGs are dimension-checked. An `.ico` is a container that may
89        // legitimately hold several sizes — a hand-supplied one usually does —
90        // so a single expected number would be wrong for it.
91        if !is_png(&path) {
92            continue;
93        }
94
95        let (width, height) = icons::dimensions(&path)?;
96        if width != icon.size || height != icon.size {
97            return Err(CacheBusterError::IconMismatch {
98                path: logical,
99                expected: icon.size,
100                found: format!("{width}x{height}"),
101            });
102        }
103    }
104
105    Ok(source)
106}
107
108/// Rejects anything in the icon directory that is neither a source nor derived.
109///
110/// A stale `favicon-16.png` from a previous design, or a size somebody dropped
111/// in expecting it to be picked up, is invisible until a wrong picture shows up
112/// in a browser tab. A closed set makes that a boot failure instead.
113fn validate_favicon_directory(root: &Path) -> Result<(), CacheBusterError> {
114    let directory: PathBuf = root.join(FAVICON_DIRECTORY);
115    let Ok(entries) = std::fs::read_dir(&directory) else {
116        // Absent entirely is a missing *source*, which says something more
117        // useful than "the directory has odd contents".
118        return Ok(());
119    };
120
121    let mut unexpected: Vec<String> = Vec::new();
122    for entry in entries.flatten() {
123        let path: PathBuf = entry.path();
124        if path.is_dir() {
125            continue;
126        }
127        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
128            continue;
129        };
130        if !ALLOWED_FAVICON_FILES.contains(&logical_name(name).as_str()) {
131            unexpected.push(name.to_string());
132        }
133    }
134
135    if unexpected.is_empty() {
136        return Ok(());
137    }
138
139    unexpected.sort();
140    let mut listed: String = String::new();
141    for name in &unexpected {
142        // Writing into a String cannot fail.
143        let _ = write!(listed, "\n  - {name}");
144    }
145
146    Err(CacheBusterError::UnexpectedFavicons {
147        directory: FAVICON_DIRECTORY,
148        count: unexpected.len(),
149        unexpected: listed,
150        allowed: ALLOWED_FAVICON_FILES.join(", "),
151    })
152}
153
154/// `favicon.a1b2….svg` → `favicon.svg`.
155///
156/// The directory is checked after hashing, so the names on disk carry a content
157/// hash the allowed-set does not.
158fn logical_name(name: &str) -> String {
159    let parts: Vec<&str> = name.split('.').collect();
160    parts
161        .into_iter()
162        .filter(|segment| {
163            !(segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit()))
164        })
165        .collect::<Vec<&str>>()
166        .join(".")
167}
168
169/// Whether a path points somewhere this server does not serve.
170fn is_external(path: &str) -> bool {
171    path.starts_with("http://") || path.starts_with("https://")
172}
173
174fn is_png(path: &Path) -> bool {
175    path.extension()
176        .is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::{is_external, is_png, logical_name};
182
183    #[test]
184    fn a_cdn_url_is_external_and_therefore_exempt() {
185        assert!(is_external("https://cdnjs.cloudflare.com/a/b.css"));
186        assert!(is_external("http://example.com/a.js"));
187        assert!(!is_external("static/stylesheet/main.css"));
188        assert!(!is_external("/static/stylesheet/main.css"));
189    }
190
191    #[test]
192    fn a_hashed_name_reduces_to_the_one_the_allowed_set_lists() {
193        assert_eq!(
194            String::from("favicon.svg"),
195            logical_name("favicon.aa676972bbd2b68e94ef8e91e81d20be.svg")
196        );
197        assert_eq!(
198            String::from("favicon-512.png"),
199            logical_name("favicon-512.aa676972bbd2b68e94ef8e91e81d20be.png")
200        );
201        // Unhashed names pass through, for the pre-build state.
202        assert_eq!(String::from("icon-192.png"), logical_name("icon-192.png"));
203    }
204
205    #[test]
206    fn only_the_pngs_are_dimension_checked() {
207        assert!(is_png(std::path::Path::new("a/icon-192.png")));
208        assert!(is_png(std::path::Path::new("a/icon-192.PNG")));
209        // An .ico may legitimately hold several sizes at once.
210        assert!(!is_png(std::path::Path::new("a/favicon.ico")));
211        assert!(!is_png(std::path::Path::new("a/favicon.svg")));
212    }
213}