webserver_base/assets/
validate.rs1use 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
26pub 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 let _ = write!(listed, "\n - {path}");
52 }
53
54 Err(CacheBusterError::UnresolvedAssets {
55 count: missing.len(),
56 missing: listed,
57 })
58}
59
60pub 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 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
108fn 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 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 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
154fn 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
169fn 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 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 assert!(!is_png(std::path::Path::new("a/favicon.ico")));
211 assert!(!is_png(std::path::Path::new("a/favicon.svg")));
212 }
213}