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.
|
|
|
|
|
//!
|
|
|
|
|
//! 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;
|
|
|
|
|
|
|
|
|
|
fn fail(problems: &[String]) -> ! {
|
|
|
|
|
eprintln!();
|
|
|
|
|
eprintln!(
|
|
|
|
|
"capabilities do not match generate_handler! ({} problem{}):",
|
|
|
|
|
problems.len(),
|
|
|
|
|
if problems.len() == 1 { "" } else { "s" }
|
|
|
|
|
);
|
|
|
|
|
for p in problems {
|
|
|
|
|
eprintln!(" - {p}");
|
|
|
|
|
}
|
|
|
|
|
eprintln!();
|
|
|
|
|
eprintln!(
|
|
|
|
|
"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."
|
|
|
|
|
);
|
|
|
|
|
eprintln!();
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 04:29:51 +00:00
|
|
|
fn main() {
|
2026-09-22 22:26:18 -07:00
|
|
|
// tauri-build already emits rerun-if-changed for `capabilities` and `permissions`.
|
|
|
|
|
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()]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
let problems = command_census::check(&commands, &files);
|
|
|
|
|
if !problems.is_empty() {
|
|
|
|
|
fail(&problems);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
/// see, so it is refused.
|
|
|
|
|
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.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(&[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()
|
|
|
|
|
)]);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|