//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses //! to build unless every registered command is granted in exactly one capability file — the //! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri //! 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`. #[path = "src/command_census.rs"] mod command_census; use std::path::Path; /// 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!( "{what} ({} problem{}):", problems.len(), if problems.len() == 1 { "" } else { "s" } ); for p in problems { eprintln!(" - {p}"); } eprintln!(); 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`, `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( "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.", ); }; check_tauri_config(); let files = read_capabilities(); let problems = command_census::check(&commands, &files); if !problems.is_empty() { 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); // `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so // there is no owned form; leaking is fine in a process that exits right after. let leaked: Vec<&'static str> = commands .into_iter() .map(|c| &*Box::leak(c.into_boxed_str())) .collect(); let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice()); let attributes = tauri_build::Attributes::new() .app_manifest(tauri_build::AppManifest::new().commands(leaked)); if let Err(error) = tauri_build::try_build(attributes) { // Same shape as `tauri_build::build()`: message on stdout, then exit 1. println!("{error:#}"); std::process::exit(1); } } /// tauri-build writes `permissions/autogenerated/.toml` for every manifest command /// and never deletes one, so a command removed from `lib.rs` would leave a permission a /// capability could still reference (and the build would pass). Delete only the stale files: /// 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 — 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 { return; }; 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(); let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let live = file.extension().is_some_and(|e| e == "toml") && commands.iter().any(|c| c == stem); if !live { std::fs::remove_file(&file) .unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display())); } } } else { 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); } }