Skip to main content

eui_core/host/
share.rs

1//! VAPP-241 (eApps P1): a package's PUBLIC LINK definitions, the mirror of
2//! `packages/exponential-ui/src/host/share.ts`; `fixtures/package-share.json`
3//! locks both. The links themselves are the host's.
4
5use serde_json::{Map, Value};
6
7use super::package::PackageIssue;
8
9pub const MAX_SHARES: usize = 5;
10pub const MAX_SHARE_EXPIRES_DAYS: i64 = 3650;
11const SHARE_KEYS: &[&str] = &["name", "mode", "kind", "fields", "query", "expires"];
12
13fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
14    PackageIssue { path: path.into(), message: message.into() }
15}
16
17fn valid_name(name: &str) -> bool {
18    let mut chars = name.chars();
19    chars.next().is_some_and(|c| c.is_ascii_lowercase()) && name.chars().count() <= 24 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
20}
21
22fn is_file(field: Option<&Value>) -> bool {
23    let Some(f) = field else { return false };
24    f.get("format").and_then(Value::as_str) == Some("attachment") || f.get("items").and_then(|i| i.get("format")).and_then(Value::as_str) == Some("attachment")
25}
26
27fn show(v: Option<&Value>) -> String {
28    match v {
29        Some(Value::String(s)) => s.clone(),
30        Some(other) => other.to_string(),
31        None => "undefined".into(),
32    }
33}
34
35/// Every problem with a package's `share`, each at its entry.
36pub fn validate_package_shares(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
37    let Some(share) = pkg.get("share") else { return Vec::new() };
38    let Some(list) = share.as_array() else {
39        return vec![issue("/share", "share is a list of {name, mode, kind, fields?, query?, expires?}")];
40    };
41    let mut issues = Vec::new();
42    if list.len() > MAX_SHARES {
43        issues.push(issue("/share", format!("at most {MAX_SHARES} public links, got {}", list.len())));
44    }
45    let records = pkg.get("records").and_then(Value::as_object);
46    let queries = pkg.get("queries").and_then(Value::as_object);
47    let mut seen: Vec<&str> = Vec::new();
48    for (i, s) in list.iter().enumerate() {
49        let at = format!("/share/{i}");
50        let Some(s) = s.as_object() else {
51            issues.push(issue(at, "a public link is {name, mode, kind, fields?, query?, expires?}"));
52            continue;
53        };
54        let mut keys: Vec<&String> = s.keys().collect();
55        keys.sort();
56        for k in keys {
57            if !SHARE_KEYS.contains(&k.as_str()) {
58                issues.push(issue(format!("{at}/{k}"), format!("unknown public link key {k}")));
59            }
60        }
61        match s.get("name").and_then(Value::as_str) {
62            Some(name) if valid_name(name) => {
63                if seen.contains(&name) {
64                    issues.push(issue(format!("{at}/name"), format!("duplicate public link {name}")));
65                } else {
66                    seen.push(name);
67                }
68            }
69            _ => issues.push(issue(format!("{at}/name"), "a name is a lowercase letter, then lowercase letters, digits and underscores (24 at most)")),
70        }
71        let mode = s.get("mode").and_then(Value::as_str);
72        if !matches!(mode, Some("submit" | "view")) {
73            issues.push(issue(format!("{at}/mode"), "mode is submit or view"));
74        }
75        let kind = s.get("kind").and_then(Value::as_str);
76        let def = kind.and_then(|k| records.and_then(|r| r.get(k))).filter(|d| d.is_object());
77        if def.is_none() {
78            issues.push(issue(format!("{at}/kind"), format!("{} names no declared kind", show(s.get("kind")))));
79        }
80        let schema = def.and_then(|d| d.get("schema")).and_then(Value::as_object);
81        let props = schema.and_then(|x| x.get("properties")).and_then(Value::as_object);
82        let required: Vec<&Value> = schema.and_then(|x| x.get("required")).and_then(Value::as_array).map(|l| l.iter().collect()).unwrap_or_default();
83        let refs = def.and_then(|d| d.get("refs")).and_then(Value::as_object);
84        if let Some(fields) = s.get("fields") {
85            match fields.as_array().filter(|l| !l.is_empty() && l.iter().all(Value::is_string)) {
86                None => issues.push(issue(format!("{at}/fields"), "fields is a non-empty list of field names")),
87                Some(list) => {
88                    for (j, f) in list.iter().enumerate() {
89                        let name = f.as_str().unwrap_or("");
90                        let field = props.and_then(|p| p.get(name));
91                        if def.is_some() && field.is_none() {
92                            issues.push(issue(format!("{at}/fields/{j}"), format!("{name} is not a field of {}", show(s.get("kind")))));
93                        }
94                        if is_file(field) {
95                            issues.push(issue(format!("{at}/fields/{j}"), format!("a public link cannot carry the file field {name}")));
96                        }
97                        if refs.is_some_and(|r| r.contains_key(name)) {
98                            issues.push(issue(format!("{at}/fields/{j}"), format!("a public link cannot carry the ref field {name}")));
99                        }
100                    }
101                    if mode == Some("submit") {
102                        for name in &required {
103                            if !list.contains(name) {
104                                issues.push(issue(format!("{at}/fields"), format!("a submit link needs the required field {}", show(Some(name)))));
105                            }
106                        }
107                    }
108                }
109            }
110        }
111        if mode == Some("submit") && required.iter().any(|n| n.as_str().and_then(|n| props.and_then(|p| p.get(n))).is_some_and(|f| is_file(Some(f)))) {
112            issues.push(issue(format!("{at}/kind"), "a submit link cannot fill a required file field"));
113        }
114        // A guest has no picker for a ref; with `fields` the checks above already speak.
115        if mode == Some("submit") && s.get("fields").is_none() {
116            for name in required.iter().filter_map(|n| n.as_str()) {
117                if refs.is_some_and(|r| r.contains_key(name)) {
118                    issues.push(issue(format!("{at}/kind"), format!("a submit link cannot fill the required ref field {name}")));
119                }
120            }
121        }
122        if let Some(query) = s.get("query") {
123            if mode != Some("view") {
124                issues.push(issue(format!("{at}/query"), "only a view link has a query"));
125            } else {
126                match query.as_str().and_then(|q| queries.and_then(|m| m.get(q)).map(|d| (q, d))) {
127                    None => issues.push(issue(format!("{at}/query"), format!("{} names no package query", show(Some(query))))),
128                    Some((q, d)) => {
129                        let from = d.get("from").and_then(Value::as_str).map(str::trim).unwrap_or("");
130                        if !from.is_empty() && Some(from) != kind {
131                            issues.push(issue(format!("{at}/query"), format!("query {q} reads records:{from}, the link's kind is {}", show(s.get("kind")))));
132                        }
133                    }
134                }
135            }
136        }
137        if let Some(expires) = s.get("expires") {
138            let ok = expires.as_i64().filter(|d| expires.as_f64() == Some(*d as f64)).is_some_and(|d| (1..=MAX_SHARE_EXPIRES_DAYS).contains(&d));
139            if !ok {
140                issues.push(issue(format!("{at}/expires"), format!("expires is a whole number of days, 1 to {MAX_SHARE_EXPIRES_DAYS}")));
141            }
142        }
143    }
144    issues
145}