eui_core/host/
capabilities.rs1use serde_json::{Map, Value};
8
9use super::package::PackageIssue;
10
11pub const MAX_CAPABILITIES: usize = 16;
12pub const MAX_CAPABILITY_WHY: usize = 200;
13pub 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];
27pub const CAPABILITY_ORIGIN_SCOPES: &[&str] = &["http.fetch"];
29pub 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
45pub 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
67pub 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}