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
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:
@@ -972,12 +972,16 @@ mod tests {
|
|||||||
|
|
||||||
/// `build.rs` derives the AppManifest from the handler list and this reads back what
|
/// `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
|
/// 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
|
/// `gen/schemas/acl-manifests.json` is fresh. This guards against the committed/generated
|
||||||
/// `registered_commands` ever lost half the list, `build.rs` would declare half a
|
/// artifact diverging from `generate_handler!` — a stale `acl-manifests.json`, or a
|
||||||
/// manifest and this would still compare it against… the same half. So the count is
|
/// tauri-build naming change — using the same `registered_commands` parser `build.rs` used
|
||||||
/// pinned too, from a source that is not the parser: the `#[tauri::command]` scan in
|
/// to derive the manifest in the first place. It is *not* independent of a parser dropout on
|
||||||
/// `every_command_is_registered_exactly_once` guarantees definitions == registrations,
|
/// its own: if `registered_commands` lost half the list, `build.rs` would declare half a
|
||||||
/// and here the embedded set must match the number of registrations that scan found.
|
/// 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]
|
#[test]
|
||||||
fn the_generated_app_manifest_matches_the_handler_list() {
|
fn the_generated_app_manifest_matches_the_handler_list() {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
@@ -1021,4 +1025,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(app["default_permission"].is_null(), "no app `default` permission set");
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,7 +320,9 @@ function wrapperCommands(): Map<string, string> {
|
|||||||
for (const [name, calls] of callsByWrapper) {
|
for (const [name, calls] of callsByWrapper) {
|
||||||
expect(calls, `${name} must call invoke exactly once`).toHaveLength(1);
|
expect(calls, `${name} must call invoke exactly once`).toHaveLength(1);
|
||||||
const command = stringLiteralText(calls[0].arguments[0]) ?? "<not a string literal>";
|
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);
|
map.set(name, command);
|
||||||
}
|
}
|
||||||
expect(map.size).toBeGreaterThan(100);
|
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
|
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`
|
imports wrappers from the shared `lib/tauri-commands.ts` (`viewerGetState`, `viewerChooseFile`
|
||||||
in `ViewerApp.tsx`; `viewerReadFile`, `viewerPollFile`, `viewerWriteFile` in `EditorPane.tsx`).
|
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
|
- **Only `lib/tauri-commands.ts` may import `@tauri-apps/api/core`**, checked against *every*
|
||||||
non-test file). That is what makes the rest of the test complete: no other file can invoke.
|
code file under `src/` — tests included, not just the files the two closures below cover,
|
||||||
- Build the wrapper map from `tauri-commands.ts`: each `export const NAME = …` chunk contains
|
because the viewer's closure can reach anything a relative import can. A specifier this scan
|
||||||
exactly one `invoke<…>("<literal>"` (115 wrappers, 115 commands today). A chunk with zero or
|
cannot read as a literal (a computed `import()`/`require()` argument) is treated as an offender
|
||||||
more than one literal, or a non-literal first argument, fails the test.
|
too, so an un-auditable dynamic import fails the same way a literal one to
|
||||||
- The **viewer closure** is the transitive set of relative imports from `src/viewer/main.tsx`
|
`@tauri-apps/api/core` would. That is what makes the rest of the test complete: no other file
|
||||||
(`.ts`/`.tsx`, tests excluded, `.css` ignored). Today it reaches `lib/tauri-commands`,
|
can invoke.
|
||||||
`lib/types`, `components/ui/{Button,StatusIndicator,unavailable}` and
|
- 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
|
`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
|
files. `V` = commands of the wrappers those files reach; `M` = commands of the wrappers reached
|
||||||
imported by every other non-test file under `src/`.
|
by every other non-test file under `src/`.
|
||||||
- Assert `M ∩ V = ∅`; `allow-<slug>(V)` **equals** the bare set of `file-viewer.json` (no
|
- 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
|
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
|
`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 |
|
| 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 |
|
| 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 |
|
| 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-*` |
|
| 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 |
|
| `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 |
|
| 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`.
|
`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
|
- `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"]`.
|
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
|
- 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 }`,
|
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
|
rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a
|
||||||
|
|||||||
Reference in New Issue
Block a user