//! Security assertions for an app's own tests (`tests/security.rs`): who may call which command, //! read from the app's capability files, and whether a command checks its caller first. //! //! ```ignore //! use waybetter_host::testkit::Capabilities; //! let caps = Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")); //! caps.assert_host_commands(); // the host's shell-only / tab-only commands //! caps.assert_shell_only("draft_list"); // the app's own //! caps.assert_tab_only("tab_report"); //! waybetter_host::testkit::assert_guarded(include_str!("../src/desktop.rs"), "draft_list", "shell_only(&webview)?"); //! ``` //! //! A capability counts as the shell's when its webviews are exactly `["shell"]`, it is `local` and //! has no `remote`; as a tab's when its webviews are exactly `["tab-*"]` and it is not `local`. //! Anything else granting a command fails both checks. use crate::commands; use crate::policy::SHELL_LABEL; use serde_json::Value; use std::path::Path; /// One capability file: its name (for messages) and its JSON. #[derive(Clone, Debug)] pub struct Capability { pub file: String, pub json: Value, } impl Capability { pub fn identifier(&self) -> &str { self.json["identifier"].as_str().unwrap_or(&self.file) } /// The permissions it grants, as strings (`allow-tabs-sync`, `core:event:default`, ...). pub fn permissions(&self) -> Vec { self.json["permissions"] .as_array() .map(|list| list.iter().filter_map(|p| p.as_str().map(str::to_owned)).collect()) .unwrap_or_default() } fn webviews(&self) -> Vec<&str> { self.json["webviews"].as_array().map(|list| list.iter().filter_map(Value::as_str).collect()).unwrap_or_default() } /// The local `shell` webview and nothing else. pub fn is_shell(&self) -> bool { self.webviews() == [SHELL_LABEL] && self.json["local"] == Value::Bool(true) && self.json.get("remote").is_none() } /// Tab webviews (`tab-*`) and nothing else, never local pages. pub fn is_tab(&self) -> bool { self.webviews() == ["tab-*"] && self.json["local"] == Value::Bool(false) } } /// An app's capability files. #[derive(Clone, Debug)] pub struct Capabilities(pub Vec); impl Capabilities { /// Every `*.json` in `dir` (the app's `capabilities` folder), so a new file is checked too. pub fn from_dir(dir: impl AsRef) -> Self { let dir = dir.as_ref(); let mut files: Vec<_> = std::fs::read_dir(dir) .unwrap_or_else(|error| panic!("capabilities folder {}: {error}", dir.display())) .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().is_some_and(|ext| ext == "json")) .collect(); files.sort(); assert!(!files.is_empty(), "no capability files in {}", dir.display()); Self( files .into_iter() .map(|path| { let text = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); Self::parse(file, &text) }) .collect(), ) } /// Capability files given as `(name, json)` (e.g. from `include_str!`). pub fn from_sources(sources: &[(&str, &str)]) -> Self { Self(sources.iter().map(|(file, text)| Self::parse((*file).to_owned(), text)).collect()) } fn parse(file: String, text: &str) -> Capability { let json = serde_json::from_str(text).unwrap_or_else(|error| panic!("{file}: invalid JSON: {error}")); Capability { file, json } } /// The capability with this identifier. pub fn get(&self, identifier: &str) -> &Capability { self.0 .iter() .find(|cap| cap.identifier() == identifier) .unwrap_or_else(|| panic!("no capability {identifier:?}")) } /// The capabilities granting `permission`. pub fn granting(&self, permission: &str) -> Vec<&Capability> { self.0.iter().filter(|cap| cap.permissions().iter().any(|p| p == permission)).collect() } /// `command` is granted, and only to the local shell webview. pub fn assert_shell_only(&self, command: &str) { let permission = commands::permission(command); let granting = self.granting(&permission); assert!(granting.iter().any(|cap| cap.is_shell()), "the shell lacks {permission}"); for cap in granting { assert!(cap.is_shell(), "{permission} is shell-only, but capability {:?} ({}) grants it", cap.identifier(), cap.file); } } /// `command` is granted, and only to tab webviews (never the shell or another local page). pub fn assert_tab_only(&self, command: &str) { let permission = commands::permission(command); let granting = self.granting(&permission); assert!(!granting.is_empty(), "no tab capability grants {permission}"); for cap in granting { assert!(cap.is_tab(), "{permission} is tab-only, but capability {:?} ({}) grants it", cap.identifier(), cap.file); } } /// Every command of an optional group (e.g. [`commands::STORE`]) is granted, and only to the /// local shell webview. pub fn assert_shell_only_group(&self, group: &[&str]) { for command in group { self.assert_shell_only(command); } } /// `command` is granted to no capability at all. pub fn assert_not_granted(&self, command: &str) { let permission = commands::permission(command); let granting: Vec<_> = self.granting(&permission).iter().map(|cap| cap.identifier().to_owned()).collect(); assert!(granting.is_empty(), "{permission} is granted by {granting:?}"); } /// `command` is granted, and only to tab webviews on `site`'s own pages: every capability /// granting it is a tab capability whose remote URLs are exactly `https:///*` of the /// site's hosts. pub fn assert_site_only(&self, command: &str, site: &crate::site::SiteSpec) { self.assert_tab_only(command); let permission = commands::permission(command); let allowed: Vec = site.hosts.iter().map(|host| format!("https://{host}/*")).collect(); for cap in self.granting(&permission) { let urls: Vec<&str> = cap.json["remote"]["urls"].as_array().map(|list| list.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); assert!( !urls.is_empty() && urls.iter().all(|url| allowed.iter().any(|ok| ok == url)), "{permission} is for {allowed:?} only, but capability {:?} ({}) grants it on {urls:?}", cap.identifier(), cap.file ); } } /// The host's commands ([`commands::SHELL`], [`commands::TAB`], [`commands::SITE`]) keep their /// callers. Check the site commands' URLs with [`Self::assert_site_only`] too. pub fn assert_host_commands(&self) { for command in commands::SHELL { self.assert_shell_only(command); } for command in commands::TAB.iter().chain(commands::SITE) { self.assert_tab_only(command); } } /// No capability exposes these Tauri plugins' JS APIs (`updater`, `process`, ...): only Rust /// drives them. pub fn assert_no_plugin_apis(&self, plugins: &[&str]) { for cap in &self.0 { for permission in cap.permissions() { assert!( !plugins.iter().any(|plugin| permission.starts_with(&format!("{plugin}:"))), "capability {:?} exposes a plugin API: {permission}", cap.identifier() ); } } } } /// The first statement of function `function` in `source` contains `guard` (e.g. /// `shell_only(&webview)?`): the command checks its caller before it does anything else. pub fn assert_guarded(source: &str, function: &str, guard: &str) { let at = source .find(&format!("fn {function}(")) .unwrap_or_else(|| panic!("no function {function} in the given source")); let body = &source[at..]; let body = &body[body.find('{').unwrap_or_else(|| panic!("{function} has no body")) + 1..]; let first = &body[..body.find(';').unwrap_or(body.len())]; assert!(first.contains(guard), "{function} must check `{guard}` first, starts with: {}", first.trim()); } /// The host's own command wrappers check their caller first (shell-only: `shell_only`, tab-only: /// `tab_only`). Runs against the host's source, so an app's tests re-check the version it builds with. pub fn assert_host_guards() { let source = include_str!("app.rs"); for command in commands::SHELL { assert_guarded(source, command, "shell_only(&webview)?"); } for command in commands::TAB { assert_guarded(source, command, "tab_only(&webview)?"); } for command in commands::SITE { assert_guarded(source, command, "site_tab_only(config(&app).site, &webview)?"); } } /// The store commands' wrappers check their caller first. Runs against the host's source. pub fn assert_store_guards() { let source = include_str!("store_commands.rs"); for command in commands::STORE { assert_guarded(source, command, "shell_only(&webview)?"); } } #[cfg(test)] mod tests { use super::*; #[test] fn the_store_commands_check_their_caller_first() { assert_store_guards(); } fn caps_with(shell_extra: &str, tab_extra: &str, site_extra: &str) -> Capabilities { let host_shell: Vec = commands::SHELL.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect(); let host_tab: Vec = commands::TAB.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect(); let host_site: Vec = commands::SITE.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect(); let site = format!( r#"{{"identifier":"site-tab","webviews":["tab-*"],"local":false,"remote":{{"urls":["https://x.com/*"]}},"permissions":[{}{site_extra}]}}"#, host_site.join(",") ); let shell = format!( r#"{{"identifier":"shell","webviews":["shell"],"local":true,"permissions":["core:event:default",{}{shell_extra}]}}"#, host_shell.join(",") ); let tab = format!( r#"{{"identifier":"tab-chrome","webviews":["tab-*"],"local":false,"remote":{{"urls":["https://*/*"]}},"permissions":[{}{tab_extra}]}}"#, host_tab.join(",") ); Capabilities::from_sources(&[("shell.json", &shell), ("tab-chrome.json", &tab), ("site-tab.json", &site)]) } fn caps(shell_extra: &str, tab_extra: &str) -> Capabilities { caps_with(shell_extra, tab_extra, "") } #[test] fn host_commands_pass_when_granted_to_their_callers() { let caps = caps("", ""); caps.assert_host_commands(); caps.assert_not_granted("draft_list"); caps.assert_no_plugin_apis(&["updater", "process"]); } #[test] #[should_panic(expected = "shell-only")] fn a_shell_command_granted_to_tabs_fails() { caps("", r#","allow-tabs-sync""#).assert_host_commands(); } #[test] #[should_panic(expected = "tab-only")] fn a_tab_command_granted_to_the_shell_fails() { caps(r#","allow-tab-shortcut""#, "").assert_host_commands(); } #[test] fn site_commands_are_for_the_site_only() { use crate::site::testing::SITE; caps("", "").assert_site_only("open_panel", &SITE); // Granted to every tab (any site), a site command fails. let wide = std::panic::catch_unwind(|| caps("", r#","allow-open-panel""#).assert_site_only("open_panel", &SITE)); assert!(wide.is_err()); } #[test] #[should_panic(expected = "plugin API")] fn plugin_apis_are_refused() { caps(r#","updater:default""#, "").assert_no_plugin_apis(&["updater"]); } #[test] fn guards_are_read_from_the_first_statement() { let source = "async fn a(webview: Webview) -> Result<(), String> {\n shell_only(&webview)?;\n Ok(())\n}\nfn b(webview: Webview) { let x = 1; shell_only(&webview)?; }"; assert_guarded(source, "a", "shell_only(&webview)?"); assert!(std::panic::catch_unwind(|| assert_guarded(source, "b", "shell_only(&webview)?")).is_err()); } #[test] fn the_host_commands_check_their_callers_first() { assert_host_guards(); } }