maincopy_server/build_support/
frontend_digest.rs1use 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}