Skip to main content

maincopy_server/build_support/
frontend_digest.rs

1//! Shared, versioned digest contract for embedded frontend assets.
2//!
3//! Cargo compiles this source into both the package build script and the
4//! runtime library. Keep it independent from either crate's module graph.
5
6use std::fmt;
7
8use blake3::Hasher;
9
10pub(crate) const FRONTEND_BUNDLE_PREFIX: &str = "frontend-b3-v1-";
11pub(crate) const FRONTEND_ASSET_PREFIX: &str = "frontend-asset-b3-v1-";
12
13const FRONTEND_BUNDLE_CONTEXT: &str = "maincopy frontend bundle digest v1";
14const FRONTEND_ASSET_CONTEXT: &str = "maincopy frontend asset digest v1";
15const DIGEST_KIND: &[u8] = b"maincopy-frontend";
16const DIGEST_VERSION: u16 = 1;
17
18#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
19pub enum FrontendAssetKind {
20    Css,
21    JavaScript,
22}
23
24impl FrontendAssetKind {
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Css => "css",
28            Self::JavaScript => "javascript",
29        }
30    }
31
32    const fn tag(self) -> u8 {
33        match self {
34            Self::Css => 0,
35            Self::JavaScript => 1,
36        }
37    }
38}
39
40impl fmt::Display for FrontendAssetKind {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(self.as_str())
43    }
44}
45
46#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
47pub enum FrontendAssetName {
48    Stylesheet,
49    JavaScript,
50}
51
52impl FrontendAssetName {
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Stylesheet => "site.css",
56            Self::JavaScript => "site.js",
57        }
58    }
59
60    pub fn parse(value: &str) -> Result<Self, FrontendAssetNameParseError> {
61        match value {
62            "site.css" => Ok(Self::Stylesheet),
63            "site.js" => Ok(Self::JavaScript),
64            _ => Err(FrontendAssetNameParseError),
65        }
66    }
67}
68
69impl fmt::Display for FrontendAssetName {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        formatter.write_str(self.as_str())
72    }
73}
74
75impl std::str::FromStr for FrontendAssetName {
76    type Err = FrontendAssetNameParseError;
77
78    fn from_str(value: &str) -> Result<Self, Self::Err> {
79        Self::parse(value)
80    }
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
84#[error("frontend asset name is not recognized")]
85pub struct FrontendAssetNameParseError;
86
87#[derive(Clone, Copy)]
88pub(crate) struct FrontendDigestInput<'bytes> {
89    pub(crate) kind: FrontendAssetKind,
90    pub(crate) bytes: &'bytes [u8],
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
94pub(crate) enum FrontendDigestContractError {
95    #[error("frontend bundle must contain at least one asset")]
96    EmptyBundle,
97    #[error("frontend bundle must start with one CSS asset")]
98    MissingStylesheet,
99    #[error("frontend bundle assets must be unique and ordered by their typed kind")]
100    AssetOrder,
101}
102
103pub(crate) fn frontend_asset_digest(kind: FrontendAssetKind, bytes: &[u8]) -> [u8; 32] {
104    let mut transcript = Transcript::new(FRONTEND_ASSET_CONTEXT);
105    transcript.tag(kind.tag());
106    transcript.bytes(bytes);
107    transcript.finish()
108}
109
110pub(crate) fn frontend_bundle_digest(
111    assets: &[FrontendDigestInput<'_>],
112) -> Result<[u8; 32], FrontendDigestContractError> {
113    let Some(first) = assets.first() else {
114        return Err(FrontendDigestContractError::EmptyBundle);
115    };
116    if first.kind != FrontendAssetKind::Css {
117        return Err(FrontendDigestContractError::MissingStylesheet);
118    }
119    if assets.windows(2).any(|pair| pair[0].kind >= pair[1].kind) {
120        return Err(FrontendDigestContractError::AssetOrder);
121    }
122
123    let mut transcript = Transcript::new(FRONTEND_BUNDLE_CONTEXT);
124    transcript.sequence_len(assets.len());
125    for asset in assets {
126        transcript.tag(asset.kind.tag());
127        transcript.bytes(asset.bytes);
128    }
129    Ok(transcript.finish())
130}
131
132struct Transcript(Hasher);
133
134impl Transcript {
135    fn new(context: &'static str) -> Self {
136        let mut transcript = Self(Hasher::new_derive_key(context));
137        transcript.bytes(DIGEST_KIND);
138        transcript.0.update(&DIGEST_VERSION.to_be_bytes());
139        transcript
140    }
141
142    fn tag(&mut self, value: u8) {
143        self.0.update(&[value]);
144    }
145
146    fn bytes(&mut self, bytes: &[u8]) {
147        self.0.update(&(bytes.len() as u64).to_be_bytes());
148        self.0.update(bytes);
149    }
150
151    fn sequence_len(&mut self, length: usize) {
152        self.0.update(&(length as u64).to_be_bytes());
153    }
154
155    fn finish(self) -> [u8; 32] {
156        *self.0.finalize().as_bytes()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn typed_asset_framing_is_unambiguous() {
166        let css = frontend_asset_digest(FrontendAssetKind::Css, b"a{color:red}");
167        let javascript = frontend_asset_digest(FrontendAssetKind::JavaScript, b"a{color:red}");
168        assert_ne!(css, javascript);
169
170        let one = frontend_bundle_digest(&[FrontendDigestInput {
171            kind: FrontendAssetKind::Css,
172            bytes: b"ab",
173        }])
174        .unwrap();
175        let another = frontend_bundle_digest(&[FrontendDigestInput {
176            kind: FrontendAssetKind::Css,
177            bytes: b"a",
178        }])
179        .unwrap();
180        assert_ne!(one, another);
181    }
182
183    #[test]
184    fn bundle_contract_rejects_missing_or_misordered_css() {
185        assert_eq!(
186            frontend_bundle_digest(&[]),
187            Err(FrontendDigestContractError::EmptyBundle)
188        );
189        assert_eq!(
190            frontend_bundle_digest(&[FrontendDigestInput {
191                kind: FrontendAssetKind::JavaScript,
192                bytes: b"js",
193            }]),
194            Err(FrontendDigestContractError::MissingStylesheet)
195        );
196        assert_eq!(
197            frontend_bundle_digest(&[
198                FrontendDigestInput {
199                    kind: FrontendAssetKind::Css,
200                    bytes: b"one",
201                },
202                FrontendDigestInput {
203                    kind: FrontendAssetKind::Css,
204                    bytes: b"two",
205                },
206            ]),
207            Err(FrontendDigestContractError::AssetOrder)
208        );
209    }
210}