Skip to main content

entracte_lib/plugins/
manifest.rs

1//! Plugin manifest types, parsing, and validation. Pure — no I/O.
2//!
3//! The manifest is versioned (`MANIFEST_VERSION`) like the content-pack
4//! format. Validation is first-error-wins with user-facing messages, the
5//! same shape as `content_pack::validate_pack`. The load-time half of the
6//! capability model lives here: a code-bearing plugin's declared `imports`
7//! must all be valid capabilities, and a content plugin must declare none.
8//! The runtime half (checking the module's actual wasm import section
9//! against this list, and per-call scope enforcement) lands with the
10//! runtime slice.
11
12use serde::{Deserialize, Serialize};
13
14use super::asset::{validate_asset, AssetKind, ManifestAsset, MAX_ASSETS};
15use crate::scheduler::content_pack::{validate_pack, ContentPack};
16
17/// Manifest schema version this build reads and writes. Bumped only on a
18/// breaking change to the manifest shape.
19pub const MANIFEST_VERSION: u32 = 1;
20
21/// Host-function ABI version this build exposes to wasm modules. A module
22/// built against a different ABI is refused rather than mis-bound.
23pub const SUPPORTED_ABI_VERSION: u32 = 1;
24
25/// Defensive caps so a malformed or hostile manifest can't bloat state or
26/// stall the UI. Generous relative to any hand-authored plugin.
27const 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/// Which extension point a plugin provides. Exactly one per manifest.
33#[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    /// Whether this kind ships an executable wasm module (and therefore must
43    /// declare a `module`, an `abi_version`, and at least one import). Only
44    /// detectors run code; content and export plugins are declarative data.
45    fn is_code_bearing(self) -> bool {
46        matches!(self, PluginKind::Detector)
47    }
48}
49
50/// A host-function capability a module imports. Serialised as the
51/// colon-delimited string form used in the manifest's `imports` array and
52/// shown verbatim in the consent dialog. Scoped variants carry the exact
53/// path / origin the grant is bound to.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Capability {
56    /// Read the foreground window title.
57    DetectForegroundWindow,
58    /// Check whether a named process is running (host does the matching).
59    DetectProcesses,
60    /// Read a host-scoped sentinel value under a granted path.
61    DetectFile(String),
62    /// Write break stats to a path under the granted scope.
63    ExportFile(String),
64    /// POST break stats to the granted origin — the only capability that
65    /// can leave the machine.
66    ExportHttp(String),
67}
68
69impl Capability {
70    /// Parse a capability from its manifest string form. Scoped forms carry
71    /// the scope after the second colon (`export:http:<origin>`); the scope
72    /// itself may contain colons (e.g. `host:port`), so we split only twice.
73    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    /// The canonical manifest/consent string form, round-tripping [`Self::parse`].
104    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    /// Whether this capability is meaningful for the given plugin kind — a
115    /// detector cannot import an `export:*` function and vice versa.
116    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/// Detector configuration: the parameters the host matches against when it
127/// computes a detector's gated booleans. Only meaningful for a detector
128/// plugin. The `detect:file:<path>` scope lives in the capability itself, so
129/// only the process pattern needs declaring here.
130#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DetectConfig {
132    /// Substring (case-insensitive) matched against running process names by
133    /// the `host_process_running` host function. Requires a `detect:processes`
134    /// import.
135    #[serde(default)]
136    pub process_name: Option<String>,
137}
138
139/// Where a declarative export adapter delivers break stats.
140#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "snake_case")]
142pub enum ExportSink {
143    /// Write to a local file (the granted path).
144    File,
145    /// POST to a user-controlled URL — the only sink that leaves the machine.
146    Http,
147}
148
149/// The serialisation the host renders break stats in before delivery.
150#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "snake_case")]
152pub enum ExportFormat {
153    Csv,
154    Json,
155}
156
157/// A declarative export adapter: on the named events, the host renders its
158/// own break stats in `format` and delivers them to `destination` via `sink`.
159/// No wasm — the plugin runs no code; the destination is fixed here (and
160/// shown in the consent dialog), so the plugin can never redirect the data.
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162pub struct ExportConfig {
163    pub sink: ExportSink,
164    pub format: ExportFormat,
165    /// File path (for `file`) or URL (for `http`). For `http` it must be an
166    /// `http(s)://` URL; the origin is the consent boundary.
167    pub destination: String,
168    /// Which scheduler events trigger a delivery. Reuses the hook event
169    /// vocabulary; must be non-empty.
170    pub on: Vec<crate::hooks::HookEvent>,
171}
172
173/// The detached signature over `canonical(manifest-without-signature)` plus
174/// the module hash. ed25519; keys and signature are base64.
175#[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/// A parsed plugin manifest. A content plugin carries a typed
183/// [`ContentPack`] payload (validated via `content_pack::validate_pack`);
184/// code-bearing kinds carry a wasm `module` and declared `imports` instead.
185#[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    /// The declared module filename (provenance/metadata, e.g. `module.wasm`).
197    #[serde(default)]
198    pub module: Option<String>,
199    /// The wasm module itself, base64-encoded, so a code-bearing plugin ships
200    /// as a single signed file. Excluded from the signing payload — the
201    /// signature binds its hash instead (see `signature::signing_payload`).
202    #[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    /// Inline images a content plugin's routine steps may reference, each
215    /// bound by its `sha256`. Excluded from the signing payload by blob
216    /// (`data_base64`); the hash stays in the canonical manifest. Only content
217    /// plugins may carry assets. Omitted entirely when empty so a plugin that
218    /// ships none signs over a manifest with no `assets` key at all. See
219    /// [`super::asset`].
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub assets: Vec<ManifestAsset>,
222    pub signature: Signature,
223}
224
225/// Parse a manifest from JSON, mapping serde errors to a user-facing string.
226/// Does not validate beyond shape — call [`validate_manifest`] next.
227pub 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
238/// A reverse-DNS-ish id: non-empty, lowercase `[a-z0-9.-]`, at least one
239/// dot, length-capped. Kept pragmatic — it's a uniqueness key, not a
240/// security boundary.
241fn 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
260/// Validate a parsed manifest: supported versions, well-formed id and name,
261/// kind/module/imports consistency, and that every import is a capability
262/// valid for the kind. The signature is verified separately
263/// ([`super::verify_signature`]). Returns a clear, user-facing error on the
264/// first problem.
265pub 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        // Declarative kinds (content, export): no module, no ABI, no imports.
313        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        // Content and export are the only declarative kinds.
327        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
381/// Validate a manifest's assets and the routine references to them. Only
382/// content plugins may carry assets; each must be a sound, in-cap image or
383/// audio file (see [`validate_asset`]) with a unique id. Every `step.asset`
384/// must resolve to an *image* asset, and every `step.sound` / breath phase cue
385/// to an *audio* asset. First-error-wins, like the rest of the file.
386fn 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
439/// Validate a declarative export config: a non-empty, length-capped
440/// destination (an `http(s)://` URL for the http sink), and at least one
441/// trigger event.
442fn 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()); // no process_name → no extra requirement
856        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(); // imports detect:foreground-window only
872        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    /// A content manifest whose one routine's one step references `asset_id`.
927    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        // Declare an asset, but add a second step that references nothing — so
963        // the reference check sees both the Some and None step-asset arms.
964        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    /// Swap the single routine's step to reference `id` as a sound (clearing
996    /// its image ref), and optionally give the routine a breath inhale cue.
997    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}