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",
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(provider_id, action_id, &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
280fn safe_action_url(url: &str) -> bool {
281    Url::parse(url)
282        .map(|parsed| parsed.scheme() == "https")
283        .unwrap_or(false)
284}
285
286fn action_html(provider_id: &str, action_id: &str, label: &str, url: &str) -> String {
287    format!(
288        r#"<a class="greentic-add-button {}" href="{}" target="_blank" rel="noopener noreferrer">{}</a>"#,
289        html_escape_attr(&css_class(&format!(
290            "greentic-add-{provider_id}-{action_id}"
291        ))),
292        html_escape_attr(url),
293        html_escape_text(label)
294    )
295}
296
297fn css_class(value: &str) -> String {
298    let mut out = String::new();
299    for ch in value.chars().flat_map(char::to_lowercase) {
300        if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
301            out.push(ch);
302        } else if !out.ends_with('-') {
303            out.push('-');
304        }
305    }
306    let out = out.trim_matches('-').to_string();
307    if out.is_empty() {
308        "greentic-add".to_string()
309    } else {
310        out
311    }
312}
313
314fn url_encode(value: &str) -> String {
315    let mut out = String::new();
316    for byte in value.bytes() {
317        let ch = byte as char;
318        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
319            out.push(ch);
320        } else {
321            out.push_str(&format!("%{byte:02X}"));
322        }
323    }
324    out
325}
326
327fn html_escape_text(value: &str) -> String {
328    value
329        .replace('&', "&amp;")
330        .replace('<', "&lt;")
331        .replace('>', "&gt;")
332}
333
334fn html_escape_attr(value: &str) -> String {
335    html_escape_text(value)
336        .replace('"', "&quot;")
337        .replace('\'', "&#39;")
338}
339
340fn is_secret_key(key: &str) -> bool {
341    let key = key.to_ascii_lowercase();
342    key == "token"
343        || key == "secret"
344        || key == "password"
345        || key.ends_with("_token")
346        || key.ends_with("_secret")
347        || key.contains("access_token")
348        || key.contains("refresh_token")
349        || key.contains("id_token")
350        || key.contains("device_code")
351        || key.contains("bot_access_token")
352}
353
354fn diagnostic(
355    provider_id: &str,
356    action_id: Option<&str>,
357    reason: &str,
358    detail: &str,
359) -> FinalSetupActionDiagnostic {
360    FinalSetupActionDiagnostic {
361        provider_id: provider_id.to_string(),
362        action_id: action_id.map(ToOwned::to_owned),
363        reason: reason.to_string(),
364        detail: detail.to_string(),
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use serde_json::json;
372
373    fn descriptor(action: Value) -> Value {
374        json!({
375            "schema_id": "greentic.setup.actions.v1",
376            "provider_id": "messaging-test",
377            "actions": [action]
378        })
379    }
380
381    #[test]
382    fn resolves_whole_url_placeholder_without_encoding_scheme() {
383        let resolved = resolve_final_setup_actions(
384            "messaging-test",
385            &descriptor(json!({
386                "id": "add",
387                "label": "Add to Test",
388                "kind": "deep_link",
389                "url_template": "{add_url}",
390                "requires": ["add_url"],
391                "visible_when": {"setup_status.ok": true}
392            })),
393            &json!({
394                "setup_status": {"ok": true},
395                "values": {"add_url": "https://example.test/add?x=1"}
396            }),
397        );
398
399        assert_eq!(resolved.diagnostics, Vec::new());
400        assert_eq!(resolved.actions[0].url, "https://example.test/add?x=1");
401    }
402
403    #[test]
404    fn resolves_template_placeholders_with_url_encoding() {
405        let resolved = resolve_final_setup_actions(
406            "messaging-test",
407            &descriptor(json!({
408                "id": "telegram",
409                "label": "Add",
410                "kind": "deep_link",
411                "url_template": "https://t.me/{bot_username}",
412                "requires": ["bot_username"]
413            })),
414            &json!({"values": {"bot_username": "bot name"}}),
415        );
416
417        assert_eq!(resolved.actions[0].url, "https://t.me/bot%20name");
418    }
419
420    #[test]
421    fn suppresses_action_when_visible_when_does_not_match() {
422        let resolved = resolve_final_setup_actions(
423            "messaging-test",
424            &descriptor(json!({
425                "id": "add",
426                "label": "Add",
427                "kind": "deep_link",
428                "url_template": "{add_url}",
429                "requires": ["add_url"],
430                "visible_when": {"setup_status.ok": true}
431            })),
432            &json!({"setup_status": {"ok": false}, "values": {"add_url": "https://example.test"}}),
433        );
434
435        assert!(resolved.actions.is_empty());
436        assert!(resolved.diagnostics.is_empty());
437    }
438
439    #[test]
440    fn suppresses_secret_required_values() {
441        let resolved = resolve_final_setup_actions(
442            "messaging-test",
443            &descriptor(json!({
444                "id": "bad",
445                "label": "Bad",
446                "kind": "deep_link",
447                "url_template": "https://example.test/{access_token}",
448                "requires": ["access_token"]
449            })),
450            &json!({"values": {"access_token": "secret"}}),
451        );
452
453        assert!(resolved.actions.is_empty());
454        assert_eq!(resolved.diagnostics[0].reason, "missing_required_value");
455    }
456
457    #[test]
458    fn rejects_unsafe_url_schemes_and_escapes_html() {
459        let rejected = resolve_final_setup_actions(
460            "messaging-test",
461            &descriptor(json!({
462                "id": "bad",
463                "label": "Bad",
464                "kind": "deep_link",
465                "url_template": "javascript:alert(1)",
466                "requires": []
467            })),
468            &json!({}),
469        );
470        assert!(rejected.actions.is_empty());
471        assert_eq!(rejected.diagnostics[0].reason, "invalid_url_scheme");
472
473        let escaped = resolve_final_setup_actions(
474            "messaging-test",
475            &descriptor(json!({
476                "id": "add",
477                "label": "Add \"<&>",
478                "kind": "deep_link",
479                "url_template": "{add_url}",
480                "requires": ["add_url"]
481            })),
482            &json!({"values": {"add_url": "https://example.test/?q=\"<&>"}}),
483        );
484        assert!(escaped.actions[0].html.contains("&quot;&lt;&amp;&gt;"));
485        assert!(escaped.actions[0].html.contains("Add \"&lt;&amp;&gt;"));
486    }
487}