docs(acl): reconcile spec prose with the shipped implementation
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m49s
Build App (Preview) / build-linux (pull_request) Successful in 5m16s
Build App (Preview) / build-windows (pull_request) Successful in 10m4s
Build App (Preview) / prune-previews (pull_request) Successful in 9s

Final-wave cleanups from the whole-branch review (final-review.md Minor
1-5): spec §4 now says selective pruning, not "deletes the directory
every build"; spec §3.3 now describes the TypeScript-AST scan
(fail-closed Vite-order resolution, namespace imports as member access
only, the every-code-file boundary check) instead of the old
regex/chunk description; the viewer spec's historical "every command
is callable from every window" line gets a dated "closed by the
AppManifest lockdown" note; the lib.rs doc comment on
the_generated_app_manifest_matches_the_handler_list no longer claims
independence from the shared parser it actually reuses; and the vitest
command-name regex now allows digits, matching Rust's [a-z0-9_]+.

Also adds a cargo test backstop
(the_tauri_config_capability_check_runs_against_the_real_tree) that
runs build.rs's tauri-config capability check against the real
app/src-tauri tree on every `cargo test`, closing the gap where a new
tauri.<platform>.conf.json on an already-built tree only gets checked
by build.rs on a clean build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:25:50 -07:00
co-authored by Claude Opus 5.5
parent a479bce639
commit 71ba5076db
4 changed files with 93 additions and 20 deletions
+50 -6
View File
@@ -972,12 +972,16 @@ mod tests {
/// `build.rs` derives the AppManifest from the handler list and this reads back what
/// tauri-build actually embedded. `cargo test` runs the build script first, so
/// `gen/schemas/acl-manifests.json` is fresh. Independent of the shared parser: if
/// `registered_commands` ever lost half the list, `build.rs` would declare half a
/// manifest and this would still compare it against… the same half. So the count is
/// pinned too, from a source that is not the parser: the `#[tauri::command]` scan in
/// `every_command_is_registered_exactly_once` guarantees definitions == registrations,
/// and here the embedded set must match the number of registrations that scan found.
/// `gen/schemas/acl-manifests.json` is fresh. This guards against the committed/generated
/// artifact diverging from `generate_handler!` — a stale `acl-manifests.json`, or a
/// tauri-build naming change — using the same `registered_commands` parser `build.rs` used
/// to derive the manifest in the first place. It is *not* independent of a parser dropout on
/// its own: if `registered_commands` lost half the list, `build.rs` would declare half a
/// manifest and this would still compare it against the same half. That guarantee is
/// transitive, not local — `every_command_is_registered_exactly_once` covers it, by
/// cross-checking the parser's output against an independent `#[tauri::command]` scan, so a
/// parser regression that silently dropped commands fails there rather than going unnoticed
/// here.
#[test]
fn the_generated_app_manifest_matches_the_handler_list() {
use std::collections::BTreeSet;
@@ -1021,4 +1025,44 @@ mod tests {
);
assert!(app["default_permission"].is_null(), "no app `default` permission set");
}
/// `build.rs`'s `check_tauri_config` (inline `app.security.capabilities`, a JSON5/TOML tauri
/// config, `TAURI_CONFIG`) only runs inside the build script, so it only re-runs on a clean
/// build or in CI — cargo's incremental build has no reason to notice a new
/// `tauri.<platform>.conf.json` dropped into an already-built tree (CLAUDE.md, "Known
/// limit"). This runs the same check, using the same `command_census` functions build.rs
/// calls, directly against the real `app/src-tauri` directory on every `cargo test`, so that
/// gap is closed locally too.
#[test]
fn the_tauri_config_capability_check_runs_against_the_real_tree() {
let dir = env!("CARGO_MANIFEST_DIR");
let mut problems = Vec::new();
for entry in std::fs::read_dir(dir).expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = path
.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned();
match crate::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(crate::command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(crate::command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
assert!(
problems.is_empty(),
"cargo test found what build.rs would refuse on a clean build: {problems:?}"
);
}
}
+3 -1
View File
@@ -320,7 +320,9 @@ function wrapperCommands(): Map<string, string> {
for (const [name, calls] of callsByWrapper) {
expect(calls, `${name} must call invoke exactly once`).toHaveLength(1);
const command = stringLiteralText(calls[0].arguments[0]) ?? "<not a string literal>";
expect(command, `${name} must invoke a string literal (a computed name cannot be audited)`).toMatch(/^[a-z_]+$/);
expect(command, `${name} must invoke a string literal (a computed name cannot be audited)`).toMatch(
/^[a-z0-9_]+$/,
);
map.set(name, command);
}
expect(map.size).toBeGreaterThan(100);
@@ -291,19 +291,44 @@ a command that genuinely needs both is a design change worth a visible edit to
that is wrong for the tree as built, because the viewer does not call `invoke` itself — it
imports wrappers from the shared `lib/tauri-commands.ts` (`viewerGetState`, `viewerChooseFile`
in `ViewerApp.tsx`; `viewerReadFile`, `viewerPollFile`, `viewerWriteFile` in `EditorPane.tsx`).
So the test follows imports instead:
So the test follows imports instead, and does so by parsing every file with the TypeScript
compiler (`ts.createSourceFile`), not a regex scan, so comments, strings, template substitutions
and regex literals are the parser's problem rather than ours:
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`** (true today for every
non-test file). That is what makes the rest of the test complete: no other file can invoke.
- Build the wrapper map from `tauri-commands.ts`: each `export const NAME = …` chunk contains
exactly one `invoke<…>("<literal>"` (115 wrappers, 115 commands today). A chunk with zero or
more than one literal, or a non-literal first argument, fails the test.
- The **viewer closure** is the transitive set of relative imports from `src/viewer/main.tsx`
(`.ts`/`.tsx`, tests excluded, `.css` ignored). Today it reaches `lib/tauri-commands`,
`lib/types`, `components/ui/{Button,StatusIndicator,unavailable}` and
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`**, checked against *every*
code file under `src/` — tests included, not just the files the two closures below cover,
because the viewer's closure can reach anything a relative import can. A specifier this scan
cannot read as a literal (a computed `import()`/`require()` argument) is treated as an offender
too, so an un-auditable dynamic import fails the same way a literal one to
`@tauri-apps/api/core` would. That is what makes the rest of the test complete: no other file
can invoke.
- The wrapper map is read from `tauri-commands.ts`'s AST: each exported `const NAME = …` must
call `invoke` exactly once, inside its own function body (not at module load, not inside an
IIFE, not as a default argument), with a string-literal command name matching Rust's
`[a-z0-9_]+` (115 wrappers, 115 commands today). Zero or more than one call, a non-literal
argument, `invoke` referenced other than as a direct call, or any dynamic `import()`/`require()`
in the file at all, fails the test.
- Every other module edge is resolved the way Vite 6 actually resolves it, in Vite's own order:
the exact relative path if it names a file; else, for a `.js`/`.mjs`/`.cjs`/`.jsx` path, its
TypeScript twin (`.js`→`.ts`, then `.tsx`); else `path + ext` over `resolve.extensions`
(`.mjs .js .mts .ts .jsx .tsx .json`) in that order; else, for a directory, `index + ext` in the
same order. A path alias, a query/fragment suffix (`?worker`, `?raw`), a directory import that
would switch Vite to package-entry resolution, or a relative import that resolves outside
`src/`, all fail the test rather than being silently skipped or misresolved — resolution is
fail-closed, not best-effort.
- A wrapper counts as used when a file names it directly (a named import, a named re-export by
name) or reaches it through a namespace import (`import * as X from ".../tauri-commands"`)
**used only as member access**, `X.name` (including `typeof X.name` in a type position). Any
other use of that namespace binding — passed to a function, spread, indexed, aliased again —
fails closed rather than being read as "no wrapper used"; `export *`/`export * as` of the
wrappers file is refused outright, since it cannot be resolved to specific names.
- The **viewer closure** is the transitive set of module targets reachable from
`src/viewer/main.tsx` under that same fail-closed resolution (static imports, `export … from`,
literal `import()`; tests excluded, non-code assets ignored). Today it reaches
`lib/tauri-commands`, `lib/types`, `components/ui/{Button,StatusIndicator,unavailable}` and
`components/projects/home/filePreview`, none of which import a wrapper except the two viewer
files. `V` = commands of the wrappers those files import; `M` = commands of the wrappers
imported by every other non-test file under `src/`.
files. `V` = commands of the wrappers those files reach; `M` = commands of the wrappers reached
by every other non-test file under `src/`.
- Assert `M ∩ V = ∅`; `allow-<slug>(V)` **equals** the bare set of `file-viewer.json` (no
over-grant to the untrusted window); `allow-<slug>(M)` is a **subset** of the bare set of
`default.json`; and every wrapper's command is in exactly one of the two files (a cheap mirror
@@ -389,7 +414,7 @@ code that runs in each window only reaches wrappers that window is granted.
| A registered command is left out of `default.json` | Runtime: that feature's invoke rejected with `not allowed. Permissions associated with this command: allow-…` (release: `Command … not allowed by ACL`) | `build.rs` §3.2 fails the build before any binary exists; the vitest §3.3 fails locally |
| Typo in an `allow-*` string | Build | tauri's `validate_capabilities` and the §3.2 check, both at build time |
| Parser regression yields an empty command list | Would silently restore the unguarded state | §3.2 step 2 panics on empty; §3.4 asserts `acl-manifests.json` has the full set |
| Stale `permissions/autogenerated/<cmd>.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 deletes the directory every build; the directory is gitignored |
| Stale `permissions/autogenerated/<cmd>.toml` after a command is removed | A capability could still reference a dead permission and validate | §3.2 step 3 selectively prunes only the files whose `<name>` is no longer a registered command, every build (never `remove_dir_all` — Decision 9); live files are left alone so the build settles; the directory is gitignored |
| A `deny-*` added "for the viewer only" | Denies the command for main too (global) | §3.2 rejects any bare `deny-*` |
| `file-viewer.json` grants a main-only command | The viewer window gains reach | §3.2 `expected_windows` assertion and §3.3 equality check |
| Main-window code imports a `viewer*` wrapper (or the viewer imports a main one) | Runtime denial in that window | §3.3 `M ∩ V = ∅` and the per-side set checks |
@@ -56,7 +56,9 @@ AppManifest spec, `2026-09-22-app-manifest-lockdown-design.md`).
`close()`. `lib.rs`'s `on_window_event` returns early for any label but `main`.
- `build.rs` is a bare `tauri_build::build()`: **every app command is callable from every local
window**. `capabilities/default.json` gates plugin commands only and lists `windows: ["main"]`.
App commands need no capability entry (CLAUDE.md, "Key Conventions").
App commands need no capability entry (CLAUDE.md, "Key Conventions"). *(Historical snapshot at
`3537b23`. Closed 2026-09-22 by the AppManifest lockdown
(`2026-09-22-app-manifest-lockdown-design.md`) — see §6 below.)*
- The frontend does not know a terminal's cwd. Terminal execs start in `/workspace`; each project
path is bind-mounted at `/workspace/<mount_name>` (`ProjectPath { host_path, mount_name }`,
rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a