1use std::path::Path;
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ContentEncoding {
75 Identity,
77 Brotli,
79}
80
81impl ContentEncoding {
82 pub const fn token(self) -> Option<&'static str> {
84 match self {
85 Self::Identity => None,
86 Self::Brotli => Some("br"),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy)]
96pub struct EmbeddedAsset {
97 pub path: &'static str,
100 pub bytes: &'static [u8],
104 pub raw_len: usize,
107 pub encoding: ContentEncoding,
109 pub content_type: &'static str,
111 pub etag: &'static str,
117}
118
119impl EmbeddedAsset {
120 pub fn decode(&self) -> Option<Vec<u8>> {
125 match self.encoding {
126 ContentEncoding::Identity => Some(self.bytes.to_vec()),
127 ContentEncoding::Brotli => decode_brotli(self.bytes, self.raw_len),
128 }
129 }
130
131 pub fn is_encoded(&self) -> bool {
133 matches!(self.encoding, ContentEncoding::Brotli)
134 }
135
136 pub fn etag_for(&self, encoded: bool) -> String {
142 if !encoded {
143 return self.etag.to_string();
144 }
145 match self.etag.strip_suffix('"') {
146 Some(head) => format!("{head}-br\""),
147 None => format!("{}-br", self.etag),
148 }
149 }
150}
151
152fn decode_brotli(payload: &[u8], raw_len: usize) -> Option<Vec<u8>> {
154 use std::io::Read as _;
155
156 let mut out = Vec::with_capacity(raw_len);
157 let mut decoder = brotli_decompressor::Decompressor::new(payload, 4096);
158 decoder.read_to_end(&mut out).ok()?;
159 Some(out)
160}
161
162#[derive(Debug, Clone, Copy)]
164pub struct EmbeddedAssets {
165 root: &'static str,
166 files: &'static [EmbeddedAsset],
167}
168
169impl EmbeddedAssets {
170 pub const fn new(root: &'static str, files: &'static [EmbeddedAsset]) -> Self {
176 Self { root, files }
177 }
178
179 pub const fn root(&self) -> &'static str {
181 self.root
182 }
183
184 pub const fn files(&self) -> &'static [EmbeddedAsset] {
186 self.files
187 }
188
189 pub fn len(&self) -> usize {
191 self.files.len()
192 }
193
194 pub fn is_empty(&self) -> bool {
196 self.files.is_empty()
197 }
198
199 pub fn total_bytes(&self) -> u64 {
201 self.files.iter().map(|file| file.bytes.len() as u64).sum()
202 }
203
204 pub fn total_raw_bytes(&self) -> u64 {
209 self.files.iter().map(|file| file.raw_len as u64).sum()
210 }
211
212 pub fn get(&self, path: &str) -> Option<&'static EmbeddedAsset> {
215 self.files
216 .binary_search_by(|file| file.path.cmp(path))
217 .ok()
218 .map(|index| &self.files[index])
219 }
220
221 pub fn registered() -> Option<&'static EmbeddedAssets> {
232 let mut found = inventory::iter::<EmbeddedAssets>();
233 match (found.next(), found.next()) {
234 (Some(assets), None) => Some(assets),
235 (None, _) => None,
236 (Some(_), Some(_)) => panic!(
237 "more than one compiled-in asset table is registered; \
238 use `#[webx::main(embed)]` (or `#[webx::embed_assets]`) \
239 exactly once per binary"
240 ),
241 }
242 }
243
244 pub fn read(&self, path: &str) -> Option<&'static [u8]> {
255 let asset = self.get(path.trim_start_matches('/'))?;
256 match asset.encoding {
257 ContentEncoding::Identity => Some(asset.bytes),
258 ContentEncoding::Brotli => crate::spa::decoded_once(asset),
259 }
260 }
261
262 pub fn read_str(&self, path: &str) -> Option<&'static str> {
264 std::str::from_utf8(self.read(path)?).ok()
265 }
266
267 pub fn debug_assert_sorted(&self) {
270 debug_assert!(
271 self.files
272 .windows(2)
273 .all(|pair| pair[0].path < pair[1].path),
274 "EmbeddedAssets must be sorted by path, with no duplicates"
275 );
276 }
277
278 pub fn shadowed_by(&self, disk_root: &Path) -> Vec<&'static str> {
284 let mut shadowed = Vec::new();
285 collect_shadowed(self, disk_root, "", &mut shadowed);
286 shadowed
287 }
288}
289
290fn collect_shadowed(
291 assets: &EmbeddedAssets,
292 dir: &Path,
293 prefix: &str,
294 shadowed: &mut Vec<&'static str>,
295) {
296 let Ok(entries) = std::fs::read_dir(dir) else {
297 return;
298 };
299 for entry in entries.flatten() {
300 let name = entry.file_name();
301 let Some(name) = name.to_str() else {
302 continue;
303 };
304 let key = if prefix.is_empty() {
305 name.to_string()
306 } else {
307 format!("{prefix}/{name}")
308 };
309 let path = entry.path();
310 if path.is_dir() {
311 collect_shadowed(assets, &path, &key, shadowed);
312 } else if let Some(asset) = assets.get(&key) {
313 shadowed.push(asset.path);
314 }
315 }
316}
317
318inventory::collect!(EmbeddedAssets);
322
323#[cfg(test)]
324mod tests {
325 use super::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
326
327 const FILES: &[EmbeddedAsset] = &[
328 EmbeddedAsset {
329 path: "assets/app.css",
330 bytes: b"a{}",
331 raw_len: 3,
332 encoding: ContentEncoding::Identity,
333 content_type: "text/css",
334 etag: "\"sha256-1\"",
335 },
336 EmbeddedAsset {
337 path: "index.html",
338 bytes: b"index",
339 raw_len: 5,
340 encoding: ContentEncoding::Identity,
341 content_type: "text/html",
342 etag: "\"sha256-2\"",
343 },
344 EmbeddedAsset {
345 path: "only-embedded.txt",
346 bytes: b"embedded",
347 raw_len: 8,
348 encoding: ContentEncoding::Identity,
349 content_type: "text/plain",
350 etag: "\"sha256-3\"",
351 },
352 ];
353
354 fn brotli(source: &str) -> Vec<u8> {
356 use std::io::Write as _;
357
358 let mut out = Vec::new();
359 let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
360 encoder.write_all(source.as_bytes()).unwrap();
361 encoder.flush().unwrap();
362 drop(encoder);
363 out
364 }
365
366 #[test]
367 fn get_finds_every_entry() {
368 let assets = EmbeddedAssets::new("wwwroot", FILES);
369 assets.debug_assert_sorted();
370 for file in FILES {
371 assert_eq!(assets.get(file.path).unwrap().bytes, file.bytes);
372 }
373 assert_eq!(assets.len(), 3);
374 assert_eq!(assets.total_bytes(), 16);
375 assert_eq!(assets.total_raw_bytes(), 16);
376 }
377
378 #[test]
379 fn an_encoded_entry_decodes_back_to_the_original_file() {
380 let source = "body { color: red; }".repeat(32);
381 let payload = brotli(&source);
382 assert!(
383 payload.len() < source.len(),
384 "the fixture must actually compress"
385 );
386
387 let encoded = EmbeddedAsset {
388 path: "app.css",
389 bytes: Box::leak(payload.into_boxed_slice()),
390 raw_len: source.len(),
391 encoding: ContentEncoding::Brotli,
392 content_type: "text/css",
393 etag: "\"sha256-x\"",
394 };
395 assert!(encoded.is_encoded());
396 assert_eq!(encoded.decode().unwrap(), source.as_bytes());
397
398 assert_eq!(FILES[0].decode().unwrap(), b"a{}");
400 assert!(!FILES[0].is_encoded());
401 }
402
403 #[test]
404 fn totals_separate_the_stored_payload_from_the_original_size() {
405 let source = "x".repeat(4096);
406 let payload = brotli(&source);
407 let encoded: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
408 path: "big.txt",
409 bytes: Box::leak(payload.into_boxed_slice()),
410 raw_len: 4096,
411 encoding: ContentEncoding::Brotli,
412 content_type: "text/plain",
413 etag: "\"sha256-y\"",
414 }));
415 let assets = EmbeddedAssets::new("wwwroot", std::slice::from_ref(encoded));
416 assert!(assets.total_bytes() < assets.total_raw_bytes());
417 assert_eq!(assets.total_raw_bytes(), 4096);
418 }
419
420 #[test]
421 fn each_representation_gets_its_own_validator() {
422 let plain = &FILES[0];
423 assert_eq!(plain.etag_for(false), "\"sha256-1\"");
424 assert_eq!(plain.etag_for(true), "\"sha256-1-br\"");
425 assert_ne!(plain.etag_for(false), plain.etag_for(true));
426
427 let bare = EmbeddedAsset {
430 etag: "sha256-z",
431 ..*plain
432 };
433 assert_eq!(bare.etag_for(true), "sha256-z-br");
434 }
435
436 #[test]
437 fn encoding_tokens_are_the_http_ones() {
438 assert_eq!(ContentEncoding::Identity.token(), None);
439 assert_eq!(ContentEncoding::Brotli.token(), Some("br"));
440 }
441
442 #[test]
443 fn get_misses_unknown_and_prefix_paths() {
444 let assets = EmbeddedAssets::new("wwwroot", FILES);
445 assert!(assets.get("assets").is_none());
446 assert!(assets.get("assets/nope.css").is_none());
447 assert!(assets.get("index.htm").is_none());
448 assert!(assets.get("").is_none());
449 }
450
451 #[test]
452 fn read_returns_verbatim_entries_without_a_copy() {
453 let assets = EmbeddedAssets::new("wwwroot", FILES);
454 let bytes = assets.read("index.html").unwrap();
455 assert!(std::ptr::eq(bytes, FILES[1].bytes));
456 assert_eq!(assets.read("/assets/app.css").unwrap(), b"a{}");
458 assert_eq!(assets.read_str("only-embedded.txt"), Some("embedded"));
459 assert!(assets.read("missing.txt").is_none());
460 }
461
462 #[test]
463 fn read_decodes_compressed_entries_once() {
464 let source = "p { margin: 0; }".repeat(64);
465 let encoded: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
466 path: "read-decoded.css",
467 bytes: Box::leak(brotli(&source).into_boxed_slice()),
468 raw_len: source.len(),
469 encoding: ContentEncoding::Brotli,
470 content_type: "text/css",
471 etag: "\"sha256-r\"",
472 }));
473 let assets = EmbeddedAssets::new("wwwroot", std::slice::from_ref(encoded));
474
475 let first = assets.read_str("read-decoded.css").unwrap();
476 assert_eq!(first, source);
477 let second = assets.read_str("read-decoded.css").unwrap();
478 assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
479 }
480
481 #[test]
482 fn read_str_rejects_non_utf8() {
483 let binary = EmbeddedAsset {
484 path: "logo.bin",
485 bytes: &[0xff, 0xfe],
486 raw_len: 2,
487 encoding: ContentEncoding::Identity,
488 content_type: "application/octet-stream",
489 etag: "\"sha256-b\"",
490 };
491 let files: &'static [EmbeddedAsset] = Box::leak(Box::new([binary]));
492 let assets = EmbeddedAssets::new("wwwroot", files);
493 assert_eq!(assets.read("logo.bin"), Some(&[0xff, 0xfe][..]));
494 assert!(assets.read_str("logo.bin").is_none());
495 }
496
497 #[test]
498 fn root_is_reported_as_written() {
499 assert_eq!(EmbeddedAssets::new("wwwroot", FILES).root(), "wwwroot");
500 assert_eq!(
501 EmbeddedAssets::new("../wwwroot", FILES).root(),
502 "../wwwroot"
503 );
504 }
505
506 #[test]
507 fn shadowed_by_reports_same_named_disk_files() {
508 let dir = tempfile::tempdir().unwrap();
509 std::fs::create_dir_all(dir.path().join("assets")).unwrap();
510 std::fs::write(dir.path().join("assets/app.css"), b"disk").unwrap();
511 std::fs::write(dir.path().join("only-embedded.txt"), b"disk").unwrap();
512 std::fs::write(dir.path().join("only-disk.txt"), b"disk").unwrap();
513
514 let mut shadowed = EmbeddedAssets::new("wwwroot", FILES).shadowed_by(dir.path());
515 shadowed.sort_unstable();
516 assert_eq!(shadowed, vec!["assets/app.css", "only-embedded.txt"]);
517 }
518
519 #[test]
520 fn shadowed_by_missing_directory_is_empty() {
521 let dir = tempfile::tempdir().unwrap();
522 let assets = EmbeddedAssets::new("wwwroot", FILES);
523 assert!(assets
524 .shadowed_by(&dir.path().join("does-not-exist"))
525 .is_empty());
526 }
527}