2026-09-22 22:26:18 -07:00
|
|
|
//! 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.
|
|
|
|
|
//!
|
2026-09-22 22:32:46 -07:00
|
|
|
//! 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/`.
|
|
|
|
|
//!
|
2026-09-22 22:26:18 -07:00
|
|
|
//! 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;
|
|
|
|
|
|
2026-09-22 22:32:46 -07:00
|
|
|
/// 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) -> ! {
|
2026-09-22 22:26:18 -07:00
|
|
|
eprintln!();
|
|
|
|
|
eprintln!(
|
2026-09-22 22:32:46 -07:00
|
|
|
"{what} ({} problem{}):",
|
2026-09-22 22:26:18 -07:00
|
|
|
problems.len(),
|
|
|
|
|
if problems.len() == 1 { "" } else { "s" }
|
|
|
|
|
);
|
|
|
|
|
for p in problems {
|
|
|
|
|
eprintln!(" - {p}");
|
|
|
|
|
}
|
|
|
|
|
eprintln!();
|
2026-09-22 22:32:46 -07:00
|
|
|
eprintln!("{hint}");
|
2026-09-22 22:26:18 -07:00
|
|
|
eprintln!();
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 22:32:46 -07:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
fn main() {
|
2026-09-22 22:32:46 -07:00
|
|
|
// tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
|
|
|
|
|
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
|
2026-09-22 22:26:18 -07:00
|
|
|
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 {
|
2026-09-22 22:32:46 -07:00
|
|
|
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.",
|
|
|
|
|
);
|
2026-09-22 22:26:18 -07:00
|
|
|
};
|
|
|
|
|
|
2026-09-22 22:32:46 -07:00
|
|
|
check_tauri_config();
|
|
|
|
|
let files = read_capabilities();
|
2026-09-22 22:26:18 -07:00
|
|
|
|
|
|
|
|
let problems = command_census::check(&commands, &files);
|
|
|
|
|
if !problems.is_empty() {
|
2026-09-22 22:32:46 -07:00
|
|
|
fail(
|
|
|
|
|
"capabilities do not match generate_handler!",
|
|
|
|
|
&problems,
|
|
|
|
|
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
|
|
|
|
|
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
|
|
|
|
|
capabilities/default.json. See src/command_census.rs.",
|
|
|
|
|
);
|
2026-09-22 22:26:18 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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/<command>.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
|
2026-09-22 22:32:46 -07:00
|
|
|
/// 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`).
|
2026-09-22 22:26:18 -07:00
|
|
|
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();
|
2026-09-22 22:32:46 -07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-09-22 22:26:18 -07:00
|
|
|
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 {
|
2026-09-22 22:32:46 -07:00
|
|
|
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-<command> 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<command_census::CapabilityFile> {
|
|
|
|
|
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;
|
2026-09-22 22:26:18 -07:00
|
|
|
}
|
2026-09-22 22:32:46 -07:00
|
|
|
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);
|
2026-09-22 22:26:18 -07:00
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|