diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 02b48a8..8957f8a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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..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:?}" + ); + } } diff --git a/app/src/test/capabilities.test.ts b/app/src/test/capabilities.test.ts index 978d7df..3a56b06 100644 --- a/app/src/test/capabilities.test.ts +++ b/app/src/test/capabilities.test.ts @@ -320,7 +320,9 @@ function wrapperCommands(): Map { for (const [name, calls] of callsByWrapper) { expect(calls, `${name} must call invoke exactly once`).toHaveLength(1); const command = stringLiteralText(calls[0].arguments[0]) ?? ""; - 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); diff --git a/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md b/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md index 08bb813..7abb9bf 100644 --- a/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md +++ b/docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md @@ -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<…>(""` (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-(V)` **equals** the bare set of `file-viewer.json` (no over-grant to the untrusted window); `allow-(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/.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/.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 `` 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 | diff --git a/docs/superpowers/specs/2026-09-22-terminal-file-viewer-design.md b/docs/superpowers/specs/2026-09-22-terminal-file-viewer-design.md index 8b11ee6..f3a25e3 100644 --- a/docs/superpowers/specs/2026-09-22-terminal-file-viewer-design.md +++ b/docs/superpowers/specs/2026-09-22-terminal-file-viewer-design.md @@ -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/` (`ProjectPath { host_path, mount_name }`, rows with an empty `mount_name` are skipped at container creation). `/workspace` itself is not a