host: run_app — the window, shell, tab webviews and generic commands for every app
The `app` feature (App / run_app, AppConfig, SiteHooks): registers the Tauri plugins every app uses (single-instance, deep-link and updater opt-in, window-state, opener, process, notification, dialog), makes app data, loads profiles and the language, builds the window (restore, lost-geometry reset, helper-close routing, native resize follow) and the transparent shell, and serves tabs_sync, tab_navigate, tab_action, open_external_url, shell_focus, shell_raise, window_system_menu, profiles_get/set, i18n_get/set (shell-only) and tab_shortcut, tab_link_drag (tab-only). The tab builder is generic; the app fills in SiteHooks. `commands` lists them (manifest() for build.rs), `testkit` checks capability files and caller guards, i18n::page_script is origin-guarded. check:tauri now checks the app feature.
This commit is contained in:
parent
4be7102bb2
commit
0b6b17a9c3
8 changed files with 1616 additions and 45 deletions
239
crates/waybetter-host/src/testkit.rs
Normal file
239
crates/waybetter-host/src/testkit.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//! 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<String> {
|
||||
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<Capability>);
|
||||
|
||||
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<Path>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// `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:?}");
|
||||
}
|
||||
|
||||
/// The host's commands ([`commands::SHELL`], [`commands::TAB`]) keep their callers.
|
||||
pub fn assert_host_commands(&self) {
|
||||
for command in commands::SHELL {
|
||||
self.assert_shell_only(command);
|
||||
}
|
||||
for command in commands::TAB {
|
||||
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)?");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn caps(shell_extra: &str, tab_extra: &str) -> Capabilities {
|
||||
let host_shell: Vec<String> = commands::SHELL.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();
|
||||
let host_tab: Vec<String> = commands::TAB.iter().map(|c| format!("\"{}\"", commands::permission(c))).collect();
|
||||
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)])
|
||||
}
|
||||
|
||||
#[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]
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue