1use std::{collections::HashMap, fmt::Write as _, path::Path};
40
41use serde::Deserialize;
42use sha2::{Digest, Sha256};
43
44use cloudillo_types::meta_adapter::DocFormat;
45
46use crate::prelude::*;
47
48const DEFAULT_PUBLISHER_TAG: &str = "cloudillo.org";
51
52const SHELL_MANIFESTS_FILE: &str = "shell-apps.json";
55
56const APP_MANIFEST_FILE: &str = "cloudillo.json";
60
61#[derive(Debug, Default)]
63pub struct BundledAppRegistry {
64 formats: HashMap<Box<str>, DocFormat>,
65 pub rules_hash: Box<str>,
70 pub app_count: usize,
72}
73
74impl BundledAppRegistry {
75 pub fn load<F>(dist_dir: &Path, validate_search: F) -> Self
83 where
84 F: Fn(&serde_json::Value) -> Result<(), String>,
85 {
86 let mut manifests = read_manifests(dist_dir);
87 if manifests.is_empty() {
88 info!(dist_dir = %dist_dir.display(), "No bundled app manifests found");
89 return Self::default();
90 }
91 manifests.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
94
95 let mut formats: HashMap<Box<str>, (DocFormat, bool)> = HashMap::new();
98 let mut app_count = 0usize;
99 for LoadedManifest { manifest, updated_at } in &manifests {
100 let publisher = manifest.publisher.as_deref().unwrap_or(DEFAULT_PUBLISHER_TAG);
101 let mut contributed = false;
102 for ct in &manifest.content_types {
103 if let Some(search) = &ct.search
104 && let Err(e) = validate_search(search)
105 {
106 error!(app = %manifest.id, content_type = %ct.mime_type, error = %e,
107 "Bundled manifest has an invalid search block; skipping this format");
108 continue;
109 }
110 let primary = ct.priority.as_deref() == Some("primary");
111 if let Some((held, held_primary)) = formats.get(ct.mime_type.as_str())
112 && (*held_primary || !primary)
113 {
114 warn!(content_type = %ct.mime_type, held_by = %held.app_name,
115 challenger = %manifest.id,
116 "Two bundled apps declare the same content type; keeping the first");
117 continue;
118 }
119 let format = DocFormat {
120 content_type: ct.mime_type.as_str().into(),
121 publisher_tag: publisher.into(),
122 app_name: manifest.id.as_str().into(),
123 format_version: ct.format_version.as_deref().and_then(|v| {
124 let encoded = encode_format_version(v);
125 if encoded.is_none() {
126 warn!(app = %manifest.id, content_type = %ct.mime_type,
127 format_version = %v, "Unparseable formatVersion; ignoring it");
128 }
129 encoded
130 }),
131 store_tp: ct.store_tp.as_deref().map(Into::into),
132 nav_param: ct.nav_param.as_deref().map(Into::into),
133 search: ct.search.clone(),
134 x: None,
135 updated_at: *updated_at,
136 };
137 formats.insert(ct.mime_type.as_str().into(), (format, primary));
138 contributed = true;
139 }
140 app_count += usize::from(contributed);
141 }
142
143 let formats: HashMap<Box<str>, DocFormat> =
144 formats.into_iter().map(|(ct, (fmt, _))| (ct, fmt)).collect();
145 let rules_hash = rules_hash(&formats);
146 info!(apps = app_count, formats = formats.len(), rules_hash = %rules_hash,
147 "Bundled app manifests loaded");
148 Self { formats, rules_hash, app_count }
149 }
150
151 pub fn get(&self, content_type: &str) -> Option<&DocFormat> {
152 self.formats.get(content_type)
153 }
154
155 pub fn iter(&self) -> impl Iterator<Item = &DocFormat> {
156 self.formats.values()
157 }
158
159 pub fn is_empty(&self) -> bool {
160 self.formats.is_empty()
161 }
162
163 pub fn len(&self) -> usize {
164 self.formats.len()
165 }
166}
167
168struct LoadedManifest {
171 manifest: BundledManifest,
172 updated_at: Timestamp,
173}
174
175#[derive(Debug, Deserialize)]
181#[serde(rename_all = "camelCase")]
182struct BundledManifest {
183 id: String,
184 publisher: Option<String>,
187 #[serde(default)]
188 content_types: Vec<BundledContentType>,
189}
190
191#[derive(Debug, Deserialize)]
192#[serde(rename_all = "camelCase")]
193struct BundledContentType {
194 mime_type: String,
195 priority: Option<String>,
198 store_tp: Option<String>,
199 nav_param: Option<String>,
200 format_version: Option<String>,
202 search: Option<serde_json::Value>,
203}
204
205fn read_manifests(dist_dir: &Path) -> Vec<LoadedManifest> {
207 let mut out = Vec::new();
208 read_manifest_file(&dist_dir.join(SHELL_MANIFESTS_FILE), &mut out);
209
210 let apps_dir = dist_dir.join("apps");
211 let entries = match std::fs::read_dir(&apps_dir) {
212 Ok(entries) => entries,
213 Err(e) => {
214 info!(dir = %apps_dir.display(), error = %e, "No bundled app directory");
215 return out;
216 }
217 };
218 for entry in entries.flatten() {
219 read_manifest_file(&entry.path().join(APP_MANIFEST_FILE), &mut out);
220 }
221 out
222}
223
224fn read_manifest_file(path: &Path, out: &mut Vec<LoadedManifest>) {
227 let text = match std::fs::read_to_string(path) {
228 Ok(text) => text,
229 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
230 Err(e) => {
231 warn!(path = %path.display(), error = %e, "Cannot read bundled app manifest");
232 return;
233 }
234 };
235 let updated_at = file_mtime(path);
236 match parse_manifests(&text) {
237 Ok(manifests) => out
238 .extend(manifests.into_iter().map(|manifest| LoadedManifest { manifest, updated_at })),
239 Err(e) => warn!(path = %path.display(), error = %e, "Invalid bundled app manifest"),
240 }
241}
242
243fn parse_manifests(text: &str) -> ClResult<Vec<BundledManifest>> {
246 let value: serde_json::Value = serde_json::from_str(text)?;
247 match value {
248 serde_json::Value::Array(items) => items
249 .into_iter()
250 .map(|item| serde_json::from_value(item).map_err(Error::from))
251 .collect(),
252 other => Ok(vec![serde_json::from_value(other)?]),
253 }
254}
255
256fn file_mtime(path: &Path) -> Timestamp {
257 std::fs::metadata(path)
258 .and_then(|meta| meta.modified())
259 .ok()
260 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
261 .map_or(Timestamp(0), |d| Timestamp(d.as_secs().cast_signed()))
262}
263
264fn rules_hash(formats: &HashMap<Box<str>, DocFormat>) -> Box<str> {
274 let mut entries: Vec<(&str, String)> = formats
275 .iter()
276 .filter_map(|(ct, fmt)| fmt.search.as_ref().map(|s| (&**ct, s.to_string())))
277 .collect();
278 entries.sort_unstable();
279
280 let mut hasher = Sha256::new();
281 for (content_type, search) in entries {
282 hasher.update((content_type.len() as u64).to_le_bytes());
286 hasher.update(content_type.as_bytes());
287 hasher.update((search.len() as u64).to_le_bytes());
288 hasher.update(search.as_bytes());
289 }
290 let digest = hasher.finalize();
291 let mut out = String::with_capacity(16);
292 for b in &digest[..8] {
293 let _ = write!(&mut out, "{b:02x}");
294 }
295 out.into()
296}
297
298pub fn encode_format_version(s: &str) -> Option<i64> {
307 let mut parts = s.split('.');
308 let mut component = || -> Option<i64> {
309 let part = parts.next()?;
310 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
312 return None;
313 }
314 part.parse::<i64>().ok().filter(|v| *v <= 999)
315 };
316 let major = component()?;
317 let minor = component()?;
318 let patch = component()?;
319 if parts.next().is_some() {
320 return None;
321 }
322 Some(major * 1_000_000 + minor * 1_000 + patch)
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
327mod tests {
328 use super::*;
329
330 fn validate(v: &serde_json::Value) -> Result<(), String> {
333 if v.get("bad").is_some() { Err("bad rules".to_owned()) } else { Ok(()) }
334 }
335
336 #[test]
337 fn a_version_encodes_the_way_the_frontend_encodes_it() {
338 assert_eq!(encode_format_version("0.0.0"), Some(0));
339 assert_eq!(encode_format_version("1.0.0"), Some(1_000_000));
340 assert_eq!(encode_format_version("2.1.42"), Some(2_001_042));
341 assert_eq!(encode_format_version("999.999.999"), Some(999_999_999));
343 }
344
345 #[test]
346 fn anything_the_encoding_cannot_represent_is_no_version_at_all() {
347 assert_eq!(encode_format_version("1000.0.0"), None);
350 assert_eq!(encode_format_version("1.1000.0"), None);
351 assert_eq!(encode_format_version("1.0"), None);
352 assert_eq!(encode_format_version("1.0.0.0"), None);
353 assert_eq!(encode_format_version("x"), None);
354 assert_eq!(encode_format_version(""), None);
355 assert_eq!(encode_format_version("1.0.+0"), None);
356 assert_eq!(encode_format_version("-1.0.0"), None);
357 assert_eq!(encode_format_version("1.0.0-beta"), None);
358 }
359
360 fn rules(title: &str) -> serde_json::Value {
361 serde_json::json!({ "v": 1, "parts": [{ "kind": "p", "title": [title] }] })
362 }
363
364 fn write_app(dist: &Path, id: &str, manifest: &serde_json::Value) {
365 let dir = dist.join("apps").join(id);
366 std::fs::create_dir_all(&dir).expect("create app dir");
367 std::fs::write(dir.join(APP_MANIFEST_FILE), manifest.to_string()).expect("write manifest");
368 }
369
370 fn app_manifest(id: &str, mime: &str, search: Option<&serde_json::Value>) -> serde_json::Value {
371 serde_json::json!({
372 "id": id,
373 "name": id,
374 "version": "1.2.3",
375 "kind": "bundled",
376 "icon": "file",
377 "contentTypes": [{
378 "mimeType": mime,
379 "storeTp": "RTDB",
380 "navParam": "nav",
381 "formatVersion": "1.0.0",
382 "search": search
383 }]
384 })
385 }
386
387 #[test]
388 fn a_missing_dist_tree_is_an_empty_registry_not_a_failure() {
389 let reg = BundledAppRegistry::load(Path::new("/nonexistent/dist"), validate);
392 assert!(reg.is_empty());
393 assert_eq!(reg.app_count, 0);
394 }
395
396 #[test]
397 fn both_the_per_app_files_and_the_shell_aggregate_are_read() {
398 let dir = tempfile::tempdir().expect("tempdir");
399 let dist = dir.path();
400 write_app(
401 dist,
402 "notillo",
403 &app_manifest("notillo", "cloudillo/notillo", Some(&rules("ti"))),
404 );
405 std::fs::write(
408 dist.join(SHELL_MANIFESTS_FILE),
409 serde_json::json!([
410 app_manifest("files", "cloudillo/files", None),
411 app_manifest("feed", "cloudillo/feed", Some(&rules("fe"))),
412 ])
413 .to_string(),
414 )
415 .expect("write shell manifests");
416
417 let reg = BundledAppRegistry::load(dist, validate);
418
419 assert_eq!(reg.len(), 3);
420 assert_eq!(reg.app_count, 3);
421 let notillo = reg.get("cloudillo/notillo").expect("notillo missing");
422 assert_eq!(&*notillo.app_name, "notillo");
423 assert_eq!(&*notillo.publisher_tag, DEFAULT_PUBLISHER_TAG);
424 assert_eq!(notillo.format_version, Some(1_000_000));
425 assert_eq!(notillo.nav_param.as_deref(), Some("nav"));
426 assert_eq!(notillo.store_tp.as_deref(), Some("RTDB"));
427 assert!(reg.get("cloudillo/files").is_some());
430 }
431
432 #[test]
433 fn a_manifest_may_name_its_own_publisher() {
434 let dir = tempfile::tempdir().expect("tempdir");
435 let mut manifest = app_manifest("thirdillo", "x/thirdillo", None);
436 manifest["publisher"] = serde_json::json!("other.example");
437 write_app(dir.path(), "thirdillo", &manifest);
438
439 let reg = BundledAppRegistry::load(dir.path(), validate);
440
441 let fmt = reg.get("x/thirdillo").expect("thirdillo missing");
442 assert_eq!(&*fmt.publisher_tag, "other.example");
443 }
444
445 #[test]
446 fn a_primary_declaration_wins_a_contested_content_type() {
447 let dir = tempfile::tempdir().expect("tempdir");
448 let dist = dir.path();
449 write_app(dist, "aaa", &app_manifest("aaa", "cloudillo/shared", Some(&rules("a"))));
451 let mut zzz = app_manifest("zzz", "cloudillo/shared", Some(&rules("z")));
452 zzz["contentTypes"][0]["priority"] = serde_json::json!("primary");
453 write_app(dist, "zzz", &zzz);
454
455 let reg = BundledAppRegistry::load(dist, validate);
456
457 assert_eq!(reg.len(), 1);
458 let fmt = reg.get("cloudillo/shared").expect("shared missing");
459 assert_eq!(&*fmt.app_name, "zzz");
460 }
461
462 #[test]
463 fn without_a_primary_the_first_app_in_sorted_order_keeps_it() {
464 let dir = tempfile::tempdir().expect("tempdir");
466 let dist = dir.path();
467 write_app(dist, "aaa", &app_manifest("aaa", "cloudillo/shared", Some(&rules("a"))));
468 write_app(dist, "zzz", &app_manifest("zzz", "cloudillo/shared", Some(&rules("z"))));
469
470 let reg = BundledAppRegistry::load(dist, validate);
471
472 let fmt = reg.get("cloudillo/shared").expect("shared missing");
473 assert_eq!(&*fmt.app_name, "aaa");
474 }
475
476 #[test]
477 fn an_invalid_search_block_costs_only_its_own_format() {
478 let dir = tempfile::tempdir().expect("tempdir");
480 let dist = dir.path();
481 write_app(
482 dist,
483 "broken",
484 &app_manifest("broken", "cloudillo/broken", Some(&serde_json::json!({ "bad": true }))),
485 );
486 write_app(
487 dist,
488 "notillo",
489 &app_manifest("notillo", "cloudillo/notillo", Some(&rules("ti"))),
490 );
491
492 let reg = BundledAppRegistry::load(dist, validate);
493
494 assert!(reg.get("cloudillo/broken").is_none());
495 assert!(reg.get("cloudillo/notillo").is_some());
496 }
497
498 #[test]
499 fn unparseable_json_costs_only_its_own_file() {
500 let dir = tempfile::tempdir().expect("tempdir");
501 let dist = dir.path();
502 std::fs::create_dir_all(dist.join("apps").join("junk")).expect("create junk dir");
503 std::fs::write(dist.join("apps").join("junk").join(APP_MANIFEST_FILE), "{ not json")
504 .expect("write junk");
505 write_app(
506 dist,
507 "notillo",
508 &app_manifest("notillo", "cloudillo/notillo", Some(&rules("ti"))),
509 );
510
511 let reg = BundledAppRegistry::load(dist, validate);
512
513 assert_eq!(reg.len(), 1);
514 assert!(reg.get("cloudillo/notillo").is_some());
515 }
516
517 #[test]
518 fn the_rules_hash_tracks_the_search_blocks_and_nothing_else() {
519 let mut a: HashMap<Box<str>, DocFormat> = HashMap::new();
520 let fmt = |ct: &str, nav: &str, search: serde_json::Value| DocFormat {
521 content_type: ct.into(),
522 publisher_tag: "cloudillo.org".into(),
523 app_name: "notillo".into(),
524 format_version: Some(1_000_000),
525 store_tp: Some("RTDB".into()),
526 nav_param: Some(nav.into()),
527 search: Some(search),
528 x: None,
529 updated_at: Timestamp(0),
530 };
531 a.insert("x/one".into(), fmt("x/one", "nav", rules("ti")));
532 a.insert("x/two".into(), fmt("x/two", "nav", rules("tj")));
533
534 let mut b: HashMap<Box<str>, DocFormat> = HashMap::new();
537 b.insert("x/two".into(), fmt("x/two", "nav", rules("tj")));
538 b.insert("x/one".into(), fmt("x/one", "nav", rules("ti")));
539 assert_eq!(rules_hash(&a), rules_hash(&b));
540
541 let mut nav_changed = a.clone();
543 nav_changed.insert("x/one".into(), fmt("x/one", "page", rules("ti")));
544 assert_eq!(rules_hash(&a), rules_hash(&nav_changed));
545
546 let mut rules_changed = a.clone();
548 rules_changed.insert("x/one".into(), fmt("x/one", "nav", rules("changed")));
549 assert_ne!(rules_hash(&a), rules_hash(&rules_changed));
550 }
551}
552
553