1#[cfg(feature = "serve")]
10use std::path::PathBuf;
11
12struct Asset {
14 path: &'static str,
15 content_type: &'static str,
16 bytes: &'static [u8],
17}
18
19const HTML: &str = "text/html; charset=utf-8";
20const CSS: &str = "text/css; charset=utf-8";
21const JS: &str = "application/javascript; charset=utf-8";
22#[cfg(not(feature = "tier-c"))]
24const JSON: &str = "application/json; charset=utf-8";
25
26#[cfg(feature = "tier-c")]
31const EMBEDDED: &[Asset] = &[Asset {
32 path: "/",
33 content_type: HTML,
34 bytes: include_bytes!("../web/lite.html"),
35}];
36
37#[cfg(not(feature = "tier-c"))]
40const EMBEDDED: &[Asset] = &[
41 Asset {
42 path: "/",
43 content_type: HTML,
44 bytes: include_bytes!("../web/index.html"),
45 },
46 Asset {
47 path: "/global.css",
48 content_type: CSS,
49 bytes: include_bytes!("../web/global.css"),
50 },
51 Asset {
54 path: "/lite.html",
55 content_type: HTML,
56 bytes: include_bytes!("../web/lite.html"),
57 },
58 Asset {
59 path: "/zquery.min.js",
60 content_type: JS,
61 bytes: include_bytes!("../web/zquery.min.js"),
62 },
63 Asset {
64 path: "/app/app.js",
65 content_type: JS,
66 bytes: include_bytes!("../web/app/app.js"),
67 },
68 Asset {
69 path: "/app/store.js",
70 content_type: JS,
71 bytes: include_bytes!("../web/app/store.js"),
72 },
73 Asset {
74 path: "/app/routes.js",
75 content_type: JS,
76 bytes: include_bytes!("../web/app/routes.js"),
77 },
78 Asset {
79 path: "/app/nav.js",
80 content_type: JS,
81 bytes: include_bytes!("../web/app/nav.js"),
82 },
83 Asset {
84 path: "/app/lib/feed.js",
85 content_type: JS,
86 bytes: include_bytes!("../web/app/lib/feed.js"),
87 },
88 Asset {
89 path: "/app/lib/edits.js",
90 content_type: JS,
91 bytes: include_bytes!("../web/app/lib/edits.js"),
92 },
93 Asset {
94 path: "/app/lib/catalog.js",
95 content_type: JS,
96 bytes: include_bytes!("../web/app/lib/catalog.js"),
97 },
98 Asset {
99 path: "/app/lib/discovery.js",
100 content_type: JS,
101 bytes: include_bytes!("../web/app/lib/discovery.js"),
102 },
103 Asset {
104 path: "/app/lib/detail.js",
105 content_type: JS,
106 bytes: include_bytes!("../web/app/lib/detail.js"),
107 },
108 Asset {
109 path: "/app/lib/i18n.js",
110 content_type: JS,
111 bytes: include_bytes!("../web/app/lib/i18n.js"),
112 },
113 Asset {
114 path: "/app/lib/pair.js",
115 content_type: JS,
116 bytes: include_bytes!("../web/app/lib/pair.js"),
117 },
118 Asset {
119 path: "/app/lib/crypto/bytes.js",
120 content_type: JS,
121 bytes: include_bytes!("../web/app/lib/crypto/bytes.js"),
122 },
123 Asset {
124 path: "/app/lib/crypto/sha256.js",
125 content_type: JS,
126 bytes: include_bytes!("../web/app/lib/crypto/sha256.js"),
127 },
128 Asset {
129 path: "/app/lib/crypto/hmac.js",
130 content_type: JS,
131 bytes: include_bytes!("../web/app/lib/crypto/hmac.js"),
132 },
133 Asset {
134 path: "/app/lib/crypto/hkdf.js",
135 content_type: JS,
136 bytes: include_bytes!("../web/app/lib/crypto/hkdf.js"),
137 },
138 Asset {
139 path: "/app/lib/viz/index.js",
140 content_type: JS,
141 bytes: include_bytes!("../web/app/lib/viz/index.js"),
142 },
143 Asset {
144 path: "/app/lib/viz/util.js",
145 content_type: JS,
146 bytes: include_bytes!("../web/app/lib/viz/util.js"),
147 },
148 Asset {
149 path: "/app/lib/viz/links.js",
150 content_type: JS,
151 bytes: include_bytes!("../web/app/lib/viz/links.js"),
152 },
153 Asset {
154 path: "/app/lib/viz/charts.js",
155 content_type: JS,
156 bytes: include_bytes!("../web/app/lib/viz/charts.js"),
157 },
158 Asset {
159 path: "/app/lib/viz/gauges.js",
160 content_type: JS,
161 bytes: include_bytes!("../web/app/lib/viz/gauges.js"),
162 },
163 Asset {
164 path: "/app/lib/viz/glyphs.js",
165 content_type: JS,
166 bytes: include_bytes!("../web/app/lib/viz/glyphs.js"),
167 },
168 Asset {
169 path: "/app/components/top-bar.js",
170 content_type: JS,
171 bytes: include_bytes!("../web/app/components/top-bar.js"),
172 },
173 Asset {
174 path: "/app/components/dashboard-page.js",
175 content_type: JS,
176 bytes: include_bytes!("../web/app/components/dashboard-page.js"),
177 },
178 Asset {
179 path: "/app/components/sensor-modal.js",
180 content_type: JS,
181 bytes: include_bytes!("../web/app/components/sensor-modal.js"),
182 },
183 Asset {
184 path: "/app/components/pairing-modal.js",
185 content_type: JS,
186 bytes: include_bytes!("../web/app/components/pairing-modal.js"),
187 },
188 Asset {
189 path: "/app/components/manage-modal.js",
190 content_type: JS,
191 bytes: include_bytes!("../web/app/components/manage-modal.js"),
192 },
193 Asset {
194 path: "/app/components/group-modal.js",
195 content_type: JS,
196 bytes: include_bytes!("../web/app/components/group-modal.js"),
197 },
198 Asset {
199 path: "/app/components/mesh-modal.js",
200 content_type: JS,
201 bytes: include_bytes!("../web/app/components/mesh-modal.js"),
202 },
203 Asset {
204 path: "/app/components/network-view.js",
205 content_type: JS,
206 bytes: include_bytes!("../web/app/components/network-view.js"),
207 },
208 Asset {
209 path: "/app/components/alarm-bar.js",
210 content_type: JS,
211 bytes: include_bytes!("../web/app/components/alarm-bar.js"),
212 },
213 Asset {
217 path: "/app/i18n/en.json",
218 content_type: JSON,
219 bytes: include_bytes!("../web/app/i18n/en.json"),
220 },
221];
222
223#[cfg(not(feature = "tier-c"))]
227fn locale_asset(path: &str) -> Option<(&'static str, &'static [u8])> {
228 let bytes: Option<&'static [u8]> = match path {
229 #[cfg(feature = "locale-sw")]
230 "/app/i18n/sw.json" => Some(include_bytes!("../web/app/i18n/sw.json")),
231 #[cfg(feature = "locale-ar")]
232 "/app/i18n/ar.json" => Some(include_bytes!("../web/app/i18n/ar.json")),
233 #[cfg(feature = "locale-fr")]
234 "/app/i18n/fr.json" => Some(include_bytes!("../web/app/i18n/fr.json")),
235 #[cfg(feature = "locale-pt")]
236 "/app/i18n/pt.json" => Some(include_bytes!("../web/app/i18n/pt.json")),
237 #[cfg(feature = "locale-hi")]
238 "/app/i18n/hi.json" => Some(include_bytes!("../web/app/i18n/hi.json")),
239 _ => None,
240 };
241 bytes.map(|bytes| (JSON, bytes))
242}
243
244#[cfg(not(feature = "tier-c"))]
253pub(crate) fn embedded_locales() -> Vec<&'static str> {
254 [
255 ("en", true),
256 ("sw", cfg!(feature = "locale-sw")),
257 ("ar", cfg!(feature = "locale-ar")),
258 ("fr", cfg!(feature = "locale-fr")),
259 ("pt", cfg!(feature = "locale-pt")),
260 ("hi", cfg!(feature = "locale-hi")),
261 ]
262 .into_iter()
263 .filter_map(|(tag, embedded)| embedded.then_some(tag))
264 .collect()
265}
266
267#[cfg(feature = "tier-c")]
273pub(crate) fn embedded_locales() -> Vec<&'static str> {
274 Vec::new()
275}
276
277fn embedded_get(path: &str) -> Option<(&'static str, Vec<u8>)> {
279 if let Some(asset) = EMBEDDED.iter().find(|a| a.path == path) {
280 return Some((asset.content_type, asset.bytes.to_vec()));
281 }
282 #[cfg(not(feature = "tier-c"))]
283 if let Some((content_type, bytes)) = locale_asset(path) {
284 return Some((content_type, bytes.to_vec()));
285 }
286 None
287}
288
289fn mime_for(path: &str) -> &'static str {
291 if path.ends_with(".html") {
292 HTML
293 } else if path.ends_with(".css") {
294 CSS
295 } else if path.ends_with(".js") || path.ends_with(".mjs") {
296 JS
297 } else if path.ends_with(".svg") {
298 "image/svg+xml"
299 } else if path.ends_with(".json") {
300 "application/json; charset=utf-8"
301 } else if path.ends_with(".ico") {
302 "image/x-icon"
303 } else if path.ends_with(".woff2") {
304 "font/woff2"
305 } else {
306 "application/octet-stream"
307 }
308}
309
310#[derive(Clone, Debug)]
312pub enum Assets {
313 Embedded,
315 #[cfg(feature = "serve")]
317 Dir(PathBuf),
318}
319
320impl Assets {
321 pub fn get(&self, path: &str) -> Option<(&'static str, Vec<u8>)> {
335 match self {
336 Assets::Embedded => embedded_get(path),
337 #[cfg(feature = "serve")]
338 Assets::Dir(root) => {
339 let relative = if path == "/" {
340 "index.html"
341 } else {
342 path.trim_start_matches('/')
343 };
344 if relative.contains("..") {
346 return None;
347 }
348 let bytes = std::fs::read(root.join(relative)).ok()?;
349 Some((mime_for(relative), bytes))
350 }
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
365 fn nothing_served_fetches_from_off_the_device() {
366 const LOADS: &[&str] = &[
367 "src=\"http",
368 "src='http",
369 "href=\"http",
370 "href='http",
371 "@import",
372 "url(http",
373 "fetch(\"http",
374 "fetch('http",
375 ];
376 for asset in EMBEDDED {
377 let text = String::from_utf8_lossy(asset.bytes);
378 for form in LOADS {
379 assert!(
380 !text.contains(form),
381 "{} loads from off the device with `{form}`",
382 asset.path
383 );
384 }
385 }
386 }
387
388 #[test]
389 fn embedded_serves_the_shell_for_root() {
390 let (content_type, bytes) = Assets::Embedded.get("/").expect("shell present");
391 assert_eq!(content_type, HTML);
392 assert!(!bytes.is_empty());
393 }
394
395 #[cfg(not(feature = "tier-c"))]
396 #[test]
397 fn embedded_serves_the_app_entry_and_framework() {
398 assert!(Assets::Embedded.get("/zquery.min.js").is_some());
399 assert!(Assets::Embedded.get("/app/app.js").is_some());
400 assert!(Assets::Embedded.get("/global.css").is_some());
401 }
402
403 #[cfg(feature = "tier-c")]
404 #[test]
405 fn tier_c_embeds_only_the_floor_page() {
406 let (content_type, _) = Assets::Embedded.get("/").expect("floor page present");
409 assert_eq!(content_type, HTML);
410 assert!(Assets::Embedded.get("/app/app.js").is_none());
411 assert!(Assets::Embedded.get("/zquery.min.js").is_none());
412 }
413
414 #[cfg(not(feature = "tier-c"))]
415 #[test]
416 fn the_full_bundle_also_embeds_the_floor_page() {
417 let (content_type, _) = Assets::Embedded
420 .get("/lite.html")
421 .expect("floor page embedded");
422 assert_eq!(content_type, HTML);
423 }
424
425 #[cfg(not(feature = "tier-c"))]
426 #[test]
427 fn embedded_serves_the_lib_modules() {
428 assert!(Assets::Embedded.get("/app/lib/feed.js").is_some());
431 assert!(Assets::Embedded.get("/app/lib/catalog.js").is_some());
432 assert!(Assets::Embedded.get("/app/lib/discovery.js").is_some());
433 assert!(Assets::Embedded.get("/app/lib/viz/index.js").is_some());
434 }
435
436 #[cfg(not(feature = "tier-c"))]
437 #[test]
438 fn embedded_serves_the_pairing_and_crypto_modules() {
439 for path in [
442 "/app/components/pairing-modal.js",
443 "/app/lib/pair.js",
444 "/app/lib/crypto/bytes.js",
445 "/app/lib/crypto/sha256.js",
446 "/app/lib/crypto/hmac.js",
447 "/app/lib/crypto/hkdf.js",
448 ] {
449 assert!(Assets::Embedded.get(path).is_some(), "missing {path}");
450 }
451 }
452
453 #[cfg(not(feature = "tier-c"))]
454 #[test]
455 fn every_viz_kind_is_known_to_the_page_renderer() {
456 let (_, bytes) = Assets::Embedded
461 .get("/app/lib/viz/index.js")
462 .expect("viz/index.js is embedded");
463 let js = String::from_utf8(bytes).expect("viz/index.js is utf8");
464 for viz in pamoja_profile::Viz::ALL {
465 let token = format!("'{}'", viz.kind());
466 assert!(
467 js.contains(&token),
468 "the page renderer does not know viz kind {}",
469 viz.kind()
470 );
471 }
472 }
473
474 #[cfg(all(not(feature = "tier-c"), feature = "all-locales"))]
475 #[test]
476 fn the_full_build_embeds_all_six_seed_locales() {
477 assert_eq!(embedded_locales(), ["en", "sw", "ar", "fr", "pt", "hi"]);
478 }
479
480 #[cfg(not(feature = "tier-c"))]
481 #[test]
482 fn english_is_embedded_and_every_listed_locale_is_served() {
483 assert_eq!(embedded_locales().first(), Some(&"en"));
486 for locale in embedded_locales() {
487 let path = format!("/app/i18n/{locale}.json");
488 assert!(
489 Assets::Embedded.get(&path).is_some(),
490 "listed but not served: {locale}"
491 );
492 }
493 }
494
495 #[cfg(feature = "tier-c")]
496 #[test]
497 fn tier_c_embeds_no_locale_bundles() {
498 assert!(embedded_locales().is_empty());
500 assert!(Assets::Embedded.get("/app/i18n/en.json").is_none());
501 }
502
503 #[cfg(not(feature = "tier-c"))]
504 #[test]
505 fn the_embedded_bundle_fits_the_flash_budget() {
506 const FLASH_BUDGET: usize = 600 * 1024;
509 let mut total: usize = EMBEDDED.iter().map(|a| a.bytes.len()).sum();
510 for locale in embedded_locales() {
511 if locale == "en" {
512 continue;
513 }
514 if let Some((_, bytes)) = locale_asset(&format!("/app/i18n/{locale}.json")) {
515 total += bytes.len();
516 }
517 }
518 assert!(
519 total <= FLASH_BUDGET,
520 "embedded bundle is {total} bytes, over the {FLASH_BUDGET}-byte flash budget"
521 );
522 }
523
524 #[test]
525 fn an_unknown_path_resolves_to_nothing() {
526 assert!(Assets::Embedded.get("/secret").is_none());
527 }
528
529 #[test]
530 fn mime_types_follow_the_extension() {
531 assert_eq!(mime_for("index.html"), HTML);
532 assert_eq!(mime_for("app/app.js"), JS);
533 assert_eq!(mime_for("global.css"), CSS);
534 }
535}