1use std::collections::{BTreeMap, BTreeSet};
39use std::fs;
40use std::io::Write;
41use std::path::Path;
42
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45
46use crate::BundleError;
47
48pub const ASSET_INDEX_FILE: &str = ".mesofact-assets.json";
53
54pub const ASSET_INDEX_VERSION: u32 = 1;
57
58pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
62
63pub const CACHE_CONTROL_REVALIDATE: &str = "no-cache";
67
68const MIN_COMPRESS_BYTES: u64 = 1024;
71
72const MAX_KEPT_PERCENT: u64 = 95;
75
76const BROTLI_MAX_QUALITY_BELOW: u64 = 4 * 1024 * 1024;
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Encoding {
86 Br,
88 Gzip,
90}
91
92impl Encoding {
93 pub const ALL: [Encoding; 2] = [Encoding::Br, Encoding::Gzip];
95
96 pub fn token(self) -> &'static str {
98 match self {
99 Encoding::Br => "br",
100 Encoding::Gzip => "gzip",
101 }
102 }
103
104 pub fn suffix(self) -> &'static str {
106 match self {
107 Encoding::Br => ".br",
108 Encoding::Gzip => ".gz",
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct AssetEntry {
116 pub sha256: String,
119 pub size: u64,
122 #[serde(default)]
125 pub immutable: bool,
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub encodings: Vec<Encoding>,
129}
130
131impl AssetEntry {
132 pub fn etag(&self, encoding: Option<Encoding>) -> String {
137 etag_for(&self.sha256, encoding)
138 }
139
140 pub fn cache_control(&self) -> &'static str {
142 if self.immutable {
143 CACHE_CONTROL_IMMUTABLE
144 } else {
145 CACHE_CONTROL_REVALIDATE
146 }
147 }
148}
149
150pub fn sha256_hex(bytes: &[u8]) -> String {
153 format!("{:x}", Sha256::digest(bytes))
154}
155
156pub fn etag_for(sha256: &str, encoding: Option<Encoding>) -> String {
158 match encoding {
159 None => format!("\"{sha256}\""),
160 Some(e) => format!("\"{sha256}.{}\"", e.token()),
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct AssetIndex {
167 pub version: u32,
168 pub assets: BTreeMap<String, AssetEntry>,
171}
172
173impl AssetIndex {
174 pub fn parse(raw: &[u8]) -> Option<Self> {
178 let index: AssetIndex = serde_json::from_slice(raw).ok()?;
179 (index.version == ASSET_INDEX_VERSION).then_some(index)
180 }
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct ImmutableGlobs {
196 patterns: Vec<Vec<String>>,
197}
198
199impl ImmutableGlobs {
200 pub fn new<S: AsRef<str>>(patterns: &[S]) -> Result<Self, BundleError> {
205 let mut compiled = Vec::with_capacity(patterns.len());
206 for p in patterns {
207 let p = p.as_ref();
208 let bad = |why: &str| {
209 BundleError::AssetPolicy(format!("immutable pattern {p:?}: {why}"))
210 };
211 if p.is_empty() {
212 return Err(bad("empty pattern"));
213 }
214 if p.starts_with('/') {
215 return Err(bad("must be relative to the build output, not absolute"));
216 }
217 if p.contains('[') || p.contains(']') {
218 return Err(bad("character classes are not supported"));
219 }
220 let segs: Vec<String> = p.split('/').map(str::to_string).collect();
221 if segs.iter().any(|s| s.is_empty() || s == "." || s == "..") {
222 return Err(bad("empty, `.` and `..` segments are not allowed"));
223 }
224 compiled.push(segs);
225 }
226 Ok(Self { patterns: compiled })
227 }
228
229 pub fn is_empty(&self) -> bool {
230 self.patterns.is_empty()
231 }
232
233 pub fn matches(&self, rel: &str) -> bool {
236 let path: Vec<&str> = rel.split('/').filter(|s| !s.is_empty()).collect();
237 self.patterns.iter().any(|p| match_segments(p, &path))
238 }
239}
240
241fn match_segments(pat: &[String], path: &[&str]) -> bool {
242 match pat.split_first() {
243 None => path.is_empty(),
244 Some((head, rest)) if head == "**" => {
245 (0..=path.len()).any(|skip| match_segments(rest, &path[skip..]))
246 }
247 Some((head, rest)) => match path.split_first() {
248 Some((seg, path_rest)) => match_segment(head, seg) && match_segments(rest, path_rest),
249 None => false,
250 },
251 }
252}
253
254fn match_segment(pat: &str, s: &str) -> bool {
257 let p: Vec<char> = pat.chars().collect();
258 let t: Vec<char> = s.chars().collect();
259 let (mut pi, mut ti) = (0, 0);
260 let mut star: Option<(usize, usize)> = None;
261 while ti < t.len() {
262 if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
263 pi += 1;
264 ti += 1;
265 } else if pi < p.len() && p[pi] == '*' {
266 star = Some((pi, ti));
267 pi += 1;
268 } else if let Some((sp, st)) = star {
269 pi = sp + 1;
270 ti = st + 1;
271 star = Some((sp, st + 1));
272 } else {
273 return false;
274 }
275 }
276 while pi < p.len() && p[pi] == '*' {
277 pi += 1;
278 }
279 pi == p.len()
280}
281
282pub fn is_content_hashed_hydrate_output(rel: &str) -> bool {
295 rel.rsplit_once('.')
296 .and_then(|(stem, ext)| (ext == "js").then_some(stem))
299 .and_then(|stem| stem.rsplit_once('.'))
300 .is_some_and(|(_, tok)| {
301 let tok = tok.strip_prefix("chunk-").unwrap_or(tok);
302 tok.len() >= 8 && tok.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
303 })
304}
305
306pub const DECLARATION_FILE: &str = "mesofact.config.toml";
309
310pub fn declared_immutable(project_root: &Path) -> Result<ImmutableGlobs, BundleError> {
330 let path = project_root.join(DECLARATION_FILE);
331 let raw = match fs::read_to_string(&path) {
332 Ok(raw) => raw,
333 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ImmutableGlobs::default()),
334 Err(e) => return Err(BundleError::Io(format!("reading {}: {e}", path.display()))),
335 };
336 #[derive(Deserialize)]
337 struct Sliver {
338 #[serde(default)]
339 build: Option<BuildSliver>,
340 }
341 #[derive(Deserialize)]
342 struct BuildSliver {
343 #[serde(default)]
344 immutable: Vec<String>,
345 }
346 let sliver: Sliver = toml::from_str(&raw)
347 .map_err(|e| BundleError::AssetPolicy(format!("parsing {}: {e}", path.display())))?;
348 ImmutableGlobs::new(&sliver.build.map(|b| b.immutable).unwrap_or_default())
349}
350
351pub fn is_finalize_artifact(dir: &Path, rel: &str) -> bool {
357 if rel.rsplit('/').next() == Some(ASSET_INDEX_FILE) {
358 return true;
359 }
360 Encoding::ALL.iter().any(|e| {
361 rel.strip_suffix(e.suffix())
362 .is_some_and(|base| !base.is_empty() && dir.join(base).is_file())
363 })
364}
365
366pub fn finalize_component_output(project_root: &Path, out_dir: &Path) -> Result<(), BundleError> {
385 let policy = OutputPolicy::load(project_root, out_dir)?;
386 if policy.mesofact_build {
387 let html = out_dir.join("html");
388 finalize_served_dir(&html, &|rel| policy.is_immutable(&format!("html/{rel}")))?;
389 let hydrate = out_dir.join("hydrate");
390 if hydrate.is_dir() {
391 finalize_served_dir(&hydrate, &|rel| policy.is_immutable(&format!("hydrate/{rel}")))?;
392 }
393 } else if out_dir.is_dir() {
394 finalize_served_dir(out_dir, &|rel| policy.is_immutable(rel))?;
395 }
396 Ok(())
397}
398
399#[derive(Debug, Clone, Default)]
411pub struct OutputPolicy {
412 declared: ImmutableGlobs,
413 mesofact_build: bool,
414}
415
416impl OutputPolicy {
417 pub fn load(project_root: &Path, out_dir: &Path) -> Result<Self, BundleError> {
420 Ok(Self {
421 declared: declared_immutable(project_root)?,
422 mesofact_build: out_dir.join("html").is_dir(),
423 })
424 }
425
426 pub fn is_immutable(&self, out_rel: &str) -> bool {
428 if !self.mesofact_build {
429 return self.declared.matches(out_rel);
430 }
431 if let Some(rel) = out_rel.strip_prefix("html/") {
432 self.declared.matches(rel)
433 } else if let Some(rel) = out_rel.strip_prefix("hydrate/") {
434 is_content_hashed_hydrate_output(rel)
435 } else {
436 false
437 }
438 }
439}
440
441pub fn finalize_served_dir(
445 dir: &Path,
446 immutable: &dyn Fn(&str) -> bool,
447) -> Result<AssetIndex, BundleError> {
448 let io = |what: &str, p: &Path, e: std::io::Error| {
449 BundleError::Io(format!("{what} {}: {e}", p.display()))
450 };
451
452 let mut files = BTreeSet::new();
453 walk_rel(dir, "", &mut files)?;
454
455 let prior = fs::read(dir.join(ASSET_INDEX_FILE))
456 .ok()
457 .and_then(|raw| AssetIndex::parse(&raw))
458 .map(|i| i.assets)
459 .unwrap_or_default();
460
461 for (rel, entry) in &prior {
465 if files.contains(rel) {
466 continue;
467 }
468 for enc in &entry.encodings {
469 let variant = format!("{rel}{}", enc.suffix());
470 if files.remove(&variant) {
471 let path = dir.join(&variant);
472 fs::remove_file(&path).map_err(|e| io("removing orphaned", &path, e))?;
473 }
474 }
475 }
476
477 let mut assets = BTreeMap::new();
478 for rel in &files {
479 if is_variant_of_listed(rel, &files) || rel.rsplit('/').next() == Some(ASSET_INDEX_FILE) {
480 continue;
481 }
482 let path = dir.join(rel);
483 let bytes = fs::read(&path).map_err(|e| io("reading", &path, e))?;
484 let sha256 = sha256_hex(&bytes);
485 let size = bytes.len() as u64;
486 let previous = prior.get(rel).filter(|p| p.sha256 == sha256);
487
488 let mut encodings = Vec::new();
489 let worth_compressing = size >= MIN_COMPRESS_BYTES && is_compressible(rel);
490 for enc in Encoding::ALL {
491 let variant = dir.join(format!("{rel}{}", enc.suffix()));
492 if !worth_compressing {
493 if prior.get(rel).is_some_and(|p| p.encodings.contains(&enc)) && variant.is_file() {
496 fs::remove_file(&variant).map_err(|e| io("removing stale", &variant, e))?;
497 }
498 continue;
499 }
500 if previous.is_some_and(|p| p.encodings.contains(&enc)) && variant.is_file() {
501 encodings.push(enc);
502 continue;
503 }
504 let compressed = compress(enc, &bytes)
505 .map_err(|e| io(&format!("{} compressing", enc.token()), &path, e))?;
506 if (compressed.len() as u64) * 100 <= size * MAX_KEPT_PERCENT {
507 fs::write(&variant, &compressed).map_err(|e| io("writing", &variant, e))?;
508 encodings.push(enc);
509 } else if variant.is_file() && prior.get(rel).is_some_and(|p| p.encodings.contains(&enc)) {
510 fs::remove_file(&variant).map_err(|e| io("removing stale", &variant, e))?;
511 }
512 }
513
514 assets.insert(
515 rel.clone(),
516 AssetEntry { sha256, size, immutable: immutable(rel), encodings },
517 );
518 }
519
520 let index = AssetIndex { version: ASSET_INDEX_VERSION, assets };
521 let json = serde_json::to_vec_pretty(&index)
522 .map_err(|e| BundleError::Io(format!("serializing asset index: {e}")))?;
523 let index_path = dir.join(ASSET_INDEX_FILE);
524 fs::write(&index_path, json).map_err(|e| io("writing", &index_path, e))?;
525 Ok(index)
526}
527
528fn is_variant_of_listed(rel: &str, files: &BTreeSet<String>) -> bool {
531 Encoding::ALL
532 .iter()
533 .any(|e| rel.strip_suffix(e.suffix()).is_some_and(|base| files.contains(base)))
534}
535
536fn is_compressible(rel: &str) -> bool {
539 let ext = rel.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
540 matches!(
541 ext.as_str(),
542 "html" | "htm" | "css" | "js" | "mjs" | "cjs" | "json" | "map" | "xml" | "svg" | "txt"
543 | "md" | "csv" | "sh" | "wasm" | "webmanifest" | "ico" | "ttf" | "otf"
544 )
545}
546
547fn compress(enc: Encoding, bytes: &[u8]) -> std::io::Result<Vec<u8>> {
548 match enc {
549 Encoding::Br => {
550 let large = bytes.len() as u64 >= BROTLI_MAX_QUALITY_BELOW;
551 let params = brotli::enc::BrotliEncoderParams {
552 quality: if large { 9 } else { 11 },
553 lgwin: 24,
554 ..Default::default()
555 };
556 let mut out = Vec::with_capacity(bytes.len() / 3);
557 brotli::BrotliCompress(&mut &bytes[..], &mut out, ¶ms)?;
558 Ok(out)
559 }
560 Encoding::Gzip => {
561 let mut enc = flate2::write::GzEncoder::new(
562 Vec::with_capacity(bytes.len() / 2),
563 flate2::Compression::best(),
564 );
565 enc.write_all(bytes)?;
566 enc.finish()
567 }
568 }
569}
570
571fn walk_rel(abs: &Path, rel: &str, out: &mut BTreeSet<String>) -> Result<(), BundleError> {
572 let entries =
573 fs::read_dir(abs).map_err(|e| BundleError::Io(format!("reading {}: {e}", abs.display())))?;
574 for entry in entries {
575 let entry = entry.map_err(|e| BundleError::Io(format!("reading {}: {e}", abs.display())))?;
576 let name = entry.file_name().to_string_lossy().into_owned();
577 let child = if rel.is_empty() { name } else { format!("{rel}/{name}") };
578 let ty = entry
579 .file_type()
580 .map_err(|e| BundleError::Io(format!("stat {}: {e}", entry.path().display())))?;
581 if ty.is_dir() {
582 walk_rel(&entry.path(), &child, out)?;
583 } else if ty.is_file() {
584 out.insert(child);
585 }
586 }
587 Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 fn globs(p: &[&str]) -> ImmutableGlobs {
595 ImmutableGlobs::new(p).unwrap()
596 }
597
598 #[test]
599 fn globs_match_whole_paths_and_never_reach_into_snippets_by_accident() {
600 let g = globs(&["noise_table_browser-*.js", "*_bg.wasm"]);
601 assert!(g.matches("noise_table_browser-ccb80901df6f7edc.js"));
602 assert!(g.matches("noise_table_browser-ccb80901df6f7edc_bg.wasm"));
603 assert!(!g.matches("snippets/noise_table_browser-0f1e2d3c/inline0.js"));
606 assert!(!g.matches("index.html"));
607 assert!(!g.matches("audio_worklet_processor.js"));
608 }
609
610 #[test]
611 fn double_star_spans_zero_or_more_directories() {
612 let g = globs(&["**/*_bg.wasm", "assets/**"]);
613 assert!(g.matches("app_bg.wasm"));
614 assert!(g.matches("a/b/app_bg.wasm"));
615 assert!(g.matches("assets/x.png"));
616 assert!(g.matches("assets/deep/x.png"));
617 assert!(!g.matches("other/x.png"));
618 }
619
620 #[test]
621 fn star_and_question_mark_stay_within_a_segment() {
622 let g = globs(&["a*z", "v?.js"]);
623 assert!(g.matches("az"));
624 assert!(g.matches("abcz"));
625 assert!(!g.matches("ab/cz"));
626 assert!(g.matches("v1.js"));
627 assert!(!g.matches("v12.js"));
628 }
629
630 #[test]
631 fn patterns_that_can_only_be_mistakes_are_refused() {
632 for bad in ["", "/abs.js", "a/../b", "a//b", "[ab].js", "./x"] {
633 assert!(ImmutableGlobs::new(&[bad]).is_err(), "{bad:?} should be refused");
634 }
635 }
636
637 #[test]
638 fn only_bundler_shaped_hydrate_names_are_content_hashed() {
639 for k in ["home.a1b2c3d4.js", "home.chunk-a1b2c3d4.js", "x.AbCd1234_-.js"] {
640 assert!(is_content_hashed_hydrate_output(k), "{k}");
641 }
642 for k in ["app.js", "app.min.js", "home.abc.js", "home.a1b2c3d4.css"] {
643 assert!(!is_content_hashed_hydrate_output(k), "{k}");
644 }
645 }
646
647 #[test]
648 fn declared_immutable_reads_the_build_table_and_refuses_bad_patterns() {
649 let dir = tempfile::tempdir().unwrap();
650 assert!(declared_immutable(dir.path()).unwrap().is_empty(), "no config file");
651
652 fs::write(
653 dir.path().join(DECLARATION_FILE),
654 "[build]\npublic_dir = \"public\"\nimmutable = [\"*_bg.wasm\"]\n[sources.x]\nscope = \"global\"\n",
655 )
656 .unwrap();
657 assert!(declared_immutable(dir.path()).unwrap().matches("x_bg.wasm"));
658
659 fs::write(dir.path().join(DECLARATION_FILE), "[build]\nimmutable = [\"/abs\"]\n").unwrap();
660 assert!(declared_immutable(dir.path()).is_err());
661
662 fs::write(dir.path().join(DECLARATION_FILE), "[build]\nimmutable = \"*.js\"\n").unwrap();
663 assert!(declared_immutable(dir.path()).is_err(), "a bare string is not a list");
664 }
665
666 fn js_body(n: usize) -> Vec<u8> {
667 "export const value = 'repetitive text compresses well';\n".repeat(n).into_bytes()
668 }
669
670 #[test]
671 fn finalize_indexes_marks_declared_files_and_writes_smaller_variants() {
672 let dir = tempfile::tempdir().unwrap();
673 let root = dir.path();
674 fs::write(root.join("app-0123abcd.js"), js_body(200)).unwrap();
675 fs::write(root.join("index.html"), b"<html>tiny</html>").unwrap();
676 fs::write(root.join("logo.png"), vec![7u8; 4096]).unwrap();
677
678 let index = finalize_served_dir(root, &|rel| rel.starts_with("app-")).unwrap();
679
680 let js = &index.assets["app-0123abcd.js"];
681 assert!(js.immutable);
682 assert_eq!(js.encodings, vec![Encoding::Br, Encoding::Gzip]);
683 assert_eq!(js.sha256, format!("{:x}", Sha256::digest(js_body(200))));
684 let br = fs::read(root.join("app-0123abcd.js.br")).unwrap();
685 let mut decoded = Vec::new();
686 brotli::BrotliDecompress(&mut &br[..], &mut decoded).unwrap();
687 assert_eq!(decoded, js_body(200), "the variant must decode to the identity bytes");
688
689 assert!(index.assets["index.html"].encodings.is_empty());
691 assert!(!index.assets["index.html"].immutable);
692 assert!(index.assets["logo.png"].encodings.is_empty());
693 assert!(!root.join("logo.png.br").exists());
694
695 assert_eq!(index.assets.len(), 3);
697 let on_disk = AssetIndex::parse(&fs::read(root.join(ASSET_INDEX_FILE)).unwrap()).unwrap();
698 assert_eq!(on_disk, index);
699 assert!(is_finalize_artifact(root, "app-0123abcd.js.gz"));
700 assert!(is_finalize_artifact(root, ASSET_INDEX_FILE));
701 assert!(!is_finalize_artifact(root, "app-0123abcd.js"));
702 }
703
704 #[test]
705 fn refinalizing_reuses_variants_for_unchanged_bytes_and_replaces_them_for_changed_ones() {
706 let dir = tempfile::tempdir().unwrap();
707 let root = dir.path();
708 fs::write(root.join("a.js"), js_body(100)).unwrap();
709 finalize_served_dir(root, &|_| false).unwrap();
710
711 fs::write(root.join("a.js.br"), b"sentinel").unwrap();
714 finalize_served_dir(root, &|_| false).unwrap();
715 assert_eq!(fs::read(root.join("a.js.br")).unwrap(), b"sentinel");
716
717 fs::write(root.join("a.js"), js_body(150)).unwrap();
719 finalize_served_dir(root, &|_| false).unwrap();
720 let mut decoded = Vec::new();
721 brotli::BrotliDecompress(&mut &fs::read(root.join("a.js.br")).unwrap()[..], &mut decoded)
722 .unwrap();
723 assert_eq!(decoded, js_body(150));
724
725 fs::write(root.join("a.js"), b"x").unwrap();
727 let index = finalize_served_dir(root, &|_| false).unwrap();
728 assert!(index.assets["a.js"].encodings.is_empty());
729 assert!(!root.join("a.js.br").exists());
730 assert!(!root.join("a.js.gz").exists());
731 }
732
733 #[test]
734 fn a_deleted_source_takes_its_variants_with_it() {
735 let dir = tempfile::tempdir().unwrap();
736 let root = dir.path();
737 fs::write(root.join("gone.js"), js_body(100)).unwrap();
738 fs::write(root.join("kept.js"), js_body(100)).unwrap();
739 finalize_served_dir(root, &|_| false).unwrap();
740 assert!(root.join("gone.js.br").exists());
741
742 fs::remove_file(root.join("gone.js")).unwrap();
743 let index = finalize_served_dir(root, &|_| false).unwrap();
744
745 assert!(!root.join("gone.js.br").exists());
746 assert!(!root.join("gone.js.gz").exists());
747 assert_eq!(index.assets.keys().collect::<Vec<_>>(), vec!["kept.js"]);
748 }
749
750 #[test]
751 fn a_shipped_archive_with_no_identity_sibling_is_an_asset_not_a_variant() {
752 let dir = tempfile::tempdir().unwrap();
753 fs::write(dir.path().join("data.tar.gz"), vec![1u8; 2048]).unwrap();
754 let index = finalize_served_dir(dir.path(), &|_| false).unwrap();
755 assert!(index.assets.contains_key("data.tar.gz"));
756 }
757
758 #[test]
759 fn component_output_finalizes_html_and_hydrate_roots_of_a_mesofact_build() {
760 let project = tempfile::tempdir().unwrap();
761 fs::write(project.path().join(DECLARATION_FILE), "[build]\nimmutable = [\"img/*\"]\n").unwrap();
762 let out = project.path().join("dist");
763 fs::create_dir_all(out.join("html/img")).unwrap();
764 fs::create_dir_all(out.join("hydrate")).unwrap();
765 fs::write(out.join("html/index.html"), js_body(50)).unwrap();
766 fs::write(out.join("html/img/a.png"), b"png").unwrap();
767 fs::write(out.join("hydrate/home.a1b2c3d4.js"), js_body(50)).unwrap();
768 fs::write(out.join("hydrate/polyfill.js"), js_body(50)).unwrap();
769
770 finalize_component_output(project.path(), &out).unwrap();
771
772 let html = AssetIndex::parse(&fs::read(out.join("html").join(ASSET_INDEX_FILE)).unwrap()).unwrap();
773 assert!(html.assets["img/a.png"].immutable);
774 assert!(!html.assets["index.html"].immutable);
775 let hydrate =
776 AssetIndex::parse(&fs::read(out.join("hydrate").join(ASSET_INDEX_FILE)).unwrap()).unwrap();
777 assert!(hydrate.assets["home.a1b2c3d4.js"].immutable);
778 assert!(!hydrate.assets["polyfill.js"].immutable);
779 assert!(!out.join(ASSET_INDEX_FILE).exists(), "a mesofact build's dist/ root is not served");
780 }
781
782 #[test]
783 fn etags_differ_per_representation() {
784 let e = AssetEntry { sha256: "ab".into(), size: 1, immutable: false, encodings: vec![] };
785 assert_eq!(e.etag(None), "\"ab\"");
786 assert_eq!(e.etag(Some(Encoding::Br)), "\"ab.br\"");
787 assert_eq!(e.etag(Some(Encoding::Gzip)), "\"ab.gzip\"");
788 }
789}