Skip to main content

greentic_setup/
setup_final_actions.rs

1use serde::Serialize;
2use serde_json::{Map as JsonMap, Value};
3use url::Url;
4
5pub const SETUP_ACTIONS_EXTENSION: &str = "greentic.setup.actions.v1";
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
8pub struct ResolvedFinalSetupAction {
9    pub provider_id: String,
10    pub action_id: String,
11    pub label: String,
12    pub kind: String,
13    pub url: String,
14    pub opens_new_window: bool,
15    pub copyable: bool,
16    pub html: String,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
20pub struct FinalSetupActionDiagnostic {
21    pub provider_id: String,
22    pub action_id: Option<String>,
23    pub reason: String,
24    pub detail: String,
25}
26
27#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
28pub struct FinalSetupActionResolution {
29    pub actions: Vec<ResolvedFinalSetupAction>,
30    pub diagnostics: Vec<FinalSetupActionDiagnostic>,
31}
32
33pub fn resolve_final_setup_actions(
34    provider_id: &str,
35    descriptor: &Value,
36    public_state: &Value,
37) -> FinalSetupActionResolution {
38    let mut resolution = FinalSetupActionResolution::default();
39    if descriptor.get("schema_id").and_then(Value::as_str) != Some(SETUP_ACTIONS_EXTENSION) {
40        resolution.diagnostics.push(diagnostic(
41            provider_id,
42            None,
43            "invalid_schema",
44            "setup action descriptor must use greentic.setup.actions.v1",
45        ));
46        return resolution;
47    }
48    if descriptor.get("provider_id").and_then(Value::as_str) != Some(provider_id) {
49        resolution.diagnostics.push(diagnostic(
50            provider_id,
51            None,
52            "provider_mismatch",
53            "setup action descriptor provider_id does not match setup target",
54        ));
55        return resolution;
56    }
57    let Some(actions) = descriptor.get("actions").and_then(Value::as_array) else {
58        resolution.diagnostics.push(diagnostic(
59            provider_id,
60            None,
61            "invalid_actions",
62            "setup action descriptor actions must be an array",
63        ));
64        return resolution;
65    };
66
67    let context = build_public_context(public_state);
68    for action in actions {
69        match resolve_action(provider_id, action, &context) {
70            Ok(Some(action)) => resolution.actions.push(action),
71            Ok(None) => {}
72            Err(err) => resolution.diagnostics.push(err),
73        }
74    }
75    resolution
76}
77
78fn resolve_action(
79    provider_id: &str,
80    action: &Value,
81    context: &Value,
82) -> Result<Option<ResolvedFinalSetupAction>, FinalSetupActionDiagnostic> {
83    let action_id = action
84        .get("id")
85        .and_then(Value::as_str)
86        .unwrap_or_default()
87        .trim();
88    let action_id_opt = (!action_id.is_empty()).then(|| action_id.to_string());
89    let label = required_str(provider_id, action_id_opt.as_deref(), action, "label")?;
90    let kind = required_str(provider_id, action_id_opt.as_deref(), action, "kind")?;
91    let template = required_str(
92        provider_id,
93        action_id_opt.as_deref(),
94        action,
95        "url_template",
96    )?;
97    if action_id.is_empty() {
98        return Err(diagnostic(
99            provider_id,
100            None,
101            "missing_id",
102            "setup action id is required",
103        ));
104    }
105    if kind != "deep_link" {
106        return Err(diagnostic(
107            provider_id,
108            Some(action_id),
109            "unsupported_kind",
110            "only deep_link setup actions are supported",
111        ));
112    }
113    if !visible_when_matches(action.get("visible_when"), context) {
114        return Ok(None);
115    }
116    for name in action
117        .get("requires")
118        .and_then(Value::as_array)
119        .into_iter()
120        .flatten()
121        .filter_map(Value::as_str)
122    {
123        if is_secret_key(name)
124            || value_at_path(context, name)
125                .and_then(public_scalar)
126                .is_none()
127        {
128            return Err(diagnostic(
129                provider_id,
130                Some(action_id),
131                "missing_required_value",
132                &format!("required value {name} is not available as a public value"),
133            ));
134        }
135    }
136    let Some(url) = resolve_template(&template, context) else {
137        return Err(diagnostic(
138            provider_id,
139            Some(action_id),
140            "unresolved_template",
141            "url_template contains an unresolved placeholder",
142        ));
143    };
144    if !safe_action_url(&url) {
145        return Err(diagnostic(
146            provider_id,
147            Some(action_id),
148            "invalid_url_scheme",
149            "resolved setup action URL must use https or a supported app deep-link scheme",
150        ));
151    }
152    let opens_new_window = action
153        .get("opens_new_window")
154        .and_then(Value::as_bool)
155        .unwrap_or(true);
156    let copyable = action
157        .get("copyable")
158        .and_then(Value::as_bool)
159        .unwrap_or(true);
160    Ok(Some(ResolvedFinalSetupAction {
161        provider_id: provider_id.to_string(),
162        action_id: action_id.to_string(),
163        label: label.clone(),
164        kind: kind.clone(),
165        html: action_html(&label, &url),
166        url,
167        opens_new_window,
168        copyable,
169    }))
170}
171
172fn required_str(
173    provider_id: &str,
174    action_id: Option<&str>,
175    action: &Value,
176    field: &str,
177) -> Result<String, FinalSetupActionDiagnostic> {
178    action
179        .get(field)
180        .and_then(Value::as_str)
181        .map(str::trim)
182        .filter(|value| !value.is_empty())
183        .map(ToOwned::to_owned)
184        .ok_or_else(|| {
185            diagnostic(
186                provider_id,
187                action_id,
188                "missing_field",
189                &format!("setup action {field} is required"),
190            )
191        })
192}
193
194fn build_public_context(public_state: &Value) -> Value {
195    let mut root = JsonMap::new();
196    merge_public_values(&mut root, public_state);
197    if let Some(values) = public_state.get("values") {
198        root.insert("values".to_string(), values.clone());
199        merge_public_values(&mut root, values);
200    }
201    if let Some(status) = public_state.get("setup_status") {
202        root.insert("setup_status".to_string(), status.clone());
203    }
204    Value::Object(root)
205}
206
207fn merge_public_values(target: &mut JsonMap<String, Value>, source: &Value) {
208    let Some(source) = source.as_object() else {
209        return;
210    };
211    for (key, value) in source {
212        if is_secret_key(key) || value.is_null() {
213            continue;
214        }
215        match value {
216            Value::String(_) | Value::Number(_) | Value::Bool(_) => {
217                target.insert(key.clone(), value.clone());
218            }
219            Value::Object(_) => merge_public_values(target, value),
220            _ => {}
221        }
222    }
223}
224
225fn visible_when_matches(visible_when: Option<&Value>, context: &Value) -> bool {
226    let Some(conditions) = visible_when.and_then(Value::as_object) else {
227        return true;
228    };
229    conditions.iter().all(|(path, expected)| {
230        value_at_path(context, path).is_some_and(|actual| actual == expected)
231    })
232}
233
234fn resolve_template(template: &str, context: &Value) -> Option<String> {
235    if let Some(name) = whole_placeholder(template) {
236        return value_at_path(context, name).and_then(public_scalar);
237    }
238    let mut output = String::with_capacity(template.len());
239    let mut rest = template;
240    while let Some(start) = rest.find('{') {
241        let (prefix, after_start) = rest.split_at(start);
242        output.push_str(prefix);
243        let after_start = &after_start[1..];
244        let end = after_start.find('}')?;
245        let (name, after_end) = after_start.split_at(end);
246        let value = value_at_path(context, name).and_then(public_scalar)?;
247        output.push_str(&url_encode(&value));
248        rest = &after_end[1..];
249    }
250    output.push_str(rest);
251    Some(output)
252}
253
254fn whole_placeholder(template: &str) -> Option<&str> {
255    let name = template.strip_prefix('{')?.strip_suffix('}')?;
256    (!name.is_empty()
257        && name
258            .chars()
259            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-'))
260    .then_some(name)
261}
262
263fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
264    if path.split('.').any(is_secret_key) {
265        return None;
266    }
267    path.split('.')
268        .try_fold(value, |current, segment| current.as_object()?.get(segment))
269}
270
271fn public_scalar(value: &Value) -> Option<String> {
272    match value {
273        Value::String(value) if !value.is_empty() => Some(value.clone()),
274        Value::Number(value) => Some(value.to_string()),
275        Value::Bool(value) => Some(value.to_string()),
276        _ => None,
277    }
278}
279
280/// Schemes a resolved `deep_link` action URL may use. `https` is the default
281/// for hosted share links; the native-app deep-link schemes let a provider hand
282/// the user straight into its desktop/mobile client (e.g. Webex's
283/// `webexteams://im?email=…`) instead of a browser round-trip. Must stay in sync
284/// with `isSafeFinalSetupActionUrl` in `assets/setup-ui/app.js`.
285const SAFE_ACTION_URL_SCHEMES: &[&str] = &["https", "webexteams"];
286
287fn safe_action_url(url: &str) -> bool {
288    Url::parse(url)
289        .map(|parsed| SAFE_ACTION_URL_SCHEMES.contains(&parsed.scheme()))
290        .unwrap_or(false)
291}
292
293/// Build the copy-paste anchor for a final setup action. Deliberately
294/// class-free: the snippet is pasted into arbitrary external pages, so it must
295/// not depend on Greentic stylesheets. Same shape for every provider.
296fn action_html(label: &str, url: &str) -> String {
297    format!(
298        r#"<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>"#,
299        html_escape_attr(url),
300        html_escape_text(label)
301    )
302}
303
304fn url_encode(value: &str) -> String {
305    let mut out = String::new();
306    for byte in value.bytes() {
307        let ch = byte as char;
308        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
309            out.push(ch);
310        } else {
311            out.push_str(&format!("%{byte:02X}"));
312        }
313    }
314    out
315}
316
317fn html_escape_text(value: &str) -> String {
318    value
319        .replace('&', "&amp;")
320        .replace('<', "&lt;")
321        .replace('>', "&gt;")
322}
323
324fn html_escape_attr(value: &str) -> String {
325    html_escape_text(value)
326        .replace('"', "&quot;")
327        .replace('\'', "&#39;")
328}
329
330pub(crate) fn is_secret_key(key: &str) -> bool {
331    let key = key.to_ascii_lowercase();
332    key == "token"
333        || key == "secret"
334        || key == "password"
335        || key.ends_with("_token")
336        || key.ends_with("_secret")
337        || key.contains("access_token")
338        || key.contains("refresh_token")
339        || key.contains("id_token")
340        || key.contains("device_code")
341        || key.contains("bot_access_token")
342}
343
344fn diagnostic(
345    provider_id: &str,
346    action_id: Option<&str>,
347    reason: &str,
348    detail: &str,
349) -> FinalSetupActionDiagnostic {
350    FinalSetupActionDiagnostic {
351        provider_id: provider_id.to_string(),
352        action_id: action_id.map(ToOwned::to_owned),
353        reason: reason.to_string(),
354        detail: detail.to_string(),
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use serde_json::json;
362
363    fn descriptor(action: Value) -> Value {
364        json!({
365            "schema_id": "greentic.setup.actions.v1",
366            "provider_id": "messaging-test",
367            "actions": [action]
368        })
369    }
370
371    #[test]
372    fn resolves_whole_url_placeholder_without_encoding_scheme() {
373        let resolved = resolve_final_setup_actions(
374            "messaging-test",
375            &descriptor(json!({
376                "id": "add",
377                "label": "Add to Test",
378                "kind": "deep_link",
379                "url_template": "{add_url}",
380                "requires": ["add_url"],
381                "visible_when": {"setup_status.ok": true}
382            })),
383            &json!({
384                "setup_status": {"ok": true},
385                "values": {"add_url": "https://example.test/add?x=1"}
386            }),
387        );
388
389        assert_eq!(resolved.diagnostics, Vec::new());
390        assert_eq!(resolved.actions[0].url, "https://example.test/add?x=1");
391    }
392
393    #[test]
394    fn resolves_template_placeholders_with_url_encoding() {
395        let resolved = resolve_final_setup_actions(
396            "messaging-test",
397            &descriptor(json!({
398                "id": "telegram",
399                "label": "Add",
400                "kind": "deep_link",
401                "url_template": "https://t.me/{bot_username}",
402                "requires": ["bot_username"]
403            })),
404            &json!({"values": {"bot_username": "bot name"}}),
405        );
406
407        assert_eq!(resolved.actions[0].url, "https://t.me/bot%20name");
408    }
409
410    #[test]
411    fn resolves_webex_native_app_deep_link_scheme() {
412        // Webex's "Add to Webex" action hands the user straight to the native
413        // client via a `webexteams://` deep link. It must survive the scheme
414        // allowlist rather than being rejected as an unsafe scheme.
415        let resolved = resolve_final_setup_actions(
416            "messaging-test",
417            &descriptor(json!({
418                "id": "add-to-webex",
419                "label": "Add to Webex",
420                "kind": "deep_link",
421                "url_template": "webexteams://im?email={bot_email}",
422                "requires": ["bot_email"]
423            })),
424            &json!({"values": {"bot_email": "barbara@example.com"}}),
425        );
426
427        assert_eq!(
428            resolved.actions[0].url,
429            "webexteams://im?email=barbara%40example.com"
430        );
431        assert!(resolved.diagnostics.is_empty());
432    }
433
434    #[test]
435    fn suppresses_action_when_visible_when_does_not_match() {
436        let resolved = resolve_final_setup_actions(
437            "messaging-test",
438            &descriptor(json!({
439                "id": "add",
440                "label": "Add",
441                "kind": "deep_link",
442                "url_template": "{add_url}",
443                "requires": ["add_url"],
444                "visible_when": {"setup_status.ok": true}
445            })),
446            &json!({"setup_status": {"ok": false}, "values": {"add_url": "https://example.test"}}),
447        );
448
449        assert!(resolved.actions.is_empty());
450        assert!(resolved.diagnostics.is_empty());
451    }
452
453    #[test]
454    fn suppresses_secret_required_values() {
455        let resolved = resolve_final_setup_actions(
456            "messaging-test",
457            &descriptor(json!({
458                "id": "bad",
459                "label": "Bad",
460                "kind": "deep_link",
461                "url_template": "https://example.test/{access_token}",
462                "requires": ["access_token"]
463            })),
464            &json!({"values": {"access_token": "secret"}}),
465        );
466
467        assert!(resolved.actions.is_empty());
468        assert_eq!(resolved.diagnostics[0].reason, "missing_required_value");
469    }
470
471    #[test]
472    fn rejects_unsafe_url_schemes_and_escapes_html() {
473        let rejected = resolve_final_setup_actions(
474            "messaging-test",
475            &descriptor(json!({
476                "id": "bad",
477                "label": "Bad",
478                "kind": "deep_link",
479                "url_template": "javascript:alert(1)",
480                "requires": []
481            })),
482            &json!({}),
483        );
484        assert!(rejected.actions.is_empty());
485        assert_eq!(rejected.diagnostics[0].reason, "invalid_url_scheme");
486
487        let escaped = resolve_final_setup_actions(
488            "messaging-test",
489            &descriptor(json!({
490                "id": "add",
491                "label": "Add \"<&>",
492                "kind": "deep_link",
493                "url_template": "{add_url}",
494                "requires": ["add_url"]
495            })),
496            &json!({"values": {"add_url": "https://example.test/?q=\"<&>"}}),
497        );
498        assert!(escaped.actions[0].html.contains("&quot;&lt;&amp;&gt;"));
499        assert!(escaped.actions[0].html.contains("Add \"&lt;&amp;&gt;"));
500    }
501}