1use std::path::Path;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ContentEncoding {
62 Identity,
64 Brotli,
66}
67
68impl ContentEncoding {
69 pub const fn token(self) -> Option<&'static str> {
71 match self {
72 Self::Identity => None,
73 Self::Brotli => Some("br"),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy)]
83pub struct EmbeddedAsset {
84 pub path: &'static str,
87 pub bytes: &'static [u8],
91 pub raw_len: usize,
94 pub encoding: ContentEncoding,
96 pub content_type: &'static str,
98 pub etag: &'static str,
104}
105
106impl EmbeddedAsset {
107 pub fn decode(&self) -> Option<Vec<u8>> {
112 match self.encoding {
113 ContentEncoding::Identity => Some(self.bytes.to_vec()),
114 ContentEncoding::Brotli => decode_brotli(self.bytes, self.raw_len),
115 }
116 }
117
118 pub fn is_encoded(&self) -> bool {
120 matches!(self.encoding, ContentEncoding::Brotli)
121 }
122
123 pub fn etag_for(&self, encoded: bool) -> String {
129 if !encoded {
130 return self.etag.to_string();
131 }
132 match self.etag.strip_suffix('"') {
133 Some(head) => format!("{head}-br\""),
134 None => format!("{}-br", self.etag),
135 }
136 }
137}
138
139fn decode_brotli(payload: &[u8], raw_len: usize) -> Option<Vec<u8>> {
141 use std::io::Read as _;
142
143 let mut out = Vec::with_capacity(raw_len);
144 let mut decoder = brotli_decompressor::Decompressor::new(payload, 4096);
145 decoder.read_to_end(&mut out).ok()?;
146 Some(out)
147}
148
149#[derive(Debug, Clone, Copy)]
151pub struct EmbeddedAssets {
152 root: &'static str,
153 files: &'static [EmbeddedAsset],
154}
155
156impl EmbeddedAssets {
157 pub const fn new(root: &'static str, files: &'static [EmbeddedAsset]) -> Self {
163 Self { root, files }
164 }
165
166 pub const fn root(&self) -> &'static str {
168 self.root
169 }
170
171 pub const fn files(&self) -> &'static [EmbeddedAsset] {
173 self.files
174 }
175
176 pub fn len(&self) -> usize {
178 self.files.len()
179 }
180
181 pub fn is_empty(&self) -> bool {
183 self.files.is_empty()
184 }
185
186 pub fn total_bytes(&self) -> u64 {
188 self.files.iter().map(|file| file.bytes.len() as u64).sum()
189 }
190
191 pub fn total_raw_bytes(&self) -> u64 {
196 self.files.iter().map(|file| file.raw_len as u64).sum()
197 }
198
199 pub fn get(&self, path: &str) -> Option<&'static EmbeddedAsset> {
202 self.files
203 .binary_search_by(|file| file.path.cmp(path))
204 .ok()
205 .map(|index| &self.files[index])
206 }
207
208 pub fn debug_assert_sorted(&self) {
211 debug_assert!(
212 self.files
213 .windows(2)
214 .all(|pair| pair[0].path < pair[1].path),
215 "EmbeddedAssets must be sorted by path, with no duplicates"
216 );
217 }
218
219 pub fn shadowed_by(&self, disk_root: &Path) -> Vec<&'static str> {
225 let mut shadowed = Vec::new();
226 collect_shadowed(self, disk_root, "", &mut shadowed);
227 shadowed
228 }
229}
230
231fn collect_shadowed(
232 assets: &EmbeddedAssets,
233 dir: &Path,
234 prefix: &str,
235 shadowed: &mut Vec<&'static str>,
236) {
237 let Ok(entries) = std::fs::read_dir(dir) else {
238 return;
239 };
240 for entry in entries.flatten() {
241 let name = entry.file_name();
242 let Some(name) = name.to_str() else {
243 continue;
244 };
245 let key = if prefix.is_empty() {
246 name.to_string()
247 } else {
248 format!("{prefix}/{name}")
249 };
250 let path = entry.path();
251 if path.is_dir() {
252 collect_shadowed(assets, &path, &key, shadowed);
253 } else if let Some(asset) = assets.get(&key) {
254 shadowed.push(asset.path);
255 }
256 }
257}
258
259inventory::collect!(EmbeddedAssets);
263
264#[cfg(test)]
265mod tests {
266 use super::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
267
268 const FILES: &[EmbeddedAsset] = &[
269 EmbeddedAsset {
270 path: "assets/app.css",
271 bytes: b"a{}",
272 raw_len: 3,
273 encoding: ContentEncoding::Identity,
274 content_type: "text/css",
275 etag: "\"sha256-1\"",
276 },
277 EmbeddedAsset {
278 path: "index.html",
279 bytes: b"index",
280 raw_len: 5,
281 encoding: ContentEncoding::Identity,
282 content_type: "text/html",
283 etag: "\"sha256-2\"",
284 },
285 EmbeddedAsset {
286 path: "only-embedded.txt",
287 bytes: b"embedded",
288 raw_len: 8,
289 encoding: ContentEncoding::Identity,
290 content_type: "text/plain",
291 etag: "\"sha256-3\"",
292 },
293 ];
294
295 fn brotli(source: &str) -> Vec<u8> {
297 use std::io::Write as _;
298
299 let mut out = Vec::new();
300 let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
301 encoder.write_all(source.as_bytes()).unwrap();
302 encoder.flush().unwrap();
303 drop(encoder);
304 out
305 }
306
307 #[test]
308 fn get_finds_every_entry() {
309 let assets = EmbeddedAssets::new("wwwroot", FILES);
310 assets.debug_assert_sorted();
311 for file in FILES {
312 assert_eq!(assets.get(file.path).unwrap().bytes, file.bytes);
313 }
314 assert_eq!(assets.len(), 3);
315 assert_eq!(assets.total_bytes(), 16);
316 assert_eq!(assets.total_raw_bytes(), 16);
317 }
318
319 #[test]
320 fn an_encoded_entry_decodes_back_to_the_original_file() {
321 let source = "body { color: red; }".repeat(32);
322 let payload = brotli(&source);
323 assert!(
324 payload.len() < source.len(),
325 "the fixture must actually compress"
326 );
327
328 let encoded = EmbeddedAsset {
329 path: "app.css",
330 bytes: Box::leak(payload.into_boxed_slice()),
331 raw_len: source.len(),
332 encoding: ContentEncoding::Brotli,
333 content_type: "text/css",
334 etag: "\"sha256-x\"",
335 };
336 assert!(encoded.is_encoded());
337 assert_eq!(encoded.decode().unwrap(), source.as_bytes());
338
339 assert_eq!(FILES[0].decode().unwrap(), b"a{}");
341 assert!(!FILES[0].is_encoded());
342 }
343
344 #[test]
345 fn totals_separate_the_stored_payload_from_the_original_size() {
346 let source = "x".repeat(4096);
347 let payload = brotli(&source);
348 let encoded: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
349 path: "big.txt",
350 bytes: Box::leak(payload.into_boxed_slice()),
351 raw_len: 4096,
352 encoding: ContentEncoding::Brotli,
353 content_type: "text/plain",
354 etag: "\"sha256-y\"",
355 }));
356 let assets = EmbeddedAssets::new("wwwroot", std::slice::from_ref(encoded));
357 assert!(assets.total_bytes() < assets.total_raw_bytes());
358 assert_eq!(assets.total_raw_bytes(), 4096);
359 }
360
361 #[test]
362 fn each_representation_gets_its_own_validator() {
363 let plain = &FILES[0];
364 assert_eq!(plain.etag_for(false), "\"sha256-1\"");
365 assert_eq!(plain.etag_for(true), "\"sha256-1-br\"");
366 assert_ne!(plain.etag_for(false), plain.etag_for(true));
367
368 let bare = EmbeddedAsset {
371 etag: "sha256-z",
372 ..*plain
373 };
374 assert_eq!(bare.etag_for(true), "sha256-z-br");
375 }
376
377 #[test]
378 fn encoding_tokens_are_the_http_ones() {
379 assert_eq!(ContentEncoding::Identity.token(), None);
380 assert_eq!(ContentEncoding::Brotli.token(), Some("br"));
381 }
382
383 #[test]
384 fn get_misses_unknown_and_prefix_paths() {
385 let assets = EmbeddedAssets::new("wwwroot", FILES);
386 assert!(assets.get("assets").is_none());
387 assert!(assets.get("assets/nope.css").is_none());
388 assert!(assets.get("index.htm").is_none());
389 assert!(assets.get("").is_none());
390 }
391
392 #[test]
393 fn root_is_reported_as_written() {
394 assert_eq!(EmbeddedAssets::new("wwwroot", FILES).root(), "wwwroot");
395 assert_eq!(
396 EmbeddedAssets::new("../wwwroot", FILES).root(),
397 "../wwwroot"
398 );
399 }
400
401 #[test]
402 fn shadowed_by_reports_same_named_disk_files() {
403 let dir = tempfile::tempdir().unwrap();
404 std::fs::create_dir_all(dir.path().join("assets")).unwrap();
405 std::fs::write(dir.path().join("assets/app.css"), b"disk").unwrap();
406 std::fs::write(dir.path().join("only-embedded.txt"), b"disk").unwrap();
407 std::fs::write(dir.path().join("only-disk.txt"), b"disk").unwrap();
408
409 let mut shadowed = EmbeddedAssets::new("wwwroot", FILES).shadowed_by(dir.path());
410 shadowed.sort_unstable();
411 assert_eq!(shadowed, vec!["assets/app.css", "only-embedded.txt"]);
412 }
413
414 #[test]
415 fn shadowed_by_missing_directory_is_empty() {
416 let dir = tempfile::tempdir().unwrap();
417 let assets = EmbeddedAssets::new("wwwroot", FILES);
418 assert!(assets
419 .shadowed_by(&dir.path().join("does-not-exist"))
420 .is_empty());
421 }
422}