1use serde::{Deserialize, Serialize};
13
14use super::asset::{validate_asset, AssetKind, ManifestAsset, MAX_ASSETS};
15use crate::scheduler::content_pack::{validate_pack, ContentPack};
16
17pub const MANIFEST_VERSION: u32 = 1;
20
21pub const SUPPORTED_ABI_VERSION: u32 = 1;
24
25const MAX_STRING_LEN: usize = 1_000;
28const MAX_ID_LEN: usize = 128;
29const MAX_IMPORTS: usize = 16;
30const MAX_SCOPE_LEN: usize = 512;
31
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum PluginKind {
36 Content,
37 Detector,
38 Export,
39}
40
41impl PluginKind {
42 fn is_code_bearing(self) -> bool {
46 matches!(self, PluginKind::Detector)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Capability {
56 DetectForegroundWindow,
58 DetectProcesses,
60 DetectFile(String),
62 ExportFile(String),
64 ExportHttp(String),
67}
68
69impl Capability {
70 pub fn parse(raw: &str) -> Result<Self, String> {
74 let unscoped = match raw {
75 "detect:foreground-window" => Some(Capability::DetectForegroundWindow),
76 "detect:processes" => Some(Capability::DetectProcesses),
77 _ => None,
78 };
79 if let Some(cap) = unscoped {
80 return Ok(cap);
81 }
82
83 let (prefix, scope) = raw
84 .split_once(':')
85 .and_then(|(head, rest)| rest.split_once(':').map(|(mid, tail)| (head, mid, tail)))
86 .map(|(head, mid, tail)| (format!("{head}:{mid}"), tail))
87 .ok_or_else(|| format!("unknown capability '{raw}'"))?;
88
89 if scope.trim().is_empty() {
90 return Err(format!("capability '{raw}' is missing a scope"));
91 }
92 if scope.chars().count() > MAX_SCOPE_LEN {
93 return Err(format!("capability '{prefix}' scope is too long"));
94 }
95 match prefix.as_str() {
96 "detect:file" => Ok(Capability::DetectFile(scope.to_string())),
97 "export:file" => Ok(Capability::ExportFile(scope.to_string())),
98 "export:http" => Ok(Capability::ExportHttp(scope.to_string())),
99 _ => Err(format!("unknown capability '{raw}'")),
100 }
101 }
102
103 pub fn as_string(&self) -> String {
105 match self {
106 Capability::DetectForegroundWindow => "detect:foreground-window".to_string(),
107 Capability::DetectProcesses => "detect:processes".to_string(),
108 Capability::DetectFile(s) => format!("detect:file:{s}"),
109 Capability::ExportFile(s) => format!("export:file:{s}"),
110 Capability::ExportHttp(s) => format!("export:http:{s}"),
111 }
112 }
113
114 fn allowed_for(&self, kind: PluginKind) -> bool {
117 match self {
118 Capability::DetectForegroundWindow
119 | Capability::DetectProcesses
120 | Capability::DetectFile(_) => kind == PluginKind::Detector,
121 Capability::ExportFile(_) | Capability::ExportHttp(_) => kind == PluginKind::Export,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DetectConfig {
132 #[serde(default)]
136 pub process_name: Option<String>,
137}
138
139#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum ExportSink {
143 File,
145 Http,
147}
148
149#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum ExportFormat {
153 Csv,
154 Json,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162pub struct ExportConfig {
163 pub sink: ExportSink,
164 pub format: ExportFormat,
165 pub destination: String,
168 pub on: Vec<crate::hooks::HookEvent>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct Signature {
177 pub alg: String,
178 pub public_key: String,
179 pub sig: String,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct Manifest {
187 pub manifest_version: u32,
188 pub id: String,
189 pub name: String,
190 pub version: String,
191 #[serde(default)]
192 pub author: String,
193 #[serde(default)]
194 pub description: String,
195 pub kind: PluginKind,
196 #[serde(default)]
198 pub module: Option<String>,
199 #[serde(default)]
203 pub module_base64: Option<String>,
204 #[serde(default)]
205 pub abi_version: Option<u32>,
206 #[serde(default)]
207 pub imports: Vec<String>,
208 #[serde(default)]
209 pub detect: Option<DetectConfig>,
210 #[serde(default)]
211 pub export: Option<ExportConfig>,
212 #[serde(default)]
213 pub content: Option<ContentPack>,
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub assets: Vec<ManifestAsset>,
222 pub signature: Signature,
223}
224
225pub fn parse_manifest(json: &str) -> Result<Manifest, String> {
228 serde_json::from_str(json).map_err(|e| format!("not a valid plugin manifest: {e}"))
229}
230
231fn check_string(value: &str, what: &str) -> Result<(), String> {
232 if value.chars().count() > MAX_STRING_LEN {
233 return Err(format!("{what} exceeds {MAX_STRING_LEN} characters"));
234 }
235 Ok(())
236}
237
238fn validate_id(id: &str) -> Result<(), String> {
242 if id.trim().is_empty() {
243 return Err("plugin is missing an id".to_string());
244 }
245 if id.chars().count() > MAX_ID_LEN {
246 return Err(format!("plugin id exceeds {MAX_ID_LEN} characters"));
247 }
248 if !id.contains('.') {
249 return Err("plugin id must be reverse-DNS (e.g. com.example.name)".to_string());
250 }
251 if !id
252 .chars()
253 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-')
254 {
255 return Err("plugin id may only contain a-z, 0-9, '.', and '-'".to_string());
256 }
257 Ok(())
258}
259
260pub fn validate_manifest(m: &Manifest) -> Result<(), String> {
266 if m.manifest_version != MANIFEST_VERSION {
267 return Err(format!(
268 "unsupported manifest version {} (this build reads version {MANIFEST_VERSION})",
269 m.manifest_version
270 ));
271 }
272
273 validate_id(&m.id)?;
274
275 if m.name.trim().is_empty() {
276 return Err("plugin is missing a name".to_string());
277 }
278 check_string(&m.name, "plugin name")?;
279 if m.version.trim().is_empty() {
280 return Err("plugin is missing a version".to_string());
281 }
282 check_string(&m.version, "plugin version")?;
283 check_string(&m.author, "plugin author")?;
284 check_string(&m.description, "plugin description")?;
285
286 if m.kind.is_code_bearing() {
287 match &m.module {
288 Some(path) if !path.trim().is_empty() => check_string(path, "module path")?,
289 _ => {
290 return Err(format!("a {:?} plugin must reference a module", m.kind).to_lowercase())
291 }
292 }
293 match m.abi_version {
294 Some(v) if v == SUPPORTED_ABI_VERSION => {}
295 Some(v) => {
296 return Err(format!(
297 "unsupported ABI version {v} (this build exposes version {SUPPORTED_ABI_VERSION})"
298 ))
299 }
300 None => return Err("a code-bearing plugin must declare an abi_version".to_string()),
301 }
302 if m.imports.is_empty() {
303 return Err("a code-bearing plugin must import at least one capability".to_string());
304 }
305 if m.content.is_some() {
306 return Err("a code-bearing plugin must not carry a content payload".to_string());
307 }
308 if m.export.is_some() {
309 return Err("a code-bearing plugin must not carry an export config".to_string());
310 }
311 } else {
312 if m.module.is_some() || m.module_base64.is_some() {
314 return Err(format!("a {:?} plugin must not reference a module", m.kind).to_lowercase());
315 }
316 if m.abi_version.is_some() {
317 return Err(
318 format!("a {:?} plugin must not declare an abi_version", m.kind).to_lowercase(),
319 );
320 }
321 if !m.imports.is_empty() {
322 return Err(
323 format!("a {:?} plugin must not import capabilities", m.kind).to_lowercase(),
324 );
325 }
326 if m.kind == PluginKind::Content {
328 match &m.content {
329 Some(pack) => validate_pack(pack)?,
330 None => return Err("a content plugin must carry a content payload".to_string()),
331 }
332 if m.export.is_some() {
333 return Err("a content plugin must not carry an export config".to_string());
334 }
335 } else {
336 match &m.export {
337 Some(cfg) => validate_export_config(cfg)?,
338 None => return Err("an export plugin must carry an export config".to_string()),
339 }
340 if m.content.is_some() {
341 return Err("an export plugin must not carry a content payload".to_string());
342 }
343 }
344 }
345
346 if m.imports.len() > MAX_IMPORTS {
347 return Err(format!(
348 "plugin imports more than {MAX_IMPORTS} capabilities"
349 ));
350 }
351 let mut seen = std::collections::HashSet::new();
352 for raw in &m.imports {
353 let cap = Capability::parse(raw)?;
354 if !seen.insert(cap.as_string()) {
355 return Err(format!("duplicate capability '{raw}'"));
356 }
357 if !cap.allowed_for(m.kind) {
358 return Err(
359 format!("capability '{raw}' is not valid for a {:?} plugin", m.kind).to_lowercase(),
360 );
361 }
362 }
363
364 if let Some(detect) = &m.detect {
365 if m.kind != PluginKind::Detector {
366 return Err("only a detector plugin may carry a detect config".to_string());
367 }
368 if let Some(pattern) = &detect.process_name {
369 check_string(pattern, "detect process_name")?;
370 if !m.imports.iter().any(|i| i == "detect:processes") {
371 return Err("detect.process_name requires a 'detect:processes' import".to_string());
372 }
373 }
374 }
375
376 validate_assets(m)?;
377
378 Ok(())
379}
380
381fn validate_assets(m: &Manifest) -> Result<(), String> {
387 if !m.assets.is_empty() && m.kind != PluginKind::Content {
388 return Err(format!("a {:?} plugin must not carry assets", m.kind).to_lowercase());
389 }
390 if m.assets.len() > MAX_ASSETS {
391 return Err(format!("plugin carries more than {MAX_ASSETS} assets"));
392 }
393 let mut kinds: std::collections::HashMap<&str, AssetKind> = std::collections::HashMap::new();
394 for asset in &m.assets {
395 let (_, kind) = validate_asset(asset)?;
396 if kinds.insert(asset.id.as_str(), kind).is_some() {
397 return Err(format!("duplicate asset id '{}'", asset.id));
398 }
399 }
400 let resolve = |id: &str, want_audio: bool, rid: &str| -> Result<(), String> {
401 match kinds.get(id) {
402 None => Err(format!("routine '{rid}' references unknown asset '{id}'")),
403 Some(kind) if kind.is_audio() != want_audio => Err(format!(
404 "routine '{rid}' references '{id}' as {} but it is {}",
405 if want_audio { "a sound" } else { "an image" },
406 if kind.is_audio() { "audio" } else { "an image" }
407 )),
408 Some(_) => Ok(()),
409 }
410 };
411 if let Some(pack) = &m.content {
412 for r in &pack.routines {
413 for st in &r.steps {
414 if let Some(id) = &st.asset {
415 resolve(id, false, &r.id)?;
416 }
417 if let Some(id) = &st.sound {
418 resolve(id, true, &r.id)?;
419 }
420 }
421 if let Some(sounds) = r.breath.as_ref().and_then(|b| b.sounds.as_ref()) {
422 for id in [
423 &sounds.inhale,
424 &sounds.hold,
425 &sounds.exhale,
426 &sounds.hold_out,
427 ]
428 .into_iter()
429 .flatten()
430 {
431 resolve(id, true, &r.id)?;
432 }
433 }
434 }
435 }
436 Ok(())
437}
438
439fn validate_export_config(cfg: &ExportConfig) -> Result<(), String> {
443 if cfg.destination.trim().is_empty() {
444 return Err("export destination is empty".to_string());
445 }
446 check_string(&cfg.destination, "export destination")?;
447 if cfg.sink == ExportSink::Http
448 && !(cfg.destination.starts_with("http://") || cfg.destination.starts_with("https://"))
449 {
450 return Err("an http export destination must be an http(s):// URL".to_string());
451 }
452 if cfg.on.is_empty() {
453 return Err("an export plugin must subscribe to at least one event".to_string());
454 }
455 Ok(())
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn sig() -> Signature {
463 Signature {
464 alg: "ed25519".to_string(),
465 public_key: "AA==".to_string(),
466 sig: "AA==".to_string(),
467 }
468 }
469
470 fn detector_manifest() -> Manifest {
471 Manifest {
472 manifest_version: MANIFEST_VERSION,
473 id: "com.example.focus".to_string(),
474 name: "Focus detector".to_string(),
475 version: "1.0.0".to_string(),
476 author: "Jane".to_string(),
477 description: "Suppress while focused.".to_string(),
478 kind: PluginKind::Detector,
479 module: Some("module.wasm".to_string()),
480 module_base64: None,
481 abi_version: Some(SUPPORTED_ABI_VERSION),
482 imports: vec!["detect:foreground-window".to_string()],
483 detect: None,
484 export: None,
485 content: None,
486 assets: Vec::new(),
487 signature: sig(),
488 }
489 }
490
491 fn content_manifest() -> Manifest {
492 Manifest {
493 manifest_version: MANIFEST_VERSION,
494 id: "com.example.pack".to_string(),
495 name: "Idea pack".to_string(),
496 version: "1.0.0".to_string(),
497 author: String::new(),
498 description: String::new(),
499 kind: PluginKind::Content,
500 module: None,
501 module_base64: None,
502 abi_version: None,
503 imports: vec![],
504 detect: None,
505 export: None,
506 content: Some(sample_pack()),
507 assets: Vec::new(),
508 signature: sig(),
509 }
510 }
511
512 fn sample_pack() -> ContentPack {
513 use crate::scheduler::content_pack::PackHints;
514 ContentPack {
515 version: crate::scheduler::content_pack::CONTENT_PACK_VERSION,
516 name: "Idea pack".to_string(),
517 hints: PackHints {
518 micro_physical: vec!["Roll your shoulders".to_string()],
519 ..PackHints::default()
520 },
521 routines: vec![],
522 }
523 }
524
525 fn export_manifest() -> Manifest {
526 Manifest {
527 manifest_version: MANIFEST_VERSION,
528 id: "com.example.export".to_string(),
529 name: "CSV export".to_string(),
530 version: "1.0.0".to_string(),
531 author: "Jane".to_string(),
532 description: String::new(),
533 kind: PluginKind::Export,
534 module: None,
535 module_base64: None,
536 abi_version: None,
537 imports: vec![],
538 detect: None,
539 export: Some(ExportConfig {
540 sink: ExportSink::Http,
541 format: ExportFormat::Json,
542 destination: "http://127.0.0.1:8080/entracte".to_string(),
543 on: vec![crate::hooks::HookEvent::BreakEnd],
544 }),
545 content: None,
546 assets: Vec::new(),
547 signature: sig(),
548 }
549 }
550
551 #[test]
552 fn validate_accepts_a_well_formed_export_plugin() {
553 assert!(validate_manifest(&export_manifest()).is_ok());
554 }
555
556 #[test]
557 fn validate_rejects_export_without_a_config() {
558 let mut m = export_manifest();
559 m.export = None;
560 assert!(validate_manifest(&m)
561 .unwrap_err()
562 .contains("must carry an export config"));
563 }
564
565 #[test]
566 fn validate_rejects_export_with_empty_or_non_http_destination() {
567 let mut m = export_manifest();
568 m.export.as_mut().unwrap().destination = " ".to_string();
569 assert!(validate_manifest(&m)
570 .unwrap_err()
571 .contains("destination is empty"));
572
573 let mut m = export_manifest();
574 m.export.as_mut().unwrap().destination = "ftp://evil/x".to_string();
575 assert!(validate_manifest(&m)
576 .unwrap_err()
577 .contains("must be an http(s):// URL"));
578 }
579
580 #[test]
581 fn validate_rejects_export_with_no_events() {
582 let mut m = export_manifest();
583 m.export.as_mut().unwrap().on = vec![];
584 assert!(validate_manifest(&m)
585 .unwrap_err()
586 .contains("at least one event"));
587 }
588
589 #[test]
590 fn validate_rejects_export_carrying_module_or_content() {
591 let mut m = export_manifest();
592 m.module = Some("m.wasm".to_string());
593 assert!(validate_manifest(&m)
594 .unwrap_err()
595 .contains("must not reference a module"));
596
597 let mut m = export_manifest();
598 m.content = Some(sample_pack());
599 assert!(validate_manifest(&m)
600 .unwrap_err()
601 .contains("must not carry a content payload"));
602 }
603
604 #[test]
605 fn validate_rejects_export_config_on_a_content_plugin() {
606 let mut m = content_manifest();
607 m.export = export_manifest().export;
608 assert!(validate_manifest(&m)
609 .unwrap_err()
610 .contains("must not carry an export config"));
611 }
612
613 #[test]
614 fn validate_rejects_export_config_on_a_detector() {
615 let mut m = detector_manifest();
616 m.export = export_manifest().export;
617 assert!(validate_manifest(&m)
618 .unwrap_err()
619 .contains("a code-bearing plugin must not carry an export config"));
620 }
621
622 #[test]
623 fn validate_accepts_a_file_export_with_any_destination() {
624 let mut m = export_manifest();
625 let cfg = m.export.as_mut().unwrap();
626 cfg.sink = ExportSink::File;
627 cfg.format = ExportFormat::Csv;
628 cfg.destination = "/home/me/breaks.csv".to_string();
629 assert!(validate_manifest(&m).is_ok());
630 }
631
632 #[test]
633 fn parse_rejects_garbage() {
634 assert!(parse_manifest("{ not json").is_err());
635 }
636
637 #[test]
638 fn manifest_serde_round_trips() {
639 let json = serde_json::to_string(&detector_manifest()).unwrap();
640 let back = parse_manifest(&json).unwrap();
641 assert_eq!(back, detector_manifest());
642 assert!(json.contains("\"kind\":\"detector\""));
643 }
644
645 #[test]
646 fn capability_parse_round_trips_every_form() {
647 for raw in [
648 "detect:foreground-window",
649 "detect:processes",
650 "detect:file:/home/me/.flag",
651 "export:file:/home/me/out.csv",
652 "export:http:127.0.0.1:8080",
653 ] {
654 let cap = Capability::parse(raw).unwrap();
655 assert_eq!(cap.as_string(), raw, "round-trip for {raw}");
656 }
657 }
658
659 #[test]
660 fn capability_parse_rejects_unknown_and_unscoped() {
661 assert!(Capability::parse("detect:webcam")
662 .unwrap_err()
663 .contains("unknown"));
664 assert!(Capability::parse("export:file")
665 .unwrap_err()
666 .contains("unknown"));
667 assert!(Capability::parse("export:file:")
668 .unwrap_err()
669 .contains("missing a scope"));
670 }
671
672 #[test]
673 fn capability_http_scope_keeps_host_and_port() {
674 let cap = Capability::parse("export:http:localhost:9000").unwrap();
675 assert_eq!(cap, Capability::ExportHttp("localhost:9000".to_string()));
676 }
677
678 #[test]
679 fn validate_accepts_a_well_formed_detector() {
680 assert!(validate_manifest(&detector_manifest()).is_ok());
681 }
682
683 #[test]
684 fn validate_accepts_a_well_formed_content_plugin() {
685 assert!(validate_manifest(&content_manifest()).is_ok());
686 }
687
688 #[test]
689 fn validate_rejects_wrong_manifest_version() {
690 let mut m = detector_manifest();
691 m.manifest_version = 99;
692 assert!(validate_manifest(&m)
693 .unwrap_err()
694 .contains("unsupported manifest version 99"));
695 }
696
697 #[test]
698 fn validate_rejects_bad_ids() {
699 let mut m = detector_manifest();
700 m.id = "missing".to_string();
701 assert!(validate_manifest(&m).unwrap_err().contains("reverse-DNS"));
702
703 m.id = "com.Example.Caps".to_string();
704 assert!(validate_manifest(&m).unwrap_err().contains("a-z"));
705
706 m.id = " ".to_string();
707 assert!(validate_manifest(&m).unwrap_err().contains("missing an id"));
708 }
709
710 #[test]
711 fn validate_rejects_unsupported_abi() {
712 let mut m = detector_manifest();
713 m.abi_version = Some(SUPPORTED_ABI_VERSION + 1);
714 assert!(validate_manifest(&m)
715 .unwrap_err()
716 .contains("unsupported ABI version"));
717 }
718
719 #[test]
720 fn validate_requires_module_and_abi_for_code_bearing() {
721 let mut m = detector_manifest();
722 m.module = None;
723 assert!(validate_manifest(&m)
724 .unwrap_err()
725 .contains("must reference a module"));
726
727 let mut m = detector_manifest();
728 m.abi_version = None;
729 assert!(validate_manifest(&m)
730 .unwrap_err()
731 .contains("must declare an abi_version"));
732
733 let mut m = detector_manifest();
734 m.imports = vec![];
735 assert!(validate_manifest(&m)
736 .unwrap_err()
737 .contains("must import at least one capability"));
738 }
739
740 #[test]
741 fn validate_forbids_module_and_imports_on_content() {
742 let mut m = content_manifest();
743 m.module = Some("x.wasm".to_string());
744 assert!(validate_manifest(&m)
745 .unwrap_err()
746 .contains("must not reference a module"));
747
748 let mut m = content_manifest();
749 m.imports = vec!["detect:processes".to_string()];
750 assert!(validate_manifest(&m)
751 .unwrap_err()
752 .contains("must not import"));
753
754 let mut m = content_manifest();
755 m.content = None;
756 assert!(validate_manifest(&m)
757 .unwrap_err()
758 .contains("must carry a content payload"));
759 }
760
761 #[test]
762 fn validate_rejects_capability_mismatched_to_kind() {
763 let mut m = detector_manifest();
764 m.imports = vec!["export:http:127.0.0.1:8080".to_string()];
765 assert!(validate_manifest(&m)
766 .unwrap_err()
767 .contains("not valid for a detector"));
768 }
769
770 #[test]
771 fn validate_rejects_duplicate_imports() {
772 let mut m = detector_manifest();
773 m.imports = vec![
774 "detect:processes".to_string(),
775 "detect:processes".to_string(),
776 ];
777 assert!(validate_manifest(&m)
778 .unwrap_err()
779 .contains("duplicate capability"));
780 }
781
782 #[test]
783 fn validate_rejects_too_many_imports() {
784 let mut m = detector_manifest();
785 m.imports = (0..(MAX_IMPORTS + 1))
786 .map(|i| format!("detect:file:/p/{i}"))
787 .collect();
788 assert!(validate_manifest(&m).unwrap_err().contains("more than"));
789 }
790
791 #[test]
792 fn capability_parse_rejects_overlong_scope() {
793 let raw = format!("detect:file:{}", "a".repeat(MAX_SCOPE_LEN + 1));
794 assert!(Capability::parse(&raw).unwrap_err().contains("too long"));
795 }
796
797 #[test]
798 fn capability_parse_rejects_unknown_scoped_prefix() {
799 assert!(Capability::parse("foo:bar:baz")
800 .unwrap_err()
801 .contains("unknown"));
802 }
803
804 #[test]
805 fn validate_rejects_overlong_string_field() {
806 let mut m = detector_manifest();
807 m.description = "a".repeat(MAX_STRING_LEN + 1);
808 assert!(validate_manifest(&m).unwrap_err().contains("exceeds"));
809 }
810
811 #[test]
812 fn validate_rejects_overlong_id() {
813 let mut m = detector_manifest();
814 m.id = format!("com.{}", "a".repeat(MAX_ID_LEN));
815 assert!(validate_manifest(&m).unwrap_err().contains("exceeds"));
816 }
817
818 #[test]
819 fn validate_rejects_empty_name_and_version() {
820 let mut m = detector_manifest();
821 m.name = " ".to_string();
822 assert!(validate_manifest(&m)
823 .unwrap_err()
824 .contains("missing a name"));
825
826 let mut m = detector_manifest();
827 m.version = String::new();
828 assert!(validate_manifest(&m)
829 .unwrap_err()
830 .contains("missing a version"));
831 }
832
833 #[test]
834 fn validate_accepts_a_detector_with_a_process_detect_config() {
835 let mut m = detector_manifest();
836 m.imports = vec!["detect:processes".to_string()];
837 m.detect = Some(DetectConfig {
838 process_name: Some("zoom".to_string()),
839 });
840 assert!(validate_manifest(&m).is_ok());
841 }
842
843 #[test]
844 fn validate_rejects_detect_config_on_non_detector() {
845 let mut m = content_manifest();
846 m.detect = Some(DetectConfig::default());
847 assert!(validate_manifest(&m)
848 .unwrap_err()
849 .contains("only a detector plugin may carry a detect config"));
850 }
851
852 #[test]
853 fn validate_accepts_a_detector_with_an_empty_detect_config() {
854 let mut m = detector_manifest();
855 m.detect = Some(DetectConfig::default()); assert!(validate_manifest(&m).is_ok());
857 }
858
859 #[test]
860 fn validate_rejects_an_overlong_process_name() {
861 let mut m = detector_manifest();
862 m.imports = vec!["detect:processes".to_string()];
863 m.detect = Some(DetectConfig {
864 process_name: Some("a".repeat(MAX_STRING_LEN + 1)),
865 });
866 assert!(validate_manifest(&m).unwrap_err().contains("exceeds"));
867 }
868
869 #[test]
870 fn validate_rejects_process_name_without_the_processes_import() {
871 let mut m = detector_manifest(); m.detect = Some(DetectConfig {
873 process_name: Some("zoom".to_string()),
874 });
875 assert!(validate_manifest(&m)
876 .unwrap_err()
877 .contains("requires a 'detect:processes' import"));
878 }
879
880 #[test]
881 fn validate_forbids_content_payload_on_code_bearing() {
882 let mut m = detector_manifest();
883 m.content = Some(sample_pack());
884 assert!(validate_manifest(&m)
885 .unwrap_err()
886 .contains("must not carry a content payload"));
887 }
888
889 #[test]
890 fn validate_forbids_abi_version_on_content() {
891 let mut m = content_manifest();
892 m.abi_version = Some(SUPPORTED_ABI_VERSION);
893 assert!(validate_manifest(&m)
894 .unwrap_err()
895 .contains("must not declare an abi_version"));
896 }
897
898 fn png_asset(id: &str) -> ManifestAsset {
899 use base64::prelude::{Engine, BASE64_STANDARD};
900 let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
901 bytes.extend_from_slice(&[0, 0, 0, 13]);
902 bytes.extend_from_slice(b"IHDR");
903 bytes.extend_from_slice(&10u32.to_be_bytes());
904 bytes.extend_from_slice(&10u32.to_be_bytes());
905 bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
906 let hash = crate::plugins::sha256(&bytes);
907 ManifestAsset {
908 id: id.to_string(),
909 sha256: hash.iter().map(|b| format!("{b:02x}")).collect(),
910 data_base64: BASE64_STANDARD.encode(&bytes),
911 }
912 }
913
914 fn ogg_asset(id: &str) -> ManifestAsset {
915 use base64::prelude::{Engine, BASE64_STANDARD};
916 let mut bytes = b"OggS".to_vec();
917 bytes.extend_from_slice(&[0u8; 32]);
918 let hash = crate::plugins::sha256(&bytes);
919 ManifestAsset {
920 id: id.to_string(),
921 sha256: hash.iter().map(|b| format!("{b:02x}")).collect(),
922 data_base64: BASE64_STANDARD.encode(&bytes),
923 }
924 }
925
926 fn content_manifest_referencing(asset_id: &str) -> Manifest {
928 use crate::scheduler::{
929 Routine, RoutineCategory, RoutineDifficulty, RoutineKind, RoutineStep,
930 };
931 let mut m = content_manifest();
932 let pack = m.content.as_mut().unwrap();
933 pack.routines.push(Routine {
934 id: "r1".to_string(),
935 label: "R".to_string(),
936 kind: RoutineKind::Micro,
937 category: RoutineCategory::Mobility,
938 difficulty: RoutineDifficulty::Gentle,
939 steps: vec![RoutineStep {
940 text: "stretch".to_string(),
941 seconds: 5,
942 asset: Some(asset_id.to_string()),
943 sound: None,
944 }],
945 pacing: None,
946 max_step_secs: None,
947 breath: None,
948 });
949 m
950 }
951
952 #[test]
953 fn validate_accepts_content_with_a_referenced_asset() {
954 let mut m = content_manifest_referencing("twist");
955 m.assets = vec![png_asset("twist")];
956 assert!(validate_manifest(&m).is_ok());
957 }
958
959 #[test]
960 fn validate_accepts_a_mix_of_referencing_and_plain_steps() {
961 use crate::scheduler::RoutineStep;
962 let mut m = content_manifest_referencing("twist");
965 m.assets = vec![png_asset("twist")];
966 m.content.as_mut().unwrap().routines[0]
967 .steps
968 .push(RoutineStep {
969 text: "rest".to_string(),
970 seconds: 5,
971 asset: None,
972 sound: None,
973 });
974 assert!(validate_manifest(&m).is_ok());
975 }
976
977 #[test]
978 fn validate_rejects_assets_on_a_non_content_plugin() {
979 let mut m = detector_manifest();
980 m.assets = vec![png_asset("twist")];
981 assert!(validate_manifest(&m)
982 .unwrap_err()
983 .contains("must not carry assets"));
984 }
985
986 #[test]
987 fn validate_rejects_an_unresolved_asset_reference() {
988 let mut m = content_manifest_referencing("missing");
989 m.assets = vec![png_asset("twist")];
990 assert!(validate_manifest(&m)
991 .unwrap_err()
992 .contains("references unknown asset"));
993 }
994
995 fn with_step_sound(asset_id: &str) -> Manifest {
998 let mut m = content_manifest_referencing("ignored");
999 let step = &mut m.content.as_mut().unwrap().routines[0].steps[0];
1000 step.asset = None;
1001 step.sound = Some(asset_id.to_string());
1002 m
1003 }
1004
1005 #[test]
1006 fn validate_accepts_a_step_sound_referencing_audio() {
1007 let mut m = with_step_sound("chime");
1008 m.assets = vec![ogg_asset("chime")];
1009 assert!(validate_manifest(&m).is_ok());
1010 }
1011
1012 #[test]
1013 fn validate_rejects_a_step_sound_that_points_at_an_image() {
1014 let mut m = with_step_sound("pic");
1015 m.assets = vec![png_asset("pic")];
1016 assert!(validate_manifest(&m)
1017 .unwrap_err()
1018 .contains("as a sound but it is an image"));
1019 }
1020
1021 #[test]
1022 fn validate_rejects_a_step_image_that_points_at_audio() {
1023 let mut m = content_manifest_referencing("chime");
1024 m.assets = vec![ogg_asset("chime")];
1025 assert!(validate_manifest(&m)
1026 .unwrap_err()
1027 .contains("as an image but it is audio"));
1028 }
1029
1030 #[test]
1031 fn validate_accepts_breath_phase_cues_referencing_audio() {
1032 use crate::scheduler::{BreathPattern, BreathSounds};
1033 let mut m = content_manifest_referencing("ignored");
1034 let r = &mut m.content.as_mut().unwrap().routines[0];
1035 r.steps[0].asset = None;
1036 r.breath = Some(BreathPattern {
1037 inhale: 4,
1038 hold: 0,
1039 exhale: 4,
1040 hold_out: 0,
1041 cycles: None,
1042 then: None,
1043 sounds: Some(BreathSounds {
1044 inhale: Some("chime".to_string()),
1045 ..Default::default()
1046 }),
1047 });
1048 m.assets = vec![ogg_asset("chime")];
1049 assert!(validate_manifest(&m).is_ok());
1050 }
1051
1052 #[test]
1053 fn validate_rejects_duplicate_asset_ids() {
1054 let mut m = content_manifest();
1055 m.assets = vec![png_asset("twist"), png_asset("twist")];
1056 assert!(validate_manifest(&m)
1057 .unwrap_err()
1058 .contains("duplicate asset id"));
1059 }
1060
1061 #[test]
1062 fn validate_rejects_too_many_assets() {
1063 let mut m = content_manifest();
1064 m.assets = (0..=MAX_ASSETS)
1065 .map(|i| png_asset(&format!("a{i}")))
1066 .collect();
1067 assert!(validate_manifest(&m).unwrap_err().contains("more than"));
1068 }
1069}