Skip to main content

maincopy_server/frontend_assets/
mod.rs

1//! Typed, content-addressed application assets embedded at build time.
2
3use 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}