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('&', "&")
330 .replace('<', "<")
331 .replace('>', ">")
332}
333
334fn html_escape_attr(value: &str) -> String {
335 html_escape_text(value)
336 .replace('"', """)
337 .replace('\'', "'")
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(""<&>"));
485 assert!(escaped.actions[0].html.contains("Add \"<&>"));
486 }
487}