Skip to main content

eui_core/host/
capabilities.rs

1//! VAPP-260 (format version 5): a package's CAPABILITY MANIFEST, the mirror
2//! of `packages/exponential-ui/src/host/capabilities.ts`;
3//! `fixtures/host-router.json` (`acme.capabilities`, `acme.badcapabilities`)
4//! locks both. The catalog's tools and consent are the Engine's
5//! (`packages/eengine/fixtures/capabilities.json`).
6
7use serde_json::{Map, Value};
8
9use super::package::PackageIssue;
10
11pub const MAX_CAPABILITIES: usize = 16;
12pub const MAX_CAPABILITY_WHY: usize = 200;
13/// The scope ids a manifest may name (`catalog/host.json` `package.capabilities.scopes`).
14pub const CAPABILITY_SCOPES: &[&str] = &[
15    "camera.capture",
16    "clipboard.read",
17    "clipboard.write",
18    "files.read",
19    "files.write",
20    "http.fetch",
21    "location.read",
22    "notifications.post",
23    "photos.read",
24    "print.pdf",
25    "share.send",
26];
27/// The scopes whose entry lists its `origins` (and only these may).
28pub const CAPABILITY_ORIGIN_SCOPES: &[&str] = &["http.fetch"];
29/// `catalog/host.json` `package.capabilities.originPattern`, checked by hand in [`capability_origin_valid`].
30pub const CAPABILITY_ORIGIN_PATTERN: &str = "^https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:[0-9]{1,5})?$";
31const ENTRY_KEYS: &[&str] = &["id", "why", "origins"];
32
33fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
34    PackageIssue { path: path.into(), message: message.into() }
35}
36
37fn label_valid(label: &str) -> bool {
38    let bytes = label.as_bytes();
39    !bytes.is_empty()
40        && bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
41        && bytes[0] != b'-'
42        && bytes[bytes.len() - 1] != b'-'
43}
44
45/// Is `origin` an `https://host[:port]` origin ([`CAPABILITY_ORIGIN_PATTERN`])?
46pub fn capability_origin_valid(origin: &str) -> bool {
47    let Some(rest) = origin.strip_prefix("https://") else { return false };
48    let (host, port) = match rest.split_once(':') {
49        Some((host, port)) => (host, Some(port)),
50        None => (rest, None),
51    };
52    if let Some(port) = port {
53        if port.is_empty() || port.len() > 5 || !port.bytes().all(|b| b.is_ascii_digit()) {
54            return false;
55        }
56    }
57    host.split('.').all(label_valid)
58}
59
60fn show(v: Option<&Value>) -> String {
61    match v {
62        None => "undefined".into(),
63        Some(other) => other.to_string(),
64    }
65}
66
67/// Every problem with a package's `capabilities` (unset = none).
68pub fn validate_package_capabilities(p: &Map<String, Value>) -> Vec<PackageIssue> {
69    let Some(value) = p.get("capabilities") else { return Vec::new() };
70    let Some(list) = value.as_array() else { return vec![issue("/capabilities", "capabilities is a list of {id, why, origins?}")] };
71    let mut issues = Vec::new();
72    if list.len() > MAX_CAPABILITIES {
73        issues.push(issue("/capabilities", format!("at most {MAX_CAPABILITIES} capabilities")));
74    }
75    let mut seen: Vec<&str> = Vec::new();
76    for (i, entry) in list.iter().enumerate() {
77        let at = format!("/capabilities/{i}");
78        let Some(obj) = entry.as_object() else {
79            issues.push(issue(at, "a capability is {id, why, origins?}"));
80            continue;
81        };
82        let mut keys: Vec<&String> = obj.keys().collect();
83        keys.sort();
84        for key in keys {
85            if !ENTRY_KEYS.contains(&key.as_str()) {
86                issues.push(issue(format!("{at}/{key}"), format!("unknown key {key}")));
87            }
88        }
89        let id = obj.get("id").and_then(Value::as_str);
90        match id {
91            Some(id) if CAPABILITY_SCOPES.contains(&id) => {
92                if seen.contains(&id) {
93                    issues.push(issue(format!("{at}/id"), format!("duplicate capability {id}")));
94                } else {
95                    seen.push(id);
96                }
97            }
98            Some(id) => issues.push(issue(format!("{at}/id"), format!("unknown capability {id}"))),
99            None => issues.push(issue(format!("{at}/id"), format!("unknown capability {}", show(obj.get("id"))))),
100        }
101        match obj.get("why").and_then(Value::as_str) {
102            Some(why) if !why.trim().is_empty() => {
103                if why.chars().count() > MAX_CAPABILITY_WHY {
104                    issues.push(issue(format!("{at}/why"), format!("why is at most {MAX_CAPABILITY_WHY} characters")));
105                }
106            }
107            _ => issues.push(issue(format!("{at}/why"), "why is a required string")),
108        }
109        let wants_origins = id.is_some_and(|id| CAPABILITY_ORIGIN_SCOPES.contains(&id));
110        match obj.get("origins") {
111            None => {
112                if wants_origins {
113                    issues.push(issue(format!("{at}/origins"), format!("{} lists its origins", id.unwrap_or_default())));
114                }
115            }
116            Some(_) if !wants_origins => issues.push(issue(format!("{at}/origins"), format!("origins are only for {}", CAPABILITY_ORIGIN_SCOPES.join(", ")))),
117            Some(Value::Array(origins)) if !origins.is_empty() => {
118                let mut distinct: Vec<&str> = Vec::new();
119                for (j, origin) in origins.iter().enumerate() {
120                    match origin.as_str() {
121                        Some(o) if capability_origin_valid(o) => {
122                            if distinct.contains(&o) {
123                                issues.push(issue(format!("{at}/origins/{j}"), format!("duplicate origin {o}")));
124                            } else {
125                                distinct.push(o);
126                            }
127                        }
128                        _ => issues.push(issue(format!("{at}/origins/{j}"), "an origin is https://host[:port]")),
129                    }
130                }
131            }
132            Some(_) => issues.push(issue(format!("{at}/origins"), "origins is a non-empty list")),
133        }
134    }
135    issues
136}