1use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use thiserror::Error;
7
8use crate::frontend_digest_contract::{
9 FRONTEND_ASSET_PREFIX, FRONTEND_BUNDLE_PREFIX, FrontendDigestInput, frontend_asset_digest,
10 frontend_bundle_digest,
11};
12pub use crate::frontend_digest_contract::{
13 FrontendAssetKind, FrontendAssetName, FrontendAssetNameParseError,
14};
15
16const DIGEST_HEX_LENGTH: usize = 64;
17pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
18
19macro_rules! frontend_enum_serde {
20 ($enum:ident, $error:literal, { $($variant:ident => $wire:literal),+ $(,)? }) => {
21 impl Serialize for $enum {
22 fn serialize<SerializerType>(
23 &self,
24 serializer: SerializerType,
25 ) -> Result<SerializerType::Ok, SerializerType::Error>
26 where
27 SerializerType: Serializer,
28 {
29 serializer.serialize_str(match self {
30 $(Self::$variant => $wire),+
31 })
32 }
33 }
34
35 impl<'de> Deserialize<'de> for $enum {
36 fn deserialize<DeserializerType>(
37 deserializer: DeserializerType,
38 ) -> Result<Self, DeserializerType::Error>
39 where
40 DeserializerType: Deserializer<'de>,
41 {
42 match String::deserialize(deserializer)?.as_str() {
43 $($wire => Ok(Self::$variant)),+,
44 _ => Err(de::Error::custom($error)),
45 }
46 }
47 }
48 };
49}
50
51frontend_enum_serde!(FrontendAssetKind, "frontend asset kind is not recognized", {
52 Css => "css",
53 JavaScript => "java_script",
54});
55frontend_enum_serde!(FrontendAssetName, "frontend asset name is not recognized", {
56 Stylesheet => "stylesheet",
57 JavaScript => "java_script",
58});
59
60#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum FrontendDigestKind {
63 Bundle,
64 Asset,
65}
66
67impl FrontendDigestKind {
68 const fn prefix(self) -> &'static str {
69 match self {
70 Self::Bundle => FRONTEND_BUNDLE_PREFIX,
71 Self::Asset => FRONTEND_ASSET_PREFIX,
72 }
73 }
74}
75
76#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
77pub enum FrontendDigestParseError {
78 #[error("{kind:?} frontend digest must start with {expected}")]
79 InvalidPrefix {
80 kind: FrontendDigestKind,
81 expected: &'static str,
82 },
83 #[error("{kind:?} frontend digest must contain exactly 32 encoded bytes")]
84 InvalidLength { kind: FrontendDigestKind },
85 #[error("{kind:?} frontend digest must use lowercase hexadecimal")]
86 InvalidEncoding { kind: FrontendDigestKind },
87}
88
89macro_rules! frontend_digest {
90 ($digest:ident, $kind:ident, $prefix:ident) => {
91 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92 pub struct $digest([u8; 32]);
93
94 impl $digest {
95 const fn from_generated(bytes: [u8; 32]) -> Self {
96 Self(bytes)
97 }
98
99 pub fn parse(value: &str) -> Result<Self, FrontendDigestParseError> {
100 parse_digest(value, FrontendDigestKind::$kind).map(Self)
101 }
102
103 pub const fn as_bytes(&self) -> &[u8; 32] {
104 &self.0
105 }
106 }
107
108 impl fmt::Display for $digest {
109 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110 format_digest($prefix, &self.0, formatter)
111 }
112 }
113
114 impl FromStr for $digest {
115 type Err = FrontendDigestParseError;
116
117 fn from_str(value: &str) -> Result<Self, Self::Err> {
118 Self::parse(value)
119 }
120 }
121
122 impl Serialize for $digest {
123 fn serialize<SerializerType>(
124 &self,
125 serializer: SerializerType,
126 ) -> Result<SerializerType::Ok, SerializerType::Error>
127 where
128 SerializerType: Serializer,
129 {
130 serializer.collect_str(self)
131 }
132 }
133
134 impl<'de> Deserialize<'de> for $digest {
135 fn deserialize<DeserializerType>(
136 deserializer: DeserializerType,
137 ) -> Result<Self, DeserializerType::Error>
138 where
139 DeserializerType: Deserializer<'de>,
140 {
141 let value = String::deserialize(deserializer)?;
142 Self::parse(&value).map_err(de::Error::custom)
143 }
144 }
145 };
146}
147
148frontend_digest!(FrontendBundleDigest, Bundle, FRONTEND_BUNDLE_PREFIX);
149frontend_digest!(FrontendAssetDigest, Asset, FRONTEND_ASSET_PREFIX);
150
151#[derive(Clone, Debug, Eq, PartialEq)]
152#[non_exhaustive]
153pub struct FrontendAsset {
154 pub kind: FrontendAssetKind,
155 pub digest: FrontendAssetDigest,
156 pub public_path: &'static str,
157 pub bytes: &'static [u8],
158}
159
160impl FrontendAsset {
161 pub const fn name(&self) -> FrontendAssetName {
162 match self.kind {
163 FrontendAssetKind::Css => FrontendAssetName::Stylesheet,
164 FrontendAssetKind::JavaScript => FrontendAssetName::JavaScript,
165 }
166 }
167
168 pub const fn mime(&self) -> &'static str {
169 match self.kind {
170 FrontendAssetKind::Css => "text/css; charset=utf-8",
171 FrontendAssetKind::JavaScript => "text/javascript; charset=utf-8",
172 }
173 }
174
175 pub fn etag(&self) -> String {
176 format!("\"{}\"", self.digest)
177 }
178}
179
180#[derive(Clone, Debug, Eq, PartialEq)]
181#[non_exhaustive]
182pub struct FrontendAssetManifest {
183 pub bundle_digest: FrontendBundleDigest,
184 pub css: FrontendAsset,
185 pub javascript: Option<FrontendAsset>,
186}
187
188impl FrontendAssetManifest {
189 pub fn lookup(
190 &self,
191 bundle: &FrontendBundleDigest,
192 name: FrontendAssetName,
193 ) -> Option<&FrontendAsset> {
194 if bundle != &self.bundle_digest {
195 return None;
196 }
197 match name {
198 FrontendAssetName::Stylesheet => Some(&self.css),
199 FrontendAssetName::JavaScript => self.javascript.as_ref(),
200 }
201 }
202
203 pub fn validate(&self) -> Result<(), FrontendManifestError> {
204 validate_asset(self.bundle_digest, &self.css)?;
205 if let Some(javascript) = &self.javascript {
206 validate_asset(self.bundle_digest, javascript)?;
207 }
208
209 let calculated = if let Some(javascript) = &self.javascript {
210 frontend_bundle_digest(&[
211 FrontendDigestInput {
212 kind: self.css.kind,
213 bytes: self.css.bytes,
214 },
215 FrontendDigestInput {
216 kind: javascript.kind,
217 bytes: javascript.bytes,
218 },
219 ])
220 } else {
221 frontend_bundle_digest(&[FrontendDigestInput {
222 kind: self.css.kind,
223 bytes: self.css.bytes,
224 }])
225 }
226 .map_err(|error| FrontendManifestError::DigestContract {
227 message: error.to_string().into_boxed_str(),
228 })?;
229 if calculated != self.bundle_digest.0 {
230 return Err(FrontendManifestError::BundleDigestMismatch);
231 }
232 Ok(())
233 }
234}
235
236pub fn embedded_manifest() -> &'static FrontendAssetManifest {
237 &GENERATED_FRONTEND_MANIFEST
238}
239
240#[derive(Clone, Debug, Eq, Error, PartialEq)]
241pub enum FrontendManifestError {
242 #[error("frontend {name} content digest does not match its embedded bytes")]
243 AssetDigestMismatch { name: FrontendAssetName },
244 #[error("frontend {name} path does not match its bundle digest and typed name")]
245 AssetPathMismatch { name: FrontendAssetName },
246 #[error("frontend bundle digest does not match its embedded assets")]
247 BundleDigestMismatch,
248 #[error("frontend digest contract failed: {message}")]
249 DigestContract { message: Box<str> },
250}
251
252fn validate_asset(
253 bundle: FrontendBundleDigest,
254 asset: &FrontendAsset,
255) -> Result<(), FrontendManifestError> {
256 if frontend_asset_digest(asset.kind, asset.bytes) != asset.digest.0 {
257 return Err(FrontendManifestError::AssetDigestMismatch { name: asset.name() });
258 }
259 let expected_path = format!("/app-assets/{bundle}/{}", asset.name());
260 if asset.public_path != expected_path {
261 return Err(FrontendManifestError::AssetPathMismatch { name: asset.name() });
262 }
263 Ok(())
264}
265
266fn parse_digest(
267 value: &str,
268 kind: FrontendDigestKind,
269) -> Result<[u8; 32], FrontendDigestParseError> {
270 let Some(hex) = value.strip_prefix(kind.prefix()) else {
271 return Err(FrontendDigestParseError::InvalidPrefix {
272 kind,
273 expected: kind.prefix(),
274 });
275 };
276 if hex.len() != DIGEST_HEX_LENGTH {
277 return Err(FrontendDigestParseError::InvalidLength { kind });
278 }
279 if !hex
280 .bytes()
281 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
282 {
283 return Err(FrontendDigestParseError::InvalidEncoding { kind });
284 }
285
286 let mut bytes = [0_u8; 32];
287 for (index, pair) in hex.as_bytes().as_chunks::<2>().0.iter().enumerate() {
288 let high =
289 decode_nibble(pair[0]).ok_or(FrontendDigestParseError::InvalidEncoding { kind })?;
290 let low =
291 decode_nibble(pair[1]).ok_or(FrontendDigestParseError::InvalidEncoding { kind })?;
292 bytes[index] = high << 4 | low;
293 }
294 Ok(bytes)
295}
296
297const fn decode_nibble(byte: u8) -> Option<u8> {
298 match byte {
299 b'0'..=b'9' => Some(byte - b'0'),
300 b'a'..=b'f' => Some(byte - b'a' + 10),
301 _ => None,
302 }
303}
304
305fn format_digest(
306 prefix: &str,
307 bytes: &[u8; 32],
308 formatter: &mut fmt::Formatter<'_>,
309) -> fmt::Result {
310 formatter.write_str(prefix)?;
311 for byte in bytes {
312 write!(formatter, "{byte:02x}")?;
313 }
314 Ok(())
315}
316
317include!(concat!(env!("OUT_DIR"), "/frontend_manifest.rs"));
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn generated_manifest_matches_every_embedded_byte_and_path() {
325 let manifest = embedded_manifest();
326 manifest.validate().unwrap();
327 assert_eq!(manifest.css.name(), FrontendAssetName::Stylesheet);
328 assert_eq!(manifest.css.kind, FrontendAssetKind::Css);
329 assert!(!manifest.css.bytes.is_empty());
330 assert_eq!(
331 manifest.css.public_path,
332 format!("/app-assets/{}/site.css", manifest.bundle_digest)
333 );
334 let javascript = manifest.javascript.as_ref().unwrap();
335 assert_eq!(javascript.name(), FrontendAssetName::JavaScript);
336 assert_eq!(javascript.kind, FrontendAssetKind::JavaScript);
337 assert!(!javascript.bytes.is_empty());
338 assert_eq!(
339 javascript.public_path,
340 format!("/app-assets/{}/site.js", manifest.bundle_digest)
341 );
342 }
343
344 #[test]
345 fn exact_manifest_lookup_never_falls_back() {
346 let manifest = embedded_manifest();
347 assert_eq!(
348 manifest.lookup(&manifest.bundle_digest, FrontendAssetName::Stylesheet),
349 Some(&manifest.css)
350 );
351 assert_eq!(
352 manifest.lookup(&manifest.bundle_digest, FrontendAssetName::JavaScript),
353 manifest.javascript.as_ref()
354 );
355 let different = FrontendBundleDigest([0x55; 32]);
356 assert!(
357 manifest
358 .lookup(&different, FrontendAssetName::Stylesheet)
359 .is_none()
360 );
361 }
362
363 #[test]
364 fn public_digest_encodings_are_full_strict_and_distinct() {
365 let manifest = embedded_manifest();
366 let bundle = manifest.bundle_digest.to_string();
367 let asset = manifest.css.digest.to_string();
368 assert_eq!(bundle.len(), FRONTEND_BUNDLE_PREFIX.len() + 64);
369 assert_eq!(asset.len(), FRONTEND_ASSET_PREFIX.len() + 64);
370 assert_ne!(bundle, asset);
371 assert_eq!(
372 FrontendBundleDigest::parse(&bundle).unwrap(),
373 manifest.bundle_digest
374 );
375 assert_eq!(
376 FrontendAssetDigest::parse(&asset).unwrap(),
377 manifest.css.digest
378 );
379 assert_eq!(
380 serde_json::to_value(manifest.bundle_digest).unwrap(),
381 bundle
382 );
383 assert_eq!(serde_json::to_value(manifest.css.digest).unwrap(), asset);
384 assert_eq!(
385 serde_json::from_value::<FrontendBundleDigest>(bundle.clone().into()).unwrap(),
386 manifest.bundle_digest
387 );
388 assert_eq!(
389 serde_json::from_value::<FrontendAssetDigest>(asset.clone().into()).unwrap(),
390 manifest.css.digest
391 );
392 assert!(FrontendBundleDigest::parse(&bundle.to_ascii_uppercase()).is_err());
393 assert!(FrontendBundleDigest::parse(&asset).is_err());
394 }
395
396 #[test]
397 fn header_metadata_is_typed_and_exact() {
398 let css = &embedded_manifest().css;
399 assert_eq!(css.mime(), "text/css; charset=utf-8");
400 assert_eq!(
401 IMMUTABLE_CACHE_CONTROL,
402 "public, max-age=31536000, immutable"
403 );
404 assert_eq!(css.etag(), format!("\"{}\"", css.digest));
405 }
406
407 #[test]
408 fn optional_javascript_manifest_keeps_javascript_semantics() {
409 let css_bytes: &'static [u8] = b"body{color:black}";
410 let javascript_bytes: &'static [u8] = b"console.log('maincopy')";
411 let bundle_digest = FrontendBundleDigest(
412 frontend_bundle_digest(&[
413 FrontendDigestInput {
414 kind: FrontendAssetKind::Css,
415 bytes: css_bytes,
416 },
417 FrontendDigestInput {
418 kind: FrontendAssetKind::JavaScript,
419 bytes: javascript_bytes,
420 },
421 ])
422 .unwrap(),
423 );
424 let css_path = Box::leak(format!("/app-assets/{bundle_digest}/site.css").into_boxed_str());
425 let javascript_path =
426 Box::leak(format!("/app-assets/{bundle_digest}/site.js").into_boxed_str());
427 let css = FrontendAsset {
428 kind: FrontendAssetKind::Css,
429 digest: FrontendAssetDigest(frontend_asset_digest(FrontendAssetKind::Css, css_bytes)),
430 public_path: css_path,
431 bytes: css_bytes,
432 };
433 let javascript = FrontendAsset {
434 kind: FrontendAssetKind::JavaScript,
435 digest: FrontendAssetDigest(frontend_asset_digest(
436 FrontendAssetKind::JavaScript,
437 javascript_bytes,
438 )),
439 public_path: javascript_path,
440 bytes: javascript_bytes,
441 };
442
443 let invalid_css = FrontendAssetManifest {
444 bundle_digest,
445 css: javascript.clone(),
446 javascript: None,
447 };
448 assert!(matches!(
449 invalid_css.validate(),
450 Err(FrontendManifestError::DigestContract { message })
451 if message.as_ref() == "frontend bundle must start with one CSS asset"
452 ));
453 let invalid_javascript = FrontendAssetManifest {
454 bundle_digest,
455 css: css.clone(),
456 javascript: Some(css.clone()),
457 };
458 assert!(matches!(
459 invalid_javascript.validate(),
460 Err(FrontendManifestError::DigestContract { message })
461 if message.as_ref()
462 == "frontend bundle assets must be unique and ordered by their typed kind"
463 ));
464
465 let manifest = FrontendAssetManifest {
466 bundle_digest,
467 css,
468 javascript: Some(javascript.clone()),
469 };
470
471 manifest.validate().unwrap();
472 let asset = manifest
473 .lookup(&bundle_digest, FrontendAssetName::JavaScript)
474 .unwrap();
475
476 assert_eq!(asset.name(), FrontendAssetName::JavaScript);
477 assert_eq!(asset.kind, FrontendAssetKind::JavaScript);
478 assert_eq!(asset.digest, javascript.digest);
479 assert_eq!(asset.public_path, javascript.public_path);
480 assert_eq!(asset.mime(), "text/javascript; charset=utf-8");
481 assert_eq!(asset.etag(), javascript.etag());
482 assert_eq!(asset.bytes, javascript_bytes);
483 }
484
485 #[test]
486 fn public_enum_wire_names_are_stable() {
487 assert_eq!(serde_json::to_value(FrontendAssetKind::Css).unwrap(), "css");
488 assert_eq!(
489 serde_json::to_value(FrontendAssetKind::JavaScript).unwrap(),
490 "java_script"
491 );
492 assert_eq!(
493 serde_json::to_value(FrontendAssetName::Stylesheet).unwrap(),
494 "stylesheet"
495 );
496 assert_eq!(
497 serde_json::to_value(FrontendAssetName::JavaScript).unwrap(),
498 "java_script"
499 );
500 assert_eq!(
501 serde_json::from_value::<FrontendAssetName>("stylesheet".into()).unwrap(),
502 FrontendAssetName::Stylesheet
503 );
504 }
505
506 #[test]
507 fn asset_name_parser_accepts_only_exact_public_names() {
508 assert_eq!(
509 FrontendAssetName::parse("site.css").unwrap(),
510 FrontendAssetName::Stylesheet
511 );
512 for invalid in ["SITE.CSS", "site.js", "../site.css", "site.css/extra"] {
513 if invalid == "site.js" {
514 assert_eq!(
515 FrontendAssetName::parse(invalid).unwrap(),
516 FrontendAssetName::JavaScript
517 );
518 } else {
519 assert!(FrontendAssetName::parse(invalid).is_err());
520 }
521 }
522 }
523}