diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs index 5af47e7..cf8ad8d 100644 --- a/app/src-tauri/build.rs +++ b/app/src-tauri/build.rs @@ -4,6 +4,11 @@ //! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window //! could call any command. //! +//! Because the census can only vouch for what it reads, the build also stops on any capability +//! tauri would load that the census does not: anything in `capabilities/` other than a +//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config +//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`. +//! //! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles, //! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`. @@ -12,10 +17,12 @@ mod command_census; use std::path::Path; -fn fail(problems: &[String]) -> ! { +/// Stops the build. `what` names the check that failed, so a malformed capability file, a +/// stray entry or a hand-written permission does not read as a grant/handler mismatch. +fn fail(what: &str, problems: &[String], hint: &str) -> ! { eprintln!(); eprintln!( - "capabilities do not match generate_handler! ({} problem{}):", + "{what} ({} problem{}):", problems.len(), if problems.len() == 1 { "" } else { "s" } ); @@ -23,49 +30,52 @@ fn fail(problems: &[String]) -> ! { eprintln!(" - {p}"); } eprintln!(); - eprintln!( - "Every app command needs exactly one bare `allow-` grant: \ - `viewer_*` commands in capabilities/file-viewer.json, everything else in \ - capabilities/default.json. See src/command_census.rs." - ); + eprintln!("{hint}"); eprintln!(); std::process::exit(1); } +const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \ + `windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \ + (tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \ + src/command_census.rs can only vouch for what it reads."; + +fn file_name(path: &Path) -> String { + path.file_name() + .expect("a directory entry has a file name") + .to_string_lossy() + .into_owned() +} + fn main() { - // tauri-build already emits rerun-if-changed for `capabilities` and `permissions`. + // tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the + // tauri config files, and rerun-if-env-changed for TAURI_CONFIG. println!("cargo:rerun-if-changed=src/lib.rs"); println!("cargo:rerun-if-changed=src/command_census.rs"); let lib_rs = std::fs::read_to_string("src/lib.rs") .expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable"); let Some(commands) = command_census::registered_commands(&lib_rs) else { - fail(&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from" - .to_string()]); + fail( + "missing generate_handler! block", + &["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from" + .to_string()], + "build.rs derives the AppManifest from that block; see src/command_census.rs.", + ); }; - let mut files = Vec::new(); - for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") { - let path = entry.expect("readable entry in capabilities/").path(); - if path.extension().is_some_and(|e| e == "json") { - let name = path - .file_name() - .expect("a file name") - .to_string_lossy() - .into_owned(); - let json = - std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); - match command_census::capability_file(&name, &json) { - Ok(file) => files.push(file), - Err(problem) => fail(&[problem]), - } - } - } - files.sort_by(|a, b| a.name.cmp(&b.name)); + check_tauri_config(); + let files = read_capabilities(); let problems = command_census::check(&commands, &files); if !problems.is_empty() { - fail(&problems); + fail( + "capabilities do not match generate_handler!", + &problems, + "Every app command needs exactly one bare `allow-` grant: \ + `viewer_*` commands in capabilities/file-viewer.json, everything else in \ + capabilities/default.json. See src/command_census.rs.", + ); } prune_permissions(&commands); @@ -92,7 +102,8 @@ fn main() { /// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would /// touch every mtime and re-run this script — and recompile the crate — on every cargo /// invocation. Anything else under `permissions/` is a hand-written grant the census cannot -/// see, so it is refused. +/// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never +/// loads and which is skipped (see `command_census::is_os_junk`). fn prune_permissions(commands: &[String]) { let root = Path::new("permissions"); let Ok(entries) = std::fs::read_dir(root) else { @@ -100,6 +111,10 @@ fn prune_permissions(commands: &[String]) { }; for entry in entries { let path = entry.expect("readable entry in permissions/").path(); + if path.is_file() && command_census::is_os_junk(&file_name(&path)) { + // .DS_Store and friends: tauri never loads them, so they cannot grant anything. + continue; + } if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() { for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") { let file = file.expect("readable entry").path(); @@ -112,11 +127,82 @@ fn prune_permissions(commands: &[String]) { } } } else { - fail(&[format!( - "{} is not generated by build.rs; hand-written permissions are not allowed \ - (every grant is a bare allow-* string in a capability file)", - path.display() - )]); + fail( + "hand-written permission", + &[format!( + "{} is not generated by build.rs; hand-written permissions are not allowed \ + (every grant is a bare allow-* string in a capability file)", + path.display() + )], + "permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \ + an app command is granted by listing allow- in a capability file.", + ); } } } + +/// Every capability tauri will load, read the way the census reads it — or the build stops. +/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level +/// `*.json`, so anything else tauri could load is refused rather than granted unchecked. +fn read_capabilities() -> Vec { + let mut files = Vec::new(); + let mut stray = Vec::new(); + let mut invalid = Vec::new(); + for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") { + let path = entry.expect("readable entry in capabilities/").path(); + let name = file_name(&path); + let is_file = path.is_file(); + if is_file && command_census::is_os_junk(&name) { + continue; + } + if let Some(problem) = command_census::stray_capability_entry(&name, is_file) { + stray.push(problem); + continue; + } + let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + match command_census::capability_file(&name, &json) { + Ok(file) => files.push(file), + Err(problem) => invalid.push(problem), + } + } + stray.sort(); + if !stray.is_empty() { + fail("stray entry in capabilities/", &stray, LAYOUT_HINT); + } + invalid.sort(); + if !invalid.is_empty() { + fail("invalid capability file", &invalid, LAYOUT_HINT); + } + files.sort_by(|a, b| a.name.cmp(&b.name)); + files +} + +/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config +/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot +/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML). +fn check_tauri_config() { + let mut problems = Vec::new(); + for entry in std::fs::read_dir(".").expect("readable src-tauri/") { + let path = entry.expect("readable entry in src-tauri/").path(); + let name = file_name(&path); + match command_census::tauri_config_file(&name) { + None => {} + Some(false) => problems.push(format!( + "{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \ + declare capabilities it cannot see" + )), + Some(true) => { + let json = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + problems.extend(command_census::tauri_config_problem(&name, &json)); + } + } + } + if let Ok(json) = std::env::var("TAURI_CONFIG") { + problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json)); + } + problems.sort(); + if !problems.is_empty() { + fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT); + } +} diff --git a/app/src-tauri/src/command_census.rs b/app/src-tauri/src/command_census.rs index 7eb56bc..c81c914 100644 --- a/app/src-tauri/src/command_census.rs +++ b/app/src-tauri/src/command_census.rs @@ -75,6 +75,16 @@ pub struct CapabilityFile { pub fn capability_file(name: &str, json: &str) -> Result { let value: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?; + // `webviews` would extend the grants to webviews by label (the browser-view pop-out is + // meant to be in no capability), and `remote` would extend them to a remote origin. The + // census reasons about `windows` only, so either key is refused rather than half-checked. + for key in ["webviews", "remote"] { + if value.get(key).is_some() { + return Err(format!( + "{name}: `{key}` is not allowed; capabilities here are scoped by `windows` only" + )); + } + } let windows = value["windows"] .as_array() .ok_or_else(|| format!("{name}: `windows` must be an array"))? @@ -105,6 +115,79 @@ pub fn capability_file(name: &str, json: &str) -> Result Ok(CapabilityFile { name: name.to_string(), windows, bare }) } +/// Why an entry directly under `capabilities/` cannot be a capability the census reads, or +/// `None` if it is one (a top-level `*.json` file). tauri-build loads `capabilities/**/*` with +/// the extensions `json`, `toml` and (with a feature) `json5`, subdirectories included; the +/// census reads only top-level JSON, so anything else tauri might load is refused rather than +/// left for tauri to grant from unchecked. OS and editor junk, which tauri never loads, is the +/// caller's to skip first (see [`is_os_junk`]). +pub fn stray_capability_entry(name: &str, is_file: bool) -> Option { + if !is_file { + return Some(format!( + "capabilities/{name} is not a regular file; tauri loads capabilities from \ + subdirectories too, so every capability must be a top-level capabilities/*.json" + )); + } + if name.ends_with(".json") { + return None; + } + Some(format!( + "capabilities/{name} is not a .json file; tauri may load it (it reads .toml and .json5 \ + too) but the census cannot check it, so every capability must be a top-level \ + capabilities/*.json" + )) +} + +/// Files the OS or an editor drops next to real ones (`.DS_Store`, `Thumbs.db`, `desktop.ini`, +/// Vim swap files, `name~` backups). tauri-build loads only `json`/`toml`/`json5` from +/// `capabilities/` and `permissions/`, so a junk name with one of those extensions (an Emacs +/// `.#default.json` lock, a macOS `._default.json`) is *not* junk: tauri would try to load it, +/// and the caller must refuse it. +pub fn is_os_junk(name: &str) -> bool { + let loadable = [".json", ".json5", ".toml"].iter().any(|e| name.ends_with(e)); + !loadable + && (matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini") + || name.ends_with(".swp") + || name.ends_with(".swo") + || name.ends_with('~')) +} + +/// Which files next to `Cargo.toml` tauri reads as its config: `tauri.conf.json[5]`, +/// `Tauri.toml` and the per-platform `tauri..conf.json[5]` / `Tauri..toml` +/// (tauri-utils `config/parse.rs`). `Some(true)` = JSON the census can read, `Some(false)` = a +/// format it cannot (JSON5/TOML), `None` = not a tauri config file. +pub fn tauri_config_file(name: &str) -> Option { + if name.starts_with("tauri.") && name.ends_with(".conf.json") { + Some(true) + } else if (name.starts_with("tauri.") && name.ends_with(".conf.json5")) + || (name.starts_with("Tauri.") && name.ends_with(".toml")) + { + Some(false) + } else { + None + } +} + +/// A problem with a tauri config (a `tauri*.conf.json` file, or the `TAURI_CONFIG` JSON that +/// tauri-build merges over it), or `None`. `app.security.capabilities` is refused whenever it +/// is non-empty: an inline object is a capability the census never sees, and a list of +/// identifiers switches every *other* capability file off, which the census also assumes is +/// not happening. +pub fn tauri_config_problem(name: &str, json: &str) -> Option { + let value: serde_json::Value = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => return Some(format!("{name}: not valid JSON: {e}")), + }; + match value.pointer("/app/security/capabilities") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::Array(a)) if a.is_empty() => None, + Some(_) => Some(format!( + "{name}: app.security.capabilities is not allowed; every capability lives in a \ + top-level capabilities/*.json file, where the census checks it" + )), + } +} + /// Everything that must hold between the handler list and the capability files. Returns every /// violation rather than the first, so a batch of forgotten grants is one build failure; an /// empty vector is a pass. @@ -324,6 +407,70 @@ mod tests { assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json")); } + #[test] + fn webviews_and_remote_keys_are_refused() { + let with = |extra: &str| { + format!(r#"{{"windows": ["main"], {extra}, "permissions": ["allow-check-docker"]}}"#) + }; + let err = capability_file("d.json", &with(r#""webviews": ["browser-view-*"]"#)).unwrap_err(); + assert!(err.contains("d.json") && err.contains("`webviews`"), "{err}"); + let err = capability_file("d.json", &with(r#""remote": {"urls": ["https://*"]}"#)).unwrap_err(); + assert!(err.contains("`remote`"), "{err}"); + // Present-but-empty is still refused: the key itself is the widening surface. + assert!(capability_file("d.json", &with(r#""webviews": []"#)).is_err()); + } + + #[test] + fn only_top_level_json_files_are_capabilities() { + assert_eq!(stray_capability_entry("default.json", true), None); + for name in ["extra.toml", "extra.json5", "notes.txt", ".DS_Store"] { + let err = stray_capability_entry(name, true).expect(name); + assert!(err.contains(name) && err.contains("not a .json file"), "{err}"); + } + let err = stray_capability_entry("sub", false).unwrap(); + assert!(err.contains("capabilities/sub") && err.contains("not a regular file"), "{err}"); + // A directory named like a capability is still a directory. + assert!(stray_capability_entry("x.json", false).is_some()); + } + + #[test] + fn os_junk_is_recognised_but_never_something_tauri_would_load() { + for junk in [".DS_Store", "Thumbs.db", "desktop.ini", ".default.json.swp", ".x.swo", "default.json~"] { + assert!(is_os_junk(junk), "{junk}"); + } + for real in ["default.json", "x.toml", "x.json5", ".#default.json", "._default.json", "notes.txt", "extra"] { + assert!(!is_os_junk(real), "{real}"); + } + } + + #[test] + fn tauri_config_files_are_found_by_name_and_format() { + assert_eq!(tauri_config_file("tauri.conf.json"), Some(true)); + assert_eq!(tauri_config_file("tauri.linux.conf.json"), Some(true)); + assert_eq!(tauri_config_file("tauri.conf.json5"), Some(false)); + assert_eq!(tauri_config_file("tauri.windows.conf.json5"), Some(false)); + assert_eq!(tauri_config_file("Tauri.toml"), Some(false)); + assert_eq!(tauri_config_file("Tauri.macos.toml"), Some(false)); + assert_eq!(tauri_config_file("Cargo.toml"), None); + assert_eq!(tauri_config_file("build.rs"), None); + } + + #[test] + fn inline_capabilities_in_the_tauri_config_are_refused() { + let ok = r#"{"app": {"security": {"csp": "default-src 'self'"}}}"#; + assert_eq!(tauri_config_problem("tauri.conf.json", ok), None); + assert_eq!(tauri_config_problem("t", r#"{"app": {"security": {"capabilities": []}}}"#), None); + assert_eq!(tauri_config_problem("t", r#"{"build": {"beforeBuildCommand": ""}}"#), None); + let inline = r#"{"app": {"security": {"capabilities": [ + {"identifier": "x", "windows": ["file-viewer-*"], "permissions": ["allow-read-container-file"]} + ]}}}"#; + let err = tauri_config_problem("tauri.conf.json", inline).unwrap(); + assert!(err.contains("tauri.conf.json") && err.contains("app.security.capabilities"), "{err}"); + let by_name = r#"{"app": {"security": {"capabilities": ["default"]}}}"#; + assert!(tauri_config_problem("TAURI_CONFIG", by_name).unwrap().contains("TAURI_CONFIG")); + assert!(tauri_config_problem("t", "{").unwrap().contains("not valid JSON")); + } + #[test] fn a_correct_census_has_no_problems() { assert_eq!(check(&cmds(THREE), &good_files()), Vec::::new());