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:
CRTkafa 2026-09-29 11:51:24 +07:00
parent 4be7102bb2
commit 0b6b17a9c3
8 changed files with 1616 additions and 45 deletions

View 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();
}
}