|
| 1 | +//! Regression guard for the Tauri ACL. |
| 2 | +//! |
| 3 | +//! Every IPC command registered in `tauri::generate_handler![ ... ]` must also be |
| 4 | +//! granted by the `[default]` permission set in `permissions/default.toml`, and |
| 5 | +//! the capability in `capabilities/default.json` must reference that set. |
| 6 | +//! Otherwise the desktop build rejects the call at runtime with |
| 7 | +//! `Command <name> not allowed by ACL` — the failure mode that hit claude-code-trace |
| 8 | +//! when a feature added the handler but forgot the matching `commands.allow` |
| 9 | +//! entry. The check runs in both directions so a stale ACL grant is caught too. |
| 10 | +
|
| 11 | +use std::collections::{BTreeMap, BTreeSet}; |
| 12 | + |
| 13 | +const LIB_RS: &str = include_str!("../src/lib.rs"); |
| 14 | +const DEFAULT_TOML: &str = include_str!("../permissions/default.toml"); |
| 15 | +const CAPABILITY_JSON: &str = include_str!("../capabilities/default.json"); |
| 16 | + |
| 17 | +/// Contents of every double-quoted string in `s`. |
| 18 | +fn quoted_strings(s: &str) -> BTreeSet<String> { |
| 19 | + s.split('"') |
| 20 | + .enumerate() |
| 21 | + .filter(|(i, _)| i % 2 == 1) |
| 22 | + .map(|(_, part)| part.to_string()) |
| 23 | + .collect() |
| 24 | +} |
| 25 | + |
| 26 | +/// Command names registered in `tauri::generate_handler![ ... ]` (last path segment). |
| 27 | +fn handler_commands() -> BTreeSet<String> { |
| 28 | + let start = LIB_RS |
| 29 | + .find("generate_handler![") |
| 30 | + .expect("generate_handler! macro present in lib.rs"); |
| 31 | + let rest = &LIB_RS[start..]; |
| 32 | + let open = rest.find('[').expect("opening [ for generate_handler!"); |
| 33 | + let close = rest.find(']').expect("closing ] for generate_handler!"); |
| 34 | + rest[open + 1..close] |
| 35 | + .split(',') |
| 36 | + .map(str::trim) |
| 37 | + .filter(|t| !t.is_empty()) |
| 38 | + .map(|t| t.rsplit("::").next().unwrap().trim().to_string()) |
| 39 | + .collect() |
| 40 | +} |
| 41 | + |
| 42 | +/// Identifiers referenced by the `[default]` permission set. |
| 43 | +fn default_permission_set() -> BTreeSet<String> { |
| 44 | + let key = "permissions = ["; |
| 45 | + let start = DEFAULT_TOML |
| 46 | + .find(key) |
| 47 | + .expect("[default] permissions array present"); |
| 48 | + let after = &DEFAULT_TOML[start + key.len()..]; |
| 49 | + let end = after.find(']').expect("closing ] for default permissions"); |
| 50 | + quoted_strings(&after[..end]) |
| 51 | +} |
| 52 | + |
| 53 | +/// Map of each `[[permission]]` identifier to the commands it allows. |
| 54 | +fn permission_blocks() -> BTreeMap<String, BTreeSet<String>> { |
| 55 | + let mut map = BTreeMap::new(); |
| 56 | + for chunk in DEFAULT_TOML.split("[[permission]]").skip(1) { |
| 57 | + let Some(id_line) = chunk |
| 58 | + .lines() |
| 59 | + .find(|l| l.trim_start().starts_with("identifier")) |
| 60 | + else { |
| 61 | + continue; |
| 62 | + }; |
| 63 | + let Some(id) = quoted_strings(id_line).into_iter().next() else { |
| 64 | + continue; |
| 65 | + }; |
| 66 | + let key = "commands.allow = ["; |
| 67 | + let cmds = match chunk.find(key) { |
| 68 | + Some(s) => { |
| 69 | + let after = &chunk[s + key.len()..]; |
| 70 | + let end = after.find(']').unwrap_or(after.len()); |
| 71 | + quoted_strings(&after[..end]) |
| 72 | + } |
| 73 | + None => BTreeSet::new(), |
| 74 | + }; |
| 75 | + map.insert(id, cmds); |
| 76 | + } |
| 77 | + map |
| 78 | +} |
| 79 | + |
| 80 | +/// Commands granted by the `[default]` permission set. |
| 81 | +fn acl_granted_commands() -> BTreeSet<String> { |
| 82 | + let referenced = default_permission_set(); |
| 83 | + let blocks = permission_blocks(); |
| 84 | + referenced |
| 85 | + .iter() |
| 86 | + .filter_map(|id| blocks.get(id)) |
| 87 | + .flat_map(|cmds| cmds.iter().cloned()) |
| 88 | + .collect() |
| 89 | +} |
| 90 | + |
| 91 | +#[test] |
| 92 | +fn handler_and_acl_grant_the_same_commands() { |
| 93 | + let handler = handler_commands(); |
| 94 | + let granted = acl_granted_commands(); |
| 95 | + |
| 96 | + let missing_from_acl: Vec<_> = handler.difference(&granted).collect(); |
| 97 | + assert!( |
| 98 | + missing_from_acl.is_empty(), |
| 99 | + "commands registered in generate_handler! but missing an ACL `commands.allow` entry \ |
| 100 | + (these fail at runtime with `Command <name> not allowed by ACL`): {missing_from_acl:?}" |
| 101 | + ); |
| 102 | + |
| 103 | + let extra_in_acl: Vec<_> = granted.difference(&handler).collect(); |
| 104 | + assert!( |
| 105 | + extra_in_acl.is_empty(), |
| 106 | + "ACL grants commands that are not registered in generate_handler!: {extra_in_acl:?}" |
| 107 | + ); |
| 108 | +} |
| 109 | + |
| 110 | +#[test] |
| 111 | +fn capability_references_the_default_permission_set() { |
| 112 | + // The granular commands.allow entries only take effect if the capability |
| 113 | + // actually pulls in the local `default` permission set. |
| 114 | + assert!( |
| 115 | + quoted_strings(CAPABILITY_JSON).contains("default"), |
| 116 | + "capabilities/default.json must reference the \"default\" permission set so the \ |
| 117 | + app command grants in permissions/default.toml are applied" |
| 118 | + ); |
| 119 | +} |
| 120 | + |
| 121 | +#[test] |
| 122 | +fn core_session_commands_are_acl_granted() { |
| 123 | + let granted = acl_granted_commands(); |
| 124 | + for cmd in ["load_session", "list_sessions", "get_settings"] { |
| 125 | + assert!(granted.contains(cmd), "{cmd} must be ACL-granted"); |
| 126 | + } |
| 127 | +} |
0 commit comments