diff --git a/.github/actions/victauri-test/action.yml b/.github/actions/victauri-test/action.yml index 12b79ae0..26bde4b5 100644 --- a/.github/actions/victauri-test/action.yml +++ b/.github/actions/victauri-test/action.yml @@ -34,7 +34,7 @@ inputs: victauri-version: description: Version of victauri-cli to install required: false - default: "0.8.8" + default: "0.9.0" cli-source-path: description: >- Build & install victauri-cli from this local repo path instead of @@ -104,7 +104,17 @@ runs: elapsed=0 echo "Waiting for Victauri server on port ${VICTAURI_PORT}..." - until curl -sf "http://127.0.0.1:${VICTAURI_PORT}/health" > /dev/null 2>&1; do + # A 2xx — or a 429: /health is rate-limited from the public bucket, and a + # rate-limited answer still proves the server is up (R4-NET1). + server_answers() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${VICTAURI_PORT}/health" 2>/dev/null) || true + case "$code" in + 2??|429) return 0 ;; + *) return 1 ;; + esac + } + until server_answers; do if [ "$elapsed" -ge "$HEALTH_TIMEOUT" ]; then echo "::error::Victauri server did not become healthy within ${HEALTH_TIMEOUT}s on port ${VICTAURI_PORT}" exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb8f27d2..fc0fc008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,19 @@ jobs: if: matrix.os == 'ubuntu-latest' run: cargo clippy --workspace --all-targets --no-default-features -- -D warnings + # The workspace step above does NOT compile the plugin without SQLite: demo-app and + # gauntlet-app depend on victauri-plugin with default features, and cargo's feature + # unification keeps `sqlite` on for every member. Only a `-p victauri-plugin` build + # actually compiles the `not(feature = "sqlite")` paths a consumer with + # `default-features = false` gets. + - name: Clippy (plugin alone, no SQLite) + if: matrix.os == 'ubuntu-latest' + run: cargo clippy -p victauri-plugin --all-targets --no-default-features -- -D warnings + + - name: Plugin unit tests (no SQLite) + if: matrix.os == 'ubuntu-latest' + run: cargo test -p victauri-plugin --no-default-features --lib + - name: Clippy (release profile — incl. tests) if: matrix.os == 'ubuntu-latest' run: cargo clippy --workspace --all-targets --release -- -D warnings @@ -336,6 +349,43 @@ jobs: path: gauntlet.log if-no-files-found: ignore + # The VS Code extension: typecheck, bundle, and its unit tests (code generation escaping, + # discovery/connection resolution, health semantics) under plain Node. + vscode: + name: VS Code extension + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + defaults: + run: + working-directory: editors/vscode + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + # rustc lets the codegen test prove each generated literal lexes back to its input. + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (2026-06) + with: + toolchain: stable + + - name: Install dependencies (no install scripts) + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Typecheck + run: npm run lint + + - name: Build + run: npm run build + + - name: Unit tests + run: npm test + docs: name: Docs runs-on: ubuntu-latest @@ -377,8 +427,52 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} + # --locked: check the committed lockfile's versions (the ones CI and a fresh + # `cargo install --locked` resolve), and fail if Cargo.lock is out of date instead of + # silently re-resolving. - name: Check MSRV - run: cargo check --workspace + run: cargo check --workspace --locked + + # victauri-plugin accepts rusqlite `>=0.32, <0.41` so cargo unifies onto the app's own + # rusqlite (`links = "sqlite3"` allows one libsqlite3-sys per build). The lockfile pins the + # OLD end; this job proves the NEW end: the security setters changed return type across the + # range (`SetupOutcome` in database.rs keeps them fail-closed on every version). + rusqlite-range: + name: rusqlite range (newest) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install system dependencies + uses: ./.github/actions/linux-deps + + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (2026-06) + with: + toolchain: stable + + - name: Move rusqlite to the newest version the range allows + # Fails if the update did not reach the newest minor the workspace range allows + # (`>=0.32, <0.41` in Cargo.toml) — otherwise this job would silently keep testing 0.32. + # Widen NEWEST together with the range. + env: + NEWEST: "0.40." + run: | + cargo update -p rusqlite + resolved=$(grep -A1 '^name = "rusqlite"' Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p') + echo "rusqlite resolved to ${resolved:-}" + case "$resolved" in + "$NEWEST"*) ;; + *) echo "::error::rusqlite resolved to '$resolved', expected $NEWEST* (the newest the range allows)"; exit 1 ;; + esac + + - name: Clippy (plugin, sqlite) + run: cargo clippy -p victauri-plugin --all-targets --features sqlite -- -D warnings + + - name: Plugin unit tests (incl. query_db / db_health) + run: cargo test -p victauri-plugin --features sqlite --lib security: name: Security Audit @@ -439,10 +533,15 @@ jobs: - name: Generate coverage run: cargo llvm-cov --workspace --lcov --output-path lcov.info - - name: Upload coverage to Codecov - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v4.7.1 + # No Codecov upload: without a token it failed on every run (tokenless upload + # refused) while reporting green, and the action ran an unverified `latest` + # CLI. The report is kept as an artifact instead. + - name: Keep the coverage report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - files: lcov.info + name: lcov + path: lcov.info + retention-days: 14 semver: name: Semver Checks @@ -466,27 +565,27 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} # Prebuilt binary — avoids ~3 min compiling cargo-semver-checks from source. + # Pinned: the version this action pin defaults to (0.48) cannot read the rustdoc JSON of + # current stable ("unsupported rustdoc format v60"), and failed before checking anything. - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2 with: - tool: cargo-semver-checks - - # INFORMATIONAL — not a required gate (removed from ci-success). cargo-semver- - # checks builds rustdoc and is currently broken in CI in two INDEPENDENT ways, - # neither of which is an API break: - # - victauri-plugin: rustdoc hits an upstream E0119 ("conflicting - # implementations of From") compiling `time` (pulled by - # tauri → cookie) on every stable toolchain tested (1.93.1, 1.95) and the - # latest time (0.3.48). The baseline build uses fresh crates.io resolution, - # so pinning our lock can't fix it. - # - victauri-core: cargo-semver-checks' fresh baseline resolution fails in CI - # (passes locally only with a warm cache). - # A plain `cargo doc --workspace` succeeds, so these are tooling/toolchain - # issues, not semver violations. Keep it running for signal; use local - # `cargo semver-checks` as the pre-release gate. Re-add to ci-success once the - # upstream time/rustdoc bug is fixed. + tool: cargo-semver-checks@0.50.0 + + # INFORMATIONAL — not a required gate: a new stable rustdoc JSON format can outrun + # the pinned cargo-semver-checks, which is a tooling failure, not an API break. Note + # that for a 0.x MAJOR bump (0.8.8 -> 0.9.0) this step checks NOTHING: every lint is + # already permitted by a major bump, so it reports "0 checks … 254 skip" and passes. + # It only has teeth between patch releases of one 0.x line. RELEASING.md step 3 covers + # what to run for a major bump (`--release-type minor`, to list what actually broke). + # A failure is surfaced as a warning annotation below, never swallowed silently; + # RELEASING.md makes the local run the pre-release gate. - name: Check semver (informational) + id: semver continue-on-error: true run: cargo semver-checks check-release --workspace + - name: Surface a semver-checks failure + if: steps.semver.outcome == 'failure' + run: echo "::warning title=Semver Checks::cargo semver-checks failed (API break, or a toolchain/tool mismatch) — read the log above" e2e: name: E2E Integration (${{ matrix.os }}) @@ -566,6 +665,46 @@ jobs: env: VICTAURI_E2E: "1" + # One run of the suite is a weak detector for a host crash that depends on timing: the + # 0.9.0 Linux heap corruption (a Tauri handle cloned/dropped off the main thread) killed the + # host in only ~25-50% of runs and hid behind nine green single runs. So on Linux the + # heap-sensitive tests run again against three FRESH apps, and any dead host fails the job. + - name: Heap-safety repeats against fresh apps (Linux) + if: runner.os == 'Linux' + env: + VICTAURI_E2E: "1" + run: | + # The suite's app is stopped on purpose here; clear APP_PID so the failure step below + # does not report it as a host that died. + if [ -n "$APP_PID" ]; then kill "$APP_PID" 2>/dev/null || true; fi + echo "APP_PID=" >> "$GITHUB_ENV" + sleep 1 + deaths=0 + for i in 1 2 3; do + xvfb-run -a ./target/debug/demo-app >/tmp/repeat-$i.out 2>/tmp/repeat-$i.err & + pid=$! + for t in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7373/health || true) + case "$code" in 2??|429) break ;; esac + sleep 1 + done + cargo test -q -p demo-app --test integration -- --test-threads=1 --exact \ + concurrent_window_introspection_is_heap_safe \ + webview_access_main_thread_safe_under_ipc_load \ + webview_reload_during_introspection_is_safe || true + sleep 2 + if ! pgrep -x demo-app >/dev/null || grep -qE 'malloc|corrupted|double free|unaligned|free\(\)' /tmp/repeat-$i.err /tmp/repeat-$i.out; then + deaths=$((deaths + 1)) + echo "::error::repeat $i: the demo-app host died" + tail -n 40 /tmp/repeat-$i.err /tmp/repeat-$i.out + fi + pkill -x demo-app || true + kill "$pid" 2>/dev/null || true + sleep 1 + done + echo "host deaths: $deaths/3" + test "$deaths" -eq 0 + # Only on failure, and only where we captured it. If the host died, its own output names # the cause; without this the log shows nothing but "connection refused". - name: Show demo app output on failure (Unix) @@ -601,7 +740,7 @@ jobs: # Listing ~15 matrix legs as required checks is brittle (a renamed job # silently stops being required). This job depends on all of them and fails # if any FAILED or was CANCELLED — a SKIPPED job (a conditional leg that - # legitimately did not apply, e.g. `semver` on push or `e2e` on a PR) passes. + # legitimately did not apply, e.g. `semver` on a push to main) passes. # Require ONLY "CI Success" in branch protection. # ───────────────────────────────────────────────────────────────────── ci-success: @@ -614,7 +753,9 @@ jobs: live-app, gauntlet, docs, + vscode, msrv, + rusqlite-range, security, deny, coverage, diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index e1d6a287..ad6fc929 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -42,6 +42,10 @@ jobs: app: [kanri, en-croissant, lettura] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Third-party apps and their install scripts run in this job: leave no + # token in .git/config for them to find. + persist-credentials: false - name: Skip non-selected apps (manual single-app runs) id: gate @@ -72,6 +76,9 @@ jobs: if: steps.gate.outputs.run == 'true' with: key: compat-${{ matrix.app }} + # Never save: third-party build scripts ran here, so this target/ must + # not become a cache another workflow restores. + save-if: false # The four apps use two JS package managers: yarn (Kanri) and pnpm (the rest). # corepack supplies both shims. (Bun was only needed for Surrealist, which was diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd8cf452..166581d5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,10 +8,10 @@ on: - '.github/workflows/docs.yml' workflow_dispatch: +# Least privilege: read-only by default. Only the deploy job (which runs no repo code) +# gets the Pages + OIDC permissions it needs. permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -20,8 +20,13 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install mdBook uses: peaceiris/actions-mdbook@062dc9e3eab0bad35531417c6bb6c6a7a184764f # v2 @@ -39,6 +44,10 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 82837457..fabc559e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,70 +1,121 @@ -name: E2E Tests +name: E2E Tests (victauri-test client suites) +# On-demand live-app run of the `victauri-test` crate's OWN e2e suites +# (e2e_demo_app / high_level_api / verify_builder). These are NOT covered by ci.yml: +# ci.yml's `e2e` job runs `demo-app --test integration` + the smoke action, which +# exercises the plugin through the demo app's test file, not these client-API suites. +# Kept manual (workflow_dispatch) because the three suites overlap heavily with the +# gating ci.yml job; run it before a release that changes victauri-test. on: workflow_dispatch: +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-Dwarnings" + VICTAURI_PORT: "7374" jobs: e2e: - name: E2E Integration Tests + name: victauri-test E2E (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 40 strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libgtk-3-dev xvfb + uses: ./.github/actions/linux-deps + with: + xvfb: "true" + # The action is pinned by SHA, so the toolchain MUST be given explicitly + # (only the @stable/@1.xx refs infer it from the ref name). - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (2026-06) + with: + toolchain: stable - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + with: + save-if: false - - name: Build demo app - run: cargo build -p demo-app + - name: Build demo app + test binaries + run: | + cargo build -p demo-app + cargo test -p victauri-test --no-run --test e2e_demo_app --test high_level_api --test verify_builder + shell: bash + # Launch the already-built binary (not `cargo run`, which would recompile inside + # the readiness window). stderr is captured so a host crash is diagnosable. - name: Start demo app (Linux) if: runner.os == 'Linux' run: | - xvfb-run --auto-servernum cargo run -p demo-app & - sleep 10 - env: - VICTAURI_PORT: "7374" + xvfb-run -a ./target/debug/demo-app >/tmp/demo-app.out 2>/tmp/demo-app.err & + echo "APP_PID=$!" >> "$GITHUB_ENV" - name: Start demo app (Windows) if: runner.os == 'Windows' shell: pwsh run: | - $env:VICTAURI_PORT = "7374" - Start-Process -NoNewWindow cargo -ArgumentList "run","-p","demo-app" - Start-Sleep -Seconds 10 + # Run a COPY so a later relink of demo-app.exe is not blocked by the running image. + Copy-Item .\target\debug\demo-app.exe .\target\debug\demo-app-run.exe -Force + $proc = Start-Process -FilePath .\target\debug\demo-app-run.exe -PassThru + "APP_PID=$($proc.Id)" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 - - name: Verify server is running - run: curl -s http://127.0.0.1:7374/health + - name: Wait for the Victauri server shell: bash + run: | + # Poll /health until it answers 2xx (curl -f fails on HTTP errors), up to 120s. + for i in $(seq 1 120); do + if curl -sf --max-time 2 "http://127.0.0.1:${VICTAURI_PORT}/health" >/dev/null; then + echo "Victauri server healthy after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Victauri server did not become healthy on :${VICTAURI_PORT} within 120s" + exit 1 - name: Run E2E tests run: cargo test -p victauri-test --test e2e_demo_app -- --test-threads=1 env: VICTAURI_E2E: "1" - VICTAURI_PORT: "7374" - name: Run high-level API tests run: cargo test -p victauri-test --test high_level_api -- --test-threads=1 env: VICTAURI_E2E: "1" - VICTAURI_PORT: "7374" - name: Run verify builder tests run: cargo test -p victauri-test --test verify_builder -- --test-threads=1 env: VICTAURI_E2E: "1" - VICTAURI_PORT: "7374" + + - name: Show demo app output on failure (Linux) + if: failure() && runner.os == 'Linux' + run: | + echo "::group::demo-app stderr" + tail -n 200 /tmp/demo-app.err 2>/dev/null || echo "(no stderr captured)" + echo "::endgroup::" + if [ -n "$APP_PID" ] && ! kill -0 "$APP_PID" 2>/dev/null; then + echo "::warning::demo-app (PID $APP_PID) is NOT running — the host process died." + fi + + - name: Cleanup (Linux) + if: always() && runner.os == 'Linux' + run: | + if [ -n "$APP_PID" ]; then kill "$APP_PID" 2>/dev/null || true; fi + + - name: Cleanup (Windows) + if: always() && runner.os == 'Windows' + shell: pwsh + run: | + if ($env:APP_PID) { Stop-Process -Id $env:APP_PID -Force -ErrorAction SilentlyContinue } diff --git a/.github/workflows/release-vscode.yml b/.github/workflows/release-vscode.yml index 36746f2a..7f78e776 100644 --- a/.github/workflows/release-vscode.yml +++ b/.github/workflows/release-vscode.yml @@ -2,51 +2,60 @@ name: Release VS Code Extension # Decoupled from the core workspace release. Ships ONLY when you tag it: # git tag vscode-v0.7.1 && git push origin vscode-v0.7.1 -# Or run manually (workflow_dispatch) with dry_run=true to just build + package. +# Or run manually (workflow_dispatch): +# - dry_run=true (default): build + typecheck + package the .vsix and upload it as a +# workflow artifact. Works from ANY branch — it never touches the `release` +# environment or any secret. +# - dry_run=false: also publishes. The publish job runs in the `release` environment, +# whose deployment-branch policy only admits tag refs, so a real manual publish MUST be +# dispatched ON A TAG ("Use workflow from" → the vscode-v* tag), not on a branch. # -# Required secrets (Settings → Secrets → Actions): +# Required secrets (environment `release`): # VSCE_PAT — Azure DevOps PAT, Marketplace → Manage scope (publisher 4da-systems) # OVSX_TOKEN — (optional) open-vsx.org token, for Cursor / VSCodium / agent IDEs +# +# Supply-chain shape (audit hardening): +# - `build` holds NO secrets and only a read-only token. Dependency install scripts are +# disabled (`--ignore-scripts`; esbuild/vsce/vsce-sign/keytar all work without them — +# verified: lint, build and `vsce package` succeed), and the checkout does not persist +# its token into .git/config. +# - `publish` downloads the already-built .vsix and only runs the pinned vsce/ovsx CLIs +# (installed with `--ignore-scripts` too). Each publish token is scoped to the single +# step that uses it. on: push: tags: ["vscode-v*"] workflow_dispatch: inputs: dry_run: - description: "Build + package only (do not publish)" + description: "Build + package only (do not publish). A real publish must be dispatched on a vscode-v* tag." type: boolean default: true -# Least-privilege: default to read-only at the workflow level. Only the single -# publish job gets `contents: write`, and only because its final step attaches the -# .vsix to a GitHub Release (softprops/action-gh-release). Marketplace / Open VSX -# publishing authenticates via VSCE_PAT / OVSX_TOKEN, not the GitHub token. permissions: contents: read jobs: - publish: - name: Build & publish VS Code extension + build: + name: Build & package VS Code extension runs-on: ubuntu-latest - environment: release + timeout-minutes: 20 permissions: - contents: write # required: attach .vsix to the GitHub Release - env: - # Job-level so the Open VSX step's `if:` can read `env.OVSX_TOKEN` — a step-level `env:` - # is NOT in scope for that same step's `if:` expression, which silently skipped the publish. - OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }} + contents: read defaults: run: working-directory: editors/vscode steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - - name: Install dependencies - run: npm ci --no-audit --no-fund + - name: Install dependencies (no install scripts) + run: npm ci --ignore-scripts --no-audit --no-fund - name: Typecheck run: npm run lint @@ -54,6 +63,9 @@ jobs: - name: Build run: npm run build + - name: Unit tests + run: npm test + - name: Verify tag matches package.json version if: startsWith(github.ref, 'refs/tags/') run: | @@ -66,24 +78,77 @@ jobs: fi # Publish tooling is installed strictly from package-lock.json above. `--no-install` - # prevents npx from fetching mutable registry code during a credentialed release. + # prevents npx from fetching mutable registry code. - name: Package .vsix run: npx --no-install vsce package --no-dependencies -o victauri.vsix + - name: Upload .vsix + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: victauri-vsix + path: editors/vscode/victauri.vsix + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish VS Code extension + needs: build + # Only when actually publishing: a tag push, or a manual dispatch with dry_run=false. + # Dry runs never enter the `release` environment, so they work from any branch. + if: startsWith(github.ref, 'refs/tags/vscode-v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false') + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release + permissions: + contents: write # required: attach .vsix to the GitHub Release + defaults: + run: + working-directory: editors/vscode + steps: + - name: Require a vscode-v* tag ref for a real publish + working-directory: . + run: | + case "$GITHUB_REF" in + refs/tags/vscode-v*) ;; + *) echo "::error::A real publish must be dispatched on a vscode-v* tag (got $GITHUB_REF). Re-run with 'Use workflow from' set to the tag, or use dry_run=true."; exit 1 ;; + esac + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + # Only the pinned publish CLIs are needed here; no install scripts run. + - name: Install publish tooling (no install scripts) + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Download .vsix + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: victauri-vsix + path: editors/vscode + - name: Publish to VS Code Marketplace - if: startsWith(github.ref, 'refs/tags/') || github.event.inputs.dry_run == 'false' run: npx --no-install vsce publish --packagePath victauri.vsix -p "$VSCE_PAT" env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - name: Publish to Open VSX (Cursor / VSCodium / agent IDEs) - # `env.OVSX_TOKEN` resolves from the job-level `env:` above (a step-level env is not in - # scope for its own `if:`), so the step now actually runs when the secret is configured. - if: ${{ (startsWith(github.ref, 'refs/tags/') || github.event.inputs.dry_run == 'false') && env.OVSX_TOKEN != '' }} - run: npx --no-install ovsx publish victauri.vsix -p "$OVSX_TOKEN" + # The token is scoped to this step only; the optional-secret check happens in the + # shell (a step-level env is not visible to that step's own `if:`). + run: | + if [ -z "$OVSX_TOKEN" ]; then + echo "OVSX_TOKEN not configured — skipping Open VSX publish." + exit 0 + fi + npx --no-install ovsx publish victauri.vsix -p "$OVSX_TOKEN" + env: + OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }} - name: Attach .vsix to GitHub Release - if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 with: name: ${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba19643e..21cf584a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,10 @@ on: env: CARGO_TERM_COLOR: always +# Read-only by default; a job that needs more (github-release) asks for it. +permissions: + contents: read + jobs: # ───────────────────────────────────────────────────────────────────── # Gate: ensure tests pass before building release artifacts @@ -23,6 +27,8 @@ jobs: timeout-minutes: 40 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install system dependencies uses: ./.github/actions/linux-deps @@ -40,10 +46,10 @@ jobs: run: npm ci --no-audit --no-fund - name: Run tests - run: cargo test --workspace + run: cargo test --workspace --locked - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings # ───────────────────────────────────────────────────────────────────── # Build release binaries for all platforms @@ -111,6 +117,8 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install system dependencies (Linux) if: runner.os == 'Linux' @@ -133,7 +141,7 @@ jobs: - name: Build (native) if: ${{ !matrix.use-cross }} - run: cargo build --release --target ${{ matrix.target }} -p ${{ matrix.binary }} + run: cargo build --release --locked --target ${{ matrix.target }} -p ${{ matrix.binary }} - name: Build (cross) if: matrix.use-cross @@ -181,8 +189,16 @@ jobs: sha="${{ github.sha }}" repo="${{ github.repository }}" echo "Requiring the CI workflow (ci.yml) to be green for $sha before publishing." + # The tagged commit must be ON main: a pull_request run for this SHA tested + # the merge of it into main, not the commit itself. + cmp=$(gh api "repos/$repo/compare/$sha...main" --jq '.status' 2>/dev/null || echo error) + if [ "$cmp" != "ahead" ] && [ "$cmp" != "identical" ]; then + echo "::error::$sha is not on main (compare status: $cmp). Tag a commit merged to main." + exit 1 + fi for i in $(seq 1 80); do - run=$(gh api "repos/$repo/actions/workflows/ci.yml/runs?head_sha=$sha&per_page=1" \ + # Only a push run on main tested exactly this commit. + run=$(gh api "repos/$repo/actions/workflows/ci.yml/runs?head_sha=$sha&event=push&branch=main&per_page=1" \ --jq '.workflow_runs[0] | "\(.status)|\(.conclusion)"' 2>/dev/null || echo "none|") status="${run%%|*}" concl="${run##*|}" @@ -200,6 +216,42 @@ jobs: echo "::error::Timed out waiting for CI to complete on $sha. Refusing to publish (no proven-green CI run)." exit 1 + # ───────────────────────────────────────────────────────────────────── + # Verify every crate exactly as it will be uploaded — in its OWN job, on its own + # runner, with NO secrets and no release environment. + # + # `cargo publish --dry-run` packages each crate and then BUILDS it, which runs every + # dependency's build script and proc-macro. When this ran in the publish job, a malicious + # build script could write `$GITHUB_ENV` / `$GITHUB_PATH` / `~/.cargo/config.toml` (e.g. + # swap the `cargo` the next step runs, or add a credential provider) and so execute in + # the later step that holds CARGO_REGISTRY_TOKEN (round-4 audit R4-REL1). A separate job + # gets a fresh runner: nothing it writes reaches the publish job. + # ───────────────────────────────────────────────────────────────────── + verify-packages: + name: Verify packages (no secrets) + runs-on: ubuntu-latest + timeout-minutes: 40 + needs: test + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install system dependencies + uses: ./.github/actions/linux-deps + + # Action code pinned to a SHA (no semver tags exist on this repo); the Rust + # channel stays dynamic via `toolchain: stable` so we still get latest stable. + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (2026-06) + with: + toolchain: stable + + # No rust-cache: verification must build from the packages, not a restored target/. + - name: Verify all packages + run: cargo publish --workspace --dry-run --locked + # ───────────────────────────────────────────────────────────────────── # Publish crates to crates.io # ───────────────────────────────────────────────────────────────────── @@ -207,19 +259,24 @@ jobs: name: Publish to crates.io runs-on: ubuntu-latest timeout-minutes: 40 - # Crates cannot be unpublished. Require the release-profile binaries to build - # before making the version irreversible. - needs: [test, build, require-ci-green] + # Crates cannot be unpublished. Require the release-profile binaries to build, the + # packages to verify (in their own secret-free job) and CI to be green before making + # the version irreversible. + needs: [test, build, verify-packages, require-ci-green] # Least privilege for the job that holds the crates.io token (audit #8). permissions: contents: read # Gate behind a protected Environment. Add required reviewers in # repo Settings → Environments → release to require approval before publish. environment: release - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # This job COMPILES NOTHING: no system packages, no cache, no build scripts — only + # `cargo publish --no-verify` (package + upload). Verification ran in + # `verify-packages`, a different runner. The token is additionally scoped to the one + # upload step, never the job. steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Assert tag matches workspace version run: | @@ -238,25 +295,26 @@ jobs: fi echo "tag matches workspace version: $VER" - - name: Install system dependencies - uses: ./.github/actions/linux-deps - # Action code pinned to a SHA (no semver tags exist on this repo); the Rust # channel stays dynamic via `toolchain: stable` so we still get latest stable. - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (2026-06) with: toolchain: stable - - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + # No rust-cache in this job: a restored target/ is code the job would then run. - name: Publish crates in dependency order + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | publish() { echo "Publishing $1..." # NB: capture cargo's exit via PIPESTATUS, NOT the pipeline's (which is # tee's and is always 0) — otherwise an auth/publish failure is masked # and the job false-greens without anything reaching crates.io. - cargo publish -p "$1" 2>&1 | tee /tmp/pub.log + # --no-verify: verified in the `verify-packages` job; here nothing is + # compiled (no build script runs) while the token is in the environment. + cargo publish -p "$1" --no-verify --locked 2>&1 | tee /tmp/pub.log rc=${PIPESTATUS[0]} if [ "$rc" -eq 0 ]; then echo "$1 published successfully" @@ -292,13 +350,16 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: Generate changelog id: changelog run: | - # Get the previous tag - PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | sed -n '2p') CURRENT_TAG=${GITHUB_REF#refs/tags/} + # Previous release tag = the nearest v* tag reachable from the commit BEFORE this + # tag. Ancestry-based (not "second-highest version"), so a backport tag such as + # v0.8.9 cut after v0.9.0 gets ITS OWN history, not a diff against v0.9.0. + PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "${CURRENT_TAG}^" 2>/dev/null || true) if [ -z "$PREVIOUS_TAG" ]; then # First release — use all commits @@ -307,8 +368,27 @@ jobs: CHANGELOG=$(git log --pretty=format:"- %s (%h)" "${PREVIOUS_TAG}..${CURRENT_TAG}") fi - # Write to file to handle multiline - echo "$CHANGELOG" > /tmp/changelog.txt + # Release notes = this version's CHANGELOG.md section (what users need: breaking + # changes, migration, security fixes), then the commit list. The raw commit subjects + # alone were chores and merges with no migration guidance. + VER="${CURRENT_TAG#v}" + awk -v hdr="## [$VER]" 'index($0, hdr) == 1 { f = 1; next } f && /^## \[/ { exit } f' \ + CHANGELOG.md > /tmp/section.txt + { + if [ -s /tmp/section.txt ]; then + cat /tmp/section.txt + else + echo "::warning::CHANGELOG.md has no '## [$VER]' section; release notes list commits only" >&2 + fi + echo + echo "See [MIGRATION.md](https://github.com/${GITHUB_REPOSITORY}/blob/${CURRENT_TAG}/MIGRATION.md) for upgrade steps. Binaries: verify with \`sha256sum -c SHA256SUMS\`." + echo + echo "
Commits" + echo + echo "$CHANGELOG" + echo + echo "
" + } > /tmp/changelog.txt echo "previous_tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" - name: Download all artifacts @@ -335,6 +415,9 @@ jobs: chmod +x release-assets/victauri-watchdog-macos-x86_64 || true chmod +x release-assets/victauri-watchdog-macos-aarch64 || true + # Checksums for every released binary (users verify with `sha256sum -c SHA256SUMS`). + (cd release-assets && sha256sum -- * > SHA256SUMS && cat SHA256SUMS) + ls -la release-assets/ - name: Create GitHub Release diff --git a/.github/workflows/surface-audit.yml b/.github/workflows/surface-audit.yml index f678222e..3607a82b 100644 --- a/.github/workflows/surface-audit.yml +++ b/.github/workflows/surface-audit.yml @@ -9,12 +9,18 @@ on: - cron: "0 9 * * 1" # Mondays 09:00 UTC workflow_dispatch: +permissions: + contents: read + jobs: audit: name: Audit published surfaces vs repo runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: @@ -25,6 +31,7 @@ jobs: run: | set +e DRIFT=0 + UNKNOWN=0 { echo "## Surface audit" echo "" @@ -36,39 +43,58 @@ jobs: # crates.io 403s any request without a User-Agent (crawler policy); an # empty curl body makes jq print NOTHING (not the "unknown" fallback), so # treat empty as unknown too — otherwise `repo gt ""` is a false DRIFT. + # An UNKNOWN result is NOT "in sync": it fails the job (separately from + # DRIFT) so a broken query can never produce a silent green. UA='victauri-surface-audit (+https://github.com/4DA-Systems/Victauri)' is_unknown() { [ -z "$1" ] || [ "$1" = unknown ] || [ "$1" = null ]; } - # --- crates.io (core; workspace version) --- + # --- crates.io: EVERY published crate vs the workspace version --- REPO_CRATE=$(grep -m1 -E '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') - PUB_CRATE=$(curl -fsSL -A "$UA" "https://crates.io/api/v1/crates/victauri-plugin" 2>/dev/null | jq -r '.crate.max_stable_version // .crate.max_version // "unknown"') - if is_unknown "$PUB_CRATE"; then PUB_CRATE=unknown; ST="⚠️ query failed"; - elif newer "$REPO_CRATE" "$PUB_CRATE"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1; - else ST="✅ in sync"; fi - echo "| crates.io (victauri-plugin) | $REPO_CRATE | $PUB_CRATE | $ST |" >> "$GITHUB_STEP_SUMMARY" + if [ -z "$REPO_CRATE" ]; then + echo "::error::could not read the workspace version from Cargo.toml" + UNKNOWN=1 + fi + for CRATE in victauri-core victauri-macros victauri-plugin victauri-test victauri-cli victauri-watchdog; do + PUB_CRATE=$(curl -fsSL -A "$UA" "https://crates.io/api/v1/crates/$CRATE" 2>/dev/null | jq -r '.crate.max_stable_version // .crate.max_version // "unknown"' 2>/dev/null) + if is_unknown "$PUB_CRATE" || is_unknown "$REPO_CRATE"; then + PUB_CRATE=unknown; ST="⚠️ UNKNOWN (query failed)"; UNKNOWN=1 + elif newer "$REPO_CRATE" "$PUB_CRATE"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1 + else ST="✅ in sync"; fi + echo "| crates.io ($CRATE) | $REPO_CRATE | $PUB_CRATE | $ST |" >> "$GITHUB_STEP_SUMMARY" + sleep 1 # crates.io crawler policy: at most 1 request/second + done # --- VS Code Marketplace --- REPO_VSC=$(node -p "require('./editors/vscode/package.json').version") PUB_VSC=$(curl -fsSL -A "$UA" -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \ -H "Accept: application/json;api-version=3.0-preview.1" -H "Content-Type: application/json" \ -d '{"filters":[{"criteria":[{"filterType":7,"value":"4da-systems.victauri"}]}],"flags":914}' 2>/dev/null \ - | jq -r '.results[0].extensions[0].versions[0].version // "unknown"') - if is_unknown "$PUB_VSC"; then PUB_VSC=unknown; ST="⚠️ not found / query failed"; - elif newer "$REPO_VSC" "$PUB_VSC"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1; + | jq -r '.results[0].extensions[0].versions[0].version // "unknown"' 2>/dev/null) + if is_unknown "$PUB_VSC" || is_unknown "$REPO_VSC"; then + PUB_VSC=unknown; ST="⚠️ UNKNOWN (not found / query failed)"; UNKNOWN=1 + elif newer "$REPO_VSC" "$PUB_VSC"; then ST="❌ DRIFT (repo ahead — publish)"; DRIFT=1 else ST="✅ in sync"; fi echo "| VS Code Marketplace (4da-systems.victauri) | $REPO_VSC | $PUB_VSC | $ST |" >> "$GITHUB_STEP_SUMMARY" echo "drift=$DRIFT" >> "$GITHUB_OUTPUT" + echo "unknown=$UNKNOWN" >> "$GITHUB_OUTPUT" - name: VS Code extension build health id: vscodehealth working-directory: editors/vscode run: | set -e - npm ci --no-audit --no-fund + npm ci --ignore-scripts --no-audit --no-fund npm run lint npm run build - echo "✅ VS Code extension still typechecks + builds" >> "$GITHUB_STEP_SUMMARY" + npm test + echo "✅ VS Code extension still typechecks, builds + passes its unit tests" >> "$GITHUB_STEP_SUMMARY" + + - name: Fail if any registry query was unknown + if: steps.versions.outputs.unknown != '0' + run: | + echo "::error::At least one published-surface query FAILED or returned nothing (UNKNOWN in the job summary). The audit cannot vouch for that surface — fix the query/registry access, do not treat this as in sync." + exit 1 - name: Fail if anything drifted if: steps.versions.outputs.drift != '0' diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ae5552..0f347814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,390 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Full-surface audit (three review lenses + a live sweep of 4DA), every finding verified against -the code before fixing. Additive/bugfix only — no public API removed or changed. +## [0.9.0] - 2026-09-30 + +A correctness-and-hardening release built from four audit rounds: a full-surface review (three +lenses + a live sweep of 4DA), a five-lens pre-audit red team (auth/network boundary, new-code +correctness, data access & injection, supply chain, semver & claim honesty), and a six-lens +adversarial audit (network/auth, hostile page, data access, local attacker/CLI, concurrency, +release/supply chain) whose every finding was reproduced before it was fixed, and a round-4 +adversarial audit + live end-to-end verification (demo, gauntlet, 4DA) of that candidate. **0.9.0 is a +deliberate, one-time semver break** (see below and MIGRATION.md) so that future field additions to +the data types are never breaking again. (The option/config structs `CodegenOptions`, +`SmokeConfig`, `VisualOptions`, `MaskRegion` and the `Junit*` report types deliberately stay +exhaustive so struct-update syntax keeps working.) + +### Breaking — public data types are now `#[non_exhaustive]` + +Output/data types across `victauri-core`, `victauri-plugin` and `victauri-test` (e.g. `IpcCall`, +`WindowState`, `DomSnapshot`, `VerificationResult`, `RecordedSession`, `VictauriState`, +`CommandTimingStats`, `PluginInfo`, `SmokeReport`, `AppEvent`'s struct variants, and several enums) +are `#[non_exhaustive]`. Code that *reads* them is unaffected; code that *builds* them by struct +literal uses the new constructors/builders instead (`IpcCall::new`, `WindowState::new(label) +.with_*(..)`, `CommandInfo::new(..).with_*(..)`, `AppEvent::console(..)`, …). `#[inspectable]` now +expands to those builders, so it keeps working unchanged. Internal-only modules and helpers +(`filmstrip`, the recording-drain plumbing, …) are no longer public API. `WebviewBridge` gained three +default-implemented methods (`try_get_window_states`, `try_list_window_labels`, +`eval_webview_resolved`) and documents that future additions will always have defaults. + +### Round 4 — adversarial audit + live verification of the 0.9.0 candidate + +Eight red-team lenses (including the CLI/local-attacker lens that never reported in round 3, and a +regression hunt over every round-3 fix) against candidate `43b7561`, then live runs on the demo, +the gauntlet (Windows, real display) and 4DA against its real 1.47 GB database. Every Medium was +reproduced live or in a V8 proof of concept, every fix has a test that failed first, and claims +that did not survive testing are listed under "Disproved" below. No Critical or High. + +**Security / correctness (Medium)** + +- **Page script could still erase the agent's evidence.** A synthetic `pagehide` (the bridge + ignored `isTrusted`) wiped every captured log and switched console + DOM capture off for the + life of the page; recordings then captured 0 events and the smoke suite failed. Only the + browser's own page-transition events are honoured now. +- **The agent key leaked through `Function.caller`** (the round-3 claim that page script cannot + read it was wrong): a page hook on `Array.prototype.filter`, a `then` getter or + `Error.prepareStackTrace` reached the injected snippet holding the key. Agent ops and the eval + plumbing are strict-mode, and a wrapper carrying the key is strict. +- **An unauthenticated `/health` flood made a healthy app look dead**: the bridge's liveness probe + and the watchdog read the 429 as "down" (live: 88k × 429, every agent call "backend not + reachable", watchdog recovery fired). A 429 from `/health` now means alive. +- **`animation scrub capture=true` took screenshots with `screenshot` disabled.** It also reported + `"captured": true` with no filmstrip when every capture failed or the sheet was too large to + compose (round 5): `captured` is now true only alongside a filmstrip, a capture failure is + returned as `capture_error`, a compose failure is an error, and a sheet over the 512 MiB limit + is refused after the first frame (with the largest `points` that fits) instead of after + accumulating every raw frame. +- **`introspect contract_record` panicked** on any non-ASCII response over 4 KiB. +- **Trusted input** sent real keystrokes when focus had not landed on the element, and a trusted + click used an unchecked page-controlled point (the title bar / close button was reachable). Both + are verified now: focus through shadow roots and frames; actionability, iframe offsets and the + window's client area. +- **Any probe error aborted an eval as "page reloaded"** (the round-3 claim that page script cannot + forge one was wrong), inviting a double-executing retry. Only a positively different page aborts + (a positive answer that same-window script forges still counts — see Round 5). +- **The `eval_js` auto-return scanner still dropped statements** (the round-3 "never drops a + statement" claim was wrong): a line break inside `/* */`, a lone CR, a line ending in `1.`, and + `of`/`yield` used as names. Differential check against node: 10,793 → 0 divergent of 35,459 + generated snippets — a result for that corpus, not a proof. Round 5 found three more shapes it + did not cover, now fixed: a regex right after the `)` of an `if`/`while`/`for`/`with` head + (`if (a) /'/.test(s)` inside a function, then another statement), `of` used as a name inside + brackets (`L(of / 2); L(3)`), and a non-ASCII identifier ending in a keyword (`énew / 2; g()`). + A new node differential over 20,000 snippets built around these shapes: 1,771 → 0 divergent of + 18,119 that parse. The scanner is a heuristic; when it cannot tell, it leaves the code unwrapped + (no auto-`return`), which is the safe direction. +- **`query_db` refused legitimate queries** with `;`, `--` or `/*` inside quotes (`LIKE '%;%'`, + present since 0.8.x), while `SELECT '--'; DELETE …` slipped past the stacked-query check. One + quote-aware lexer now drives every pre-check (the SQLite authorizer remains the security + boundary). +- **Discovery readers deleted live apps' entries** after one failed 100 ms connect, for every PID + where `/bin/kill` is missing (NixOS/Guix/containers), and for an elevated app on Windows. Only a + definitely-dead owner's entry is removed now. +- **A `VICTAURI_AUTH_TOKEN` without a port was sent to whoever held :7373** (a local squatter could + collect it). It is sent only to the live app that owns that token. +- **VS Code "Generate test" pasted page text into Rust source unescaped** (code injection on + `cargo test`); it now escapes like the CLI codegen. +- **Internal plumbing was public API** (`PageLoads`, `DrainWatermarks`, `DrainMark`, `DrainCursor`, + `VictauriState.page_loads`/`drain_watermarks`, `mcp::drain_window_into_recording`); hidden + before the tag, since hiding them later would be another break. +- **Release pipeline:** package verification ran dependency build scripts on the same runner as + the step holding `CARGO_REGISTRY_TOKEN`. It now runs in its own secret-free job; the publish job + compiles nothing. + +**Compatibility** + +- **Apps on rusqlite newer than 0.32 could not add `victauri-plugin` at all** (`links = "sqlite3"` + conflict; current rusqlite is 0.40). The plugin now accepts rusqlite `>=0.32, <0.41`, so cargo + unifies onto the app's own version (verified for apps on 0.40, 0.36, 0.32 and sqlx 0.8). The + security setters whose return types changed across that range fail closed on every version, and + CI tests the newest end. +- Verified against the dependency versions a fresh consumer resolves today (rmcp 3.5.0, + tauri 2.12.0): clippy clean and all functional tests pass. Note: tauri 2.12 needs Rust 1.90, and + its new default `staticVCRuntime` writes an `msvcrt.lib` stub that breaks *other* crates' + doctests in the same Windows workspace (upstream; not a Victauri issue). + +**Fixed (Low)**: resources honour a bare-name tool disable; both inspect action spellings work in +`disable_tools` and unknown names are warned about; `--app` on every CLI command, with exact +matching only (was substring); a clear error naming each app when several are running (was a +silent fallback to :7373 with misleading advice); `invoke_command_with_timeout` in `victauri-test`; +the bridge no longer cuts slow tools off at 120 s; the watchdog no longer logs its recovery +command; page and repo text is sanitized before it reaches terminals and CI logs (no forged +`::error` annotations); `init`/`record` refuse symlinked paths; the generated CLAUDE.md points at +the per-user token root; unpredictable UID-probe names; the fetch/XHR logs record the request +actually made (no planted IPC entries); route delays are clamped and capped; `read_app_file` and +app probes are bounded; page-callable window queries are budgeted; drain-watermark and screencast +races fixed; the main-thread lock stays held while an abandoned closure still runs; fault, +contract and page-load maps are capped; panicked background tasks are reported finished; WGC +capture runs on one COM thread with a bounded wait; `Locator::check()` works on ARIA checkboxes; +`victauri check` lists ghost names; `recording stop` flushes every window first (events captured +since the last 1 s drain tick were dropped; a window that cannot answer within 3 s is reported in +`final_flush_unreachable`); a request the guards refuse (401/403/415/429) closes its +connection, so refused requests (a web page's no-cors fetches, a local script) cannot park the +256 request slots, and a connection takes a request slot only once it has sent its first byte — +a connection that sends nothing (a page's `` across `*.localhost` names +never reaches a guard; measured in Edge: ~370 held, agent `/health` probes failing 17/30) waits +in a separate pool of 1,024 and is closed after 3 s instead of holding a slot for the header +deadline (round 5; that deadline is now 10 s, was 30 s). Residual: a local process can still fill the 256 slots with started-but-stalled +request heads until the header deadline (slow-loris is bounded, not prevented); GitHub Release notes are the version's CHANGELOG section (not raw commit +subjects) and the binaries ship with `SHA256SUMS`; tool-reference, MIGRATION, README and security +docs corrections. + +**From the regression review of the round-4 fixes** (an independent lens over the fix commits; +no Critical/High): `interact click` refused every element inside an open shadow root as +"covered by " — synthetic clicks since the actionability check was added, trusted clicks +since round 4 (reproduced live on WebView2; the hit test now descends into open shadow roots and +compares along the composed tree); the quote-aware SQL scanner missed SQLite's Tcl-style +parameters (`$a(')`… is one token) and passed one stacked query the old checker refused; the +rusqlite range forced `bundled` onto the app's rusqlite (now a separate default feature, +`bundled-sqlite`, with an opt-out); `victauri-test` cut tool calls without `timeout_ms` off at +60 s while the server allows 300 s; the VS Code extension counted a 429 on the authenticated probe +as success; plus a garbled error message, a stray doc comment, and a lost XHR log entry after a +throwing `open()`. (A proposed yield spin to shave up to ~15 ms off back-to-back main-thread round +trips on Windows was dropped while chasing the Linux host crash below; it was not the cause.) + +**Linux host crash under concurrent introspection — root cause found and fixed.** Round-4 builds +lost the Linux/WebKitGTK host in ~25–50% of CI stress runs (the E2E sequence repeated against fresh +apps; glibc `corrupted double-linked list` / `unaligned fastbin chunk`), against 0 in 104 runs for +0.8.8 and every pre-round-4 build. The cause is below Victauri but Victauri triggered it: on Linux +every Tauri handle (`AppHandle`, `Webview`, `WebviewWindow`) carries tauri-runtime-wry's `Context`, +which holds the main-thread context by value — including tao's event-loop target and its +`Rc>>` — behind an `unsafe impl Send + Sync` whose contract is "main +thread only". Cloning or dropping any handle therefore bumps a non-atomic refcount that the main +thread bumps constantly. Victauri did that on every tool call: the main-thread dispatcher cloned the +`AppHandle` on the calling thread, and the eval callback (hit by every eval, drain tick and +snapshot) was an async command, which Tauri runs — extracting and dropping its `Webview` — on a +tokio worker. Proven with Victauri absent (a bare Tauri app running the same load in-process): +background threads that only clone and drop the `AppHandle` while the page makes IPC calls abort the +process; 12 threads of `run_on_main_thread` round trips with an off-thread clone aborted 5/5 (5/5 +with an empty closure, 3/5 with no IPC at all); the same load with the handle taken on the main +thread ran 10/10 clean. Now the dispatcher's closure uses a handle owned by the main thread (the +caller only borrows the `AppHandle` to post it, which does not clone), and the eval callback plus +every other page-callable command that never waits are synchronous commands, which Tauri runs on the +main thread. Validated in CI (ubuntu-latest) on the fixed code: **0 host deaths in 40 stress runs** +(the unfixed code lost the host 37/80 the same day), while the Victauri-less repro still died 15/15 +with the off-thread clone and 0/15 without it; the release head then passed all 23 CI jobs, and the +Linux E2E job now also repeats the heap-sensitive tests against three fresh apps. Live on 4DA +(Windows, 1.47 GB database): probe battery 121/121, auth-on flood 5/5, reload soaks 35,157 and +35,625 calls with 1 and 0 errors, dogfood 155/1/3 (the one failure is 4DA's own >30 s command, as +before the fix). Every bisect, allocator, sanitizer and SQLite experiment run on the way (listed in the +round-4 report) pointed at timing because a refcount race IS timing: it explains why the 0.8.8 lock +reduced the crash, why round-4's extra waits made it worse, and why identical code crashed or passed +depending on the build. **Residual (upstream):** `victauri_eval_js` / `victauri_dom_snapshot` (page +API, opt-in) must stay async, and an app's own async commands and background `AppHandle` clones go +through the same Tauri path — reportable to Tauri/tao. + +**Disproved in round 4** (tested, not defects): eval ids leaking via `.caller` (V8 blocks the +second hop); `PRAGMA quick_check` overrunning its deadline on a 1.47 GB database (3.8 s); discovery +dirs left behind on a clean exit (removed on 0.8.8 and 0.9.0); Windows child processes inheriting +Victauri's sockets (tokio/mio sockets are not inheritable: close seen in µs, port rebindable); +Tauri lacking `#[tauri::command(rename)]` (supported since tauri-macros 2.6). + +**Residuals, measured:** a 256-column × 1 MB `query_db` row raises the host's peak memory by +~0.5 GB for that one call (at most two run at once; the result is still capped at 5 MB). With +`auth_disabled()` there is no token to separate the agent from other local callers, so a local +`/health` flood can still rate-limit the agent (with auth on, the default, it cannot: verified on +4DA, 100,547 × 429 while every agent call succeeded). + +### Security — adversarial audit round 3 (every finding reproduced before its fix) + +- **High — compound-tool permission gate bypass.** The gate read `action` only when it was a JSON + string and otherwise gated on the bare tool name, while serde still parsed + `{"action": {"go_to": null}}` (and, over REST, a positional array body) into the real action. In + the `Test` profile, which allows the bare `navigate`, `navigate.go_to` (FullControl-only) ran; + with FullControl, any per-action `disabled_tools` entry could be bypassed the same way. Proven + end to end against 0.9.0-pre: both bodies returned 200 and the webview navigated. A non-string + `action` or non-object body is now refused before dispatch, a disabled tool is reported first, + and a test reads every action enum's variants from its schema and requires each to have a + capability, so the bare-name gate can never cover an action the parser accepts. +- **Recording replay could run a command in the wrong window, or one that never ran.** Page script + could add a route that fulfils `http://ipc.localhost/` and have it recorded as a successful + IPC call; `recording replay` then invoked it for real — in the main window, whatever window + recorded it (a window's Tauri capabilities are its own). Mocked/blocked calls are now recorded + as such and never replayed, and each call replays in the window that recorded it (or not at all). +- **Page script could still observe or forge eval results** (the round-2 claim that ids never leak + was wrong): hooking `Map.prototype.set` / `Set.prototype.add` / `window.String` exposed pending + eval ids, and an `Object.prototype.toJSON` rewrote every result and log entry. Eval state is now + null-prototype tables behind closures, ids are coerced without page-replaceable globals, and the + bridge's stringify ignores a `toJSON` inherited from `Object.prototype`/`Array.prototype`. Logs + hand out deep copies. (Same-window page script can still read, rewrite or drop its own window's + results in transit — see Round 5 and docs/src/security.md.) +- **Agent-only bridge controls were callable by the page** (clear logs/routes, dialog auto-answers). + They are now reachable only with a per-process key the plugin embeds in its own evals. (The + animation scrub/sweep helpers are NOT behind the key: they stay on the public + `window.__VICTAURI__`; only the old `__VICTAURI_SCRUB__` / `__SWEEP__` globals were removed.) +- **Compact snapshot lines could still be forged** through `role` and quote-stripped attribute + values; every field is now encoded and each element yields exactly one well-formed line. +- **Windows discovery liveness** substring-matched `tasklist` output (PID 12 "matched" 123) and + counted other users' processes (the cross-user PID-reuse fix had reached only Unix). It is now an + in-process own-user check (`OpenProcess` + exit code + token SID), in the CLI bridge, the test + client and the watchdog; the bridge no longer spawns `tasklist` every 1.5 s (measured 0.5–40 s + under load, which stalled its own replies). +- **Browser requests and flooding.** Any web page could loop `` + (no `Origin` header) and drain the shared rate-limit bucket, 429-ing the agent. Requests with a + `Sec-Fetch-Site` other than `none`, an unreadable `Origin`, or a `tauri://` origin are now refused + before the limiter; authenticated callers have their own bucket; no `Access-Control-Allow-Origin` + header is sent at all. +- **Connection exhaustion.** `axum::serve` set no hyper timer: a client trickling request headers + held a connection forever, and with `auth_disabled` 64 slow bodies held every request slot. The + server now runs HTTP/1.1 with a header deadline (10 s since round 5b; 30 s before), a 30 s / 2 MiB body deadline read after + authentication, and a 256-connection cap; `/mcp` bodies are capped at 2 MiB like every route. +- **Discovery could be blocked by another local user** on Unix (shared `/tmp/victauri`, which the + owner check then refused). The root is per-user (`$XDG_RUNTIME_DIR/victauri` or + `/victauri-`); readers also scan the owner-checked legacy root. +- **Existence oracles and resource bombs in the data tools.** A symlink inside an app directory plus + `..` revealed whether files outside it exist; "missing" and "outside" now answer identically. A + 2000-column row of 1 MB blobs built gigabytes before `query_db`'s result cap applied (now charged + per cell, 256-column cap); a single `LIKE`/`replace`/`instr` step ignored the 5 s deadline for + 15–22 s (patterns capped at 1000 bytes, calls deadlined and limited to two at once); + `list_app_dir` walked unbounded trees on the async executor. +- **Release supply chain:** the crates.io token was in the publish job's environment while every + dependency's build script compiled; publishing now verifies tokenlessly and uploads `--no-verify` + from the one step holding the token (round 4 moved verification to its own job: the same runner + was still reachable). `require-ci-green` counts only a push run on `main`. + +### Fixed — adversarial audit round 3 (correctness) + +- **High — an eval could be reported as a syntax error and then run anyway**: a retried + `invoke_command` executed twice. The 750 ms parse watchdog is gone: the code is delivered first + and a check script right after it (webview evals run in order), and every outcome is settled + exactly once. +- **Reload detection**: a reload within 250 ms of injection waited the full timeout, and a + same-page ready signal delayed >250 ms discarded a real result. Each page load now carries a + nonce; only a ready signal from a *different* page aborts an eval. (Script in another page cannot + forge one; script in the same page can — see Round 5.) +- **Dropped calls leaked pending-eval slots** (a REST client that disconnected mid-call); 100 of + them wedged every eval tool until restart. Slots are now released on drop everywhere. +- **Recording drain**: an event stamped ahead of Rust's clock was re-recorded on every drain, + same-millisecond events were lost, a drain in flight across `stop`+`start` leaked old events + into the new recording, pending network requests stayed pending forever, and one lone UTF-16 + surrogate in page text (a truncated emoji) stalled a window's recording. The drain is now keyed by + a per-page sequence, scoped to its recording, and page JSON is surrogate-sanitized before parsing. +- **`#[inspectable]` advertised the wrong argument keys**: Tauri invokes commands with camelCase + keys by default (`size_kb` → `sizeKb`), so an agent following the registry got "missing required + key". `CommandArg` gains `key`; the macro honours `rename_all`/`rename`, raw identifiers, and + detects injected framework arguments (`State<'_, a::B>`, `Request`, scopes) by type. +- **`victauri record` output**: bidi/control characters from page text made rustc reject the + generated test; `:has-text("…")` inside a test id masqueraded as a text selector; `#a.b` was + treated as the id `a.b`; scrolling to text passed the text as a CSS selector. Recorded selectors + are now CSS-escaped by the bridge and parsed by shape. +- **`victauri-test`**: `Locator::check()`/`uncheck()` did nothing on React-controlled inputs + (measured on React 18: `onChange` fired 0 times); they now click and verify. A stale element + reference errors instead of reading as empty/unchecked. Visual baselines decode 16-bit, indexed + and grey+alpha PNGs. +- **A panic in any tool handler** hung the MCP request until the client timed out (and reset the + REST connection): chrono overflows on huge `since_ms`/`seconds` values reached it live. Look-back + windows now saturate, and a panic boundary around both dispatchers returns an internal error. +- `query_db` refuses a write, a PRAGMA write or stacked statements before looking up any database, + so the refusal no longer depends on whether the app has one (on Windows the demo app has none, + and a `DELETE` came back as "only WebView internal databases were found"). The demo app now + seeds a real `demo.db`. +- App-state probes run on the blocking pool with a timeout; a busy UI thread is reported as busy + instead of "no windows"; relative DB paths resolve in a fixed root order for both `query_db` and + `db_health`; `read_app_file` keeps UTF-8 when truncating mid-character; duplicate result column + names keep every value; rmcp 3.4's `ServerInfo` deprecation no longer warns in consumers. +- Chrono overflow panics on huge `since_ms`/`seconds` values, `wait_for` ignoring its deadline, + unserializable results reported as JavaScript errors, `dom_snapshot`/`find_elements` failing on + a numeric `.value` (`
  • `, ``), the auto-`return` scanner silently dropping + statements (`i++ / 2; f()`, `obj.of\nf()`, …), bfcache restores disabling console capture, + busy-UI timeouts reported as "no windows", command-timing samples for calls that never ran, trace + start/stop races and unbounded `interval_ms`, and the CLI bridge's fixed 120 s timeout cutting + off `invoke_command` calls allowed up to 300 s. + +### CI / release / docs — round 3 + +- Semver Checks pinned to cargo-semver-checks 0.50.0 (0.48 could not read current rustdoc and + passed without checking); failures are annotated. Codecov upload (failing silently on every run) + replaced by an artifact. jsdom bridge tests fail instead of silently skipping in CI/preflight. +- MCP Registry name uses the case-sensitive GitHub login (`io.github.4DA-Systems/victauri`). +- Victauri's own example apps (demo, gauntlet) opt out of Tauri's default features and so lacked + `x11`: tao then has no X11 window handle, and `screenshot` failed on Linux ("the underlying handle + is not available") — reproduced on WebKitGTK/X11, fixed, verified returning a PNG. Apps using + Tauri's defaults (as almost all do) were never affected. +- The live demo-app suites that CI never ran (`e2e.yml` last ran in May) were stale: 5 tests + asserted a 4-card page, 12 commands, a silent unknown-command result, and elements on hidden tabs. + They now match the app, restore the state they change, and pass in any order. +- victauri-core's README example compiles again and is now a doctest; visual-regression docs use + the real API; crate/compat counts corrected. + +### Security — pre-audit red team (no Critical/High found) + +- **Hostile page content could forge or suppress eval results through a page-visible global.** + Pending eval ids lived on `window.__VIC_EVAL__`; the eval bookkeeping now lives in the bridge's + frozen closure, and serialization uses a `JSON.stringify` captured before any page script ran. + (This closed that route only: same-window script can still rewrite or drop results in transit — + see Round 5.) +- **The injected bridge's log getters returned its live internal arrays**, so page script could + plant a forged "successful" IPC call (which `recording replay` would then invoke), hide its own + traffic, or freeze the recording drain. They now return copies; drain watermarks are clamped. +- **Page text could forge lines in the compact DOM snapshot** (a newline in an RSS title → a fake + `[eN] button "Save"` line steering an agent's click). Page-derived strings are now JSON-encoded. +- **`query_db` is enforced by SQLite's authorizer.** The string checks missed table-valued pragma + functions (`SELECT * FROM pragma_optimize` ran) and the parenthesized write form + (`PRAGMA user_version(5)`). Connections also run with `trusted_schema=OFF` + defensive mode, and + `db_health` never counts virtual tables (counting runs module code). (Round 5: that check was + `sql LIKE 'CREATE VIRTUAL%'`, which a crafted file defeats with `CREATE VIRTUAL` — two spaces — + planted through `writable_schema`; only SQLite's canonical `CREATE TABLE ` form is counted now. + Note that `quick_check` still connects virtual tables and, on SQLite >= 3.44, runs their + modules' `xIntegrity` — SQLite's built-ins (FTS3/4/5, R-Tree) plus any auto-extension the host + process installed; Victauri registers none.) +- **Browser-originated POSTs must be JSON** — with `auth_disabled()`, a page on any localhost origin + could fire CORS-simple (no-preflight) tool calls. The concurrency cap is now global (it was 64 + per route). +- `victauri bridge` counts only the current user's processes as alive (Unix); `query_db` absolute + paths no longer act as a file-existence oracle; page-originated evals get their own small + pending-eval budget so page script cannot starve the agent; the watchdog pins the app it watches + and trusts discovery dirs by owner, not permission bits alone. + +### Fixed — pre-audit correctness + +- The eval "window closed" detection no longer fires on a busy UI (a listing that timed out read + as "closed … it most likely ran — do not re-run it"), watches the exact window the script was + delivered to, and never stalls past its deadline. A trailing `// comment` in `eval_js` code no + longer turns the call into a parse error. ASI handling covers postfix `++`/`--`, regex literals, + keyword operators at line end and U+2028/2029. +- Recording: the background drain and `recording flush` share one per-window watermark (they + re-recorded each other's events); a new or imported recording never pulls in page history; IPC + calls are keyed by completion time, so a command slower than the drain gap no longer stays + "pending" forever. +- `trace` owns its recording by session id: restarting a trace no longer orphans the previous + recording, stopping one never stops a recording someone else started, and the 30-minute + auto-stop cannot stop a newer trace. +- `db_health` costs ~0 ms per table instead of ~25 ms (it capped out at ~200 tables per budget), + budgets its metadata phase, and reports a failed count as incomplete. +- Main-thread callers of the bridge (a sync command, a menu handler) run inline instead of risking + a deadlock on the dispatch lock; the Tauri-command eval paths go through the bridge (main thread, + serialized) instead of calling `webview.eval` from a worker thread. +- `victauri-test`: rediscovery after an app restart pins the app's identity and never falls back to + a default port; it also rediscovers on a refused connection. +- Tool annotations now match behavior (`recording`, `introspect`, `logs`, `window` are + destructive; `verify_state`/`wait_for`/`assert_semantic` run caller JS; `inspect`/`animation` + mutate the page), and several descriptions were corrected. The CLI's baked fallback tool list is + regenerated and pinned to the live tools by a test. + +### Found by the live 4DA verification (Victauri 0.9.0 inside the real app) + +- **An eval in flight when its page reloads now fails fast** ("window 'main' loaded a new page + while the call was in flight … check the app's state before re-running it") instead of waiting + out the 30s timeout. Every bridge (re)init records a per-window page load (label supplied by + Tauri, not the page); a 250ms grace keeps the page's own late ready signal from counting. + Live: the in-flight eval returned 50ms after the reload. +- **`invoke_command` takes an optional `timeout_ms`** (max 300000) for legitimately slow commands + (4DA's `get_blind_spots` takes up to 57s cold); a timeout error now says so. Aborted calls + (timeout, app exit, closed window, reload) are no longer recorded as command durations. +- **`find_elements` no longer reports a valid selector as "invalid"** while the page is + reloading (it validated against `document.body`, which is null mid-reload); it now says + "page not ready". + +### CI / release / docs + +- VS Code release: the build job holds no secrets and runs `npm ci --ignore-scripts`; publish + tokens are scoped to single steps. Surface Audit checks all six crates and fails on an unknown + registry answer. `e2e.yml` repaired. Crates now ship their LICENSE. `deny.toml` denies unknown + registries/git sources. `victauri init` writes a least-privilege CI workflow, builds `.mcp.json` + with a JSON serializer, and refuses to write through symlinks. +- README/docs: action names regenerated from the real enums; latency and "identical on all + platforms" overclaims corrected; plugin-as-dev-dependency advice fixed. + +The remainder of this entry is the first (full-surface) audit round, merged in #71: ### Fixed — agent-visible correctness @@ -63,11 +445,200 @@ the code before fixing. Additive/bugfix only — no public API removed or change names real tools. - `victauri-watchdog` discovers the app's actual port (and follows restarts) instead of assuming 7373. -- VS Code extension: DOM Explorer and Windows view showed nothing / junk; disconnection is now - detected. +- VS Code extension (released separately on its own `vscode-v*` tag — these fixes are in the + repository, not in the published 0.8.8 extension; see `editors/vscode/CHANGELOG.md`): DOM + Explorer and Windows view showed nothing / junk; disconnection is now detected; discovery finds + 0.9 apps in the per-user Unix root and no longer counts another user's process as live. - Surface Audit workflow had false-failed every week since July (crates.io 403s without a User-Agent); docs install pins, action refs and tool counts corrected. +### Round 5 — pre-publish audit + +- **Breaking (permissions): `victauri:default` no longer grants `allow-victauri-eval-js` or + `allow-victauri-dom-snapshot`.** They were in the default set although this entry describes them + as "page API, opt-in". They are the only async Victauri commands left — on Linux an async + command's argument extraction and resolver clone and drop Tauri handles on a tokio worker (the + host heap-corruption class fixed above), so any page script in an app using `victauri:default` + could drive that path in a loop — and `victauri_eval_js` is an eval reachable by page script + regardless of the app's CSP. Nothing in Victauri calls them (the bridge and every tool use only + `victauri_eval_callback`). The individual `allow-`/`deny-` permissions remain, so an app can opt + back in; see MIGRATION.md. +- **The example apps dropped Tauri handles off the main thread**, the race MIGRATION.md tells apps + to avoid: the demo's `run_pipeline` moved an `AppHandle` into `std::thread::spawn` (run three times + by the integration tests) and the gauntlet's into `tauri::async_runtime::spawn`. A host death in + CI could therefore have been the example's fault rather than Victauri's. Both now take one handle + on the main thread in `setup` (`static APP: OnceLock`) and background work only borrows + it. The pattern is documented for apps under "Linux: keep Tauri handles on the main thread" in the + compatibility docs. +- **CI never compiled the plugin without SQLite.** The "no default features" clippy step ran over the + workspace, where the example apps' default-feature dependency on the plugin kept `sqlite` on + through feature unification. CI now also runs clippy and the unit tests for `victauri-plugin` + alone with `--no-default-features` (both clean locally). The MSRV job checks with `--locked`. + The semver job's comment claimed the 0.8.8 → 0.9.0 check "passes as the intended major bump"; + for a 0.x major bump cargo-semver-checks runs no lints at all ("0 checks … 254 skip"). + RELEASING.md now says to also run `--release-type minor` for a major bump to list what actually + changed. +- Docs: the plugin README's tools-reference link 404'd (GitHub Pages paths are case-sensitive: + `/Victauri/`); the `sqlite` / `bundled-sqlite` features, including that `bundled-sqlite` also + switches the app's own rusqlite to bundled SQLite and how to opt out, are documented in the + plugin README and the configuration docs; the demo app's `.mcp.json` no longer passes the no-op + `--wait`. +- Docs: MIGRATION.md contradicted itself on browser-originated requests (one bullet said an + `Origin`-bearing POST gets 415 unless JSON, another that "any `Origin`" gets 403), and the + security docs repeated the 403 claim. The actual rule, now in both: 403 for a `Sec-Fetch-Site` + other than `none`, a non-localhost, unparseable or `tauri://` `Origin`; a localhost `Origin` is + allowed through and a POST carrying it must be JSON (415 otherwise). +- **Claims corrected: same-window page script CAN forge and suppress eval results.** Tauri (2.11.5, + `scripts/ipc-protocol.js`) sends every IPC message with the bare global `fetch(...)`, looked up + at call time, so script that wraps `window.fetch` after the bridge loads sees every + `victauri_eval_callback` body (`{"id": …, "result": …}`) and can rewrite or drop it. A jsdom + proof of concept returned an agent's `return document.title` as a page-chosen string, and forged + a liveness-probe answer that trips the "page replaced/reloaded" abort. Earlier entries in this + release said page script cannot forge or suppress results, that the reload abort cannot be + forged, and (in the security docs) that replay "never runs what did not run"; they are corrected + in place. The real boundary: page script in the same webview is trusted for the integrity and + confidentiality of that webview's eval results and probe answers (it can read, rewrite or drop + them, and so can plant IPC calls in a recording of its window); it still cannot read the agent + key or call keyed agent operations, and other windows and Rust-side tools are unaffected. Also + corrected: the animation scrub/sweep helpers (`scrubPrepare`, `scrubSeek`, `scrubRestore`, + `installSweepRecorder`, `readSweep`) are on the public `window.__VICTAURI__`, not behind the + per-process key as round 3 said. +- MIGRATION.md did not list most of the API this release hid: all of `victauri_core::middleware` + and `victauri_core::security`, `acquire_lock`/`acquire_read`/`acquire_write`, the plugin's + `privacy::{strict,observe,test}_privacy_config` and several `database` / `js_bridge` / + `screencast` items. It now lists every removed, hidden and newly `#[non_exhaustive]` item that + `cargo semver-checks --release-type minor` reports against 0.8.8 for core, plugin and test. + CONTRIBUTING.md no longer presents the hidden `acquire_*` helpers as API. +- **Regression fixed: reading the IPC or network log froze the app's UI for about a second.** + Round 3 made every log read hand out deep copies, but `getIpcLog(limit)` / `getNetworkLog` + copied every retained entry (up to 1,000 calls with bodies) before applying the limit — 859 ms + per read with 1,000 × 56 KB bodies, against ~2 ms in 0.8.8, on the UI thread, for `logs ipc`, + `slow_ipc`, ghost detection, coverage, `command_timings`, `command_catalog`, the ipc-log resource + and `check_ipc_integrity` (which read both logs). Entries are now selected and limited first and + only the returned ones are copied (still copies, never the live objects); name/status/timing + projections use a body-free view; `waitForIpcComplete` reads the newest call in place. +- **An eval could hang until its timeout** when page script made `Object.prototype.toJSON` throw: + the bridge marked the eval done and then failed to serialize both the result and its own error + fallback, and Tauri serializes callback arguments with the page's `JSON.stringify`. Outcomes (and + probe/ready signals) are now built from strings on prototype-less objects, and an unserializable + result comes back as `__victauri_unserializable`. +- **Clicks on web-component content failed as "covered":** a hit point on slotted light-DOM + content was not recognised as inside its shadow host (bridge `composedContains` and the trusted- + click probe). Both now follow `assignedSlot`. +- Route globs: `?` was an unescaped regex token, so `*/api/search?q=*` never matched; `*` is the + only wildcard and `?` now matches itself. Uncaught-error and unhandled-rejection messages are + capped at 4,096 characters like console messages; `route add` parses its rule with the pristine + `JSON.parse`; `logs … limit=0` returns no entries (it returned all of them). +- **The watchdog's `--app` was case-sensitive** while the bridge and victauri-test match + identifiers ASCII case-insensitively, and a selector that matches nothing reads as "app down" — + so `--app com.mock.app` against a healthy `com.Mock.App` fired the recovery command in a loop. It + now matches exactly like the bridge. +- CLI bridge: a JSON-RPC batch (a JSON array) is answered locally with one `-32600` error per + request (MCP removed batching in 2025-06-18; the server rejects batches without running them), + instead of being forwarded — re-sent after a post-send failure — with its reply dropped; a blank + `VICTAURI_APP=` / `--app ""` counts as unset (it selected an app named "" and every call said + "backend not reachable"); a client's JSON-RPC *response* is never answered with an invented + error; a tool call whose SSE response dies mid-stream now says it may already have run (it said + "empty or non-JSON response"). +- victauri-test: JUnit XML could contain characters XML 1.0 forbids (an ANSI escape in a page's + console error made the whole report unparseable); they are replaced and tab/LF/CR are kept as + character references. Page text in `verify()` check details, `assert_all_passed` panics and + Locator errors goes through the same terminal sanitizer as the smoke summary, so it cannot drive + the terminal or start a `::error` workflow command in a CI log. +- VS Code extension (released separately): it no longer re-sends the Bearer token to a port its app + no longer owns (discovery is re-checked before every token-bearing request), and refresh polls + cannot overlap. + +### Round 5b — every remaining Low fixed before release + +Round 5 closed its Mediums but listed ~35 Low findings from its six lenses. All of them are fixed +here (still 0.9.0), each with a test that failed first; behavior changes are in MIGRATION.md. + +**Tools** + +- `verify_state`, `assert_semantic` and `wait_for` (`expression`) accept a trailing `;` or + `// comment` and multi-line expressions; a `wait_for` expression that does not parse fails at once + instead of polling until its timeout. An expression that evaluates to `undefined` reads as `null` + (it failed with "not valid JSON"). `wait_for` `timeout_ms: 0` checks once, immediately (the page + treated 0 as its 10 s default and the call failed as "eval timed out"). +- **Refused page actions are tool errors:** `interact`, `input`, `inspect` and `route add` report a + page refusal (covered, disabled, hidden, detached, not fillable, unknown ref, invalid rule) as + `isError` with the reason and a `[hint: …]`, instead of a successful `{ok:false}` result. +- `logs slow_ipc` honours `webview_label`; `explain diff` reports the real call count and each + command once (a consecutive-only dedup double-listed interleaved commands). + +**Plugin core, data access and threading** + +- Plugin setup that runs off the main thread (a plugin registered at runtime from a background + thread via `AppHandle::plugin`) now records the real main thread and installs its handle there; + every webview/window tool used to fail with "main-thread app handle is not installed". +- Page-callable IPC queries are bounded: `victauri_get_ipc_log` returns the newest 100 calls by + default (max 1000), and ghost detection / IPC integrity no longer copy the whole event log on the + UI thread (14–17 ms → 0.1–3 ms on a 10,000-call log). +- `query_db` runs only SQLite built-ins, SQLite's own FTS/R-Tree/Geopoly functions and functions + registered as deterministic; other app-registered functions are refused by name, and + `load_extension` is never allowed. **Also fixed: R-Tree tables could not be read through + `query_db` at all** (the authorizer refused the statements R-Tree prepares when it connects). +- `db_health` never runs app-registered virtual-table code: when such a module is registered and + the file may contain virtual tables, `quick_check` runs per ordinary table + (`integrity_check_kind: "quick_check (per table)"`, new `integrity_check_note`). +- An eval result is accepted only from the window it was sent to. +- Redaction covers URL credential parameters (`access_token`, `api_key`, `sig`, …), sensitive keys + in JSON nested inside strings, JSON embedded in text, and truncated JSON; the remaining limits + are listed in the security docs. +- The request-head deadline (also the idle keep-alive timeout) is 10 s instead of 30 s, and every + Victauri HTTP client drops pooled connections after 5 s, so a pooled connection is never reused + just as the server closes it. +- `probe` / `on_ready` docs state which thread they run on and that they must not touch Tauri + handles; the main-thread round trip documents its real ~2× timeout worst case. + +**Injected bridge** + +- `css inject`'s remote-URL check is allowlist-based on a real CSS tokenizer: in a URL position only + `data:` or a scheme-less, non-`//` reference passes (`url(https:evil/x)`, `http:/evil`, + `image-set("//evil")` and escaped variants used to pass), and a `/*` inside a string can no longer + hide a following remote `url()`. +- The animation scrub/sample helpers moved behind the per-process agent key (page script could + erase or replace a sweep recording). +- Route rules match every spelling of a request (relative, absolute, `URL`, `Request`, XHR); + a route glob's behaviour is documented. +- A reused XHR gets one network-log entry per request (later requests rewrote earlier entries; a + re-`open()` mid-flight left a `pending` entry that wedged `network_idle`); `fetch()` with no + arguments rejects natively; logged URLs over 2048 characters are stored cut (large `data:` URLs + pinned up to 1000× their size); captured IPC bodies and the in-app `victauri_eval_js` / + `victauri_dom_snapshot` commands use the init-time JSON functions, so a page `toJSON` or a + replaced `JSON.stringify` cannot forge their results or hang them. + +**Clients and discovery** + +- The CLI bridge handles requests concurrently (a long `tools/call` no longer delays `ping`, + parallel calls or `notifications/cancelled`; up to 32 in flight; one backend handshake), still + never re-sending a possibly-delivered call. +- The bridge confirms the app's `/info` identity for `--app` before forwarding, refuses + `VICTAURI_PORT` combined with a selector naming a different app (also in `victauri-test`/CLI + discovery), and uses victauri-test's hardened liveness check on Unix (never a bare `PATH` lookup). +- Windows discovery readers (CLI/bridge, `victauri-test`, watchdog, VS Code) trust a discovery + folder only if the current user owns it (or `BUILTIN\Administrators` with the user a member): an + entry planted on a shared `TEMP` (e.g. MSYS2's `C:\msys64\tmp`) used to be trusted. +- On Unix, when `/tmp/victauri-` is unusable (another user created it first — a discovery + denial of service), the plugin registers under `$XDG_STATE_HOME/victauri` + (`~/.local/state/victauri`) and every reader scans it. +- `victauri doctor` exits 1 when a check fails; `init` prints plain paths (no `\\?\`) and its + suggested `.mcp.json` snippet includes `--app`; the smoke suite never stops a recording it did + not start (the check is reported skipped); IPC checkpoints are `u64` (they truncated on 32-bit — + see MIGRATION); the 401 hint names the real token locations; `TestError` display neutralizes + page-derived text (control characters, `::` workflow commands) while keeping line breaks. + +**Tests, CI and docs** + +- Eight timing-sensitive tests made deterministic (dispatch-gate, probe, file-read, rate-limiter, + lock-wait and db_health tests now synchronize or count instead of racing wall-clock bounds); + checked by running each 20–30× under full CPU oversubscription. +- The `rusqlite-range` CI job fails if the update did not actually reach rusqlite 0.40.x; the + getting-started guide says the 1.88 MSRV is Victauri's own and that a fresh tauri 2.12 tree needs + Rust 1.90; stale `/victauri//token` paths and a "token is printed to the log" claim + (it never is) corrected in the rustdoc. + ## [0.8.8] - 2026-08-12 The MCP-infrastructure release: the embedded server jumps two SDK major versions @@ -1519,7 +2090,8 @@ Initial public release. - Security headers (X-Frame-Options, X-Content-Type-Options, Cache-Control) - Screenshot error handling: `GetDIBits()` return value checked on Windows -[Unreleased]: https://github.com/4DA-Systems/victauri/compare/v0.8.8...HEAD +[Unreleased]: https://github.com/4DA-Systems/victauri/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/4DA-Systems/victauri/compare/v0.8.8...v0.9.0 [0.8.8]: https://github.com/4DA-Systems/victauri/compare/v0.8.7...v0.8.8 [0.8.7]: https://github.com/4DA-Systems/victauri/compare/v0.8.6...v0.8.7 [0.8.6]: https://github.com/4DA-Systems/victauri/compare/v0.8.5...v0.8.6 diff --git a/CLAUDE.md b/CLAUDE.md index 05291000..26198553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ victauri/ ### How It Works -1. **victauri-plugin** is added as a dev dependency to any Tauri app +1. **victauri-plugin** is added as a normal `[dependencies]` entry to any Tauri app (not `[dev-dependencies]` — the app binary can't see those); it compiles to a no-op in release builds 2. The plugin starts an axum HTTP server on `127.0.0.1:7373` inside the app process 3. This server speaks MCP protocol (Streamable HTTP + SSE) 4. Claude Code (or any MCP client) connects and gets full-stack control @@ -120,12 +120,12 @@ Standalone binary. Monitors the MCP server health endpoint. 2. **Zero-cost in release** — everything gated behind `#[cfg(debug_assertions)]` 3. **Full-stack** — webview + IPC + backend + DB, not just DOM 4. **MCP-native** — speaks the protocol AI agents already understand -5. **Cross-platform** — no CDP dependency, works on Windows/macOS/Linux identically +5. **Cross-platform** — no CDP dependency; the same tool surface on Windows/macOS/Linux, with documented limits (trusted OS-level input is Windows-only; Linux screenshots need X11/XWayland — pure Wayland errors clearly) 6. **Plugin, not framework** — one line in Cargo.toml to add, one line to remove ## Design Decisions -- **Why embedded, not external?** Eliminates the three-hop state drift that plagues Playwright. Direct AppHandle access gives sub-ms tool response times. +- **Why embedded, not external?** Eliminates the three-hop state drift that plagues Playwright. Direct AppHandle access means Rust-side tools answer in well under a millisecond; webview tools are a JS round trip through the webview (typically ~10-30 ms). - **Why axum, not stdio?** Tauri apps are GUI processes — stdin/stdout aren't wired for MCP. HTTP/SSE on localhost is the right transport for an already-running process. - **Why ref handles, not selectors?** Following Playwright MCP's proven model. Refs are semantic (ARIA-derived), short-lived, and survive DOM restructuring within a snapshot. - **Why a watchdog?** If the app crashes, the embedded MCP server dies. The watchdog detects this and can alert the agent or trigger recovery. @@ -184,6 +184,71 @@ Standalone binary. Monitors the MCP server health endpoint. - [x] Accessibility auditing (WCAG checks: alt text, labels, contrast, ARIA, headings) - [x] Performance profiling (navigation timing, resource loading, JS heap, long tasks, DOM stats) +## Current State (2026-09-27) + +### v0.9.0 round 4 — adversarial audit + live E2E of the candidate (2026-09-28/29) + +Eight red-team lenses against candidate `43b7561` (incl. the CLI/local-attacker lens round 3 +never got back, and a regression hunt over every round-3 fix), then live runs on the demo, the +gauntlet (Windows, real display) and 4DA on its real 1.47 GB DB, plus a before/after baseline +against the operator's 0.8.8 4DA. **No Critical/High.** 14 Mediums confirmed (4 live, 2 by V8 +PoC) and fixed in four isolated lanes with fail-first tests, then re-verified live — notably: a +synthetic `pagehide` wiped the agent's evidence and killed capture; the agent key leaked via +`Function.caller`; a `/health` flood made the bridge/watchdog call a healthy app dead; three +round-3 CHANGELOG claims were false (key unreadable, reload abort unforgeable, scanner never drops +a statement); **apps on rusqlite > 0.32 could not add the plugin at all** (now a `>=0.32, <0.41` +range with fail-closed setup across every version — `SetupOutcome` in database.rs; a required CI +job tests the newest end). Also verified against rmcp 3.5.0 / tauri 2.12.0 (tauri 2.12's default +static VC runtime breaks *other* crates' doctests in a Windows workspace — upstream). +Disproved with evidence (don't re-raise): eval-id `.caller` leak, quick_check overrun, discovery +dirs left on clean exit, Windows socket inheritance by child processes, missing tauri `rename`. +PR #73 (backend logs) is deferred to 0.9.1. Report: https://claude.ai/artifact/Wfmx9d2khAuCesB7SFu8ge + +**Linux host heap corruption — ROOT CAUSE (don't re-investigate):** on Linux every Tauri handle +(`AppHandle`, `Webview`, `WebviewWindow`) carries tauri-runtime-wry's `Context`, which holds the +main-thread context by value, which holds tao's `EventLoopWindowTarget` with +`windows: Rc>>` (behind `unsafe impl Send + Sync`, "main thread only"). +**Cloning or dropping ANY Tauri handle off the main thread races that non-atomic `Rc`** → glibc +`corrupted double-linked list` / `unaligned fastbin chunk`, surfacing on whatever thread allocates +next. Proven with no Victauri in the process (demo-app build with an in-process driver: bg threads +that only clone+drop the `AppHandle` abort; with the handle taken on the main thread, 10/10 clean). +Victauri did it per call: `on_main` cloned the `AppHandle` on the caller, and the eval callback was +an `async` Tauri command (Tauri extracts async args + drops the resolver's `Webview` on tokio). +Fixed: `on_main` uses a main-thread-owned handle (`bridge::install_main_app`), and every Victauri +command that does not wait for the page is SYNC (runs on the main thread); a test pins that only +`victauri_eval_js`/`victauri_dom_snapshot` are async. The 0.8.8 `MAIN_DISPATCH_LOCK` only reduced +concurrent off-main clones — it was never the real fix. Lessons: bisecting a refcount race +converges on noise (identical code split 3/3 vs 0/3 across builds); WSL never reproduced the +Victauri suite but reproduced the minimal repro instantly; the CI E2E job now repeats the +heap-sensitive tests against 3 fresh apps on Linux. + +### v0.9.0 — rounds 1-2 + a one-time `#[non_exhaustive]` break (release-prepped, NOT published) + +Triggered by an in-the-wild failure: a 4DA session ran `invoke_command quit_app` through the +bridge, the app quit, and the agent was told **"backend not reachable"** — the bridge had +**replayed the delivered call** (up to 4×) after the connection dropped. Round 1 (PR #71, merged): +a three-lens review + live 4DA sweep, ~50 verified fixes (bridge never replays a possibly-delivered +`tools/call`; `db_health` degrades instead of failing on 4DA's 1.4 GB DB; replay/trace/recording +lifecycle; JS-bridge host safety; CLI/test/VS Code/CI/docs). Round 2 (branch `release/0.9-prep`): +a five-lens pre-audit red team (auth/network, new-code correctness, data access/JS injection, +supply chain, semver/claims) — **no Critical/High**; every Medium/Low fixed, notably: eval state +moved off the page-visible global (page could forge/suppress results), log getters return copies +(page could plant a forged IPC call that replay would invoke), compact snapshot JSON-encodes page +text (line forgery), `query_db` enforced by SQLite's **authorizer** (+ `trusted_schema=OFF`, +defensive), browser-originated POSTs must be JSON, global concurrency cap, busy-UI ≠ closed window, +shared drain/flush watermark, trace owns its recording by session id, ASI edge cases, honest tool +annotations, and a test pinning the CLI's baked tool manifest to the live tools. + +**Why 0.9.0:** `VictauriState`, `IpcCall` and most public data types had all-pub fields and no +`#[non_exhaustive]`, so EVERY field addition was technically breaking. They are now +`#[non_exhaustive]` with constructors/builders (`#[inspectable]` expands to the builders). Consumers +bump `"0.8"` → `"0.9"`; 4DA needs no code change (verified against its sources). See MIGRATION.md. + +**Process notes:** the jsdom bridge tests SKIP silently unless `npm ci` has been run in +`crates/victauri-plugin/tests/bridge_tests/` (CI does it; fresh worktrees don't). The repo +pre-commit hook "sweeps stale build artifacts" and can break a cargo build running in the same +worktree — don't commit mid-gate. Unix-only code paths (bridge `ps -U`, watchdog/discovery owner +checks) are verified under WSL (`CARGO_TARGET_DIR=$HOME/vt-target`). ## Current State (2026-08-12) ### v0.8.8 — host-crash fix (Linux/WebKitGTK heap corruption), found during release prep @@ -1032,32 +1097,32 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - **victauri-macros**: `#[inspectable]` proc macro with `description`, `intent`, `category`, `example` attributes. Uses proper `syn::meta` parsing (not string matching). Generates `__schema()` returning `CommandInfo` with full intent metadata. 4 integration tests. - **victauri-plugin**: Full MCP server with **35 tools** + 3 resources. Tools organized by category: - **Standalone (20)**: eval_js, dom_snapshot, find_elements, invoke_command, screenshot, verify_state, detect_ghost_commands, check_ipc_integrity, wait_for (text/selector/url/ipc_idle/network_idle + **expression**/**event** for awaiting async backend work), assert_semantic, resolve_command, get_registry, **app_state** (app-defined backend-state probes), get_memory_stats, get_plugin_info, get_diagnostics, app_info, list_app_dir, read_app_file, query_db - - **Compound (15)**: interact (click/hover/focus/scroll/select), input (fill/type/press_key), window (get_state/list/manage/resize/move/set_title), storage (get/set/delete/cookies), navigate (go_to/back/history/dialogs), recording (start/stop/checkpoint/events/export/import/replay), inspect (styles/bounds/highlight/a11y/perf), css (inject/remove), route (network interception), trace (screencast ring buffer), animation (list/scrub/sample), logs (console/network/ipc/navigation/dialogs/events/slow_ipc), **introspect** (command_timings/coverage/command_catalog/contract_record/contract_check/contract_list/contract_clear/startup_timing/capabilities/db_health/plugin_state/processes/plugin_tasks/event_bus/event_bus_clear), **fault** (inject/list/clear/clear_all), **explain** (summary/last_action/diff) - Resources: victauri://ipc-log, victauri://windows, victauri://state with subscribe/unsubscribe. JS bridge v0.7.8 with IPC interception, network monitoring, storage access, navigation tracking, dialog capture, extended interactions, and waitFor. `EventRecorder` with 50,000 event capacity. **Release-safe**: `init()` returns a no-op plugin in release builds via `#[cfg(debug_assertions)]` gate. `VictauriBuilder` for port/capacity/auth configuration + `VICTAURI_PORT`/`VICTAURI_AUTH_TOKEN` env vars. Bearer token auth middleware (**enabled by default** with auto-generated UUID v4 token, case-insensitive per RFC 7235). `auth_disabled()` to opt out, or `auth_token("...")` for a fixed token. Token-bucket rate limiter (AtomicU64, 1000 req/sec default). Privacy layer with command allowlists/blocklists, tool disabling, regex-based output redaction, strict mode. Tool enable/disable via builder. 203 unit tests + 128 integration tests + 38 adversarial tests + 85 tool contract tests + 30 bridge tests + 22 stress tests + 19 platform tests. + - **Compound (15)**: interact (click/double_click/hover/focus/scroll_into_view/select_option), input (fill/type_text/press_key), window (get_state/list/manage/resize/move_to/set_title/introspectability), storage (get/set/delete/get_cookies), navigate (go_to/go_back/get_history/set_dialog_response/get_dialog_log), recording (start/stop/checkpoint/list_checkpoints/get_events/events_between/get_replay/export/import/replay/flush), inspect (get_styles/get_bounding_boxes/highlight/clear_highlights/audit_accessibility/get_performance), css (inject/remove), route (add/list/clear/clear_all/matches — fetch/XHR interception; Tauri IPC is observe-only), trace (start/stop/status/frames — screencast ring buffer), animation (list/scrub/sample), logs (console/network/ipc/navigation/dialogs/events/slow_ipc/clear), **introspect** (command_timings/coverage/command_catalog/contract_record/contract_check/contract_list/contract_clear/startup_timing/capabilities/db_health/plugin_state/processes/plugin_tasks/event_bus/event_bus_clear), **fault** (inject/list/clear/clear_all), **explain** (summary/last_action/diff) + Resources: victauri://ipc-log, victauri://windows, victauri://state (read on demand; `resources.subscribe` is NOT advertised — legacy subscribe/unsubscribe calls are accepted but nothing pushes updates). JS bridge (version = the crate version, injected at build time) with IPC interception, network monitoring, storage access, navigation tracking, dialog capture, extended interactions, and waitFor. `EventRecorder` with 50,000 event capacity. **Release-safe**: `init()` returns a no-op plugin in release builds via `#[cfg(debug_assertions)]` gate. `VictauriBuilder` for port/capacity/auth configuration + `VICTAURI_PORT`/`VICTAURI_AUTH_TOKEN` env vars. Bearer token auth middleware (**enabled by default** with auto-generated UUID v4 token, case-insensitive per RFC 7235). `auth_disabled()` to opt out, or `auth_token("...")` for a fixed token. Token-bucket rate limiter (AtomicU64, 1000 req/sec default). Privacy layer with command allowlists/blocklists, tool disabling, regex-based output redaction, strict mode. Tool enable/disable via builder. 203 unit tests + 128 integration tests + 38 adversarial tests + 85 tool contract tests + 30 bridge tests + 22 stress tests + 19 platform tests. - **victauri-test**: Typed MCP HTTP client (`VictauriClient`) with auto-session management (initialize + notifications/initialized). 23 convenience methods for tool calls (eval_js, dom_snapshot, click, fill, etc). 6 standalone assertion helpers: `assert_json_eq`, `assert_json_truthy`, `assert_no_a11y_violations`, `assert_performance_budget`, `assert_ipc_healthy`, `assert_state_matches`. 11 client assertion methods: `assert_eval_works`, `assert_dom_snapshot_valid`, `assert_screenshot_ok`, `assert_windows_exist`, `assert_ipc_integrity_ok`, `assert_accessible`, `assert_dom_complete_under`, `assert_heap_under_mb`, `assert_no_uncaught_errors`, `assert_recording_lifecycle`, `assert_health_hardened`. Built-in `smoke_test()` suite (11 checks, returns `SmokeReport` with timing + JUnit XML). `SmokeConfig` for custom thresholds. Supports Bearer token auth via `connect_with_token`. Published to crates.io as standalone crate. - **victauri-cli**: CLI binary (`victauri`) with 8 commands: `init` (scaffold test directory + CLAUDE.md with agent instructions), `check` (server diagnostics), `test` (built-in smoke suite — 11 checks with pass/fail + JUnit XML), `record` (capture interactions → test file), `doctor` (full setup diagnosis), `watch` (file watcher → re-run tests), `invoke` (call any Tauri IPC command from terminal), `coverage` (IPC command coverage report). `victauri test` auto-discovers the running app, runs all smoke checks, prints a summary, exits 0/1 for CI. Configurable `--max-load-ms` and `--max-heap-mb` thresholds. `victauri init` creates/appends CLAUDE.md with instructions that make AI agents prefer Victauri over CDP/Playwright. - **victauri-watchdog**: Configurable via env vars (`VICTAURI_PORT`, `VICTAURI_INTERVAL`, `VICTAURI_MAX_FAILURES`, `VICTAURI_ON_FAILURE`). Proper `tracing-subscriber` log output. Executes configurable recovery commands on failure. Fires recovery action once per failure cycle, resets on recovery. - **demo-app**: Multi-window Tauri 2 app in `examples/demo-app/` with Victauri wired up. 21 commands (greet, counter CRUD, todo CRUD, settings, contact form with validation, notifications with cross-window events, window management, app state dump, plus a fire-and-forget `run_pipeline` + `pipeline_status` mirroring a real async backend job) all decorated with `#[inspectable]`. Registers a `pipeline` `app_state` probe and emits a `pipeline-complete` event for the async-completion demo. Tab-based navigation with ARIA attributes, `data-testid` on all interactive elements. Notification panel window with event sync. 20 integration tests in `tests/integration.rs` demonstrating every Victauri testing pattern (direct client API, Locator API, IPC verification, cross-boundary state, a11y audit, perf monitoring, time-travel recording, verify builder). Includes `.mcp.json` for immediate Claude Code connection. -- **CI/CD**: GitHub Actions `ci.yml` (clippy + tests + docs on Linux/Windows/macOS, format check, Chrome extension vitest job) + `release.yml` (test gate → 12-matrix cross-platform builds → Chrome extension zip → sequential crates.io publish → GitHub Release with all artifacts). All crate code passes `cargo fmt --check`. -- **docs/**: mdbook documentation site — 10 pages covering introduction, getting started, architecture, tools reference, Chrome extension, testing, configuration, security, FAQ. +- **CI/CD**: GitHub Actions `ci.yml` (clippy + tests + docs on Linux/Windows/macOS, format check, live-app E2E on all 3 OS, cross-engine gauntlet, `CI Success` aggregate gate) + `release.yml` (test gate → cross-platform CLI/watchdog binary builds → sequential crates.io publish → GitHub Release with the binaries) + `release-vscode.yml` (VS Code extension, own `vscode-v*` tag; build job holds no secrets, publish job is environment-gated) + `surface-audit.yml` (weekly registry-drift check, fails on drift OR an unknown/failed query) + `docs.yml` (mdbook → Pages) + `e2e.yml` (manual: victauri-test client e2e suites). (The Chrome-extension jobs went with browser mode, removed 2026-06-09.) All crate code passes `cargo fmt --check`. +- **docs/**: mdbook documentation site — 10 pages: introduction, getting started, compatibility, architecture, tools reference, testing, testing Tauri apps (approach comparison), configuration, security, FAQ. ### Architecture notes: -- **victauri-browser architecture** — `MCP Client → axum HTTP :7474 → Native Messaging (stdio) → Chrome Extension Service Worker → Content Script (MAIN world)`. The Rust binary serves dual roles: HTTP server for MCP clients AND native messaging host for Chrome. Both run concurrently via tokio tasks. The `BridgeDispatch` sends UUID-tagged commands to stdout (Chrome native messaging), and a spawned reader task receives responses on stdin and resolves oneshot channels. The `mcp_handler.rs` routes all 20 tools: `get_plugin_info` and `tabs.list` are handled locally in the Rust host; everything else is dispatched to the Chrome extension via native messaging → service worker → content script relay → MAIN world bridge. The content script uses CustomEvents (`__victauri_command`/`__victauri_response`) to bridge ISOLATED ↔ MAIN worlds. Navigation uses `chrome.tabs.update()` instead of content script `window.location`, and cookies use `chrome.cookies.getAll()` for httpOnly access. -- **bridge.rs** — `WebviewBridge` trait (public) erases the Tauri `Runtime` generic, allowing the MCP handler (which can't be generic) to access webview windows AND backend resources via `Arc`. 13 methods: eval_webview, get_window_states, list_window_labels, get_native_handle, manage_window, resize_window, move_window, set_window_title + backend access: app_data_dir, app_config_dir, app_log_dir, app_local_data_dir, tauri_config. Impl provided for `AppHandle`. Backend methods have default implementations (return error) so mock bridges work without change. Cross-platform `get_native_handle`: Windows HWND, macOS CGWindowID (via ObjC runtime `windowNumber`), Linux Xlib/Xcb window ID. Testable via mock implementations. +- **victauri-browser (REMOVED 2026-06-09)** — the Chrome/Firefox-extension + native-messaging host (`:7474`) browser mode no longer exists in this repo; see the note at the top of this file. Do not reintroduce it. +- **bridge.rs** — `WebviewBridge` trait (public) erases the Tauri `Runtime` generic, allowing the MCP handler (which can't be generic) to access webview windows AND backend resources via `Arc`. 19 methods. Required (8): eval_webview, get_window_states, list_window_labels, get_native_handle, manage_window, resize_window, move_window, set_window_title. With default implementations (11): `try_get_window_states` / `try_list_window_labels` (enumeration that can report failure instead of an empty list) and `eval_webview_resolved` (eval + returns the label it actually resolved to), the trusted-input trio `native_type_text`/`native_key`/`native_click` (Windows `SendInput` impl; defaults return "not implemented on this platform"), and backend access `app_data_dir`, `app_config_dir`, `app_log_dir`, `app_local_data_dir`, `tauri_config`. Impl provided for `AppHandle`, which routes ALL webview/window access through the main thread (`on_main`, serialized by `MAIN_DISPATCH_LOCK` since 0.8.8). Defaults mean mock bridges work without change. Cross-platform `get_native_handle`: Windows HWND, macOS CGWindowID (via ObjC runtime `windowNumber`), Linux Xlib/Xcb window ID. Testable via mock implementations. - **introspection.rs** — Backend introspection and chaos engineering types: `CommandTimings` (per-command timing with min/max/avg/p95 stats), `FaultRegistry` (fault injection rules with delay/error/drop/corrupt), `ContractStore` (IPC contract baselines with JSON shape diffing for schema drift detection), `StartupTimeline` (plugin init phase timestamps), `ChildProcessInfo` + `enumerate_child_processes()` (cross-platform child process enumeration via Windows `CreateToolhelp32Snapshot`, Linux `/proc`, macOS `proc_listchildpids`). All state is thread-safe (`RwLock` + poisoning recovery). `JsonShape` recursively extracts type structure from JSON for contract comparison. `diff_shapes()` detects new fields, removed fields, and type changes between baseline and current responses. - **mcp/** — Split into `mod.rs` (handler + server startup + tests), `server.rs` (Router + server lifecycle), `rest.rs` (REST API routes), `helpers.rs` (js_string, tool_error, validate_url, sanitize_css_color), and 8 param modules (webview, window, backend, verification, recording, introspection, compound, other). rmcp `#[tool_router]` + `#[tool_handler]` macros require all tool methods in a single `impl` block, so the handler stays monolithic. `build_app()` constructs the axum `Router` independently of Tauri (testable). `StreamableHttpService` serves on `/mcp`. REST API on `/api/tools`. Health/info endpoints on `/health` and `/info`. - **REST API** (`mcp/rest.rs`) — Dual-protocol: all 35 tools accessible via `POST /api/tools/{name}` with plain JSON body, no MCP session needed. `GET /api/tools` lists available tools. Uses the same `VictauriMcpHandler.execute_tool()` dispatch that applies privacy checks, rate limiting, auth, and output redaction. Response format: `{"result": ...}` for success, `{"error": "..."}` for errors. Text results parsed as JSON when valid. Goes through the same auth/rate-limit middleware as MCP. - **tools.rs** — Tauri commands still work independently for in-app IPC. Both the MCP tools and Tauri commands use the same `pending_evals` mechanism for JS eval with return. -- **screenshot.rs** — Windows: `PrintWindow` → `GetDIBits` (BGRA) → RGBA → PNG. macOS: `CGWindowListCreateImage` → `CGBitmapContext` (RGBA) → PNG. Linux: X11 `GetImage` (BGRA ZPixmap) → RGBA via `x11rb`, with Wayland fallback via `grim` subprocess (full-screen capture). All platforms use the same custom PNG encoder with flate2 zlib compression (CRC32 + Adler32). +- **screenshot.rs** — Windows: Windows.Graphics.Capture (DWM-composited surface, so transparent / GPU-composited windows capture correctly), falling back to GDI `PrintWindow` → `GetDIBits` (BGRA) → RGBA → PNG. macOS: `CGWindowListCreateImage` → `CGBitmapContext` (RGBA) → PNG. Linux: X11 `GetImage` (BGRA ZPixmap) → RGBA via `x11rb` (works on X11 and XWayland). Pure Wayland has no safe per-window capture path, so it fails with a clear error (the old full-screen `grim` fallback was removed in 0.8.1 — it leaked the whole desktop). All platforms use the same custom PNG encoder with flate2 zlib compression (CRC32 + Adler32). - **auth.rs** — Bearer token authentication, **enabled by default** with auto-generated UUID v4 token written to discovery directory. `auth_disabled()` to opt out, or `auth_token("...")` for a fixed token. `VICTAURI_AUTH_TOKEN` env var overrides. `require_auth` axum middleware skips `/health` but protects `/mcp` and `/info`. DNS rebinding guard validates Host header. Origin guard validates Origin header (URL-parsed, blocks subdomain smuggling). Security headers: nosniff, no-store, X-Frame-Options DENY, CORS null, CSP default-src none. Rate limiter returns Retry-After on 429. - **JS bridge injection** — Uses `js_init_script()` (persistent) instead of `on_webview_ready()` + `eval()` (one-shot). This ensures the bridge survives page navigations in Vite dev mode. MutationObserver init is deferred via `DOMContentLoaded` fallback to avoid crash when `document.documentElement` isn't ready during early script execution. Bridge includes Playwright-grade actionability (10-point checks + auto-wait), stable WeakRef handles, compact accessible-text snapshots, findElements search, full IPC data capture (request args + response body), network interception (fetch + XMLHttpRequest), navigation tracking (pushState/replaceState/popstate/hashchange), dialog capture with configurable auto-responses, waitFor polling, Playwright-style actionability checks (visible, enabled, non-zero size) for click/doubleClick/hover/fill/type, and **global error capture** (`window.onerror` + `unhandledrejection` → consoleLogs with `[uncaught]` prefix). Log caps: consoleLogs 1000, networkLog 1000, navigationLog 200, dialogLog 100. -- **IPC interception** — Tauri 2.0 freezes `__TAURI_INTERNALS__` and all its methods (`invoke`, `ipc`, `postMessage`) with `configurable:false, writable:false`. Plugin init scripts run AFTER Tauri's core init, so monkey-patching is impossible. Instead, IPC is derived from the network log: Tauri sends all IPC via `fetch()` to `http://ipc.localhost/`, and our fetch interceptor captures these. `getIpcLog()` filters networkLog entries for `ipc.localhost` URLs, extracts command names from the URL path, and excludes `plugin:victauri|` calls. This approach is robust against Tauri version changes and works on all platforms. +- **IPC interception** — Tauri 2.0 freezes `__TAURI_INTERNALS__` and all its methods (`invoke`, `ipc`, `postMessage`) with `configurable:false, writable:false`. Plugin init scripts run AFTER Tauri's core init, so monkey-patching is impossible. Instead, IPC is derived from the network log: Tauri sends IPC via `fetch()` to `http://ipc.localhost/` on Windows/WebView2 and to the custom `ipc://localhost/` scheme on macOS (WKWebView) and Linux (WebKitGTK); our fetch interceptor captures both (`IPC_PREFIXES` in `js_bridge.rs`). `getIpcLog()` filters networkLog entries for either IPC prefix, extracts command names from the URL path, and excludes `plugin:victauri|` calls. This approach is robust against Tauri version changes and works on all platforms. - **eval auto-return** — `eval_with_return()` auto-prepends `return` to bare expressions (e.g. `document.title` → `return document.title`). Decided by `should_prepend_return`: a string/template/comment-aware scan that only wraps a single bare expression — a top-level `;`, a top-level `return` token, a statement keyword (after stripping leading comments), or an ASI statement boundary (a depth-0 newline that is not a visible continuation, e.g. `foo()\nbar()`) all disable the prepend. - **Multi-window safety** — Default window selection prefers "main" → first visible → any, avoiding silent failures when hidden windows lack plugin capabilities. - **CSP compatibility** — `eval()` cannot be used inside injected scripts when CSP has `script-src 'self'` without `'unsafe-eval'`. The eval wrapper uses direct `(async () => { ... })()` pattern instead. ### Key technical decisions already made: -- MCP server is EMBEDDED in Tauri process (not separate), via axum on `:7373` with **port fallback** (tries :7374-7383 if taken, writes `victauri.port` to temp dir for client discovery) +- MCP server is EMBEDDED in Tauri process (not separate), via axum on `:7373` with **port fallback** (tries :7374-7383 if taken; the bound port is advertised in the per-process discovery dir `//port` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`)) - `rmcp` 3.1.2 is the MCP SDK (was 1.5.0 until 0.8.8), feature `transport-streamable-http-server` - JS bridge uses ref handles (Playwright pattern), not CSS selectors - All plugin code gated behind `#[cfg(debug_assertions)]` — `init()` returns no-op plugin in release builds @@ -1066,39 +1131,42 @@ Tested against 4 third-party open-source Tauri 2 apps with fully built frontends - WebviewBridge trait object pattern for runtime-erased AppHandle access - `tokio::sync::Mutex` for pending_evals (async lock needed across eval timeout awaits) - `build_app()` separated from `start_server()` — router construction is testable without Tauri runtime -- IPC data pipeline derives from network log — `get_ipc_log`, `detect_ghost_commands`, `check_ipc_integrity` all call `__VICTAURI__` methods via `eval_with_return()`. IPC entries are extracted from networkLog by filtering `http://ipc.localhost/` URLs (Tauri's fetch-based IPC transport) +- IPC data pipeline derives from network log — `get_ipc_log`, `detect_ghost_commands`, `check_ipc_integrity` all call `__VICTAURI__` methods via `eval_with_return()`. IPC entries are extracted from networkLog by filtering Tauri's IPC URLs — `http://ipc.localhost/` (WebView2) or `ipc://localhost/` (WKWebView/WebKitGTK) - Eval timeout is 30s (not 10s) to support `wait_for` tool's configurable polling timeout - **Auto-event recording** — background `event_drain_loop` polls `getEventStream()` every 1s while recording is active, converting JS events (console, mutation, IPC, network, navigation) into `AppEvent` variants and feeding them into `EventRecorder`. Time-travel now works automatically without manual tool calls. -- **Port fallback** — `try_bind()` tries preferred port, then +1 through +10. Writes `/victauri.port` file for client discovery, removes on shutdown. `VictauriState.port` is `AtomicU16` updated to actual bound port. +- **Port fallback** — `try_bind()` tries preferred port, then +1 through +10. The bound port (plus token + `metadata.json` identity) is written to the per-process discovery dir `//` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`), removed on shutdown. `VictauriState.port` is `AtomicU16` updated to actual bound port. - **Dual-protocol (MCP + REST)** — REST routes (`/api/tools`) are nested in the axum Router alongside `/mcp`, sharing the same auth/rate-limit/security middleware. `VictauriMcpHandler::execute_tool()` dispatches by tool name, deserializes JSON args into the appropriate param struct, and calls the rmcp `#[tool]` method directly. No MCP session or handshake needed for REST calls. -- **REST is the robust path for agent scripting (not a fallback)** — Verified against live 4DA (2026-05-31): MCP over `/mcp` is **session-stateful** — rmcp's `StreamableHttpService` rejects a tool call with HTTP **422 "expected initialized request"** whenever the session is stale (client reconnect, server restart, missed `notifications/initialized`). REST over `POST /api/tools/{name}` is **sessionless** — every call is self-contained, so it sidesteps that whole failure class while going through the identical auth/rate-limit/privacy/dispatch pipeline (zero capability loss). The only practical delta: REST returns `{"result": ...}`/`{"error": ...}` JSON instead of MCP envelopes, and resource *subscriptions* (push) remain MCP-only. **Guidance: for one-shot tool calls and any scripted/agent-driven loop, prefer REST; reserve MCP for interactive sessions that want live resource subscriptions.** The 422 is an rmcp transport-layer behaviour, not a Victauri bug — REST exists precisely so it never blocks real work. `eval_js` over REST was the workhorse in the 4DA animation investigation. -- **Auth enabled by default** — Auto-generates a UUID v4 Bearer token on startup, written to `/victauri//token`. `VictauriClient::discover()` reads it automatically. `auth_disabled()` to opt out for simple local-only setups. `auth_token("...")` or `VICTAURI_AUTH_TOKEN` env var for fixed tokens. DNS rebinding guard + origin guard + security headers applied to all responses. +- **REST is the robust path for agent scripting (not a fallback)** — *History (2026-05-31, live 4DA):* MCP over `/mcp` was then **session-stateful**, and rmcp's `StreamableHttpService` rejected a tool call with HTTP **422 "expected initialized request"** whenever the session was stale (client reconnect, server restart, missed `notifications/initialized`). *Now:* `/mcp` is **stateless by default** (since 0.7.10; on rmcp 3.x a `2026-07-28` client is always served statelessly, and legacy clients get the constant `Mcp-Session-Id: stateless` backfill from 0.8.4), so that 422 class is gone; `build_app_stateful` remains only for legacy clients that want sessions. REST over `POST /api/tools/{name}` is **sessionless** — every call is self-contained, so it sidesteps that whole failure class while going through the identical auth/rate-limit/privacy/dispatch pipeline (zero capability loss). The only practical delta: REST returns `{"result": ...}`/`{"error": ...}` JSON instead of MCP envelopes, and MCP resources are MCP-only (read on demand — no push). **Guidance: for one-shot tool calls and any scripted/agent-driven loop, prefer REST; use MCP (via the `victauri bridge` stdio proxy) for interactive agent sessions.** `eval_js` over REST was the workhorse in the 4DA animation investigation. +- **Auth enabled by default** — Auto-generates a UUID v4 Bearer token on startup, written to `//token` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`) (never logged). `VictauriClient::discover()` reads it automatically. `auth_disabled()` to opt out for simple local-only setups. `auth_token("...")` or `VICTAURI_AUTH_TOKEN` env var for fixed tokens. DNS rebinding guard + origin guard + security headers applied to all responses. - **CLAUDE.md scaffolding** — `victauri init` creates/appends CLAUDE.md with instructions that make AI agents prefer Victauri's 35 MCP tools over CDP/Playwright. This is the highest-leverage fix for agent tool selection — agents read CLAUDE.md before choosing tools. - **`register_command_names` builder API** — Lightweight alternative to `#[inspectable]` proc macros. Pass `&["cmd1", "cmd2"]` to register commands without schema generation. `commands()` method accepts full `CommandInfo` schemas for rich metadata. - **`__TAURI_INTERNALS__` not `__TAURI__`** — All eval callbacks and invoke_command use `window.__TAURI_INTERNALS__.invoke()`, NOT `window.__TAURI__.core.invoke()`. `__TAURI_INTERNALS__` is always available regardless of `withGlobalTauri` config. `window.__TAURI__` only exists when the app sets `withGlobalTauri: true`. Discovered via real-world testing against En Croissant (v0.2.1 fix). -- **Fault injection architecture** — The `fault` tool injects rules into `FaultRegistry` (thread-safe `RwLock`). The check lives in the MCP `invoke_command` tool (`mcp/mod.rs`, the `FaultType` match), running before that tool executes a command. Fault types: `Delay` (tokio::sleep before execution), `Error` (return error, skip execution), `Drop` (return `{}`), `Corrupt` (execute then mangle response). Trigger counting with optional `max_triggers` limit. **SCOPE (verified 2026-05-31, agent-eval):** faults apply ONLY to commands driven through Victauri's own `invoke_command` tool — they do NOT intercept the app's real frontend IPC (`window.__TAURI_INTERNALS__.invoke`). That path is served below the JS `window.fetch` layer Victauri can reach, so it cannot be blocked/delayed/faulted cross-platform without CDP (confirmed empirically: a `route` block/delay on `ipc.localhost` *matches* the call but does NOT control it — the real `invoke` still succeeds with no delay). So `fault` tests a handler's error path when the agent drives it; it does NOT reproduce a failure a user clicking the UI would experience. Honest framing: this is an *agent-driven backend-handler* fault tool, not live-IPC chaos — and Tauri IPC *control* is actually a place where a CDP-based tool can do more than Victauri. +- **Fault injection architecture** — The `fault` tool injects rules into `FaultRegistry` (thread-safe `RwLock`). The check lives in the MCP `invoke_command` tool (`mcp/mod.rs`, the `FaultType` match), running before that tool executes a command. Fault types: `Delay` (tokio::sleep before execution), `Error` (return error, skip execution), `Drop` (return `{}`, skip execution), `Corrupt` (execute, then return a fixed `{"__corrupted":true,"original_length":N,"fault":"corrupt"}` marker). Trigger counting with optional `max_triggers` limit; every rule auto-expires after `FAULT_TTL` (15 min). **SCOPE (verified 2026-05-31, agent-eval):** faults apply ONLY to commands driven through Victauri's own `invoke_command` tool — they do NOT intercept the app's real frontend IPC (`window.__TAURI_INTERNALS__.invoke`). That path is served below the JS `window.fetch` layer Victauri can reach, so it cannot be blocked/delayed/faulted cross-platform without CDP (confirmed empirically: a `route` block/delay on `ipc.localhost` *matches* the call but does NOT control it — the real `invoke` still succeeds with no delay). So `fault` tests a handler's error path when the agent drives it; it does NOT reproduce a failure a user clicking the UI would experience. Honest framing: this is an *agent-driven backend-handler* fault tool, not live-IPC chaos — and Tauri IPC *control* is actually a place where a CDP-based tool can do more than Victauri. - **Command profiling** — `invoke_command` records execution duration in `CommandTimings` per command. `introspect.command_timings` aggregates min/max/avg/p95 with optional slow-command threshold filtering. The timing includes the full round-trip: JS eval injection → `__TAURI_INTERNALS__.invoke()` → Tauri IPC → Rust handler → response serialization → JS callback. - **IPC contract testing** — `contract_record` invokes a command and records the JSON shape (recursive type structure) of the response. `contract_check` re-invokes all baselined commands and diffs against recorded shapes, detecting new fields, removed fields, and type changes. This catches silent IPC breaking changes that tests miss. - **EventBusMonitor** — `Arc>>` ring buffer (1000 capacity) for Tauri native events. Automatic capture of window lifecycle events (focus, blur, resize, close, move, etc.) via `RunEvent::WindowEvent` handler — no app opt-in needed. Custom app events captured via `VictauriBuilder::listen_events(&["event-name"])` which registers `listen_any` handlers. Combined with `EventLog` app events in the `introspect.event_bus` action for a unified event timeline. - **TaskTracker** — Tracks spawned async tasks (MCP server, event drain loop, on_ready probe) via `Arc` finished flags. `introspect.plugin_tasks` reports active/finished counts. Helps agents diagnose background task failures. - **Plugin state introspection** — `introspect.plugin_state` serializes the full `VictauriState` internals: event counts, registry size, recording state, active faults, contract baselines, timing data, task status, tool invocations, uptime, and port. Answers "what does the plugin know?" in one call. - **IPC replay** — `recording.replay` re-invokes the recorded IPC commands that succeeded with no arguments and reports each response type. Recordings do NOT capture arguments, so calls that had arguments (`arg_size_bytes > 0`), failed, or never completed are skipped and reported — replaying them without arguments is guaranteed wrong. Side effects of replayed commands happen again. No response-shape diff (use `introspect contract_*` for that). -- **Explain tool** — Natural-language narration via `explain` compound tool. `summary` aggregates EventLog events over a time window into a narrative with type counts (IPC, DOM, console, state, window, interaction) and top commands. `last_action` maps events to a causal chain. `diff` counts IPC calls, DOM changes, console messages, errors, and interactions. All use `EventLog.since()` for time-windowed queries with `chrono::TimeDelta`. Internal Victauri events filtered via `AppEvent::is_internal()`. -- **Bridge ready signal** — JS bridge calls `victauri_eval_callback` with ID `__victauri_bridge_ready__` at the end of its IIFE initialization. `VictauriState.bridge_ready` (`AtomicBool`) + `bridge_notify` (`tokio::sync::Notify`) track readiness. `eval_with_return_timeout` waits up to 5s for this signal before first eval, using double-check pattern to close the race window. Eliminates first-call latency from the 2s probe mechanism. Per-window probing still used for explicitly targeted windows. -- **Discovery session tokens** — `start_server()` writes the active auth token to `/victauri//token` (user-only permissions: Unix 0o600, Windows icacls current-user-only). `VictauriClient::discover()` reads the token file and includes it as Bearer header. Zero-config: auth is on, token is auto-discovered, no manual setup needed. +- **Explain tool** — Natural-language narration via `explain` compound tool. `summary` aggregates events over a time window into a narrative with type counts (IPC, DOM, console, state, window, interaction) and top commands. `last_action` maps events to a causal chain. `diff` counts IPC calls, DOM changes, console messages, errors, and interactions. Event source (`explain_events`): while recording, `EventLog.since()` (the drain keeps it current); otherwise the bridge's `getEventStream` is read ON DEMAND (not written into `EventLog`, so a later recording never double-counts). Network events are tallied as state changes and navigations as window events. Internal Victauri events filtered via `AppEvent::is_internal()`. +- **Bridge ready signal** — JS bridge calls `victauri_eval_callback` with ID `__victauri_bridge_ready__` at the end of its IIFE initialization. `VictauriState.bridge_ready` (`AtomicBool`) + `bridge_notify` (`tokio::sync::Notify`) track readiness. `eval_with_return_timeout` waits up to 5s for this signal before first eval, using double-check pattern to close the race window. Separately, a liveness probe runs before EVERY eval — the default window as well as labeled ones (since 0.8.0) — so a dead/reloading bridge fails fast (~2s) instead of hanging the full eval timeout. +- **Discovery session tokens** — `start_server()` writes the active auth token to `//token` under the per-user discovery root (`%TEMP%\victauri` on Windows; on Unix `$XDG_RUNTIME_DIR/victauri`, else `/victauri-`, else `~/.local/state/victauri`) (user-only permissions: Unix 0o600, Windows owner-only DACL; readers verify the root and PID dir are owned by the current user). `VictauriClient::discover()` reads the token file and includes it as Bearer header. Zero-config: auth is on, token is auto-discovered, no manual setup needed. - **`AppEvent::Console` variant** — `#[non_exhaustive]` `AppEvent` enum now has `Console { level, message, timestamp }` instead of mapping console logs to `StateChange { key: "console.warn", caused_by: message }`. Explain handlers count console events separately. `parse_bridge_event()` in `server.rs` creates `Console` variants. Backward-compatible addition (non-exhaustive enum). ### Relationship to 4DA: Victauri is a standalone open-source project. They share no code. The 4DA repo is at `D:\4DA`, this repo is at `D:\runyourempire\victauri`. -**4DA consumes Victauri as a PUBLISHED crates.io dependency**, NOT a path dep — `victauri-plugin = "0.7"` and `victauri-test = "0.7"` in `src-tauri/Cargo.toml`. This means **4DA only ever sees published features.** As of 2026-05-31 its `src-tauri/Cargo.lock` resolves `victauri-plugin` to **0.7.0**, and crates.io tops out at **0.7.1** — so the animation suite + `window introspectability` (this repo's HEAD `0.7.2`) are **not yet published and 4DA cannot run them**. That is exactly why the 4DA notification-animation work fell back to raw `eval_js` over REST and found the capability gap by hand instead of via the diagnostic. +**4DA consumes Victauri as a PUBLISHED crates.io dependency**, NOT a path dep — `victauri-plugin` and `victauri-test` in `src-tauri/Cargo.toml` (`"0.8"` until 0.9.0 is published; then bump both to `"0.9"` — 4DA needs no code change, verified by compiling it against the 0.9.0 candidate). So **4DA only ever sees published features.** Note that 4DA lists `victauri-plugin` under `[dependencies]` and registers it unconditionally: release-build inertness comes from the crate's own `#[cfg(debug_assertions)]` gate (`VictauriBuilder::build` returns a bare no-op plugin in release). + +To test an unreleased Victauri inside 4DA without touching 4DA's shared root tree (4DA is a busy multi-terminal repo): -To test unreleased HEAD tools against 4DA, temporarily point its dep at this repo and revert after: ```toml -# 4DA src-tauri/Cargo.toml — TEST ONLY, revert to victauri-plugin = "0.7" -victauri-plugin = { path = "../../runyourempire/victauri/crates/victauri-plugin" } +# in a 4DA worktree's src-tauri/Cargo.toml — TEST ONLY, never committed +victauri-plugin = { path = "/crates/victauri-plugin" } +victauri-test = { path = "/crates/victauri-test" } ``` -Then `cargo build` 4DA and relaunch. `victauri:default` is in 4DA's capabilities (`default.json`; `notification.json` + `briefing-window.json` added 2026-05-31 in 4DA commit `381f26f2`) and `.mcp.json` is configured. + +Build there, stop the running 4DA (it is single-instance: `invoke_command quit_app` over REST), launch the test build with `FOURDA_DATA_DIR=D:\4DA\data` and working directory `D:\4DA\src-tauri` (without `FOURDA_DATA_DIR` it silently starts a fresh first-run instance), then relaunch the operator's build afterwards. `victauri:default` is in 4DA's capabilities (`default.json`, `notification.json`, `briefing-window.json`). 4DA's own Victauri suite: `VICTAURI_E2E=1 CARGO_TARGET_DIR=target-test cargo test --test victauri_dogfood -- --test-threads=1`. ### Owner: 4DA Systems Pty Ltd (ACN 696 078 841). Apache-2.0 license. Contact: hello@4da.ai. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b275e0d..2026642e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ All checks (test, clippy, fmt) must pass. CI runs them on Linux, Windows, and ma - All value-returning public functions must have `#[must_use]` - Prefer `let...else` over match for single-pattern extraction with early return - Prefer `map_or` over `map().unwrap_or()` -- No `unwrap()` on mutexes or RwLocks — use `victauri_core::acquire_lock`, `acquire_read`, or `acquire_write` helpers for poisoning recovery with diagnostic logging +- No `unwrap()` on mutexes or RwLocks — recover from poisoning instead (`.lock().unwrap_or_else(std::sync::PoisonError::into_inner)`, or the workspace-internal `victauri_core::acquire_lock` / `acquire_read` / `acquire_write`, which also log). Since 0.9.0 those `acquire_*` helpers are `#[doc(hidden)]` plumbing shared by the Victauri crates, not public API: don't document or recommend them for use outside this workspace - No `unreachable!()` in match arms that could be reached by malformed input - `unsafe_code = "deny"` is enforced workspace-wide — use targeted `#[allow(unsafe_code)]` with `// SAFETY:` comments for FFI diff --git a/Cargo.lock b/Cargo.lock index 7c69880d..77eb2ad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,9 +827,10 @@ dependencies = [ [[package]] name = "demo-app" -version = "0.8.8" +version = "0.9.0" dependencies = [ "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "tauri", @@ -1307,7 +1308,7 @@ dependencies = [ [[package]] name = "gauntlet-app" -version = "0.8.8" +version = "0.9.0" dependencies = [ "reqwest 0.12.28", "rusqlite", @@ -1393,6 +1394,33 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1613,6 +1641,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1764,6 +1811,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -4384,6 +4432,7 @@ dependencies = [ "dlopen2", "dpi", "gdkwayland-sys", + "gdkx11-sys", "gtk", "jni", "libc", @@ -4404,6 +4453,7 @@ dependencies = [ "windows", "windows-core", "windows-version", + "x11-dl", ] [[package]] @@ -5280,12 +5330,13 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "victauri-cli" -version = "0.8.8" +version = "0.9.0" dependencies = [ "anyhow", "axum", "clap", "ctrlc", + "futures-util", "notify", "reqwest 0.12.28", "serde_json", @@ -5299,7 +5350,7 @@ dependencies = [ [[package]] name = "victauri-core" -version = "0.8.8" +version = "0.9.0" dependencies = [ "axum", "chrono", @@ -5316,8 +5367,9 @@ dependencies = [ [[package]] name = "victauri-macros" -version = "0.8.8" +version = "0.9.0" dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5327,7 +5379,7 @@ dependencies = [ [[package]] name = "victauri-plugin" -version = "0.8.8" +version = "0.9.0" dependencies = [ "anyhow", "axum", @@ -5335,6 +5387,8 @@ dependencies = [ "chrono", "flate2", "http-body-util", + "hyper", + "hyper-util", "proptest", "raw-window-handle", "regex", @@ -5361,7 +5415,7 @@ dependencies = [ [[package]] name = "victauri-test" -version = "0.8.8" +version = "0.9.0" dependencies = [ "axum", "base64 0.22.1", @@ -5375,11 +5429,12 @@ dependencies = [ "tokio", "tower", "uuid", + "windows-sys 0.59.0", ] [[package]] name = "victauri-watchdog" -version = "0.8.8" +version = "0.9.0" dependencies = [ "anyhow", "reqwest 0.12.28", @@ -5388,6 +5443,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "windows-sys 0.59.0", ] [[package]] @@ -6171,6 +6227,7 @@ dependencies = [ "dom_query", "dpi", "dunce", + "gdkx11", "gtk", "http", "javascriptcore-rs", @@ -6197,6 +6254,28 @@ dependencies = [ "windows", "windows-core", "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e33c0967..0d31bd4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.8.8" +version = "0.9.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/4DA-Systems/victauri" @@ -46,10 +46,10 @@ use_self = "deny" redundant_pub_crate = "deny" [workspace.dependencies] -victauri-core = { version = "0.8.8", path = "crates/victauri-core" } -victauri-macros = { version = "0.8.8", path = "crates/victauri-macros" } -victauri-plugin = { version = "0.8.8", path = "crates/victauri-plugin" } -victauri-test = { version = "0.8.8", path = "crates/victauri-test" } +victauri-core = { version = "0.9.0", path = "crates/victauri-core" } +victauri-macros = { version = "0.9.0", path = "crates/victauri-macros" } +victauri-plugin = { version = "0.9.0", path = "crates/victauri-plugin" } +victauri-test = { version = "0.9.0", path = "crates/victauri-test" } # Tauri tauri = { version = "2", default-features = false } @@ -67,6 +67,7 @@ serde_json = "1" syn = { version = "2", features = ["full", "extra-traits"] } quote = "1" proc-macro2 = "1" +heck = "0.5" # Utilities schemars = { version = "1", features = ["chrono04"] } @@ -77,4 +78,6 @@ tracing = "0.1" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } url = "2" -rusqlite = { version = "0.32", features = ["bundled", "hooks", "limits"] } +# A range, so an app's own rusqlite is reused (links = "sqlite3" allows one per build). `bundled` +# is NOT forced here: victauri-plugin turns it on through its `bundled-sqlite` default feature. +rusqlite = { version = ">=0.32, <0.41", features = ["hooks", "limits"] } diff --git a/MIGRATION.md b/MIGRATION.md index 47e8a49a..400d2693 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,252 @@ # Migration Guide +## v0.8.8 → v0.9.0 (public data types are `#[non_exhaustive]` — a one-time break) + +**Bump the requirement:** `victauri-plugin = "0.9"`, `victauri-test = "0.9"` (and `victauri-core` +if you depend on it directly). `^0.8` does not pick up 0.9. + +**Most apps need no code change.** Wiring the plugin (`VictauriBuilder`, `init()`, +`CommandInfo::new`, `register_commands!`, `#[inspectable]`), calling tools, and *reading* any +result type all work unchanged. You only need to change code that **constructs** one of these types +with a struct literal outside the crate that defines it — typically a mock `WebviewBridge` in +tests, or code that builds recordings/reports by hand: + +| Was | Now | +|---|---| +| `WindowState { label, title, visible, .. }` | `WindowState::new(label).with_title(..).with_visible(..)` | +| `IpcCall { id, command, .. }` | `IpcCall::new(id, command, timestamp, result, duration_ms, arg_size_bytes, webview_label)` | +| `RecordedSession { .. }` / `RecordedEvent { .. }` | `RecordedSession::new(..)` / `RecordedEvent::new(..)` | +| `SemanticAssertion { .. }` | `SemanticAssertion::new(..)` | +| `CommandInfo { .. }` / `CommandArg { .. }` | `CommandInfo::new(name).with_args(..).with_return_type(..)…`, `CommandArg::new(..)` | +| `AppEvent::Console { level, message, timestamp }` | `AppEvent::console(level, message, timestamp)` (same for `state_change`, `dom_mutation`, `dom_interaction`, `window_event`) | +| `PrivacyConfig { disabled_tools, ..Default::default() }` | `let mut p = PrivacyConfig::default(); p.disabled_tools = ..;` | +| `VictauriState { .. }` (tests) | `VictauriState::for_tests()` then set fields (hidden, test-only) | +| `CheckResult { .. }` / `VerifyReport { .. }` | `CheckResult::new(..)` / `VerifyReport::new(..)` | + +Exhaustive `match`es on the now-`#[non_exhaustive]` enums (`InteractionKind`, `CodegenStyle`, +`FaultType`, `JsonShape`, `PrivacyProfile`, `ThresholdPreset`) need a `_ =>` arm, and patterns on +`AppEvent`'s struct variants need `..`. + +**No longer public API.** Removed: `victauri_plugin::filmstrip` (`Frame`, `compose`, +`default_cols`). Everything else below is now `#[doc(hidden)]`: still reachable (Victauri's own +crates and tests use it) but not covered by semver, so it can change in any release. The full list, +from `cargo semver-checks check-release --release-type minor` against 0.8.8: + +- `victauri-core`: the `middleware` module (`require_auth`, `rate_limit`, `origin_guard`, + `dns_rebinding_guard`, `security_headers`, `default_rate_limiter`, `AuthState`), the `security` + module (`generate_token`, `constant_time_eq`, `is_localhost_host`, `is_allowed_origin`, + `RateLimiter`, `DEFAULT_RATE_LIMIT`), and `acquire_lock` / `acquire_read` / `acquire_write`. + These were plumbing for the plugin/CLI; an app that used them should copy what it needs + (poison recovery is `.lock().unwrap_or_else(std::sync::PoisonError::into_inner)`). +- `victauri-plugin`: `privacy::{strict_privacy_config, observe_privacy_config, + test_privacy_config}` (use `VictauriBuilder::privacy_profile(PrivacyProfile::Observe | Test)` or + `.strict_privacy_mode()`); `js_bridge::{init_script, BridgeCapacities}`; + `database::{discover_databases, classify_databases, select_app_database, is_webview_internal, + query, DbCandidate}`; `screencast::{Screencast, TraceFrame}` (`Screencast::start`/`stop` also + changed signature); `mcp::build_app_stateful`; and the recording-drain plumbing listed in + CHANGELOG round 4. + +**Every type that became `#[non_exhaustive]`** (so a struct literal or an exhaustive `match` +outside its crate no longer compiles): + +- `victauri-core` structs: `IpcCall`, `WindowState`, `DomSnapshot`, `DomElement`, `ElementBounds`, + `CommandInfo`, `CommandArg`, `ScoredCommand`, `RecordedSession`, `RecordedEvent`, + `StateCheckpoint`, `VerificationResult`, `Divergence`, `MemoryDelta`, `RefHandle`, + `GhostCommand`, `GhostCommandReport`, `IpcIntegrityReport`, `StaleCall`, `ErrorCall`, + `SemanticAssertion`, `AssertionResult`; enums `InteractionKind`, `CodegenStyle`; and the struct + variants of `AppEvent` (`StateChange`, `DomMutation`, `DomInteraction`, `WindowEvent`, + `Console`). +- `victauri-plugin` structs: `VictauriState`, `PrivacyConfig`, `CommandTimingStats`, + `FaultConfig`, `ContractBaseline`, `ContractDrift`, `TypeChange`, `StartupPhase`, + `CapturedTauriEvent`, `TrackedTaskInfo`, `ChildProcessInfo`; enums `FaultType`, `JsonShape`, + `PrivacyProfile`. +- `victauri-test` structs: `PluginInfo`, `MemoryStats`, `SmokeReport`, `SmokeCheckResult`, + `CheckResult`, `VerifyReport`, `CoverageReport`, `CommandCalls`, `VisualDiff`, `LocatorMatch`, + `Bounds`; enum `ThresholdPreset`. + +**Types you can read but not build.** Some of these output types have no public constructor +because nothing outside Victauri should create them: `DomSnapshot`, `DomElement`, +`VerificationResult`, `StateCheckpoint`, `SmokeReport`, `CoverageReport` and similar reports. If a +test needs one (e.g. a `StateCheckpoint` for `RecordedSession::new`), deserialize it from JSON with +`serde_json::from_value`. + +**`WebviewBridge` implementors (mock bridges):** three methods were added, all with default +implementations, so existing impls compile unchanged — `try_get_window_states`, +`try_list_window_labels` and `eval_webview_resolved`. Future additions will also have defaults. + +**Behavior changes to be aware of:** + +- `victauri bridge` does not replay a `tools/call` whose connection dropped after it was sent; it + returns an error saying the call most likely ran (e.g. a command that quit the app). +- `eval_js` / `invoke_command` report a closed target window, a shutting-down app, or a page + reload under the call promptly instead of timing out. `invoke_command` accepts an optional + `timeout_ms` (max 300000) for slow commands. +- `recording replay` skips calls that had arguments, failed, or never completed (arguments are not + recorded); `recording import` is refused while a recording is in progress; `trace stop` only + stops the recording its own `with_events` started. +- `introspect db_health` returns partial results with `row_counts_complete` / + `integrity_check: "not completed…"` instead of failing on large databases. +- `query_db` rejects table-valued `pragma_*()` functions for non-allowlisted pragmas and the + `PRAGMA name(value)` write form. +- A POST that carries an `Origin` header and gets past the origin check below (a localhost + `http`/`https` origin, no `Sec-Fetch-Site` other than `none`) must be + `Content-Type: application/json` (415 otherwise). Clients that send no `Origin` are unaffected. +- Several tools' MCP annotations changed (`recording`, `introspect`, `logs`, `window` are now + `destructive_hint`; `verify_state`, `wait_for`, `assert_semantic`, `inspect`, `animation` are no + longer `read_only_hint`). Clients that auto-approve read-only tools will now ask for these. +- The compact `dom_snapshot` JSON-encodes page text (quotes/newlines in names appear escaped). +- `victauri-watchdog` discovers the app's port and pins its identity; with `--app` and no match it + reports the app down instead of polling 7373. +- A compound tool call whose `action` is not a string (e.g. `{"action": {"go_to": null}}`), or a + REST body that is not a JSON object, is refused as invalid parameters (REST 400). +- `eval_js` reports a syntax error at once as a parse error, a reload under the call promptly, and + an unserializable result as "the code ran" — never a timeout or a JavaScript error. +- `recording replay` runs each call in the window that recorded it (never falls back to `main`) + and never replays a call a network route fulfilled or blocked (`IpcCall::mocked`). +- The page-visible `window.__VICTAURI__` no longer carries agent-only operations (`clearRoutes`, + `setDialogAutoResponse`, `clear*Log`, …), and `window.__VICTAURI_SCRUB__` / `__SWEEP__` are gone. + Nothing in an app should have called them; the tools that use them are unchanged. +- The compact `dom_snapshot` line format quotes non-trivial `role` and attribute values; recorded + selectors are CSS-escaped (`#\:r0\:`). +- `#[inspectable]` registers each argument's IPC `key` (camelCase by default); a command using + `#[tauri::command(rename_all = "snake_case")]` or `rename = "…"` (`rename` needs tauri-macros 2.6+, + i.e. Tauri 2.11+) should repeat the option on `#[inspectable(...)]` (or put `#[inspectable]` above + `#[tauri::command(...)]`). +- Trusted (OS-level) input — `interact`/`input` with `trusted: true`, Windows only — now requires the + app window to be in the foreground (Windows refuses to move focus away from the terminal an agent + runs in, so the call fails clearly instead of typing into another app). +- `victauri-test`: `Locator::check()`/`uncheck()` click the element (and verify it changed), and a + stale element reference is `ElementNotFound` instead of an empty/false value. +- The server speaks HTTP/1.1 only and answers 403 to a request with a `Sec-Fetch-Site` header + other than `none` (which every current browser sends on a web page's cross-site or same-site + fetch), an `Origin` that is not an `http`/`https` localhost origin (`localhost`, `127.0.0.1`, + `[::1]`), an `Origin` it cannot parse, or a `tauri://` origin. A localhost `Origin` alone is + allowed through, and then meets the JSON content-type rule above (415). The server no longer + sends `Access-Control-Allow-Origin`. HTTP clients that send no `Origin` or `Sec-Fetch-Site` + (curl, reqwest, the CLI, `victauri-test`) are unaffected. +- On Unix the discovery directory moved to `$XDG_RUNTIME_DIR/victauri/` or + `/victauri-/`, and — when `/victauri-` is unusable (e.g. another + local user created it first) — `$XDG_STATE_HOME/victauri/` (default + `~/.local/state/victauri/`). The 0.9 CLI, test client, watchdog and VS Code extension scan + all of these plus the old `/victauri/` root, so they find apps built with 0.8.x; a 0.8.x + client does not find a 0.9 app. +- On Windows every discovery reader (CLI/bridge, `victauri-test`, watchdog, VS Code) now trusts a + discovery folder only if the current user owns it (or `BUILTIN\Administrators` does and the user + is a member) — the same rule the plugin uses when writing it. +- `query_db` returns at most 256 columns (a wider `SELECT *` errors), suffixes duplicate column + names (`a`, `a:1`), refuses `LIKE`/`GLOB` patterns over 1000 bytes, and answers "busy" when two + database calls are already running. "database not found" also covers a path resolving outside + the allowed roots. +- **rusqlite**: `victauri-plugin` now accepts rusqlite `>=0.32, <0.41` instead of exactly 0.32, + so an app on a newer rusqlite can add it (cargo unifies onto the app's version). Because it is + the SAME rusqlite, the plugin's default `bundled-sqlite` feature also compiles SQLite into the + app's rusqlite. An app that links a system SQLite or SQLCipher keeps its own with + `victauri-plugin = { version = "0.9", default-features = false, features = ["sqlite"] }`. +- **Discovery (`victauri-test`, CLI):** when several Victauri apps run and nothing selects one, + `VictauriClient::discover()` and the CLI commands return an error naming each app instead of + silently connecting to :7373. Select with `VICTAURI_APP=` (or + `VictauriClient::discover_app(Some(..))`, or `--app` on any CLI command) or `VICTAURI_PORT`. + `victauri bridge --app` matches the identifier exactly (it used to fall back to a substring). +- **`VICTAURI_AUTH_TOKEN` without `VICTAURI_PORT`** is sent only to the running app whose own + discovery token matches it; otherwise the client refuses and asks for `VICTAURI_PORT` — set it + when the app's discovery directory is not visible to the client (the app runs in WSL or a + container, or elevated on Windows). +- `is_alive()` (and the bridge / watchdog health checks) treat a `429` from `/health` as alive. +- Trusted input is refused unless focus actually landed on the element (`type_text`, + `press_key` — a non-focusable child of a `contenteditable` editor is refused; target the editor + itself), or the click point is actionable and inside the window's client area (`click`). +- `interact click` (synthetic and trusted) now reaches elements inside OPEN shadow roots (web + components); it used to refuse every one of them as "covered by ". +- `victauri-test`: a tool call without `timeout_ms` now waits up to the server's 300 s ceiling + (+40 s) instead of 60 s — the server still answers as soon as the call finishes; this only + stops the client abandoning (and a retry re-running) a call the app is still executing. +- VS Code: an explicitly set `victauri.port` (even `7373`) now selects that port instead of being + ignored in favour of discovery; a `429` on the authenticated `/info` probe is reported (the + token was probably not accepted) instead of showing "connected". +- `route add` refuses `delay_ms` above 120000; `read_app_file` gives up after 15 s and refuses a + non-regular file; at most 4 app-state probes, 4 file reads and 4 page-originated window queries + run at once (the next one is told "busy"). +- `introspect command_timings` has an additive `saturated` flag; `ContractStore::record` returns the + command of a baseline it evicted (`Option`); faults, contract baselines and page-load + records are capped (256 / 1024 / 256). +- Victauri's page-callable Tauri commands `victauri_eval_callback`, `victauri_get_window_state`, + `victauri_list_windows`, `victauri_get_ipc_log`, `victauri_get_registry`, + `victauri_get_memory_stats`, `victauri_verify_state`, `victauri_detect_ghost_commands` and + `victauri_check_ipc_integrity` are now synchronous, so Tauri runs them on the main thread (the + page still gets a promise; nothing to change). This keeps Tauri handles on the main thread — + cloning or dropping one elsewhere corrupted the heap on Linux (see CHANGELOG). If your own app + clones an `AppHandle` / window / webview on background threads on Linux, the same Tauri race + applies to it. That includes moving an `AppHandle` into `std::thread::spawn` or + `tauri::async_runtime::spawn` to `emit` later, because the handle is then dropped on that + thread. Take one handle on the main thread in `setup` (`static APP: OnceLock`, + `APP.set(app.handle().clone())`) and have background work borrow it (`APP.get()`; `emit` only + needs `&self`). The demo and gauntlet apps do this, and the docs' + [compatibility page](docs/src/compatibility.md) has the snippet. +- **`victauri:default` no longer grants `victauri_eval_js` / `victauri_dom_snapshot`.** These two + page-callable Tauri commands are the only async Victauri commands left (Tauri runs an async + command's argument extraction on a tokio worker, the Linux handle race above), and + `victauri_eval_js` is a CSP-independent eval that any page script could call. Nothing in Victauri + uses them — the MCP/REST tools and the injected bridge only need `victauri_eval_callback`, which + stays in the default set — so almost every app needs no change. If your own frontend code calls + them, grant them explicitly next to `victauri:default` in that window's capability file: + + ```json + "permissions": [ + "victauri:default", + "victauri:allow-victauri-eval-js", + "victauri:allow-victauri-dom-snapshot" + ] + ``` +- **Refused page actions are tool errors.** `interact` (click/double_click/hover/focus/ + scroll_into_view/select_option), `input` (fill/type_text/press_key), `inspect` (e.g. an unknown + ref) and `route add` used to return a *successful* `{"ok": false, "error": "…", "hint": "…"}` when + the page refused the action. They now return an error: MCP `isError: true` with text + `"\n\n[hint: RETRY_LATER|CHECK_INPUT]"`, REST `{"error": ""}`. With + `victauri-test`, `client.click(..)` / `Locator::click(..)` and friends now return + `Err(TestError::ToolError(msg))` instead of `Ok(json)`; if you checked `result["ok"] == false`, + match on the error instead. Successful results are unchanged. +- `wait_for` `timeout_ms: 0` now means "check once, immediately" (it meant the 10 s default), and + a `wait_for` expression that does not parse fails at once instead of polling until its timeout. + `assert_semantic` / `verify_state` read an expression that evaluates to `undefined` as `null` + (`exists` false, `falsy` true) instead of failing; a trailing `;` or `// comment` is accepted. +- **`victauri-test` IPC checkpoints are `u64`** (they were `usize`, which truncated on 32-bit): + `create_ipc_checkpoint(&mut self) -> Result`, `get_ipc_calls_since(&mut self, + checkpoint: u64)` (and the deprecated `ipc_checkpoint` / `ipc_calls_since`). Code that passes the + value straight back needs no change; drop any `as usize` / `: usize`. +- `SmokeCheckResult` has a new `skipped` field (non-breaking: the type is `#[non_exhaustive]`): the + smoke suite no longer stops a recording it did not start — that check is reported as skipped. +- `css inject` (without `allow_remote`) rejects more remote-URL forms: `https:host/x`, `http:/host`, + `\\host`, `/\host`, `blob:` and any other scheme in a URL position, and any string naming a + remote host (e.g. `content:"https://…"`); only `data:` and scheme-less relative references pass. +- The animation `scrub` / `sample` helpers (`scrubPrepare`, `scrubSeek`, `scrubRestore`, + `installSweepRecorder`, `readSweep`) are no longer callable from page script on + `window.__VICTAURI__`; the `animation` tool is unchanged. +- Network-log URLs longer than 2048 characters are stored cut with a `…[+N chars]` marker (IPC + URLs never); route matching still sees the full URL. Route patterns now match the absolute URL, + the same-origin path+query+hash and the raw string, so a rule hits every spelling of a request. +- Page-callable commands: `victauri_get_ipc_log` without `limit` returns the newest 100 calls (pass + `limit`, max 1000, for more); `victauri_check_ipc_integrity` lists at most the newest 100 stale / + error calls (its counts still cover the whole log); a page-originated `victauri_eval_js` + returning `undefined` resolves to `"null"` (it timed out). +- `query_db` allows SQLite built-ins, SQLite's own FTS/R-Tree/Geopoly functions and app functions + registered as deterministic; any other app-registered function is refused with an error naming + it, and `load_extension` is never allowed. Register pure functions with `SQLITE_DETERMINISTIC`. +- `introspect db_health` has a new `integrity_check_note` field (`null` when the whole database was + checked); `integrity_check_kind` can be `"quick_check (per table)"` when the host app registered a + non-built-in virtual-table module (so none of its code runs). +- The server's request-head deadline — also its idle keep-alive timeout — is 10 s (was 30 s). The + 0.9 clients (CLI bridge, `victauri-test`, watchdog) drop pooled connections after 5 s; an HTTP + client of your own that keeps idle connections longer than 10 s should do the same. +- `victauri bridge --app` checks that the backend's `/info` reports that identity before forwarding + (any real plugin ≥ 0.7.4 does), and `VICTAURI_PORT` together with an app selector that names a + different app is refused (bridge, CLI and `victauri-test`) instead of driving the app on that port. +- `victauri doctor` exits 1 when any check fails (warnings still exit 0). +- New in `victauri-test`: `invoke_command_with_timeout`, `discover_app`, the `terminal` sanitizer + module and `health_status_means_alive`. +- The config structs `CodegenOptions`, `SmokeConfig`, `VisualOptions`, `MaskRegion` and the + `Junit*` report types stay exhaustive (struct-update syntax keeps working); a field added to one + of them later will be called out as a breaking change. + ## v0.8.7 → v0.8.8 (MCP stack upgraded to rmcp 3.1.2 / MCP `2026-07-28`) No consumer code changes are required and no dependency-requirement change is needed diff --git a/README.md b/README.md index 394c207b..44736e19 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,14 @@ That same server also speaks [MCP](https://modelcontextprotocol.io), so any AI a - **Ghost command detection** — find high-confidence orphaned frontend calls (`confirmed_ghosts`: invoked but only ever errored "not found"), and backend commands the frontend never calls - **Cross-boundary state checking** — compare DOM state against Rust backend state and catch the drift between them - **Time-travel recording** — record interactions, checkpoint state, replay sequences, generate test files -- **Cross-platform, no WebDriver** — identical behavior on macOS, Windows, and Linux; runs headless in CI under `xvfb` -- **Zero runtime cost in release** — the server is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op and nothing listens in release builds. (The crate still compiles in; add it as a `dev-dependency` if you want it absent from the release binary entirely.) +- **Cross-platform, no WebDriver** — the same tool surface on macOS, Windows, and Linux (no CDP dependency); runs headless in CI under `xvfb`. Platform limits are documented, not hidden: trusted (OS-level) input is Windows-only (other platforms fall back to synthetic events), and window screenshots need X11/XWayland on Linux (pure Wayland fails with a clear error) +- **Zero runtime cost in release** — the server is gated behind `#[cfg(debug_assertions)]`, so `init()` is a no-op and nothing listens in release builds. Add `victauri-plugin` as a normal `[dependencies]` entry (the app binary cannot see `[dev-dependencies]`); the crate still compiles in, so to keep it out of release binaries entirely make it an optional dependency behind a Cargo feature and gate the `.plugin(...)` call on that feature **Bonus — for AI agents:** the same server speaks MCP, so Claude Code, Cursor, Windsurf, and any MCP client get this full-stack access for interactive debugging — no extra setup. ## Will Victauri work on your app? -Victauri is a **build-time dev dependency you add to your own app's source and rebuild** — not a tool you attach to an already-running or shipped app. It works when **all four** hold: +Victauri is a **development-time dependency you add to your own app's source and rebuild** (a normal `[dependencies]` entry — not `[dev-dependencies]`, which the app binary cannot use) — not a tool you attach to an already-running or shipped app. It works when **all four** hold: 1. **Tauri 2.** Tauri 1.x apps can't host it — their `webkit2gtk-sys 0.18` and Victauri's `2.x` both link the native `web_kit2` library, an unresolvable cargo conflict. (Tauri 2 is the current major; Tauri 1 is legacy.) 2. **Built from source** with the plugin wired in — one line in `Cargo.toml`, `.plugin(victauri_plugin::init())`, and a `victauri:default` capability. There is no inject-into-a-foreign-binary path. @@ -266,7 +266,7 @@ See the [Testing Guide](docs/src/testing.md) for IPC checkpoints, visual regress | `list_app_dir` | Browse files in app data/config/log/local_data directories | | `read_app_file` | Read files from app backend directories (UTF-8 or base64) | | `query_db` | Read-only SQLite queries with auto-discovery | -| `invoke_command` | Call any Tauri command directly through IPC | +| `invoke_command` | Call any Tauri command through the app's real IPC path (dispatched via the webview's `invoke`, so it needs a live window) | | `app_state` | Read app-defined backend-state probes (pipeline/queue/cache internals) — no IPC round-trip | | `get_memory_stats` | Real-time OS process memory (working set, page faults) | @@ -295,16 +295,19 @@ See the [Testing Guide](docs/src/testing.md) for IPC checkpoints, visual regress | Tool | Actions | |---|---| -| **`interact`** | `click`, `double_click`, `hover`, `focus`, `scroll`, `select` | -| **`input`** | `fill`, `type_text`, `press_key` (keyboard combos supported) | -| **`window`** | `get_state`, `list`, `manage`, `resize`, `move`, `set_title` | -| **`storage`** | `get`, `set`, `delete`, `cookies` | -| **`navigate`** | `go_to`, `back`, `history`, `dialogs` | -| **`recording`** | `start`, `stop`, `checkpoint`, `events`, `export`, `import` | -| **`inspect`** | `styles`, `bounds`, `highlight`, `audit_accessibility`, `get_performance` | -| **`logs`** | `console`, `network`, `ipc`, `navigation`, `dialogs`, `events`, `slow_ipc` | +| **`interact`** | `click`, `double_click`, `hover`, `focus`, `scroll_into_view`, `select_option` | +| **`input`** | `fill`, `type_text`, `press_key` (keyboard combos supported; `trusted: true` = OS-level input, Windows only) | +| **`window`** | `get_state`, `list`, `manage`, `resize`, `move_to`, `set_title`, `introspectability` | +| **`storage`** | `get`, `set`, `delete`, `get_cookies` | +| **`navigate`** | `go_to`, `go_back`, `get_history`, `set_dialog_response`, `get_dialog_log` | +| **`recording`** | `start`, `stop`, `checkpoint`, `list_checkpoints`, `get_events`, `events_between`, `get_replay`, `export`, `import`, `replay`, `flush` | +| **`inspect`** | `get_styles`, `get_bounding_boxes`, `highlight`, `clear_highlights`, `audit_accessibility`, `get_performance` | | **`css`** | `inject`, `remove` | -| **`introspect`** | `command_timings`, `coverage`, `contract_record`, `contract_check`, `startup_timing`, `capabilities`, `db_health`, `plugin_state`, `processes`, `plugin_tasks`, `event_bus` | +| **`route`** | `add` (block / fulfill / delay fetch+XHR), `list`, `clear`, `clear_all`, `matches` | +| **`trace`** | `start`, `stop`, `status`, `frames` (background screencast ring buffer) | +| **`animation`** | `list`, `scrub`, `sample` (Web Animations API — no CDP) | +| **`logs`** | `console`, `network`, `ipc`, `navigation`, `dialogs`, `events`, `slow_ipc`, `clear` | +| **`introspect`** | `command_timings`, `coverage`, `command_catalog`, `contract_record`, `contract_check`, `contract_list`, `contract_clear`, `startup_timing`, `capabilities`, `db_health`, `plugin_state`, `processes`, `plugin_tasks`, `event_bus`, `event_bus_clear` | | **`fault`** | `inject` (delay/error/drop/corrupt), `list`, `clear`, `clear_all` | | **`explain`** | `summary`, `last_action`, `diff` | | `get_plugin_info` | Plugin config: port, tools, privacy, version | @@ -408,7 +411,7 @@ Use the built-in composite action to run Victauri smoke tests in CI: xvfb-run -a ./target/debug/my-app & sleep 3 -- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.8.8 +- uses: 4DA-Systems/victauri/.github/actions/victauri-test@v0.9.0 with: max-load-ms: 10000 max-heap-mb: 512 diff --git a/RELEASING.md b/RELEASING.md index 75dc73a8..ac52c92e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,7 +9,7 @@ fails loudly if any published version falls behind the repo. | Secret | Used by | Scope | |---|---|---| -| `CARGO_REGISTRY_TOKEN` | core release → crates.io | crates.io API token | +| `CARGO_REGISTRY_TOKEN` | core release → crates.io | crates.io API token. Store it as a secret of the **`release` environment** (Settings → Environments → release), not as a repository secret: only an environment secret is behind that environment's required reviewers — a repository secret is readable by any workflow run on any branch. (Or drop the token entirely for crates.io Trusted Publishing.) | | `VSCE_PAT` | VS Code release → Marketplace | Azure DevOps PAT, *Marketplace → Manage* (publisher `4da-systems`) | | `OVSX_TOKEN` *(optional)* | VS Code release → Open VSX | open-vsx.org token (Cursor / VSCodium / agent IDEs) | @@ -25,12 +25,26 @@ the release's own test step runs **only on ubuntu** and publish wasn't gated on Two layers now prevent that: 1. **Local preflight** — run `./scripts/preflight.ps1` (Windows) or `./scripts/preflight.sh` - before pushing. It runs fmt + clippy + the full workspace tests + the Chrome bridge tests, - so fast failures are caught locally in seconds instead of a 16-minute CI round trip. It + before pushing. It runs fmt + clippy + the doc-count lint + `npm ci` for the jsdom JS-bridge + tests + the full workspace tests (with `VICTAURI_REQUIRE_JSDOM=1`, so the bridge tests fail + rather than silently skip), so fast failures are caught locally in seconds instead of a + 16-minute CI round trip. It **cannot** catch macOS/Linux-only bugs (you're on one OS) or the real-app E2E — that's CI. 2. **`require-ci-green` (the hard gate)** — `release.yml` will **not** publish unless the - **full CI** (all platforms + real-app E2E) concluded **success** for the exact release - commit. A tag on a non-CI-green commit is refused, loudly, before the publish step. + tagged commit is **on `main`** and the **full CI push run on `main`** (all platforms + + real-app E2E) concluded **success** for that exact commit. (A pull-request run tested the + merge of the commit into `main`, not the commit itself, so it does not count.) A tag on a + non-CI-green commit is refused, loudly, before the publish step. +3. **Semver report (local)** — `cargo semver-checks check-release --workspace` before bumping. + CI runs the same check but only as information; read its output rather than its colour. + **For a 0.x major bump (e.g. 0.8.x → 0.9.0) that command checks nothing**: a major bump + already permits every lint, so it prints "0 checks … N skip" and passes. Also run + `cargo semver-checks check-release --workspace --release-type minor` (or per crate with + `-p`) and read the failures: they are the list of what actually changed, and every item in it + must be covered by MIGRATION.md. +4. **Publish runs verified-then-tokenless** — `release.yml` first runs + `cargo publish --workspace --dry-run` with no token present, then uploads each crate with + `--no-verify` in a step that alone holds the token, so no build script ever runs next to it. **Recommended flow:** `preflight` → push to `main` → **wait for CI green on all platforms** → bump + tag. (Pushing commit + tag together still works — the gate waits for CI to finish, up @@ -46,6 +60,12 @@ git tag v0.8.0 && git push origin main --tags `release.yml`: test gate → cross-platform binaries (cli/watchdog) → publish the crates to crates.io in dependency order → GitHub Release. +Then list the release in the **MCP Registry** (`server.json`, name +`io.github.4DA-Systems/victauri` — the namespace is the GitHub login and is matched +case-sensitively): `mcp-publisher login github && mcp-publisher publish`. The registry reads the +`mcp-name:` line from the README of the *published* `victauri-cli` version, so it must be right +before the crate is uploaded. + ## VS Code extension — `vscode-v*` (decoupled) ```bash diff --git a/SECURITY.md b/SECURITY.md index 564bcdd1..01fedf82 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,9 +4,9 @@ | Version | Supported | |---------|-----------| -| 0.8.x | Yes | -| 0.7.x | Yes | -| < 0.7 | No | +| 0.9.x | Yes | +| 0.8.x | Security fixes only | +| < 0.8 | No | ## Reporting a Vulnerability diff --git a/crates/victauri-cli/Cargo.toml b/crates/victauri-cli/Cargo.toml index b736b404..44280102 100644 --- a/crates/victauri-cli/Cargo.toml +++ b/crates/victauri-cli/Cargo.toml @@ -37,6 +37,8 @@ toml_edit = "0.22" [dev-dependencies] tempfile = "3" axum = { workspace = true } +# Streaming mock bodies (an SSE response that dies mid-stream) in the bridge e2e tests. +futures-util = { version = "0.3", default-features = false } [lints] workspace = true diff --git a/crates/victauri-cli/LICENSE b/crates/victauri-cli/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-cli/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-cli/README.md b/crates/victauri-cli/README.md index 05dcfcfd..23becd52 100644 --- a/crates/victauri-cli/README.md +++ b/crates/victauri-cli/README.md @@ -43,8 +43,16 @@ Connect to a running Tauri app and report health — IPC integrity, ghost comman ```bash victauri check victauri check --junit report.xml # JUnit XML output for CI +victauri check --app com.example.app # pick one of several running apps ``` +**Several Victauri apps running?** `check`, `test`, `invoke`, `coverage`, `doctor` and `record` +refuse to guess: they list each running app as `identifier (port N, pid P)` and ask you to +select one with `--app ` (exact identifier or product name — never a +prefix) or the `VICTAURI_APP` env var, or to pin the port with `VICTAURI_PORT`. A +`VICTAURI_AUTH_TOKEN` without `VICTAURI_PORT` is only sent to the running app whose own +discovery token matches it. + ### `victauri test` Run the built-in smoke test suite (11 checks) against a running app. Exits 0/1 for CI. @@ -94,7 +102,7 @@ victauri watch --filter greet # Only run matching tests Full API docs: [docs.rs/victauri-cli](https://docs.rs/victauri-cli) -MCP Registry name: `mcp-name: io.github.4da-systems/victauri` +MCP Registry name: `mcp-name: io.github.4DA-Systems/victauri` ## License diff --git a/crates/victauri-cli/src/bridge.rs b/crates/victauri-cli/src/bridge.rs index 270861bf..17a81913 100644 --- a/crates/victauri-cli/src/bridge.rs +++ b/crates/victauri-cli/src/bridge.rs @@ -64,6 +64,8 @@ const LOCAL_INIT_INSTRUCTIONS: &str = "Victauri MCP bridge. Tools act on a runni /// A discovered, live Victauri backend. #[derive(Clone, Debug)] struct ServerInfo { + /// Owning process, when known from discovery (`None` for a `VICTAURI_PORT` override). + pid: Option, port: u16, token: Option, identifier: Option, @@ -77,7 +79,106 @@ impl ServerInfo { .as_deref() .or(self.product_name.as_deref()) .unwrap_or(""); - format!("{name} (port {})", self.port) + match self.pid { + Some(pid) => format!("{name} (port {}, pid {pid})", self.port), + None => format!("{name} (port {})", self.port), + } + } + + /// `--app` matches the bundle identifier or the product name EXACTLY (ASCII + /// case-insensitive) — never a substring (R4-CLI2: `--app com.example` bound + /// `com.example.victauri-demo`, contradicting "never a silent wrong-app binding"). + fn matches_app(&self, app: &str) -> bool { + identity_matches( + self.identifier.as_deref(), + self.product_name.as_deref(), + app, + ) + } +} + +/// An app selector matches a bundle identifier or a product name EXACTLY, ASCII +/// case-insensitive — the one rule for discovery metadata and for `/info` alike. +fn identity_matches(identifier: Option<&str>, product_name: Option<&str>, app: &str) -> bool { + identifier.is_some_and(|i| i.eq_ignore_ascii_case(app)) + || product_name.is_some_and(|p| p.eq_ignore_ascii_case(app)) +} + +/// The effective app selector: `--app`, else `VICTAURI_APP`, each trimmed, and an empty or +/// whitespace-only value treated as NOT SET — exactly as victauri-test and the watchdog read +/// `VICTAURI_APP` (R5-BR2). A set-but-empty `VICTAURI_APP=` used to select an app named "", +/// which matches nothing, so every call reported "backend not reachable" with the app running. +fn resolve_app_selector(cli: Option, env: Option) -> Option { + let normalize = |s: Option| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + normalize(cli).or_else(|| normalize(env)) +} + +/// Most forwarded requests the bridge keeps in flight at once. Past this a request is refused +/// immediately with an error rather than queued without bound (the plugin caps concurrency +/// server-side too). Local methods (`ping`, `initialize`, …) are never subject to it. +const MAX_IN_FLIGHT: usize = 32; +/// Notifications and client responses waiting to be forwarded, in order. A flood beyond this +/// is dropped (with a note on stderr) instead of growing memory without bound. +const ONE_WAY_QUEUE: usize = 1024; +/// On stdin EOF, how long the bridge lets in-flight requests finish (and queued notifications +/// drain) before it exits. +const SHUTDOWN_GRACE: Duration = Duration::from_secs(10); + +/// State shared by the stdin loop, the per-request tasks, the one-way forwarder and the +/// availability poller. +struct Bridge { + http: reqwest::Client, + /// `--app` / `VICTAURI_APP`, normalized. + app: Option, + /// The backend MCP session (a stateful legacy backend only). + session_id: Mutex>, + /// The client's `initialize` message, cached so we can hand-shake the BACKEND (replay it) + /// when we first forward a real request and after a restart. The client is answered + /// locally, so it only ever sends `initialize` once. + cached_init: Mutex>, + /// Set once a backend handshake returns no `Mcp-Session-Id`: the server is stateless, so + /// there is no session to mint or lose and we must not re-`initialize` before every call. + stateless: AtomicBool, + /// Single-flight guard for the lazy backend handshake: concurrent requests that all find no + /// session wait here for ONE handshake instead of racing several (R5B-BR5). + handshake: tokio::sync::Mutex<()>, + /// Last-known backend availability — SOLELY owned by the poller (see H1 in the audit): the + /// request path never writes it, so the down→up edge is detected in exactly one place. + backend_up: AtomicBool, + /// Set once the client sends `notifications/initialized` (its OWN handshake completion), so + /// the poller never emits a server notification before the client has finished initializing + /// — not merely before we answered `initialize` (audit #3). + client_ready: AtomicBool, + /// stdout is shared by every writer; each write locks, emits complete line(s), and flushes, + /// so replies and notifications never interleave mid-line. + stdout: Mutex, + /// Bounds the forwarded requests in flight ([`MAX_IN_FLIGHT`]). + in_flight: Arc, +} + +impl Bridge { + fn new(http: reqwest::Client, app: Option) -> Self { + Self { + http, + app, + session_id: Mutex::new(None), + cached_init: Mutex::new(None), + stateless: AtomicBool::new(false), + handshake: tokio::sync::Mutex::new(()), + backend_up: AtomicBool::new(false), + client_ready: AtomicBool::new(false), + stdout: Mutex::new(std::io::stdout()), + in_flight: Arc::new(tokio::sync::Semaphore::new(MAX_IN_FLIGHT)), + } + } + + /// Forget the backend session — only if it is still `used`: a concurrent request may already + /// have minted a fresh one, which a stale reply to an OLD session must not wipe. + fn clear_session_if(&self, used: Option<&str>) { + let mut sid = locked(&self.session_id); + if sid.as_deref() == used { + *sid = None; + } } } @@ -89,6 +190,12 @@ impl ServerInfo { /// are running (matches the Tauri bundle identifier or product name; falls back to the /// `VICTAURI_APP` env var). /// +/// Requests are handled CONCURRENTLY (R5B-BR5): local methods (`initialize`, `ping`, the +/// client's `notifications/initialized`, batches, invalid messages) are answered inline; every +/// forwarded request runs as its own task, so a long `tools/call` (up to 330 s) no longer +/// delays a `ping`, a parallel tool call, or a `notifications/cancelled` for that very call. +/// Notifications and client responses are forwarded promptly, in order, by one dedicated task. +/// /// # Errors /// /// Returns an error only if the HTTP client or stdio pipes cannot be set up — never merely @@ -98,41 +205,23 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { // now a no-op: the handshake never blocks on discovery, so there is nothing to wait for. // Tool calls discover lazily and fail fast with an actionable message when the app is down. let _ = wait; - let app = app.or_else(|| std::env::var("VICTAURI_APP").ok()); - let http = build_client()?; - - // The live backend, discovered lazily. `None` until an app is found — the bridge starts - // and serves the handshake with no backend at all. - let connection: Arc>> = Arc::new(Mutex::new(None)); - let session_id: Arc>> = Arc::new(Mutex::new(None)); - // The client's `initialize` message, cached so we can hand-shake the BACKEND (replay it) - // when we first forward a real request and after a restart. The client is answered - // locally, so it only ever sends `initialize` once. - let cached_init: Arc>> = Arc::new(Mutex::new(None)); - // Set once a backend handshake returns no `Mcp-Session-Id`: the server is stateless, so - // there is no session to mint or lose and we must not re-`initialize` before every call. - let stateless: Arc> = Arc::new(Mutex::new(false)); - // Last-known backend availability — SOLELY owned by the poller (see H1 in the audit): the - // request path never writes it, so the down→up edge is detected in exactly one place. - let backend_up = Arc::new(AtomicBool::new(false)); - // Set once the client sends `notifications/initialized` (its OWN handshake completion), so - // the poller never emits a server notification before the client has finished initializing — - // not merely before we answered `initialize` (audit #3). Gating on the client's ack, not on - // our local reply, closes the window where `list_changed` could precede the init response. - let client_ready = Arc::new(AtomicBool::new(false)); - // stdout is shared with the background poller (which writes `list_changed`); every write - // locks, emits one line, and flushes, so responses and notifications never interleave. - let stdout = Arc::new(Mutex::new(std::io::stdout())); - - spawn_availability_poller( - app.clone(), - Arc::clone(&connection), - Arc::clone(&session_id), - Arc::clone(&stateless), - Arc::clone(&backend_up), - Arc::clone(&client_ready), - Arc::clone(&stdout), - ); + let app = resolve_app_selector(app, std::env::var("VICTAURI_APP").ok()); + let bridge = Arc::new(Bridge::new(build_client()?, app)); + + spawn_availability_poller(Arc::clone(&bridge)); + + // Notifications and client responses: forwarded in arrival order by ONE task, so a + // `notifications/cancelled` reaches the backend while the call it cancels is still running, + // and never waits behind it. + let (one_way_tx, mut one_way_rx) = tokio::sync::mpsc::channel::(ONE_WAY_QUEUE); + let forwarder = { + let bridge = Arc::clone(&bridge); + tokio::spawn(async move { + while let Some(msg) = one_way_rx.recv().await { + forward_one_way(&bridge, &msg).await; + } + }) + }; // Read stdin on a DEDICATED OS thread feeding an async channel, so the blocking read never // parks a tokio worker (which, on a single-vCPU host, would starve the timer and stop the @@ -150,122 +239,254 @@ pub async fn run(wait: bool, app: Option) -> Result<()> { }); while let Some(line) = line_rx.recv().await { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; + handle_line(&bridge, &one_way_tx, line.trim()); + } + + // stdin closed: let what is already running finish (bounded), then exit. + drop(one_way_tx); + let _ = tokio::time::timeout(SHUTDOWN_GRACE, async { + let _ = forwarder.await; + let _ = bridge.in_flight.acquire_many(MAX_IN_FLIGHT as u32).await; + }) + .await; + Ok(()) +} + +/// Handle one stdin line. Never awaits: everything that talks to a backend is handed to a task +/// (a request) or to the ordered one-way queue (a notification / client response). +fn handle_line(bridge: &Arc, one_way: &tokio::sync::mpsc::Sender, line: &str) { + if line.is_empty() { + return; + } + let msg: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + eprintln!("victauri-bridge: invalid JSON on stdin: {e}"); + return; } + }; - let msg: Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(e) => { - eprintln!("victauri-bridge: invalid JSON on stdin: {e}"); - continue; - } - }; + // A JSON-RPC batch is answered here and NEVER forwarded (R5-BR1): see `batch_rejection`. + if let Value::Array(items) = &msg { + match batch_rejection(items) { + Some(reply) => write_value(&bridge.stdout, &reply), + None => eprintln!( + "victauri-bridge: dropped a JSON-RPC batch of notifications/responses \ + (batching is not supported)" + ), + } + return; + } - let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); - let id = msg.get("id").cloned().unwrap_or(Value::Null); - let is_notification = msg.get("id").is_none(); + // No `method`: a client->server RESPONSE, or an Invalid Request (R5-BR3). + if msg.get("method").is_none() { + if is_client_response(&msg) { + // A reply to a server-initiated request (sampling/elicitation/roots), which a + // stateful backend may send inside an SSE stream this bridge relays verbatim — so + // it must reach the backend. JSON-RPC never answers a response. + enqueue_one_way(one_way, msg); + } else { + write_value( + &bridge.stdout, + &json!({ + "jsonrpc": "2.0", + "id": msg.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": INVALID_REQUEST, + "message": "invalid request: a JSON-RPC request needs a `method`" + } + }), + ); + } + return; + } - match method { - // ── Answered locally — never block on a backend ────────────────────── - "initialize" => { - // Cache for the lazy backend handshake, then reply immediately. - *locked(&cached_init) = Some(msg.clone()); - write_value(&stdout, &local_initialize_response(&msg)); - } - // The client's ack of OUR local initialize. It must never reach a backend (we - // handshake the backend separately) and needs no response — but it is the point at - // which the client is ready to receive server notifications, so mark it here (NOT on - // `initialize`) so the poller can't emit `list_changed` before the client is ready. - "notifications/initialized" => { - client_ready.store(true, Ordering::Release); - // If the backend already came up during the init-handshake window, the poller - // saw that down→up edge but skipped the emit (the client wasn't ready yet), and - // there is no fresh edge to emit on later. Emit once now so the client still - // refreshes to the live tool list — idempotent and cheap. (A compliant client's - // first `tools/list` after this ack already returns the live list directly; this - // just covers a client that relies solely on the notification.) - if backend_up.load(Ordering::Acquire) { - write_notification(&stdout, "notifications/tools/list_changed"); - write_notification(&stdout, "notifications/resources/list_changed"); - } - } - "ping" => { - write_value(&stdout, &json!({"jsonrpc": "2.0", "id": id, "result": {}})); - } - // ── List methods: live when up, graceful placeholder when down ─────── - "tools/list" => { - match forward_when_up( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await - { - Some(payloads) => write_payloads(&stdout, &payloads), - // App down (or a transient fetch miss): serve the full baked fallback so - // the agent still sees every tool. The live list arrives via - // `tools/list_changed` when the app comes up. - None => write_value(&stdout, &fallback_tools_response(&id)), - } + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = msg.get("id").cloned().unwrap_or(Value::Null); + match method { + // ── Answered locally, inline — never block on a backend ───────────── + "initialize" => { + // Cache for the lazy backend handshake, then reply immediately. + *locked(&bridge.cached_init) = Some(msg.clone()); + write_value(&bridge.stdout, &local_initialize_response(&msg)); + } + // The client's ack of OUR local initialize. It must never reach a backend (we + // handshake the backend separately) and needs no response — but it is the point at + // which the client is ready to receive server notifications, so mark it here (NOT on + // `initialize`) so the poller can't emit `list_changed` before the client is ready. + "notifications/initialized" => { + bridge.client_ready.store(true, Ordering::Release); + // If the backend already came up during the init-handshake window, the poller + // saw that down→up edge but skipped the emit (the client wasn't ready yet), and + // there is no fresh edge to emit on later. Emit once now so the client still + // refreshes to the live tool list — idempotent and cheap. + if bridge.backend_up.load(Ordering::Acquire) { + write_notification(&bridge.stdout, "notifications/tools/list_changed"); + write_notification(&bridge.stdout, "notifications/resources/list_changed"); } - "resources/list" | "resources/templates/list" | "prompts/list" => { - match forward_when_up( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await - { - Some(payloads) => write_payloads(&stdout, &payloads), - // Empty (but valid) result when down — avoids a startup error for a - // capability we advertise; the real list arrives via `list_changed`. - None => write_value(&stdout, &empty_list_response(method, &id)), - } + } + "ping" => { + write_value( + &bridge.stdout, + &json!({"jsonrpc": "2.0", "id": id, "result": {}}), + ); + } + // Any other notification (incl. `notifications/cancelled`): forwarded, in order, + // without waiting for in-flight requests. + _ if !expects_reply(&msg) => enqueue_one_way(one_way, msg), + // ── A forwarded request: its own task, bounded ────────────────────── + _ => match Arc::clone(&bridge.in_flight).try_acquire_owned() { + Ok(permit) => { + let bridge = Arc::clone(bridge); + tokio::spawn(async move { + let _permit = permit; + let payloads = handle_request(&bridge, &msg).await; + write_payloads(&bridge.stdout, &payloads); + }); } - // ── Everything else (tools/call, resources/read, …) needs a live app ─ - _ => { - match forward_with_retries( - &http, - &connection, - &session_id, - &stateless, - &cached_init, - app.as_deref(), - &msg, - ) - .await - { - ForwardResult::Payloads(payloads) => write_payloads(&stdout, &payloads), - ForwardResult::Accepted => {} - ForwardResult::Unreachable(err_msg) => { - // A notification to a down backend is simply dropped (no id to answer). - if !is_notification { - write_value( - &stdout, - &json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32000, "message": err_msg } - }), - ); - } + Err(_) => write_value( + &bridge.stdout, + &json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": format!( + "victauri bridge: {MAX_IN_FLIGHT} requests are already in flight; \ + retry when one completes" + ) } - } + }), + ), + }, + } +} + +/// Queue a notification / client response for the ordered one-way forwarder. +fn enqueue_one_way(one_way: &tokio::sync::mpsc::Sender, msg: Value) { + if let Err(e) = one_way.try_send(msg) { + let method = match &e { + tokio::sync::mpsc::error::TrySendError::Full(m) + | tokio::sync::mpsc::error::TrySendError::Closed(m) => m + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + }; + eprintln!("victauri-bridge: dropped {method}: {ONE_WAY_QUEUE} messages already queued"); + } +} + +/// Forward a notification or a client response. Nothing is ever written to the client: a +/// notification to a down backend is simply dropped, and JSON-RPC never answers a response. +async fn forward_one_way(bridge: &Bridge, msg: &Value) { + let outcome = forward_with_retries(bridge, msg).await; + if !is_client_response(msg) { + return; + } + match outcome { + ForwardResult::Accepted => {} + ForwardResult::Payloads(p) if p.is_empty() => {} + ForwardResult::Payloads(p) => eprintln!( + "victauri-bridge: backend answered a client response (not relayed): {}", + p.join(" ") + ), + ForwardResult::Unreachable(e) => { + eprintln!("victauri-bridge: could not deliver a client response: {e}"); + } + } +} + +/// The reply line(s) for one forwarded request (a request always gets at least one). +async fn handle_request(bridge: &Bridge, msg: &Value) -> Vec { + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = msg.get("id").cloned().unwrap_or(Value::Null); + match method { + // ── List methods: live when up, graceful placeholder when down ─────── + "tools/list" => match forward_when_up(bridge, msg).await { + Some(payloads) => payloads, + // App down (or a transient fetch miss): serve the full baked fallback so the agent + // still sees every tool. The live list arrives via `tools/list_changed` when the + // app comes up. + None => vec![fallback_tools_response(&id).to_string()], + }, + "resources/list" | "resources/templates/list" | "prompts/list" => { + match forward_when_up(bridge, msg).await { + Some(payloads) => payloads, + // Empty (but valid) result when down — avoids a startup error for a + // capability we advertise; the real list arrives via `list_changed`. + None => vec![empty_list_response(method, &id).to_string()], } } + // ── Everything else (tools/call, resources/read, …) needs a live app ─ + _ => match forward_with_retries(bridge, msg).await { + ForwardResult::Payloads(payloads) => payloads, + // `forward_with_retries` synthesizes an error for a request answered with a bare + // 202, so this is unreachable for a request; answer defensively anyway. + ForwardResult::Accepted => vec![error_for_request( + msg, + -32603, + "backend accepted the request with no response (HTTP 202)", + )], + ForwardResult::Unreachable(err_msg) => vec![error_for_request(msg, -32000, &err_msg)], + }, } +} - Ok(()) +/// Whether `msg` is a JSON-RPC request the client is waiting on a reply to: it has both a +/// `method` and an `id`. Notifications (no `id`) and responses (no `method`) never get one. +fn expects_reply(msg: &Value) -> bool { + msg.get("method").is_some() && msg.get("id").is_some() +} + +/// A client->server JSON-RPC response: no `method`, and a `result` or an `error`. +fn is_client_response(msg: &Value) -> bool { + msg.get("method").is_none() && (msg.get("result").is_some() || msg.get("error").is_some()) +} + +/// JSON-RPC "Invalid Request" — used for a batch, which MCP does not support. +const INVALID_REQUEST: i64 = -32600; +const BATCH_UNSUPPORTED: &str = "batch requests are not supported: MCP removed JSON-RPC \ + batching in protocol revision 2025-06-18; send each message as its own line"; + +/// The local reply to a JSON-RPC batch (a JSON array on stdin), or `None` when nothing may be +/// answered. +/// +/// MCP removed batching in 2025-06-18 and the embedded server rejects a batch without executing +/// it, so the bridge never forwards one: forwarding only produced an id-less error the client +/// could not match (or, after a post-send failure, re-sent the whole batch). Instead it answers +/// the way JSON-RPC 2.0 §6 prescribes for a batch: an ARRAY holding one `-32600` error per +/// request element, each carrying that element's `id`, so every id the client is waiting on is +/// resolved. Notifications and responses inside the batch get no entry (JSON-RPC never answers +/// either); an element that is not an object gets an error with `id: null`. An empty array is +/// itself an invalid request → a single error object with `id: null`. A batch of only +/// notifications/responses → `None` (§6: "the Server MUST NOT return an empty Array"). +fn batch_rejection(items: &[Value]) -> Option { + let error = |id: Value| { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": INVALID_REQUEST, "message": BATCH_UNSUPPORTED } + }) + }; + if items.is_empty() { + return Some(error(Value::Null)); + } + let replies: Vec = items + .iter() + .filter_map(|item| { + let Some(obj) = item.as_object() else { + return Some(error(Value::Null)); + }; + let is_notification = obj.contains_key("method") && !obj.contains_key("id"); + if is_notification || is_client_response(item) { + None + } else { + Some(error(obj.get("id").cloned().unwrap_or(Value::Null))) + } + }) + .collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) } /// The `initialize` reply the bridge synthesizes itself, so the MCP server is "connected" @@ -346,14 +567,14 @@ fn locked(m: &Mutex) -> std::sync::MutexGuard<'_, T> { } /// Write one JSON-RPC value as a line to the shared stdout. -fn write_value(stdout: &Arc>, v: &Value) { +fn write_value(stdout: &Mutex, v: &Value) { let mut o = locked(stdout); let _ = writeln!(o, "{v}"); let _ = o.flush(); } /// Relay already-serialized JSON-RPC payload lines (from a backend response) verbatim. -fn write_payloads(stdout: &Arc>, payloads: &[String]) { +fn write_payloads(stdout: &Mutex, payloads: &[String]) { let mut o = locked(stdout); for payload in payloads { let _ = writeln!(o, "{payload}"); @@ -362,7 +583,7 @@ fn write_payloads(stdout: &Arc>, payloads: &[String]) { } /// Emit a server→client JSON-RPC notification (no id). -fn write_notification(stdout: &Arc>, method: &str) { +fn write_notification(stdout: &Mutex, method: &str) { write_value(stdout, &json!({ "jsonrpc": "2.0", "method": method })); } @@ -400,15 +621,48 @@ fn may_replay(msg: &Value, err: &anyhow::Error) -> bool { .is_some_and(reqwest::Error::is_connect) } +/// Whole-request HTTP timeout for a forwarded protocol message (lists, reads, …). +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); +/// Whole-request HTTP timeout for a `tools/call` that sets no `timeout_ms`: the app's own +/// eval timeout (`VICTAURI_EVAL_TIMEOUT` / `eval_timeout`) can be up to 300 s, plus the +/// server's pre-wait headroom — a 120 s cut-off failed such calls at the bridge while the +/// app kept running them (R4-CLIT). +const TOOL_CALL_DEFAULT_TIMEOUT: Duration = Duration::from_secs(330); +/// Headroom on top of a tool call's own `timeout_ms`: before its wait starts the server may +/// wait for the bridge, probe it (up to 2s) and make main-thread round trips (up to 10s each). +const TOOL_TIMEOUT_HEADROOM: Duration = Duration::from_secs(40); +/// The server's ceiling for any per-call `timeout_ms` (`invoke_command`; `wait_for` caps lower). +const MAX_TOOL_TIMEOUT_MS: u64 = 300_000; + +/// HTTP timeout for forwarding `msg`. A tool call that blocks server-side for a caller-chosen +/// `timeout_ms` (`invoke_command` up to 300s, `wait_for` up to 120s) must outlast that wait; +/// with the fixed 120s timeout such calls failed at the bridge while the command kept running. +fn request_timeout_for(msg: &Value) -> Duration { + let is_tool_call = msg.get("method").and_then(Value::as_str) == Some("tools/call"); + let fallback = if is_tool_call { + TOOL_CALL_DEFAULT_TIMEOUT + } else { + DEFAULT_REQUEST_TIMEOUT + }; + msg.pointer("/params/arguments/timeout_ms") + .and_then(Value::as_u64) + .map_or(fallback, |ms| { + DEFAULT_REQUEST_TIMEOUT + .max(Duration::from_millis(ms.min(MAX_TOOL_TIMEOUT_MS)) + TOOL_TIMEOUT_HEADROOM) + }) +} + /// Error text for a tool call that was (probably) delivered but got no response. -fn undelivered_response_message(err: &anyhow::Error, app_still_up: bool) -> String { +fn undelivered_response_message(msg: &Value, err: &anyhow::Error, app_still_up: bool) -> String { let timed_out = err .downcast_ref::() .is_some_and(reqwest::Error::is_timeout); let what = if timed_out { - "the tool call was sent to the app but no response arrived before the bridge's \ - 120s timeout" - .to_string() + format!( + "the tool call was sent to the app but no response arrived before the bridge's \ + {}s timeout", + request_timeout_for(msg).as_secs() + ) } else if app_still_up { "the tool call was sent to the app but the connection closed before a response \ arrived (the app is still running — it may have restarted or reloaded while handling \ @@ -430,48 +684,34 @@ fn undelivered_response_message(err: &anyhow::Error, app_still_up: bool) -> Stri /// Background task that watches for the backend becoming reachable and, on a down→up /// transition, tells the client to refresh its tool/resource lists — so the baked fallback /// is replaced by the live, version-accurate set with no `/mcp` reconnect. -#[allow(clippy::too_many_arguments)] -fn spawn_availability_poller( - app: Option, - connection: Arc>>, - session_id: Arc>>, - stateless: Arc>, - backend_up: Arc, - client_ready: Arc, - stdout: Arc>, -) { +fn spawn_availability_poller(bridge: Arc) { tokio::spawn(async move { loop { // Poll fast while DOWN (a freshly-started app is noticed within ~1.5s) and back off // while UP (we only need to catch a later restart). - let interval = if backend_up.load(Ordering::Acquire) { + let interval = if bridge.backend_up.load(Ordering::Acquire) { POLL_INTERVAL_UP_MS } else { POLL_INTERVAL_MS }; tokio::time::sleep(Duration::from_millis(interval)).await; - let found = discover_one(app.as_deref()).await; - let up = found.is_some(); - if let Some(info) = found { - *locked(&connection) = Some(info); - } else { - // Backend gone — drop the cached connection/session so nothing can reuse a stale - // (port, token) pair (defense-in-depth for audit #1; the request path also - // re-resolves the trusted entry on every call). - *locked(&connection) = None; - *locked(&session_id) = None; - *locked(&stateless) = false; + let up = discover_one(bridge.app.as_deref()).await.is_some(); + if !up { + // Backend gone — drop the session so nothing can reuse it (defense-in-depth for + // audit #1; the request path also re-resolves the trusted entry on every call). + *locked(&bridge.session_id) = None; + bridge.stateless.store(false, Ordering::Release); } - // The poller is the SOLE owner of `backend_up`: the request path no longer writes it, - // so the down→up edge is detected here exactly once and can never be silently consumed - // by a tool call that happened to reconnect first. - let was = backend_up.swap(up, Ordering::AcqRel); + // The poller is the SOLE owner of `backend_up`: the request path never writes it, + // so the down→up edge is detected here exactly once and can never be silently + // consumed by a tool call that happened to reconnect first. + let was = bridge.backend_up.swap(up, Ordering::AcqRel); // Only announce a refresh once the CLIENT has finished initializing (sent // `notifications/initialized`) — a server notification before that is a lifecycle // violation a strict client may reject. - if up && !was && client_ready.load(Ordering::Acquire) { - write_notification(&stdout, "notifications/tools/list_changed"); - write_notification(&stdout, "notifications/resources/list_changed"); + if up && !was && bridge.client_ready.load(Ordering::Acquire) { + write_notification(&bridge.stdout, "notifications/tools/list_changed"); + write_notification(&bridge.stdout, "notifications/resources/list_changed"); } } }); @@ -491,45 +731,66 @@ enum ForwardResult { /// reachable; `None` when the app is down (the caller then serves a local placeholder). Delegates /// to `forward_with_retries`, which re-resolves the trusted backend itself — so a live `tools/list` /// pays exactly one discovery pass and a down one fails fast to the fallback. -#[allow(clippy::too_many_arguments)] -async fn forward_when_up( - http: &reqwest::Client, - connection: &Arc>>, - session_id: &Arc>>, - stateless: &Arc>, - cached_init: &Arc>>, - app: Option<&str>, - msg: &Value, -) -> Option> { - match forward_with_retries( - http, - connection, - session_id, - stateless, - cached_init, - app, - msg, - ) - .await - { +async fn forward_when_up(bridge: &Bridge, msg: &Value) -> Option> { + match forward_with_retries(bridge, msg).await { ForwardResult::Payloads(payloads) => Some(payloads), _ => None, } } +/// Establish the backend MCP session if we have none (first forward, or after a restart +/// invalidated it) — ONE handshake at a time: concurrent requests that all find no session +/// wait for the first one's instead of racing their own (R5B-BR5). This is what makes +/// restart-recovery work — replaying a tool call with no session would 422. +async fn ensure_backend_session(bridge: &Bridge, info: &ServerInfo) { + let needed = + || !bridge.stateless.load(Ordering::Acquire) && locked(&bridge.session_id).is_none(); + if !needed() { + return; + } + let _single_flight = bridge.handshake.lock().await; + if !needed() { + return; // another request completed the handshake while we waited + } + let init = locked(&bridge.cached_init).clone(); + let Some(init) = init else { + return; + }; + let token = info.token.as_deref(); + // We don't relay the backend handshake response to the client; it already believes it + // is initialized (we answered locally). + let Ok(out) = post_message(&bridge.http, info.port, token, None, &init).await else { + return; + }; + let backend_sid = out.session_id.clone(); + if let Some(sid) = out.session_id { + *locked(&bridge.session_id) = Some(sid); + } else if !out.stale_session { + // Handshake succeeded with no session id → stateless backend. + bridge.stateless.store(true, Ordering::Release); + } + // Complete the MCP lifecycle for a STATEFUL backend (harmless for the stateless default): + // the client's `notifications/initialized` was answered locally, so replay it to the + // backend now — a stateful rmcp session may gate tool calls on having received it. + let note = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}); + let _ = post_message( + &bridge.http, + info.port, + token, + backend_sid.as_deref(), + ¬e, + ) + .await; +} + /// Forward a message to the live backend, establishing/recovering the backend session and -/// retrying across a restart. Discovers a backend first if we don't have one; returns -/// `Unreachable` (never blocks indefinitely) when no app is running. -async fn forward_with_retries( - http: &reqwest::Client, - connection: &Arc>>, - session_id: &Arc>>, - stateless: &Arc>, - cached_init: &Arc>>, - app: Option<&str>, - msg: &Value, -) -> ForwardResult { - let is_notification = msg.get("id").is_none(); +/// retrying across a restart. Discovers a backend first; returns `Unreachable` (never blocks +/// indefinitely) when no app is running. +async fn forward_with_retries(bridge: &Bridge, msg: &Value) -> ForwardResult { + let app = bridge.app.as_deref(); + // Only a request (method + id) is owed a reply; never synthesize one for a notification or + // for a client response (R5-BR3). + let is_notification = !expects_reply(msg); // SECURITY (audit #1): re-resolve the trusted backend on EVERY forward — never reuse a cached // `(port, token)` without re-confirming, right now, that the port still belongs to a live, @@ -537,82 +798,36 @@ async fn forward_with_retries( // identity and yields the port and token together. Without this, after the app shut down (its // discovery entry gone) an attacker who bound the freed port would receive the cached Bearer // token and could relay forged tool results. This is ONE discovery pass (no 1s-sleep retries): - // a live app resolves fast; a down app fails fast to the actionable message / fallback. - match scan_once(app).await { - Selection::One(info) => { - *locked(connection) = Some(info); - } + // a live app resolves fast; a down app fails fast to the actionable message / fallback. The + // backend is held in a LOCAL for this forward, so concurrent forwards never see each other's. + let mut info = match scan_once(app).await { + Selection::One(info) => info, Selection::Ambiguous(labels) => { - *locked(connection) = None; - return ForwardResult::Unreachable(format!( - "Multiple Victauri apps are running:\n {}\nSelect one with \ - `--app ` or the VICTAURI_APP env var.", - labels.join("\n ") - )); - } - Selection::None => { - *locked(connection) = None; - // Fail-fast by design: a tool call issued in the sub-second window where a restarting - // app has removed its old discovery entry but not yet written the new one gets the - // actionable "unreachable" message rather than waiting. This is the price of the audit - // #1 rule (never reuse a cached connection); the retry loop's restart patience still - // applies once a live trusted backend is found, and the next call ~1s later succeeds. - return ForwardResult::Unreachable(unreachable_message()); + return ForwardResult::Unreachable(ambiguous_message(&labels)); } - } - - let mut last_err = None; + // Fail-fast by design: a tool call issued in the sub-second window where a restarting + // app has removed its old discovery entry but not yet written the new one gets the + // actionable "unreachable" message rather than waiting. This is the price of the audit + // #1 rule (never reuse a cached connection); the retry loop's restart patience still + // applies once a live trusted backend is found, and the next call ~1s later succeeds. + Selection::None => return ForwardResult::Unreachable(unreachable_message()), + Selection::Refused(why) => return ForwardResult::Unreachable(why), + }; for attempt in 0..MAX_RETRIES { - // Re-establish a fresh backend session BEFORE replaying the real request when we have - // none (first forward, or after a restart invalidated it). This is what makes - // restart-recovery work — replaying a tool call with no session would 422. - if !*locked(stateless) { - let need_reinit = locked(session_id).is_none(); - if need_reinit { - let init = locked(cached_init).clone(); - if let Some(init) = init - && let Some((port, token)) = conn_parts(connection) - { - // We don't relay the backend handshake response to the client; it already - // believes it is initialized (we answered locally). - if let Ok(out) = post_message(http, port, token.as_deref(), None, &init).await { - let backend_sid = out.session_id.clone(); - if let Some(sid) = out.session_id { - *locked(session_id) = Some(sid); - } else if !out.stale_session { - // Handshake succeeded with no session id → stateless backend. - *locked(stateless) = true; - } - // Complete the MCP lifecycle for a STATEFUL backend (harmless for the - // stateless default): the client's `notifications/initialized` was - // answered locally, so replay it to the backend now — a stateful rmcp - // session may gate tool calls on having received it. - let note = json!({"jsonrpc": "2.0", "method": "notifications/initialized"}); - let _ = post_message( - http, - port, - token.as_deref(), - backend_sid.as_deref(), - ¬e, - ) - .await; - } - } - } - } - - let Some((port, token)) = conn_parts(connection) else { - return ForwardResult::Unreachable(unreachable_message()); - }; - let sid = locked(session_id).clone(); - - match post_message(http, port, token.as_deref(), sid.as_deref(), msg).await { + ensure_backend_session(bridge, &info).await; + let sid = locked(&bridge.session_id).clone(); + + match post_message( + &bridge.http, + info.port, + info.token.as_deref(), + sid.as_deref(), + msg, + ) + .await + { Ok(out) => { - if let Some(new_sid) = out.session_id { - *locked(session_id) = Some(new_sid); - } - if out.stale_session { eprintln!( "victauri-bridge: stale session (HTTP {}), re-establishing (attempt {}/{})", @@ -620,16 +835,18 @@ async fn forward_with_retries( attempt + 1, MAX_RETRIES ); - *locked(session_id) = None; + bridge.clear_session_if(sid.as_deref()); if attempt + 1 < MAX_RETRIES { tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; - if let Ok(new_conn) = discover_and_select(false, app).await { - *locked(connection) = Some(new_conn); + if let Ok(new_info) = discover_and_select(false, app).await { + info = new_info; } } - last_err = Some(format!("Victauri returned {}", out.status)); continue; } + if let Some(new_sid) = out.session_id { + *locked(&bridge.session_id) = Some(new_sid); + } if out.accepted { if is_notification { @@ -663,7 +880,7 @@ async fn forward_with_retries( attempt + 1, MAX_RETRIES ); - *locked(session_id) = None; + bridge.clear_session_if(sid.as_deref()); // A tool call that may already have REACHED the app must not be replayed: it // can have side effects (invoke_command, input, interact…), and the commonest // way to get here is a call that itself quit or restarted the app (`quit_app`) @@ -674,7 +891,7 @@ async fn forward_with_retries( return ForwardResult::Payloads(vec![error_for_request( msg, -32000, - &undelivered_response_message(&e, still_up), + &undelivered_response_message(msg, &e, still_up), )]); } if attempt + 1 < MAX_RETRIES { @@ -684,41 +901,35 @@ async fn forward_with_retries( .await; // Re-discover; the app may have restarted on a new port, or gone away. match discover_and_select(false, app).await { - Ok(new_conn) => { - eprintln!("victauri-bridge: reconnected to {}", new_conn.label()); - *locked(connection) = Some(new_conn); - } - Err(_) => { - *locked(connection) = None; + Ok(new_info) => { + eprintln!("victauri-bridge: reconnected to {}", new_info.label()); + info = new_info; } + Err(_) => return ForwardResult::Unreachable(unreachable_message()), } } - last_err = Some(format!("Victauri server unreachable ({e})")); - continue; } } } - // Retries exhausted — the app is down or perpetually restarting. `last_err` is logged to + // Retries exhausted — the app is down or perpetually restarting. Each failure was logged to // stderr already; the client gets the actionable remedy. - let _ = last_err; ForwardResult::Unreachable(unreachable_message()) } fn build_client() -> Result { + // The default; `post_message` sets each request's own (see `request_timeout_for`). reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) + .timeout(DEFAULT_REQUEST_TIMEOUT) .connect_timeout(std::time::Duration::from_secs(10)) .build() .map_err(Into::into) } -fn conn_parts(connection: &Arc>>) -> Option<(u16, Option)> { - locked(connection) - .as_ref() - .map(|s| (s.port, s.token.clone())) -} - /// Outcome of forwarding one JSON-RPC message to the backend. struct PostOutcome { status: u16, @@ -739,6 +950,7 @@ async fn post_message( let url = format!("http://127.0.0.1:{port}/mcp"); let mut req = http .post(&url) + .timeout(request_timeout_for(msg)) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream"); if let Some(t) = token { @@ -769,7 +981,12 @@ async fn post_message( .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); - let body = resp.text().await.unwrap_or_default(); + // A body that fails mid-read (the app died or the connection dropped mid-SSE-stream, + // after the request was delivered — possibly after progress events) is a POST-SEND + // failure, so it propagates: the caller then never re-sends a tool call and tells the + // client it may already have run. Swallowing it (`unwrap_or_default`) reported a + // misleading "empty or non-JSON response" instead (R5-BR4). + let body = resp.text().await?; if !(200..300).contains(&status) { // Surface a JSON-RPC error for the original request id. @@ -819,24 +1036,15 @@ async fn post_message( async fn scan_once(app: Option<&str>) -> Selection { // Explicit env override wins (a developer pinning a specific port). if let Ok(p) = std::env::var("VICTAURI_PORT") - && let Ok(port) = p.parse::() + && let Ok(port) = p.trim().parse::() && health_ok(port).await { - return Selection::One(ServerInfo { - port, - // An EMPTY/whitespace `VICTAURI_AUTH_TOKEN` is "not configured", NOT "send an - // empty Bearer" — it must fall through to the discovered token for this exact - // port (see `normalize_env_token`). - token: normalize_env_token(std::env::var("VICTAURI_AUTH_TOKEN").ok()) - .or_else(|| discover_token_for_port(port)), - identifier: None, - product_name: None, - }); + return port_override(port, app).await; } - // Liveness-FIRST (batched), then health. `alive_pids` snapshots every live PID in ONE OS - // call (~60ms), so a machine with many stale discovery dirs (dead dev sessions) is filtered - // for ~free instead of one process spawn PER dir. Crucially we then health-check ONLY the + // Liveness-FIRST, then health. On Unix `alive_pids` snapshots our user's live PIDs in ONE + // `ps` call, so many stale discovery dirs cost one spawn, not one each; on Windows each PID + // is checked in-process (no spawn at all). Crucially we then health-check ONLY the // live-pid entries: a stale dir whose long-dead port was reused by some other service is // never probed (that mis-order made a down-state scan hang on an unresponsive reused port). // When the app is down there are zero live entries, so zero health probes — the poller @@ -846,9 +1054,18 @@ async fn scan_once(app: Option<&str>) -> Selection { // are reused. `/health` returns a static `ok` and carries no token, so it proves *something* // is bound but does not authenticate the listener as the real app. A stale `` dir whose // recorded PID has been recycled onto another of our live processes, combined with an attacker - // binding the freed port, could still pass liveness+health+identity and receive the token. + // binding the freed port, could still pass liveness+health and receive the token. // Re-resolving on every forward (audit #1) shrinks this to a per-call coincidence rather than - // a cache-lifetime one; fully closing it needs mutual auth on `/health` (a plugin-side change). + // a cache-lifetime one. With `--app`, the identity the SERVER reports on `/info` must also + // match (R5B-BR6) — which stops a different (e.g. auth-disabled) Victauri app that took the + // port from being driven, but not a hostile listener that lies on `/info`; fully closing that + // needs mutual auth (a plugin-side change). + let entries = discover_entries(); + // Nothing discovered (the app is down): nothing to check, so no process enumeration — + // the availability poller runs this every 1.5 s. + if entries.is_empty() { + return Selection::None; + } let alive = alive_pids(); let is_alive = |pid: u32| { alive @@ -856,12 +1073,146 @@ async fn scan_once(app: Option<&str>) -> Selection { .map_or_else(|| is_process_alive(pid), |set| set.contains(&pid)) }; let mut live = Vec::new(); - for (pid, s) in discover_entries() { + for (pid, s) in entries { if is_alive(pid) && health_ok(s.port).await { live.push(s); } } - select(&live, app) + match (select(&live, app), app) { + (Selection::One(info), Some(app)) => { + match confirm_identity(&info, app, verified_backend()).await { + Ok(()) => Selection::One(info), + Err(why) => Selection::Refused(format!( + "No running Victauri app matches --app '{}': the discovery entry {} {why}. The entry is stale (the app exited and its PID was reused) or another app now holds its port. Start the app, or run `victauri doctor`.", + victauri_test::terminal::single_line(app), + victauri_test::terminal::single_line(&info.label()), + )), + } + } + (selection, _) => selection, + } +} + +/// A backend whose `/info` identity was confirmed for an app selector, so a later forward to +/// the SAME resolved entry (pid, port, token) needs no extra request. +#[derive(Clone, Debug, PartialEq, Eq)] +struct VerifiedBackend { + pid: Option, + port: u16, + token: Option, + app: String, +} + +fn verified_backend() -> &'static Mutex> { + static VERIFIED: Mutex> = Mutex::new(None); + &VERIFIED +} + +/// The identity the server on `port` reports on `/info` — `(app_identifier, +/// app_product_name)` — or `None` when it does not answer a well-formed `/info` (the token is +/// sent: `/info` is authenticated). +async fn fetch_identity( + port: u16, + token: Option<&str>, +) -> Option<(Option, Option)> { + let mut req = health_client().get(format!("http://127.0.0.1:{port}/info")); + if let Some(t) = token { + req = req.header("Authorization", format!("Bearer {t}")); + } + let resp = req.send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let info: Value = resp.json().await.ok()?; + let field = |k: &str| { + info.get(k) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + Some((field("app_identifier"), field("app_product_name"))) +} + +/// Confirm that the server `info` resolved to really IS the app `app` selects, from what the +/// server itself reports on `/info` (R5B-BR6). Discovery metadata + PID liveness alone can +/// bind a crashed app's stale entry once its PID is reused, and with auth disabled nothing +/// else would notice that a DIFFERENT app now holds the port. A confirmation is cached for the +/// exact resolved entry (pid, port, token) — re-checked whenever the resolution changes; an +/// entry with no pid (a bare `VICTAURI_PORT`) is re-checked every time. Fails closed: a server +/// that does not report a matching identity is never used. +async fn confirm_identity( + info: &ServerInfo, + app: &str, + cache: &Mutex>, +) -> Result<(), String> { + let key = VerifiedBackend { + pid: info.pid, + port: info.port, + token: info.token.clone(), + app: app.to_ascii_lowercase(), + }; + if key.pid.is_some() && locked(cache).as_ref() == Some(&key) { + return Ok(()); + } + let reported = fetch_identity(info.port, info.token.as_deref()).await; + let outcome = match &reported { + Some((id, name)) if identity_matches(id.as_deref(), name.as_deref(), app) => Ok(()), + Some((id, name)) => Err(format!( + "points at a server that reports itself as '{}'", + victauri_test::terminal::single_line( + id.as_deref() + .or(name.as_deref()) + .unwrap_or("") + ) + )), + None => Err("points at a server that did not confirm its identity on /info".to_string()), + }; + *locked(cache) = match (&outcome, key.pid) { + (Ok(()), Some(_)) => Some(key), + _ => None, + }; + outcome +} + +/// The backend `VICTAURI_PORT` names. When an app selector (`--app` / `VICTAURI_APP`) is set +/// too, the two must agree (R5B-PORTAPP1) — the selector used to be silently ignored, so the +/// bridge drove whatever app held that port: first by the discovery metadata of the one live +/// entry on that port (if any), then by the identity the server itself reports on `/info`. +async fn port_override(port: u16, app: Option<&str>) -> Selection { + let live = discover_live_servers(); + let entry = unique_entry_on_port(&live, port); + let refuse = |why: &str| { + Selection::Refused(format!( + "VICTAURI_PORT={port} {why}, but the app selector (--app / VICTAURI_APP) is '{}'. Unset one of them, or point VICTAURI_PORT at that app's port.", + victauri_test::terminal::single_line(app.unwrap_or_default()) + )) + }; + if let (Some(app), Some(e)) = (app, entry) + && (e.identifier.is_some() || e.product_name.is_some()) + && !e.matches_app(app) + { + return refuse(&format!( + "is app {}", + victauri_test::terminal::single_line(&e.label()) + )); + } + let info = ServerInfo { + pid: entry.and_then(|e| e.pid), + port, + // An EMPTY/whitespace `VICTAURI_AUTH_TOKEN` is "not configured", NOT "send an empty + // Bearer" — it must fall through to the discovered token for this exact port (see + // `normalize_env_token`). + token: normalize_env_token(std::env::var("VICTAURI_AUTH_TOKEN").ok()) + .or_else(|| entry.and_then(|e| e.token.clone())), + identifier: entry.and_then(|e| e.identifier.clone()), + product_name: entry.and_then(|e| e.product_name.clone()), + }; + if let Some(app) = app + && let Err(why) = confirm_identity(&info, app, verified_backend()).await + { + return refuse(&why); + } + Selection::One(info) } /// A single non-blocking discovery attempt — `Some` iff exactly one matching live backend is @@ -898,84 +1249,154 @@ async fn discover_and_select(wait: bool, app: Option<&str>) -> Result { - bail!( - "Multiple Victauri apps are running:\n {}\n\ - Specify which one with `victauri bridge --app ` (or set \ - VICTAURI_APP). The identifier is your Tauri bundle identifier.", - labels.join("\n ") - ); + bail!("{}", ambiguous_message(&labels)); } + Selection::Refused(why) => bail!("{why}"), } } bail!("Could not connect to a matching Victauri server") } +/// Several live apps match: name each (`identifier (port N, pid P)`) and how to pick one. +fn ambiguous_message(labels: &[String]) -> String { + format!( + "Multiple Victauri apps are running:\n {}\nSelect one with `--app ` \ + or the VICTAURI_APP env var (an exact bundle identifier or product name). If two apps \ + share that identifier, stop one or pin the port with VICTAURI_PORT.", + labels.join("\n ") + ) +} + enum Selection { One(ServerInfo), None, Ambiguous(Vec), + /// A backend was resolved but must not be used: its identity does not match the app + /// selector (R5B-BR6 / R5B-PORTAPP1). The message says why. + Refused(String), } -/// Pick the server matching `app`, or the sole running server. +/// Pick the server matching `app` exactly, or the sole running server. Several matches — +/// no selector with several apps up, or two apps sharing an identifier — are ambiguous, +/// never "the first one". fn select(live: &[ServerInfo], app: Option<&str>) -> Selection { - if live.is_empty() { - return Selection::None; - } - if let Some(app) = app { - let needle = app.to_ascii_lowercase(); - // Prefer an exact identifier/product_name match, then a substring match. - let exact = live.iter().find(|s| { - s.identifier - .as_deref() - .map(str::to_ascii_lowercase) - .as_deref() - == Some(&needle) - || s.product_name - .as_deref() - .map(str::to_ascii_lowercase) - .as_deref() - == Some(&needle) - }); - if let Some(s) = exact { - return Selection::One(s.clone()); - } - let partial = live.iter().find(|s| { - s.identifier - .as_deref() - .is_some_and(|i| i.to_ascii_lowercase().contains(&needle)) - || s.product_name - .as_deref() - .is_some_and(|p| p.to_ascii_lowercase().contains(&needle)) - }); - return match partial { - Some(s) => Selection::One(s.clone()), - None => Selection::None, - }; - } - // No app specified: fine if exactly one is running; ambiguous otherwise. - if live.len() == 1 { - Selection::One(live[0].clone()) - } else { - Selection::Ambiguous(live.iter().map(ServerInfo::label).collect()) + let matching: Vec<&ServerInfo> = live + .iter() + .filter(|s| app.is_none_or(|app| s.matches_app(app))) + .collect(); + match matching.as_slice() { + [] => Selection::None, + [one] => Selection::One((*one).clone()), + many => Selection::Ambiguous(many.iter().map(|s| s.label()).collect()), } } -/// Read `/victauri//` discovery entries (port + token + identity) as +/// Read `//` discovery entries (port + token + identity) as /// `(pid, ServerInfo)`. Pure filesystem work — NO process-liveness or HTTP health check here, /// so it stays cheap even with many stale directories. Callers apply the health-then-liveness /// filter in [`scan_once`], which is what keeps a down-state scan fast: a dead/stale port /// refuses the connection instantly, so no process enumeration runs at all. fn discover_entries() -> Vec<(u32, ServerInfo)> { - let root = std::env::temp_dir().join("victauri"); let mut out = Vec::new(); + for root in discovery_roots() { + discover_entries_in(&root, &mut out); + } + out +} + +/// The discovery roots the plugin may have written to, most specific first (mirrors the +/// plugin's `discovery_root`). On Unix the roots are per-user — `$XDG_RUNTIME_DIR/victauri` +/// when that directory is private to us, then `/victauri-` (a shared +/// `/tmp/victauri` could be pre-created by another user, blocking discovery), then the home +/// fallback `$XDG_STATE_HOME/victauri` / `$HOME/.local/state/victauri` (where the plugin +/// registers when `/victauri-` was taken over) — and the legacy `/victauri` +/// is still read, subject to the same ownership check, for pre-0.9 plugins. Other platforms +/// use `/victauri` (a per-user temp dir; Windows readers verify its ownership). +#[cfg(not(test))] +fn discovery_roots() -> Vec { + real_discovery_roots() +} + +/// Unit tests never read the machine's REAL discovery directories (a developer's running +/// apps and their tokens live there): every discovery path resolves against one private, +/// process-wide root instead. +#[cfg(test)] +fn discovery_roots() -> Vec { + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + vec![ + ROOT.get_or_init(|| tempfile::tempdir().expect("isolated discovery root")) + .path() + .to_path_buf(), + ] +} + +#[cfg_attr(all(test, not(unix)), allow(dead_code))] +fn real_discovery_roots() -> Vec { + let legacy = std::env::temp_dir().join("victauri"); + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let mut roots = Vec::new(); + if let Some(euid) = current_euid() { + if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .filter(|dir| dir.is_absolute()) + .filter(|dir| { + std::fs::symlink_metadata(dir).is_ok_and(|m| { + m.file_type().is_dir() + && m.uid() == euid + && m.permissions().mode() & 0o077 == 0 + }) + }) + { + roots.push(runtime.join("victauri")); + } + roots.push(std::env::temp_dir().join(format!("victauri-{euid}"))); + // The plugin's fallback when `/victauri-` was taken over by another + // user (R5B-LINDISC1) — scanned after it, before the legacy root. + roots.extend(home_state_root( + std::env::var_os("XDG_STATE_HOME"), + std::env::var_os("HOME"), + )); + } + roots.push(legacy); + roots + } + #[cfg(not(unix))] + { + vec![legacy] + } +} + +/// The plugin's per-user fallback discovery root under the home directory: +/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` (each only when absolute). +/// The plugin registers there when `/victauri-` is untrusted — e.g. another local +/// user pre-created it, and sticky `/tmp` stops us deleting it (R5B-LINDISC1). +#[cfg(unix)] +fn home_state_root( + xdg_state_home: Option, + home: Option, +) -> Option { + let absolute = + |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute()); + xdg_state_home + .and_then(absolute) + .or_else(|| { + home.and_then(absolute) + .map(|h| h.join(".local").join("state")) + }) + .map(|state| state.join("victauri")) +} + +fn discover_entries_in(root: &std::path::Path, out: &mut Vec<(u32, ServerInfo)>) { // The root itself is security-sensitive: its owner can rename a trusted PID // directory after our child check and swap in attacker-controlled files. - if !dir_is_trusted(&root) { - return out; + if !dir_is_trusted(root) { + return; } - let Ok(entries) = std::fs::read_dir(&root) else { - return out; + let Ok(entries) = std::fs::read_dir(root) else { + return; }; for entry in entries.filter_map(Result::ok) { let pid_str = entry.file_name().to_string_lossy().to_string(); @@ -1017,9 +1438,15 @@ fn discover_entries() -> Vec<(u32, ServerInfo)> { .map(String::from), ) }); + // A pid already found under a more specific root counts once (a stale legacy entry + // can carry a reused live pid). + if out.iter().any(|(seen, _)| *seen == pid) { + continue; + } out.push(( pid, ServerInfo { + pid: Some(pid), port, token, identifier, @@ -1027,13 +1454,25 @@ fn discover_entries() -> Vec<(u32, ServerInfo)> { }, )); } - out } -/// The discovered backends as `ServerInfo` (dropping the pid) — for callers that only need -/// the server list, e.g. token lookup for a `VICTAURI_PORT` override. -fn discover_servers() -> Vec { - discover_entries().into_iter().map(|(_, s)| s).collect() +/// The discovered backends whose owning process is alive and ours — for token lookup for a +/// `VICTAURI_PORT` override (no health check: the caller already probed that port). +fn discover_live_servers() -> Vec { + let entries = discover_entries(); + if entries.is_empty() { + return Vec::new(); + } + let alive = alive_pids(); + entries + .into_iter() + .filter(|(pid, _)| { + alive + .as_ref() + .map_or_else(|| is_process_alive(*pid), |set| set.contains(pid)) + }) + .map(|(_, s)| s) + .collect() } /// Normalize a configured `VICTAURI_AUTH_TOKEN` value: an empty or whitespace-only @@ -1050,18 +1489,26 @@ fn normalize_env_token(raw: Option) -> Option { raw.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) } -/// Token belonging to the exact server selected by a `VICTAURI_PORT` override. -/// -/// Never send a token discovered for one app to an unrelated localhost port. -fn discover_token_for_port(port: u16) -> Option { - token_for_port(&discover_servers(), port) +/// Token belonging to the exact server selected by a `VICTAURI_PORT` override (see +/// [`unique_entry_on_port`]). +#[cfg(test)] +fn token_for_port(servers: &[ServerInfo], port: u16) -> Option { + unique_entry_on_port(servers, port)?.token.clone() } -fn token_for_port(servers: &[ServerInfo], port: u16) -> Option { - servers - .iter() - .find(|server| server.port == port) - .and_then(|server| server.token.clone()) +/// The ONE live discovery entry advertising `port` — `None` when there is none, or several. +/// +/// Never send a token discovered for one app to an unrelated localhost port: only the token +/// of the ONE live, trusted discovery entry advertising that port is used (R4-DISC2). A +/// stale entry of a dead app that once held the port — or two entries claiming it — yields +/// no token (and no identity), rather than the first match's. +fn unique_entry_on_port(servers: &[ServerInfo], port: u16) -> Option<&ServerInfo> { + let mut matching = servers.iter().filter(|server| server.port == port); + let server = matching.next()?; + if matching.next().is_some() { + return None; + } + Some(server) } /// Shared, warm HTTP client for `/health` probes. Built ONCE (rebuilding per call incurred @@ -1075,6 +1522,10 @@ fn health_client() -> &'static reqwest::Client { static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); CLIENT.get_or_init(|| { reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .connect_timeout(Duration::from_millis(1200)) .timeout(Duration::from_secs(3)) .build() @@ -1082,69 +1533,67 @@ fn health_client() -> &'static reqwest::Client { }) } +/// Whether a Victauri server answers on `port`. A `429` counts: `/health` is unauthenticated +/// and rate-limited from the public bucket, so any local process can flood it, and a +/// rate-limited reply still proves the server is up. Requiring a 2xx let such a flood make +/// every tool call fail with "backend not reachable — start the app" (R4-NET1, reproduced: +/// 88k × 429 → 3/3 bridge tool calls refused). The token is NOT sent on `/health`. async fn health_ok(port: u16) -> bool { let url = format!("http://127.0.0.1:{port}/health"); health_client() .get(&url) .send() .await - .is_ok_and(|r| r.status().is_success()) + .is_ok_and(|r| victauri_test::health_status_means_alive(r.status().as_u16())) } -/// Resolve a System32 executable from `%SystemRoot%` instead of `PATH`, so an attacker-writable -/// `PATH` entry cannot shadow it. The availability poller spawns `tasklist` repeatedly, so this -/// PATH-hijack surface is continuous — always invoke it by absolute path. -#[cfg(windows)] -fn system32_exe(name: &str) -> String { - let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); - format!("{root}\\System32\\{name}") -} - -/// Resolve a core utility to an absolute path (defeats `PATH`-hijack of the poller's repeated -/// `ps`/`kill` spawns), falling back to the bare name only if neither canonical location exists. -#[cfg(not(windows))] -fn abs_bin(name: &str) -> String { - for base in ["/bin", "/usr/bin"] { - let p = format!("{base}/{name}"); - if std::path::Path::new(&p).exists() { - return p; - } - } - name.to_string() +/// No batched process enumeration on this platform: callers fall back to per-pid checks. +#[cfg(not(any(unix, windows)))] +fn alive_pids() -> Option> { + None +} + +/// Where core utilities live: the FHS locations, then the NixOS and Guix system profiles +/// (which have no `/bin/ps`). Never `PATH`: a hijacked `PATH` would decide which PIDs look +/// alive — and so where the Bearer token is sent (R5B-BR7). +#[cfg(unix)] +const BIN_DIRS: &[&str] = &[ + "/bin", + "/usr/bin", + "/run/current-system/sw/bin", + "/run/current-system/profile/bin", +]; + +/// Resolve a core utility to an absolute path in [`BIN_DIRS`]; `None` when it is not +/// installed there (the caller then uses the hardened per-PID check, never a bare name). +#[cfg(unix)] +fn abs_bin(name: &str) -> Option { + BIN_DIRS + .iter() + .map(|dir| format!("{dir}/{name}")) + .find(|p| std::path::Path::new(p).is_file()) } -/// Snapshot the set of currently-live PIDs in ONE OS call, so discovery cost stays O(1) -/// process spawns regardless of how many (possibly stale) discovery directories exist. -/// Returns `None` if enumeration fails or is empty, in which case callers fall back to the -/// per-pid `is_process_alive` (never worse than the previous behavior). +/// Windows: no batched enumeration — each discovered PID is checked in-process by +/// [`is_process_alive`] (microseconds, no spawn). The `tasklist` snapshot this replaced cost +/// ~0.5 s per poll on an idle machine and spiked past 10 s under load, which stalled the +/// bridge's own replies; its per-PID fallback also substring-matched (PID 12 "matched" 123) +/// and counted other users' processes. #[cfg(windows)] fn alive_pids() -> Option> { - // One `tasklist` in CSV form (~60ms) lists every process; column 2 is the PID. - let out = std::process::Command::new(system32_exe("tasklist.exe")) - .args(["/FO", "CSV", "/NH"]) - .output() - .ok()?; - if !out.status.success() { - return None; - } - let text = String::from_utf8_lossy(&out.stdout); - let set: HashSet = text - .lines() - .filter_map(|line| { - // Rows look like: "name","pid","session","sessname","mem" - line.split("\",\"") - .nth(1) - .and_then(|f| f.trim_matches('"').trim().parse::().ok()) - }) - .collect(); - (!set.is_empty()).then_some(set) + None } -/// One `ps` lists every PID on both Linux and macOS (portable; `/proc` is Linux-only). -#[cfg(not(windows))] +/// One `ps` lists the PIDs on both Linux and macOS (portable; `/proc` is Linux-only) — only +/// OUR OWN user's processes. `ps -A` counted every user's, so on a shared machine a stale +/// discovery entry whose PID had been recycled by ANOTHER user's process looked alive, and a +/// port squatter could then receive the token and feed forged tool results to the agent. The +/// per-pid fallback (`kill -0`) was already own-user only. +#[cfg(unix)] fn alive_pids() -> Option> { - let out = std::process::Command::new(abs_bin("ps")) - .args(["-A", "-o", "pid="]) + let uid = current_euid()?.to_string(); + let out = std::process::Command::new(abs_bin("ps")?) + .args(["-U", uid.as_str(), "-o", "pid="]) .output() .ok()?; if !out.status.success() { @@ -1158,30 +1607,13 @@ fn alive_pids() -> Option> { (!set.is_empty()).then_some(set) } -#[cfg(windows)] -fn is_process_alive(pid: u32) -> bool { - use std::process::Command; - Command::new(system32_exe("tasklist.exe")) - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .is_ok_and(|o| { - let out = String::from_utf8_lossy(&o.stdout); - out.contains(&pid.to_string()) - }) -} - -#[cfg(not(windows))] +/// A live process owned by the current user — exact PID, own-user only (audit R2-7) — via +/// victauri-test's hardened check, the same one its discovery and (by copy) the watchdog use. +/// On Unix that is `kill -0` from a fixed set of locations incl. NixOS/Guix, then a shell's +/// builtin `kill`, then `/proc` — never a `PATH` lookup. The bridge's own old copy fell back to +/// a bare `kill` on `PATH`, and found no app at all where no `kill` binary exists (R5B-BR7). fn is_process_alive(pid: u32) -> bool { - // Portable POSIX liveness check. `/proc` is Linux-only — on macOS it does not exist, - // so the old `/proc/{pid}` test always returned false and the bridge filtered out every - // discovery entry (it could find NO server on macOS). `kill -0` sends no signal but - // succeeds iff the process exists and is signalable by us — and discovery entries are - // our own user's processes. Works identically on macOS and Linux. - std::process::Command::new(abs_bin("kill")) - .args(["-0", &pid.to_string()]) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + victauri_test::process::is_own_live_process(pid) } /// Trust a discovery directory only if it is a real directory (not a symlink), owned by the @@ -1206,15 +1638,12 @@ fn dir_is_trusted(path: &std::path::Path) -> bool { #[cfg(unix)] fn current_euid() -> Option { - use std::sync::atomic::{AtomicU64, Ordering}; - - static NEXT_PROBE: AtomicU64 = AtomicU64::new(0); for _ in 0..16 { - let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); + // Unpredictable name (R4-DISC2): a guessable `_` name in the shared temp + // dir let another user pre-create every probe path and deny us our own uid. let probe = std::env::temp_dir().join(format!( - ".victauri_bridge_uidprobe_{}_{}", - std::process::id(), - sequence + ".victauri_bridge_uidprobe_{}", + uuid::Uuid::new_v4().simple() )); if let Some(uid) = uid_from_exclusive_probe(&probe) { return Some(uid); @@ -1240,16 +1669,21 @@ fn uid_from_exclusive_probe(probe: &std::path::Path) -> Option { uid } -/// On Windows the default per-user temp dir (`%LOCALAPPDATA%\Temp`) is not world-writable, so the -/// shared-temp planting attack that `dir_is_trusted` defends against on Unix does not apply; trust -/// the directory. -/// -/// HONEST CAVEAT (documented residual): this is an ASSUMPTION, not a verified ACL check. If a -/// machine redirects `TEMP`/`TMP` to a shared location (e.g. `C:\Windows\Temp` or a multi-user -/// spool), a cross-user discovery-dir planting attack is unguarded on Windows. Closing it would -/// require a Windows ownership/ACL check here (non-trivial under `#![forbid(unsafe_code)]`); -/// deferred as a non-default-config, Windows-only residual. -#[cfg(not(unix))] +/// Windows: trust a discovery directory only if it is a real directory (a symlink or junction +/// is not `is_dir()` under `symlink_metadata`) OWNED by the current user — its owner SID is the +/// token user, the token's default owner, or `BUILTIN\Administrators` when this token is a +/// member, the plugin writer's own rule — via victauri-test's check (this crate forbids +/// `unsafe`). `%TEMP%` is normally per-user, but a shared one (`C:\msys64\tmp` for an app +/// launched from MSYS2, a redirected `C:\Windows\Temp`) let another user plant +/// `victauri\\` entries pointing at a port they control; the bridge then sent them +/// the agent's calls and relayed forged results. That residual is closed (R5B-WINDISC1). +#[cfg(windows)] +fn dir_is_trusted(path: &std::path::Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) + && victauri_test::process::dir_owned_by_current_user(path) +} + +#[cfg(not(any(unix, windows)))] fn dir_is_trusted(_path: &std::path::Path) -> bool { true } @@ -1258,6 +1692,39 @@ fn dir_is_trusted(_path: &std::path::Path) -> bool { mod tests { use super::*; + /// Audit N6: the per-user root is scanned first; the legacy shared root is last. + #[cfg(unix)] + #[test] + fn discovery_roots_are_per_user_first() { + let roots = real_discovery_roots(); + let euid = current_euid().unwrap(); + assert!(roots.contains(&std::env::temp_dir().join(format!("victauri-{euid}")))); + assert_eq!(roots.last(), Some(&std::env::temp_dir().join("victauri"))); + assert_ne!(roots[0], std::env::temp_dir().join("victauri")); + // R5B-LINDISC1: the home fallback is scanned after the per-user temp root, before + // the legacy one. + let home = home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) + .expect("HOME is set in the test environment"); + let at = |p: &std::path::Path| roots.iter().position(|r| r == p); + let tmp_root = std::env::temp_dir().join(format!("victauri-{euid}")); + assert!(at(&home) > at(&tmp_root), "{roots:?}"); + assert!(at(&home) < Some(roots.len() - 1), "{roots:?}"); + } + + #[cfg(unix)] + #[test] + fn home_state_root_prefers_an_absolute_xdg_state_home() { + assert_eq!( + home_state_root(Some("/x/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/x/state/victauri")) + ); + assert_eq!( + home_state_root(Some("rel".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/home/u/.local/state/victauri")) + ); + assert_eq!(home_state_root(None, Some("rel".into())), None); + } + // ── Cold-start handshake: the bridge must answer `initialize` itself so the MCP server // is connected even with no app running (the fix for the 30s handshake timeout). ── @@ -1389,17 +1856,64 @@ mod tests { #[test] fn undelivered_message_says_the_call_likely_ran_when_the_app_exited() { let err = anyhow::anyhow!("connection reset"); - let gone = undelivered_response_message(&err, false); + let gone = undelivered_response_message(&call("tools/call"), &err, false); assert!(gone.contains("exited before responding"), "{gone}"); assert!(gone.contains("NOT retried")); assert!( !gone.contains("not reachable"), "must not claim the app was never reached" ); - let up = undelivered_response_message(&err, true); + let up = undelivered_response_message(&call("tools/call"), &err, true); assert!(up.contains("still running"), "{up}"); } + #[test] + fn liveness_is_exact_and_own_user_only() { + assert!(is_process_alive(std::process::id())); + // The old Windows check substring-matched tasklist output, and counted every + // account's processes. PID 4 (System) is live but never ours. + #[cfg(windows)] + assert!(!is_process_alive(4)); + } + + #[test] + fn a_tool_call_timeout_outlasts_the_servers_own_wait() { + let tool = |args: Value| { + json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "invoke_command", "arguments": args}}) + }; + assert_eq!( + request_timeout_for(&call("tools/list")), + DEFAULT_REQUEST_TIMEOUT + ); + // R4-CLIT: with no `timeout_ms`, a tool call is bounded only by the app's eval + // timeout (up to 300 s) — the bridge must not cut it off at 120 s. + assert_eq!( + request_timeout_for(&tool(json!({}))), + TOOL_CALL_DEFAULT_TIMEOUT + ); + assert!(TOOL_CALL_DEFAULT_TIMEOUT >= Duration::from_secs(330)); + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": 5_000}))), + DEFAULT_REQUEST_TIMEOUT + ); + // invoke_command at its 300s ceiling, and wait_for at its 120s one, finish at the + // server before the bridge gives up on them. + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": 300_000}))), + Duration::from_secs(340) + ); + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": 120_000}))), + Duration::from_secs(160) + ); + // Values past the server's ceiling are clamped the way the server clamps them. + assert_eq!( + request_timeout_for(&tool(json!({"timeout_ms": u64::MAX}))), + Duration::from_secs(340) + ); + } + #[test] fn alive_pids_enumerates_and_includes_self() { // The batched liveness snapshot (one OS call) keeps discovery fast even with many @@ -1413,6 +1927,18 @@ mod tests { } } + /// R5B-BR7: a utility missing from the canonical locations (NixOS/Guix/minimal images) + /// must never be resolved through `PATH` — a bare name lets a hijacked `PATH` decide which + /// PIDs look alive, i.e. where the Bearer token goes. + #[cfg(unix)] + #[test] + fn helper_binaries_are_never_resolved_through_path() { + assert_eq!(abs_bin("victauri-no-such-helper"), None); + if let Some(sh) = abs_bin("sh") { + assert!(std::path::Path::new(&sh).is_absolute(), "{sh}"); + } + } + #[cfg(unix)] #[test] fn uid_probe_refuses_preplanted_symlink_without_clobbering_target() { @@ -1428,6 +1954,7 @@ mod tests { fn srv(id: &str, name: &str, port: u16) -> ServerInfo { ServerInfo { + pid: Some(u32::from(port)), port, token: None, identifier: Some(id.to_string()), @@ -1489,22 +2016,183 @@ mod tests { assert_eq!(token_for_port(&servers, 7999), None); } + /// R4-CLI2 — this test used to assert the opposite (`--app demo` bound + /// `com.victauri.demo` by substring). That was the bug: `--app com.example` silently bound + /// `com.example.victauri-demo`. An exact identifier or product name still matches. + #[test] + fn app_selector_is_exact_never_substring() { + let live = vec![srv("com.victauri.demo", "Demo App", 7373)]; + assert!(matches!(select(&live, Some("demo")), Selection::None)); + assert!(matches!( + select(&live, Some("com.victauri")), + Selection::None + )); + let live = vec![srv("com.example.victauri-demo", "Demo", 7373)]; + assert!(matches!( + select(&live, Some("com.example")), + Selection::None + )); + assert!(matches!( + select(&live, Some("com.example.victauri-demo")), + Selection::One(s) if s.port == 7373 + )); + assert!(matches!(select(&live, Some("demo")), Selection::One(_))); + } + #[test] - fn substring_identifier_match() { - let live = vec![srv("com.victauri.demo", "Demo", 7373)]; - match select(&live, Some("demo")) { - Selection::One(s) => assert_eq!(s.port, 7373), - _ => panic!("substring of product/identifier should match"), + fn duplicate_identifiers_are_ambiguous_with_pids_and_ports() { + let live = vec![ + srv("com.dup.app", "Dup", 7373), + srv("com.dup.app", "Dup", 7374), + ]; + match select(&live, Some("com.dup.app")) { + Selection::Ambiguous(labels) => { + assert_eq!(labels.len(), 2); + assert!(labels[0].contains("port 7373") && labels[0].contains("pid 7373")); + assert!(labels[1].contains("port 7374")); + let msg = ambiguous_message(&labels); + assert!( + msg.contains("--app") && msg.contains("VICTAURI_PORT"), + "{msg}" + ); + } + _ => panic!("two apps sharing an identifier must be ambiguous, not first-wins"), } } - // End-to-end against REAL discovery files: the plugin writes port/token/metadata.json - // under `/victauri//`; this proves the bridge parses those real files and can - // select the right app by identity — even amid the many stale dirs left by dead processes. + #[test] + fn port_override_token_needs_one_live_entry_on_that_port() { + // R4-DISC2: a VICTAURI_PORT override used the token of ANY discovery entry that once + // advertised that port (first match), e.g. a dead app's stale entry. + let mut stale = srv("com.dead.app", "Dead", 7374); + stale.token = Some("stale-token".to_string()); + let mut live = srv("com.live.app", "Live", 7374); + live.token = Some("live-token".to_string()); + assert_eq!(token_for_port(&[stale, live.clone()], 7374), None); + assert_eq!(token_for_port(&[live], 7374).as_deref(), Some("live-token")); + } + + /// A mock `/info` reporting `identifier`, counting requests. + async fn info_server(identifier: &'static str) -> (u16, Arc) { + let hits = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let counter = Arc::clone(&hits); + let app = axum::Router::new().route( + "/info", + axum::routing::get(move || { + counter.fetch_add(1, Ordering::SeqCst); + async move { + axum::Json(json!({"app_identifier": identifier, "app_product_name": "Name"})) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (port, hits) + } + + /// R5B-BR6: identity is confirmed from `/info`, cached per resolved (pid, port, token), and + /// re-checked when the resolution changes. + #[tokio::test] + async fn app_identity_is_confirmed_once_per_resolved_entry() { + let (port, hits) = info_server("com.real.app").await; + let cache = Mutex::new(None); + let mut info = srv("com.real.app", "Real", port); + info.pid = Some(4242); + + assert!( + confirm_identity(&info, "COM.REAL.APP", &cache) + .await + .is_ok() + ); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + assert_eq!(hits.load(Ordering::SeqCst), 1, "cached for the same entry"); + // The product name selects too, like discovery's `--app`. + assert!(confirm_identity(&info, "name", &cache).await.is_ok()); + + // A new token (the app restarted) is a different entry: re-checked. + info.token = Some("fresh".into()); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + let after_restart = hits.load(Ordering::SeqCst); + assert!(after_restart >= 2, "{after_restart}"); + + // Another app's selector: refused, naming what the server reported; never cached. + let err = confirm_identity(&info, "com.other.app", &cache) + .await + .unwrap_err(); + assert!(err.contains("com.real.app"), "{err}"); + assert_eq!(*cache.lock().unwrap(), None); + + // No pid (a bare VICTAURI_PORT): checked every time. + info.pid = None; + let before = hits.load(Ordering::SeqCst); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + assert!( + confirm_identity(&info, "com.real.app", &cache) + .await + .is_ok() + ); + assert_eq!(hits.load(Ordering::SeqCst), before + 2); + } + + #[tokio::test] + async fn a_server_without_a_usable_info_is_refused() { + // Nothing listens on :9 → no identity → fail closed. + let mut info = srv("com.real.app", "Real", 9); + info.pid = Some(1); + let cache = Mutex::new(None); + let err = confirm_identity(&info, "com.real.app", &cache) + .await + .unwrap_err(); + assert!(err.contains("did not confirm"), "{err}"); + } + + #[tokio::test] + async fn a_rate_limited_health_endpoint_is_alive() { + // R4-NET1: an unauthenticated /health flood (→ 429) made every bridge tool call fail + // with "backend not reachable — start the app". + let app = axum::Router::new().route( + "/health", + axum::routing::get(|| async { axum::http::StatusCode::TOO_MANY_REQUESTS }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + assert!( + health_ok(port).await, + "a 429 from /health proves the server is alive" + ); + + let app = axum::Router::new().route( + "/health", + axum::routing::get(|| async { axum::http::StatusCode::SERVICE_UNAVAILABLE }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + assert!(!health_ok(port).await); + } + + // End-to-end against real-format discovery files: the plugin writes + // port/token/metadata.json under `//`; this proves the bridge parses those + // files and can select the right app by identity. (In unit tests `discovery_roots()` is a + // private temp root — never the machine's real one.) #[test] fn discover_servers_reads_real_metadata_and_selects() { let pid = std::process::id(); // alive → passes is_process_alive - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let dir = discovery_roots()[0].join(pid.to_string()); std::fs::create_dir_all(&dir).unwrap(); // Make ownership/permissions deterministic so `dir_is_trusted` passes regardless of // the runner's umask (a umask of 002 would otherwise leave the dir group-writable @@ -1522,7 +2210,7 @@ mod tests { ) .unwrap(); - let servers = discover_servers(); + let servers = discover_live_servers(); let mine = servers .iter() .find(|s| s.identifier.as_deref() == Some("com.test.discover")) @@ -1570,6 +2258,102 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// R5B-WINDISC1: a `` directory (or a whole root) owned by another account — SYSTEM + /// stands in, which needs an elevated run to set up — is never read. + #[cfg(windows)] + #[test] + fn windows_discovery_ignores_directories_owned_by_another_account() { + let give_to_system = |p: &std::path::Path| { + std::process::Command::new("icacls") + .arg(p) + .args(["/setowner", "*S-1-5-18", "/q"]) + .output() + .is_ok_and(|o| o.status.success()) + }; + let root = tempfile::tempdir().unwrap(); + for pid in ["21", "22"] { + let dir = root.path().join(pid); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7373").unwrap(); + std::fs::write(dir.join("token"), format!("tok-{pid}")).unwrap(); + } + if !give_to_system(&root.path().join("22")) { + eprintln!("skipped: cannot change a directory's owner (not elevated)"); + return; + } + let mut out = Vec::new(); + discover_entries_in(root.path(), &mut out); + let pids: Vec = out.iter().map(|(pid, _)| *pid).collect(); + assert_eq!(pids, [21], "a planted entry's token must never be read"); + + assert!(give_to_system(root.path())); + let mut out = Vec::new(); + discover_entries_in(root.path(), &mut out); + assert!(out.is_empty(), "nothing under a foreign root is trusted"); + } + + /// R5-BR3: only a request (method + id) is owed a reply. + #[test] + fn only_requests_expect_a_reply() { + assert!(expects_reply(&json!({"id": 1, "method": "tools/call"}))); + assert!(!expects_reply(&json!({"method": "notifications/x"}))); + assert!(!expects_reply(&json!({"id": 1, "result": {}}))); + assert!(is_client_response(&json!({"id": 1, "result": {}}))); + assert!(is_client_response(&json!({"id": 1, "error": {"code": 1}}))); + assert!(!is_client_response(&json!({"id": 1}))); + assert!(!is_client_response( + &json!({"id": 1, "method": "m", "result": {}}) + )); + } + + /// R5-BR1: a batch is rejected locally, one error per request element, per JSON-RPC §6. + #[test] + fn batch_rejection_answers_each_request_and_nothing_else() { + let reply = batch_rejection(&[ + json!({"jsonrpc":"2.0","id":1,"method":"tools/call"}), + json!({"jsonrpc":"2.0","method":"notifications/cancelled"}), + json!({"jsonrpc":"2.0","id":"srv-1","result":{}}), + json!({"jsonrpc":"2.0","id":2}), + json!("junk"), + ]) + .unwrap(); + let ids: Vec = reply + .as_array() + .unwrap() + .iter() + .map(|e| e["id"].clone()) + .collect(); + assert_eq!(ids, [json!(1), json!(2), Value::Null]); + assert!( + reply + .as_array() + .unwrap() + .iter() + .all(|e| e["error"]["code"] == INVALID_REQUEST) + ); + let empty = batch_rejection(&[]).unwrap(); + assert!(empty.is_object() && empty["id"].is_null()); + assert_eq!( + batch_rejection(&[json!({"jsonrpc":"2.0","method":"notifications/x"})]), + None + ); + } + + /// R5-BR2: blank selectors are "unset", matching victauri-test and the watchdog. + #[test] + fn app_selector_treats_blank_as_unset() { + let s = |v: &str| Some(v.to_string()); + assert_eq!(resolve_app_selector(None, None), None); + assert_eq!(resolve_app_selector(None, s("")), None); + assert_eq!(resolve_app_selector(None, s(" \t")), None); + assert_eq!(resolve_app_selector(s(""), s("com.env")), s("com.env")); + assert_eq!(resolve_app_selector(None, s(" com.env ")), s("com.env")); + assert_eq!( + resolve_app_selector(s("com.cli"), s("com.env")), + s("com.cli") + ); + } + // Round-4 audit, blocker #3 (CLI empty-token fallback): a blank `VICTAURI_AUTH_TOKEN` // must be treated as "unset" so the bridge falls through to the discovered token, NOT // sent as an empty Bearer to an auth-enabled server (which would 401 every call). diff --git a/crates/victauri-cli/src/main.rs b/crates/victauri-cli/src/main.rs index 9d3c7e3f..e868bec9 100644 --- a/crates/victauri-cli/src/main.rs +++ b/crates/victauri-cli/src/main.rs @@ -33,6 +33,8 @@ enum Commands { /// Write `JUnit` XML report to this path #[arg(long)] junit: Option, + #[command(flatten)] + target: AppTarget, }, /// Run the built-in smoke test suite against a running Tauri app Test { @@ -45,6 +47,8 @@ enum Commands { /// Write `JUnit` XML report to this path #[arg(long)] junit: Option, + #[command(flatten)] + target: AppTarget, }, /// Record user interactions and generate a test file Record { @@ -60,9 +64,14 @@ enum Commands { /// Emit `assert_ipc_called` assertions for each IPC command #[arg(long)] assert_ipc: bool, + #[command(flatten)] + target: AppTarget, }, /// Diagnose your Victauri setup — check every step from plugin wiring to tool operation - Doctor, + Doctor { + #[command(flatten)] + target: AppTarget, + }, /// Watch test files and re-run on changes Watch { /// Directory to watch (default: tests/) @@ -82,6 +91,8 @@ enum Commands { /// Print raw JSON output (no formatting) #[arg(long)] raw: bool, + #[command(flatten)] + target: AppTarget, }, /// Run as a stdio-to-HTTP MCP bridge for Claude Code and other MCP hosts Bridge { @@ -107,9 +118,28 @@ enum Commands { /// Allow empty registry (zero commands) without failing #[arg(long)] allow_empty_registry: bool, + #[command(flatten)] + target: AppTarget, }, } +/// Which running app a command talks to when several Victauri apps are up. +#[derive(clap::Args, Debug, Clone, Default)] +struct AppTarget { + /// Select the app by its exact Tauri bundle identifier (or product name) when several + /// Victauri apps are running (env: `VICTAURI_APP`). Without it the single running app is + /// used, and several running apps are an error that lists them. + #[arg(long, value_name = "IDENTIFIER")] + app: Option, +} + +/// Connect to the running app `target` selects (see [`AppTarget`]). +async fn connect(target: &AppTarget) -> Result { + victauri_test::VictauriClient::discover_app(target.app.as_deref()) + .await + .map_err(|e| anyhow::anyhow!("{}", connect_failure_message(&e.to_string()))) +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -119,18 +149,23 @@ async fn main() -> Result<()> { let root = path.unwrap_or_else(|| PathBuf::from(".")); cmd_init(&root)?; } - Commands::Check { junit } => { - cmd_check(junit.as_deref()).await?; + Commands::Check { junit, target } => { + cmd_check(junit.as_deref(), &target).await?; } Commands::Test { max_load_ms, max_heap_mb, junit, + target, } => { - cmd_test(max_load_ms, max_heap_mb, junit.as_deref()).await?; + cmd_test(max_load_ms, max_heap_mb, junit.as_deref(), &target).await?; } - Commands::Doctor => { - cmd_doctor().await?; + Commands::Doctor { target } => { + // A `[FAIL]` exits 1 so a CI step running `victauri doctor` fails with it + // (R5B-DOCTOR1); warnings alone still exit 0. + if cmd_doctor(&target).await? > 0 { + std::process::exit(1); + } } Commands::Bridge { wait, app } => { bridge::run(wait, app).await?; @@ -140,21 +175,28 @@ async fn main() -> Result<()> { test_name, locator, assert_ipc, + target, } => { - cmd_record(&output, &test_name, locator, assert_ipc).await?; + cmd_record(&output, &test_name, locator, assert_ipc, &target).await?; } Commands::Watch { dir, filter } => { cmd_watch(&dir, filter.as_deref()).await?; } - Commands::Invoke { command, args, raw } => { - cmd_invoke(&command, args.as_deref(), raw).await?; + Commands::Invoke { + command, + args, + raw, + target, + } => { + cmd_invoke(&command, args.as_deref(), raw, &target).await?; } Commands::Coverage { threshold, junit, allow_empty_registry, + target, } => { - cmd_coverage(threshold, junit.as_deref(), allow_empty_registry).await?; + cmd_coverage(threshold, junit.as_deref(), allow_empty_registry, &target).await?; } } @@ -163,20 +205,25 @@ async fn main() -> Result<()> { fn cmd_init(root: &Path) -> Result<()> { let root = std::fs::canonicalize(root) - .with_context(|| format!("directory not found: {}", root.display()))?; + .with_context(|| format!("directory not found: {}", display_path(root)))?; let (cargo_toml_path, is_tauri) = detect_project(&root)?; if !is_tauri { eprintln!( "Warning: no Tauri dependency detected in {}", - cargo_toml_path.display() + display_path(&cargo_toml_path) ); eprintln!(" Victauri is designed for Tauri apps — tests may not connect.\n"); } eprintln!("Initializing Victauri...\n"); + // Every path `init` writes must stay inside the project with no symlinked component + // (R4-FS1): a cloned repo whose `src-tauri`, `src`, `capabilities`, `tests` or `.github` + // is a symlink would otherwise redirect these writes into another project. + ensure_inside_project(&root, &cargo_toml_path)?; + // Step 1: Add dependencies to Cargo.toml let added = add_dependencies(&cargo_toml_path)?; if added { @@ -194,6 +241,7 @@ fn cmd_init(root: &Path) -> Result<()> { .unwrap_or_default(); let mut patched = false; if src_dir.exists() { + ensure_inside_project(&root, &src_dir.join("main.rs"))?; patched = try_patch_tauri_builder(&src_dir)?; } if !patched { @@ -204,6 +252,7 @@ fn cmd_init(root: &Path) -> Result<()> { // Step 3: Create .mcp.json for AI agent connection let mcp_json_path = root.join(".mcp.json"); + ensure_inside_project(&root, &mcp_json_path)?; if mcp_json_path.exists() { let content = std::fs::read_to_string(&mcp_json_path).unwrap_or_default(); if content.contains("victauri") { @@ -212,16 +261,22 @@ fn cmd_init(root: &Path) -> Result<()> { eprintln!(" [!] .mcp.json exists but doesn't reference Victauri."); eprintln!(" Add this to your mcpServers (the bridge auto-discovers the port —"); eprintln!(" prefer it over a fixed url so agents never bind the wrong app):\n"); + // The same `--app ` pin the generated file carries (R5B-INIT2). eprintln!( - " \"victauri\": {{ \"command\": \"victauri\", \"args\": [\"bridge\"] }}\n" + " {}\n", + mcp_server_entry_snippet(read_app_identifier(root.as_path()).as_deref()) ); } } else { let app_id = read_app_identifier(root.as_path()); - std::fs::write(&mcp_json_path, generate_mcp_json(app_id.as_deref())) - .with_context(|| format!("failed to write {}", mcp_json_path.display()))?; + write_new_file(&mcp_json_path, &generate_mcp_json(app_id.as_deref()))?; match app_id { - Some(id) => eprintln!(" [+] Created .mcp.json (bridge pinned to app '{id}')"), + // The identifier comes from the (possibly untrusted) project's tauri.conf.json: + // never print its control characters raw (R4-TERM1). + Some(id) => eprintln!( + " [+] Created .mcp.json (bridge pinned to app '{}')", + victauri_test::terminal::single_line(&id) + ), None => eprintln!(" [+] Created .mcp.json (AI agent configuration)"), } } @@ -230,13 +285,13 @@ fn cmd_init(root: &Path) -> Result<()> { let capabilities_dir = find_capabilities_dir(&cargo_toml_path); if let Some(caps_dir) = capabilities_dir { let cap_path = caps_dir.join("victauri.json"); + ensure_inside_project(&root, &cap_path)?; if cap_path.exists() { eprintln!(" [=] capabilities/victauri.json already exists"); } else { std::fs::create_dir_all(&caps_dir) - .with_context(|| format!("failed to create {}", caps_dir.display()))?; - std::fs::write(&cap_path, generate_capability_json()) - .with_context(|| format!("failed to write {}", cap_path.display()))?; + .with_context(|| format!("failed to create {}", display_path(&caps_dir)))?; + write_new_file(&cap_path, generate_capability_json())?; eprintln!(" [+] Created capabilities/victauri.json"); } // Verify the effective grant covers every window — a capability scoped to @@ -267,15 +322,15 @@ fn cmd_init(root: &Path) -> Result<()> { } let tests_dir = find_src_tauri(&root).map_or_else(|| root.join("tests"), |p| p.join("tests")); + ensure_inside_project(&root, &tests_dir.join("smoke.rs"))?; std::fs::create_dir_all(&tests_dir) - .with_context(|| format!("failed to create {}", tests_dir.display()))?; + .with_context(|| format!("failed to create {}", display_path(&tests_dir)))?; let smoke_path = tests_dir.join("smoke.rs"); if smoke_path.exists() { eprintln!(" [=] tests/smoke.rs already exists"); } else { - std::fs::write(&smoke_path, generate_smoke_test()) - .with_context(|| format!("failed to write {}", smoke_path.display()))?; + write_new_file(&smoke_path, generate_smoke_test())?; eprintln!(" [+] Created tests/smoke.rs (smoke tests)"); } @@ -288,19 +343,19 @@ fn cmd_init(root: &Path) -> Result<()> { } else { generate_integration_test_with_commands(&discovered_commands) }; - std::fs::write(&integration_path, &integration_content) - .with_context(|| format!("failed to write {}", integration_path.display()))?; + write_new_file(&integration_path, &integration_content)?; eprintln!(" [+] Created tests/integration.rs (integration test template)"); } // Step 6: Generate CI workflow let workflows_dir = root.join(".github").join("workflows"); let ci_path = workflows_dir.join("victauri.yml"); + ensure_inside_project(&root, &ci_path)?; if ci_path.exists() { eprintln!(" [=] .github/workflows/victauri.yml already exists"); } else { std::fs::create_dir_all(&workflows_dir) - .with_context(|| format!("failed to create {}", workflows_dir.display()))?; + .with_context(|| format!("failed to create {}", display_path(&workflows_dir)))?; // The Rust crate's directory relative to the repo root (`src-tauri` for the // standard create-tauri-app layout, `.` when Cargo.toml is at the root). let app_dir = cargo_toml_path @@ -309,13 +364,13 @@ fn cmd_init(root: &Path) -> Result<()> { .map(|p| p.to_string_lossy().replace('\\', "/")) .filter(|p| !p.is_empty()) .unwrap_or_else(|| ".".to_string()); - std::fs::write(&ci_path, generate_ci_workflow(&app_dir)) - .with_context(|| format!("failed to write {}", ci_path.display()))?; + write_new_file(&ci_path, &generate_ci_workflow(&app_dir))?; eprintln!(" [+] Created .github/workflows/victauri.yml (CI pipeline)"); } // Step 7: Add Victauri section to CLAUDE.md for AI agent guidance let claude_md_path = root.join("CLAUDE.md"); + ensure_inside_project(&root, &claude_md_path)?; if claude_md_path.exists() { let content = std::fs::read_to_string(&claude_md_path).unwrap_or_default(); // Detect a prior insertion by our sentinel marker, not a loose substring — @@ -324,20 +379,14 @@ fn cmd_init(root: &Path) -> Result<()> { if content.contains("VICTAURI:BEGIN") { eprintln!(" [=] CLAUDE.md already has the Victauri block (VICTAURI markers present)"); } else { - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(&claude_md_path) - .with_context(|| format!("failed to append to {}", claude_md_path.display()))?; - std::io::Write::write_all(&mut file, generate_claude_md_section().as_bytes()) - .with_context(|| format!("failed to write to {}", claude_md_path.display()))?; + append_to_regular_file(&claude_md_path, generate_claude_md_section())?; eprintln!( " [+] Appended a Victauri block to CLAUDE.md (between and" ); eprintln!(" markers — delete that block to opt out)"); } } else { - std::fs::write(&claude_md_path, generate_claude_md_section()) - .with_context(|| format!("failed to write {}", claude_md_path.display()))?; + write_new_file(&claude_md_path, generate_claude_md_section())?; eprintln!(" [+] Created CLAUDE.md with Victauri agent instructions"); } @@ -413,6 +462,13 @@ fn warn_on_version_skew(plugin_version: &str) -> bool { /// 401 (auth on by default) and a version-skew handshake failure both surfaced as the generic "is /// your app running?" while the app WAS running — so the operator chased the wrong fix. fn connect_failure_message(detail: &str) -> String { + // Discovery could not pick ONE app (several running, the selected one absent, or an + // explicit token matching no app): the message already names the running apps and how + // to select one. The generic "stale CLI / is your app running?" advice is wrong here + // (R4-CLI1: with two apps running, `check` used to print a 401 and blame a stale CLI). + if is_discovery_selection_error(detail) { + return format!("Could not choose a Victauri app to connect to:\n{detail}"); + } let lower = detail.to_lowercase(); let hint = if lower.contains("401") || lower.contains("unauthorized") { "Auth is ON by default. `discover()` auto-reads the per-pid discovery token, so a 401 usually \ @@ -435,6 +491,107 @@ fn connect_failure_message(detail: &str) -> String { format!("Could not connect to Victauri server: {detail}\n\n{hint}") } +/// Names from a ghost-report list whose items are `{ "name": … }` objects (the plugin's +/// shape: `frontend_only` holds `GhostCommand`s, `confirmed_ghosts` holds `{name, error}`), +/// or bare strings (older plugins). Deduplicated in order; page-controlled, so sanitized. +fn ghost_names(list: Option<&serde_json::Value>) -> Vec { + let mut names: Vec = Vec::new(); + for item in list + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + let name = item + .as_str() + .or_else(|| item.get("name").and_then(serde_json::Value::as_str)) + .or_else(|| item.get("command").and_then(serde_json::Value::as_str)); + if let Some(name) = name.map(victauri_test::terminal::single_line) + && !names.contains(&name) + { + names.push(name); + } + } + names +} + +/// The `Ghost commands` section of `victauri check`. +/// +/// `confirmed_ghosts` (invoked, never succeeded, errored "not found") are real missing +/// handlers regardless of the registry; `frontend_only` are candidates whose weight depends +/// on `reliability`. Both are listed BY NAME — the old code read the items as strings (they +/// are objects), so it printed counts but never a single name, and ignored confirmed ghosts. +fn ghost_report_lines(ghosts: &serde_json::Value) -> Vec { + const SHOWN: usize = 5; + let list = |names: &[String], out: &mut Vec| { + for name in names.iter().take(SHOWN) { + out.push(format!(" - {name}")); + } + if names.len() > SHOWN { + out.push(format!(" ... and {} more", names.len() - SHOWN)); + } + }; + let confirmed = ghost_names(ghosts.get("confirmed_ghosts")); + let candidates = ghost_names( + ghosts + .get("frontend_only") + .or_else(|| ghosts.get("ghost_commands")), + ); + // Honesty signal: `frontend_only` is only a real bug list when the introspection + // registry mirrors the app's full command set. Report candidates as candidates. + let reliability = ghosts + .get("reliability") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + + let mut out = Vec::new(); + if confirmed.is_empty() && candidates.is_empty() { + out.push(" Ghost commands: none".to_string()); + return out; + } + if !confirmed.is_empty() { + out.push(format!( + " Ghost commands: {} confirmed (invoked, errored \"not found\" — no backend handler)", + confirmed.len() + )); + list(&confirmed, &mut out); + } + if !candidates.is_empty() { + if reliability == "none" || reliability == "low" { + out.push(format!( + " Ghost command candidates: {} (reliability: {} — likely uninstrumented real \ + commands, not bugs; verify against the app's generate_handler! list)", + candidates.len(), + victauri_test::terminal::single_line(reliability) + )); + } else { + out.push(format!( + " Ghost command candidates: {} (absent from a complete registry)", + candidates.len() + )); + } + list(&candidates, &mut out); + } + out +} + +/// Capability window labels for display: from the project's JSON, so each is rendered with +/// control characters escaped — a label like `"main\n::error::x"` must not start a new line. +fn capability_labels(labels: &[String]) -> String { + labels + .iter() + .map(|l| victauri_test::terminal::single_line(l)) + .collect::>() + .join(", ") +} + +/// Whether `detail` is `victauri-test` discovery refusing to guess an app (see +/// `VictauriClient::discover_app`), rather than a connection failure. +fn is_discovery_selection_error(detail: &str) -> bool { + detail.contains("Multiple Victauri apps are running") + || detail.contains("No running Victauri app matches") + || detail.contains("VICTAURI_AUTH_TOKEN is set, but no running Victauri app") +} + /// Extract the tool count from a `get_plugin_info` response for the `victauri check` summary. /// /// The server reports it nested under `tools.total` (the shape since 0.7.x); older/odd shapes @@ -449,15 +606,10 @@ fn parse_tool_count(info: &serde_json::Value) -> String { .map_or_else(|| "?".to_string(), |n| n.to_string()) } -async fn cmd_check(junit_path: Option<&Path>) -> Result<()> { +async fn cmd_check(junit_path: Option<&Path>, target: &AppTarget) -> Result<()> { eprintln!("Connecting to running Victauri server...\n"); - let mut client = match victauri_test::VictauriClient::discover().await { - Ok(c) => c, - Err(e) => { - bail!("{}", connect_failure_message(&e.to_string())); - } - }; + let mut client = connect(target).await?; let info = client .get_plugin_info() @@ -475,6 +627,11 @@ async fn cmd_check(junit_path: Option<&Path>) -> Result<()> { .map_or("?".to_string(), |s| format!("{s}s")); eprintln!(" Victauri v{version}"); + eprintln!( + " Endpoint: {} (app: {})", + client.base_url(), + victauri_test::terminal::single_line(client.app_identifier().unwrap_or("unknown")) + ); eprintln!(" Tools: {tool_count}"); eprintln!(" Uptime: {uptime}"); @@ -532,41 +689,8 @@ async fn cmd_check(junit_path: Option<&Path>) -> Result<()> { .detect_ghost_commands() .await .context("ghost command detection failed")?; - let ghost_list = ghosts - .get("frontend_only") - .and_then(|g| g.as_array()) - .or_else(|| ghosts.get("ghost_commands").and_then(|g| g.as_array())); - // Honesty signal: `frontend_only` is only a real bug list when the introspection - // registry mirrors the app's full command set. Report candidates as candidates. - let reliability = ghosts - .get("reliability") - .and_then(|r| r.as_str()) - .unwrap_or("unknown"); - if let Some(list) = ghost_list { - if list.is_empty() { - eprintln!(" Ghost commands: none"); - } else { - if reliability == "none" || reliability == "low" { - eprintln!( - " Ghost commands: {} candidate(s) (reliability: {reliability} — likely \ - uninstrumented real commands, not bugs; verify against the app's \ - generate_handler! list)", - list.len() - ); - } else { - eprintln!(" Ghost commands: {} detected", list.len()); - } - for g in list.iter().take(5) { - if let Some(name) = g.as_str() { - eprintln!(" - {name}"); - } else if let Some(name) = g.get("command").and_then(|c| c.as_str()) { - eprintln!(" - {name}"); - } - } - if list.len() > 5 { - eprintln!(" ... and {} more", list.len() - 5); - } - } + for line in ghost_report_lines(&ghosts) { + eprintln!("{line}"); } let mem = client @@ -600,17 +724,18 @@ async fn cmd_check(junit_path: Option<&Path>) -> Result<()> { let junit = report.to_junit("victauri-check", duration); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } victauri_test::reporting::write_junit_report(&junit, path) - .with_context(|| format!("failed to write JUnit report to {}", path.display()))?; - eprintln!("JUnit report written to {}", path.display()); + .with_context(|| format!("failed to write JUnit report to {}", display_path(path)))?; + eprintln!("JUnit report written to {}", display_path(path)); } Ok(()) } -async fn cmd_doctor() -> Result<()> { +/// Run every doctor check and print the summary; returns the number of `[FAIL]` items. +async fn cmd_doctor(target: &AppTarget) -> Result { eprintln!("Victauri Doctor — checking your setup...\n"); let mut pass_count = 0u32; @@ -620,7 +745,7 @@ async fn cmd_doctor() -> Result<()> { // Check 1: Project structure let cwd = std::env::current_dir()?; let (cargo_path, is_tauri) = if let Ok(result) = detect_project(&cwd) { - eprintln!(" [PASS] Cargo.toml found: {}", result.0.display()); + eprintln!(" [PASS] Cargo.toml found: {}", display_path(&result.0)); pass_count += 1; result } else { @@ -628,7 +753,7 @@ async fn cmd_doctor() -> Result<()> { eprintln!(" Run this command from your Tauri project root.\n"); fail_count += 1; print_doctor_summary(pass_count, fail_count, warn_count); - return Ok(()); + return Ok(fail_count); }; // Check 2: Tauri dependency @@ -639,7 +764,7 @@ async fn cmd_doctor() -> Result<()> { eprintln!(" [FAIL] No Tauri dependency in Cargo.toml"); eprintln!( " Add `tauri` to [dependencies] in {}", - cargo_path.display() + display_path(&cargo_path) ); fail_count += 1; } @@ -716,7 +841,7 @@ async fn cmd_doctor() -> Result<()> { eprintln!(" eval_js / dom_snapshot will time out with no error."); eprintln!( " Run `victauri init`, or add {}:", - caps.join("victauri.json").display() + display_path(&caps.join("victauri.json")) ); eprintln!("{}", indent_block(generate_capability_json())); warn_count += 1; @@ -724,15 +849,17 @@ async fn cmd_doctor() -> Result<()> { eprintln!(" [PASS] 'victauri:default' granted to all windows"); pass_count += 1; } else { - let file = scan.file.as_deref().map_or_else( - || "the capability file".to_string(), - |p| p.display().to_string(), - ); + // Labels and file names come from the (possibly untrusted) project: print them + // with control characters escaped (R4-TERM1). + let file = scan + .file + .as_deref() + .map_or_else(|| "the capability file".to_string(), display_path); match scan.windows.as_deref() { Some(labels) if !labels.is_empty() => { eprintln!( " [WARN] 'victauri:default' only covers window(s): {}", - labels.join(", ") + capability_labels(labels) ); eprintln!(" Other windows will be invisible to Victauri (eval_js /"); eprintln!( @@ -752,7 +879,7 @@ async fn cmd_doctor() -> Result<()> { // Check 8: Test files let tests_dir = find_src_tauri(&cwd).map_or_else(|| cwd.join("tests"), |p| p.join("tests")); if tests_dir.join("smoke.rs").exists() || tests_dir.join("integration.rs").exists() { - eprintln!(" [PASS] Test files found in {}", tests_dir.display()); + eprintln!(" [PASS] Test files found in {}", display_path(&tests_dir)); pass_count += 1; } else { eprintln!(" [WARN] No Victauri test files found"); @@ -763,7 +890,7 @@ async fn cmd_doctor() -> Result<()> { // Check 9: Server connectivity eprintln!(); eprintln!(" Checking server connectivity..."); - match victauri_test::VictauriClient::discover().await { + match victauri_test::VictauriClient::discover_app(target.app.as_deref()).await { Ok(mut client) => { eprintln!(" [PASS] Connected to Victauri server"); pass_count += 1; @@ -792,7 +919,9 @@ async fn cmd_doctor() -> Result<()> { pass_count += 1; if let Ok(ver) = client.eval_js("window.__VICTAURI__.version").await { - let ver_str = ver.as_str().unwrap_or("unknown"); + // Read from the page: escape control characters (R4-TERM1). + let ver_str = + victauri_test::terminal::single_line(ver.as_str().unwrap_or("unknown")); eprintln!(" Bridge version: {ver_str}"); } } else { @@ -848,7 +977,7 @@ async fn cmd_doctor() -> Result<()> { eprintln!(); print_doctor_summary(pass_count, fail_count, warn_count); - Ok(()) + Ok(fail_count) } fn print_doctor_summary(pass: u32, fail: u32, warn: u32) { @@ -863,15 +992,15 @@ fn print_doctor_summary(pass: u32, fail: u32, warn: u32) { } } -async fn cmd_test(max_load_ms: u64, max_heap_mb: f64, junit_path: Option<&Path>) -> Result<()> { +async fn cmd_test( + max_load_ms: u64, + max_heap_mb: f64, + junit_path: Option<&Path>, + target: &AppTarget, +) -> Result<()> { eprintln!("Connecting to running Victauri server...\n"); - let mut client = match victauri_test::VictauriClient::discover().await { - Ok(c) => c, - Err(e) => { - bail!("{}", connect_failure_message(&e.to_string())); - } - }; + let mut client = connect(target).await?; eprintln!("Running built-in smoke test suite (11 checks)...\n"); @@ -892,11 +1021,11 @@ async fn cmd_test(max_load_ms: u64, max_heap_mb: f64, junit_path: Option<&Path>) let junit = verify.to_junit("victauri-smoke", report.duration); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } victauri_test::reporting::write_junit_report(&junit, path) - .with_context(|| format!("failed to write JUnit report to {}", path.display()))?; - eprintln!("JUnit report written to {}", path.display()); + .with_context(|| format!("failed to write JUnit report to {}", display_path(path)))?; + eprintln!("JUnit report written to {}", display_path(path)); } if !report.all_passed() { @@ -909,17 +1038,17 @@ async fn cmd_test(max_load_ms: u64, max_heap_mb: f64, junit_path: Option<&Path>) Ok(()) } -async fn cmd_invoke(command: &str, args_json: Option<&str>, raw: bool) -> Result<()> { +async fn cmd_invoke( + command: &str, + args_json: Option<&str>, + raw: bool, + target: &AppTarget, +) -> Result<()> { if !raw { eprintln!("Connecting to running Victauri server...\n"); } - let mut client = match victauri_test::VictauriClient::discover().await { - Ok(c) => c, - Err(e) => { - bail!("{}", connect_failure_message(&e.to_string())); - } - }; + let mut client = connect(target).await?; let args: Option = match args_json { Some(s) => Some(serde_json::from_str(s).context("invalid JSON in --args")?), @@ -949,15 +1078,11 @@ async fn cmd_coverage( threshold: Option, junit_path: Option<&Path>, allow_empty_registry: bool, + target: &AppTarget, ) -> Result<()> { eprintln!("Connecting to running Victauri server...\n"); - let mut client = match victauri_test::VictauriClient::discover().await { - Ok(c) => c, - Err(e) => { - bail!("{}", connect_failure_message(&e.to_string())); - } - }; + let mut client = connect(target).await?; let report = victauri_test::coverage::coverage_report(&mut client) .await @@ -977,28 +1102,27 @@ async fn cmd_coverage( eprintln!("{summary}"); if let Some(path) = junit_path { - let verify_report = victauri_test::VerifyReport { - results: vec![victauri_test::CheckResult { - description: format!( + let verify_report = + victauri_test::VerifyReport::new(vec![victauri_test::CheckResult::new( + format!( "IPC coverage {:.1}% ({}/{})", report.coverage_percentage, report.tested_commands, report.total_commands ), - passed: threshold.is_none_or(|t| report.meets_threshold(t)), - detail: if report.untested.is_empty() { + threshold.is_none_or(|t| report.meets_threshold(t)), + if report.untested.is_empty() { String::new() } else { format!("untested: {}", report.untested.join(", ")) }, - }], - }; + )]); let junit = verify_report.to_junit("victauri-coverage", std::time::Duration::from_secs(0)); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } victauri_test::reporting::write_junit_report(&junit, path) - .with_context(|| format!("failed to write JUnit report to {}", path.display()))?; - eprintln!("JUnit report written to {}", path.display()); + .with_context(|| format!("failed to write JUnit report to {}", display_path(path)))?; + eprintln!("JUnit report written to {}", display_path(path)); } if let Some(t) = threshold { @@ -1018,15 +1142,16 @@ async fn cmd_coverage( Ok(()) } -async fn cmd_record(output: &Path, test_name: &str, locator: bool, assert_ipc: bool) -> Result<()> { +async fn cmd_record( + output: &Path, + test_name: &str, + locator: bool, + assert_ipc: bool, + target: &AppTarget, +) -> Result<()> { eprintln!("Connecting to running Tauri app...\n"); - let mut client = match victauri_test::VictauriClient::discover().await { - Ok(c) => c, - Err(e) => { - bail!("{}", connect_failure_message(&e.to_string())); - } - }; + let mut client = connect(target).await?; let session_id = format!("record-{}", uuid::Uuid::new_v4()); client @@ -1083,17 +1208,18 @@ async fn cmd_record(output: &Path, test_name: &str, locator: bool, assert_ipc: b }; let code = victauri_core::generate_test(&session, &options); + let output = checked_record_output(&std::env::current_dir()?, output)?; + let output = output.as_path(); if let Some(parent) = output.parent() { std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; + .with_context(|| format!("failed to create directory {}", display_path(parent)))?; } - std::fs::write(output, &code) - .with_context(|| format!("failed to write {}", output.display()))?; + write_regular_file(output, &code)?; eprintln!( " Captured {event_count} events ({interaction_count} interactions, {ipc_count} IPC calls)" ); - eprintln!(" Generated test: {}", output.display()); + eprintln!(" Generated test: {}", display_path(output)); // `--test` selects the test TARGET (the file stem under tests/), not the function; // the function name is the trailing name filter. let target = output.file_stem().map_or_else( @@ -1115,11 +1241,14 @@ async fn cmd_watch(dir: &Path, filter: Option<&str>) -> Result<()> { }; if !watch_dir.exists() { - bail!("watch directory does not exist: {}", watch_dir.display()); + bail!( + "watch directory does not exist: {}", + display_path(&watch_dir) + ); } eprintln!("\x1b[36mVictauri Watch\x1b[0m"); - eprintln!(" Directory: {}", watch_dir.display()); + eprintln!(" Directory: {}", display_path(&watch_dir)); if let Some(f) = filter { eprintln!(" Filter: {f}"); } @@ -1145,7 +1274,7 @@ async fn cmd_watch(dir: &Path, filter: Option<&str>) -> Result<()> { watcher .watch(&watch_dir, RecursiveMode::Recursive) - .with_context(|| format!("failed to watch {}", watch_dir.display()))?; + .with_context(|| format!("failed to watch {}", display_path(&watch_dir)))?; run_tests_with_output(filter, None); @@ -1220,12 +1349,12 @@ fn try_patch_tauri_builder(src_dir: &Path) -> Result { continue; } let content = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; + .with_context(|| format!("failed to read {}", display_path(&path)))?; if content.contains("victauri_plugin") { eprintln!( " [=] {} already references victauri_plugin", - path.display() + display_path(&path) ); return Ok(true); } @@ -1260,19 +1389,29 @@ fn try_patch_tauri_builder(src_dir: &Path) -> Result { new_lines.push(&plugin_line); new_lines.extend_from_slice(&lines[idx..]); - let new_content = new_lines.join("\n"); + // Keep the file's line endings: `lines()` drops them, and re-joining with "\n" + // turned a CRLF source file into a whole-file LF diff. + let eol = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let new_content = new_lines.join(eol); // Preserve trailing newline if original had one let new_content = if content.ends_with('\n') { - format!("{new_content}\n") + format!("{new_content}{eol}") } else { new_content }; + // Same rule as every file `init` creates: never write through a symlink (a cloned + // repo's `src/main.rs` could point at a file outside the project). + refuse_symlink(&path)?; std::fs::write(&path, new_content) - .with_context(|| format!("failed to write {}", path.display()))?; + .with_context(|| format!("failed to write {}", display_path(&path)))?; eprintln!( " [+] Patched {} with .plugin(victauri_plugin::init())", - path.display() + display_path(&path) ); return Ok(true); } @@ -1287,22 +1426,199 @@ fn try_patch_tauri_builder(src_dir: &Path) -> Result { /// /// No `--wait`: the bridge answers the MCP handshake locally and connects to the app lazily, /// so it never blocks startup whether or not the app is running yet (`--wait` is now a no-op). +/// +/// Built with `serde_json` (never string interpolation): the identifier comes from the +/// project's `tauri.conf.json`, and a value containing quotes, backslashes or control +/// characters must be escaped, not spliced into the document. fn generate_mcp_json(app: Option<&str>) -> String { - let args = match app { - Some(id) => format!("[\"bridge\", \"--app\", \"{id}\"]"), - None => "[\"bridge\"]".to_string(), + let args: Vec<&str> = match app { + Some(id) => vec!["bridge", "--app", id], + None => vec!["bridge"], }; - format!( - r#"{{ - "mcpServers": {{ - "victauri": {{ - "command": "victauri", - "args": {args} - }} - }} -}} -"# - ) + let doc = serde_json::json!({ + "mcpServers": { + "victauri": { + "command": "victauri", + "args": args, + } + } + }); + // Serializing a `Value` built from strings cannot fail; fall back defensively anyway. + let mut out = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| { + r#"{"mcpServers":{"victauri":{"command":"victauri","args":["bridge"]}}}"#.to_string() + }); + out.push('\n'); + out +} + +/// The `"victauri": {…}` entry to paste into an existing `.mcp.json` — the same bridge args +/// [`generate_mcp_json`] writes, so a hand-merged config is pinned to the app too (R5B-INIT2). +/// Each value is JSON-encoded (the identifier is untrusted project data) and the line is +/// printed with control characters escaped. +fn mcp_server_entry_snippet(app: Option<&str>) -> String { + let mut args = vec!["bridge"]; + if let Some(id) = app { + args.extend(["--app", id]); + } + let args: Vec = args + .iter() + .map(|a| serde_json::Value::from(*a).to_string()) + .collect(); + victauri_test::terminal::single_line(&format!( + "\"victauri\": {{ \"command\": \"victauri\", \"args\": [{}] }}", + args.join(", ") + )) +} + +/// Refuse to operate on `path` if it is a symbolic link. +/// +/// `victauri init` writes into a project directory that may come from an untrusted clone; a +/// planted symlink (e.g. `CLAUDE.md -> ~/.bashrc`) would otherwise make `fs::write` / +/// append follow it and modify a file outside the project. A missing path is fine. +fn refuse_symlink(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => anyhow::bail!( + "refusing to write {}: it is a symbolic link (victauri init never writes through symlinks)", + display_path(path) + ), + _ => Ok(()), + } +} + +/// A path as shown to the user: the Windows verbatim prefix `std::fs::canonicalize` adds +/// (`\\?\C:\…`, `\\?\UNC\server\share\…`) is dropped, dunce-style (R5B-INIT1), and control +/// characters are escaped — a cloned repo's directory names are untrusted (R4-TERM1). +fn display_path(path: &Path) -> String { + let shown = path.display().to_string(); + let plain = if let Some(unc) = shown.strip_prefix(r"\\?\UNC\") { + format!(r"\\{unc}") + } else if let Some(rest) = shown + .strip_prefix(r"\\?\") + .filter(|rest| rest.as_bytes().get(1) == Some(&b':')) + { + rest.to_string() + } else { + shown + }; + victauri_test::terminal::single_line(&plain) +} + +/// Refuse to write `path` unless it lies inside the (canonical) project `root` with no +/// symlinked component between them (R4-FS1). +/// +/// `refuse_symlink` alone checked only the LAST component, so a symlinked `src-tauri`, +/// `src`, `capabilities`, `tests` or `.github` in a cloned repo redirected `init`'s writes +/// into another project. Components that do not exist yet are fine — `init` creates them as +/// real directories. As defense in depth the deepest existing ancestor must also canonicalize +/// inside `root`. +fn ensure_inside_project(root: &Path, path: &Path) -> Result<()> { + let refuse = |why: String| -> Result<()> { + anyhow::bail!( + "refusing to write {}: {why} (victauri init never writes through symlinks or \ + outside the project)", + display_path(path) + ) + }; + let Ok(relative) = path.strip_prefix(root) else { + return refuse(format!("it is outside the project {}", display_path(root))); + }; + let components: Vec<_> = relative.components().collect(); + let mut current = root.to_path_buf(); + for (i, component) in components.iter().enumerate() { + let std::path::Component::Normal(name) = component else { + return refuse("its path is not a plain relative path".to_string()); + }; + current.push(name); + match std::fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + return refuse(format!("{} is a symbolic link", display_path(¤t))); + } + Ok(meta) if i + 1 < components.len() && !meta.is_dir() => { + return refuse(format!("{} is not a directory", display_path(¤t))); + } + Ok(_) => {} + // Missing: nothing below it exists yet, so nothing below it can be a link. + Err(_) => break, + } + } + let mut existing = path.to_path_buf(); + while std::fs::symlink_metadata(&existing).is_err() { + if !existing.pop() { + break; + } + } + let canonical = std::fs::canonicalize(&existing) + .with_context(|| format!("failed to resolve {}", display_path(&existing)))?; + if !canonical.starts_with(root) { + return refuse(format!( + "it resolves outside the project ({})", + display_path(&canonical) + )); + } + Ok(()) +} + +/// Where `victauri record` writes: an output inside the working directory must not pass +/// through a symlinked directory (a cloned repo's `tests -> ../other-project/tests`), and +/// the output file itself must never be a symlink (R4-FS1). An absolute path outside the +/// working directory is the user's explicit choice; only its final component is checked. +fn checked_record_output(cwd: &Path, output: &Path) -> Result { + let cwd = std::fs::canonicalize(cwd) + .with_context(|| format!("failed to resolve {}", display_path(cwd)))?; + let absolute = if output.is_absolute() { + output.to_path_buf() + } else { + cwd.join(output) + }; + if absolute.starts_with(&cwd) { + ensure_inside_project(&cwd, &absolute)?; + } else { + refuse_symlink(&absolute)?; + } + Ok(absolute) +} + +/// Write `contents` to `path`, replacing a regular file but never following a symlink. +fn write_regular_file(path: &Path, contents: &str) -> Result<()> { + refuse_symlink(path)?; + std::fs::write(path, contents) + .with_context(|| format!("failed to write {}", display_path(path))) +} + +/// Create a NEW file with `contents`, never following or clobbering anything already at +/// `path`. `create_new` is atomic (`O_EXCL` / `CREATE_NEW`): it fails if *anything* exists there, +/// including a dangling symlink, so a link planted between the existence check and the write +/// cannot redirect it. +fn write_new_file(path: &Path, contents: &str) -> Result<()> { + refuse_symlink(path)?; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("failed to create {}", display_path(path)))?; + std::io::Write::write_all(&mut file, contents.as_bytes()) + .with_context(|| format!("failed to write {}", display_path(path)))?; + Ok(()) +} + +/// Append `contents` to an existing regular file, refusing if `path` is a symlink. +fn append_to_regular_file(path: &Path, contents: &str) -> Result<()> { + refuse_symlink(path)?; + let meta = std::fs::symlink_metadata(path) + .with_context(|| format!("failed to stat {}", display_path(path)))?; + if !meta.is_file() { + anyhow::bail!( + "refusing to append to {}: not a regular file", + display_path(path) + ); + } + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(path) + .with_context(|| format!("failed to append to {}", display_path(path)))?; + std::io::Write::write_all(&mut file, contents.as_bytes()) + .with_context(|| format!("failed to write to {}", display_path(path)))?; + Ok(()) } /// Read the Tauri bundle identifier from the project's `tauri.conf.json` so the generated @@ -1330,8 +1646,8 @@ that gives full-stack access to the webview DOM, IPC layer, Rust backend, and na windows. Available when the app is running in debug mode. **Use Victauri MCP tools for all app inspection and testing tasks.** Victauri runs inside -the app process with sub-ms response times and direct AppHandle access — it sees -everything, not just the webview. +the app process with direct AppHandle access (Rust-side tools answer in well under a +millisecond; webview tools are a JS round trip) — it sees everything, not just the webview. Key Victauri tools (the read-only backend/DB introspection below has no CDP/Playwright equivalent — and on macOS/Linux CDP can't attach to a Tauri webview at all): @@ -1374,7 +1690,12 @@ then fails with `422`/`404`. The bridge avoids that by design. automatically — just retry (the transport is stateless by default, so there is usually no session to lose). If the MCP path is genuinely wedged, the **sessionless REST API is the fallback, NOT CDP**: `POST http://127.0.0.1:/api/tools/` with - the Bearer token from `/victauri//token` (same capabilities, no session). + the app's Bearer token (same capabilities, no session). Run `victauri check` (add + `--app ` if several apps run) to see the endpoint; the token is the + `token` file in the app's `` directory under the **per-user** discovery root — + `$XDG_RUNTIME_DIR/victauri/` (else `/tmp/victauri-/`) on Linux/macOS, + `%TEMP%\victauri\` on Windows. Never take a token from a shared, world-writable directory + such as a bare `/tmp/victauri/`: another local user can plant entries there. ### Awaiting async backend work (don't guess with sleeps) @@ -1635,7 +1956,7 @@ async fn full_stack_health_check() { // ..VisualOptions::from_preset(ThresholdPreset::Standard) // }; // let diff = client.screenshot_visual("main-view", &opts).await.unwrap(); -// assert!(diff.is_match, "visual regression: {:.2}% differ", diff.diff_percentage); +// assert!(diff.is_match(opts.threshold_percent), "visual regression: {:.2}% match", diff.match_percentage); // } "# } @@ -1649,8 +1970,8 @@ fn detect_project(root: &Path) -> Result<(PathBuf, bool)> { } else { bail!( "No Cargo.toml found in {} or {}/src-tauri/", - root.display(), - root.display() + display_path(root), + display_path(root) ); }; @@ -1711,6 +2032,7 @@ fn add_dependencies(cargo_toml_path: &Path) -> Result { } if changed { + refuse_symlink(cargo_toml_path)?; std::fs::write(cargo_toml_path, doc.to_string())?; } Ok(changed) @@ -1821,8 +2143,17 @@ fn scan_tauri_commands(src_dir: &Path) -> Vec { while let Some(dir) = dirs.pop() { if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.filter_map(Result::ok) { + // `DirEntry::file_type` does not follow symlinks: a symlinked directory or + // file is skipped, so a link can neither loop the scan forever nor walk it + // out of the project (e.g. `src/escape -> /`) (R4-FS1). + let Ok(file_type) = entry.file_type() else { + continue; + }; let path = entry.path(); - if path.is_dir() { + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { dirs.push(path); } else if path.extension().is_some_and(|ext| ext == "rs") { rs_files.push(path); @@ -1963,11 +2294,17 @@ on: pull_request: branches: [main] +# Least privilege: this workflow only reads the repository. +permissions: + contents: read + jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install system dependencies run: | @@ -2006,6 +2343,10 @@ jobs: xvfb-run -a "$TARGET_DIR/debug/$BIN" & echo "APP_PID=$!" >> "$GITHUB_ENV" + # Pinned to the release TAG that matches your victauri-cli. Tags are mutable: for + # supply-chain hardening, replace `@v__VICTAURI_VERSION__` with the full 40-character + # commit SHA that tag points to (keep the tag as a trailing comment), e.g. + # git ls-remote https://github.com/4DA-Systems/victauri refs/tags/v__VICTAURI_VERSION__ - name: Victauri smoke tests uses: 4DA-Systems/victauri/.github/actions/victauri-test@v__VICTAURI_VERSION__ with: @@ -2142,6 +2483,27 @@ mod tests { ); } + #[test] + fn patch_tauri_builder_keeps_crlf_line_endings() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write( + src.join("main.rs"), + "fn main() {\r\n tauri::Builder::default()\r\n .run(tauri::generate_context!())\r\n .unwrap();\r\n}\r\n", + ) + .unwrap(); + assert!(try_patch_tauri_builder(&src).unwrap()); + let content = std::fs::read_to_string(src.join("main.rs")).unwrap(); + assert!(content.contains("victauri_plugin::init()")); + assert_eq!( + content.matches('\n').count(), + content.matches("\r\n").count(), + "every line ending stays CRLF: {content:?}" + ); + assert!(content.ends_with("}\r\n")); + } + #[test] fn patch_tauri_builder_skips_if_already_present() { let dir = tempfile::tempdir().unwrap(); @@ -2193,6 +2555,32 @@ mod tests { assert!(!patched, "should return false when no tauri::Builder found"); } + #[test] + fn display_path_drops_only_the_verbatim_prefix() { + let shown = |s: &str| display_path(Path::new(s)); + assert_eq!(shown(r"\\?\C:\proj\Cargo.toml"), r"C:\proj\Cargo.toml"); + assert_eq!(shown(r"\\?\UNC\srv\share\proj"), r"\\srv\share\proj"); + // A verbatim path with no drive-letter form stays as is (it has no plain spelling). + assert_eq!(shown(r"\\?\Volume{1234}\proj"), r"\\?\Volume{1234}\proj"); + assert_eq!(shown("src-tauri/Cargo.toml"), "src-tauri/Cargo.toml"); + assert_eq!(shown("a\nb"), r"a\nb"); + } + + #[test] + fn mcp_server_entry_snippet_pins_the_app_like_the_generated_file() { + assert_eq!( + mcp_server_entry_snippet(Some("com.x.app")), + r#""victauri": { "command": "victauri", "args": ["bridge", "--app", "com.x.app"] }"# + ); + assert_eq!( + mcp_server_entry_snippet(None), + r#""victauri": { "command": "victauri", "args": ["bridge"] }"# + ); + // Hostile identifier: JSON-escaped, and no raw control characters reach the terminal. + let s = mcp_server_entry_snippet(Some("a\"b\n::error")); + assert!(!s.contains('\n') && s.contains(r#"a\"b"#), "{s}"); + } + #[test] fn mcp_json_has_correct_structure() { let content = generate_mcp_json(None); @@ -2216,6 +2604,108 @@ mod tests { assert!(args.iter().any(|a| a.as_str() == Some("com.4da.app"))); } + #[test] + fn mcp_json_escapes_hostile_identifier() { + // A tauri.conf.json identifier is attacker-controllable in a cloned repo. It must be + // escaped as ONE JSON string, never able to inject extra args or keys. + let hostile = "x\", \"--evil\"], \"command\": \"sh\\\n"; + let content = generate_mcp_json(Some(hostile)); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let server = &parsed["mcpServers"]["victauri"]; + assert_eq!(server["command"].as_str(), Some("victauri")); + let args = server["args"].as_array().unwrap(); + assert_eq!(args.len(), 3); + assert_eq!(args[2].as_str(), Some(hostile)); + assert_eq!(server.as_object().unwrap().len(), 2); + } + + #[test] + fn write_new_file_creates_and_never_clobbers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("new.txt"); + write_new_file(&path, "hello").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + // A second write must fail rather than overwrite. + assert!(write_new_file(&path, "other").is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + } + + #[test] + fn append_refuses_non_regular_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(append_to_regular_file(dir.path(), "x").is_err()); + let f = dir.path().join("CLAUDE.md"); + std::fs::write(&f, "a").unwrap(); + append_to_regular_file(&f, "b").unwrap(); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "ab"); + } + + #[cfg(unix)] + #[test] + fn init_patches_never_follow_a_symlinked_source_or_manifest() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("outside.rs"); + let original = "fn main() {\n tauri::Builder::default()\n .run(tauri::generate_context!())\n .unwrap();\n}\n"; + std::fs::write(&outside, original).unwrap(); + let src = dir.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::os::unix::fs::symlink(&outside, src.join("main.rs")).unwrap(); + assert!(try_patch_tauri_builder(&src).is_err()); + assert_eq!(std::fs::read_to_string(&outside).unwrap(), original); + + let manifest = dir.path().join("outside.toml"); + std::fs::write(&manifest, "[package]\nname = \"x\"\n\n[dependencies]\n").unwrap(); + let link = dir.path().join("Cargo.toml"); + std::os::unix::fs::symlink(&manifest, &link).unwrap(); + assert!(add_dependencies(&link).is_err()); + assert!( + !std::fs::read_to_string(&manifest) + .unwrap() + .contains("victauri") + ); + } + + #[cfg(unix)] + #[test] + fn init_writes_refuse_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("outside.txt"); + std::fs::write(&outside, "original").unwrap(); + + // Symlink to an existing file: append must refuse and leave the target untouched. + let claude_md = dir.path().join("CLAUDE.md"); + std::os::unix::fs::symlink(&outside, &claude_md).unwrap(); + assert!(append_to_regular_file(&claude_md, "INJECTED").is_err()); + assert!(write_new_file(&claude_md, "INJECTED").is_err()); + assert_eq!(std::fs::read_to_string(&outside).unwrap(), "original"); + + // Dangling symlink: `exists()` is false, so init would try to create it — the + // create must refuse instead of following the link to create the target. + let target = dir.path().join("created-through-link.txt"); + let mcp = dir.path().join(".mcp.json"); + std::os::unix::fs::symlink(&target, &mcp).unwrap(); + assert!(!mcp.exists()); + assert!(write_new_file(&mcp, "{}").is_err()); + assert!(!target.exists()); + } + + #[cfg(unix)] + #[test] + fn cmd_init_refuses_symlinked_claude_md() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ntauri = \"2\"\n", + ) + .unwrap(); + let outside = dir.path().join("outside.md"); + std::fs::write(&outside, "keep").unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join("CLAUDE.md")).unwrap(); + let err = cmd_init(dir.path()).unwrap_err(); + assert!(format!("{err:#}").contains("symbolic link"), "{err:#}"); + assert_eq!(std::fs::read_to_string(&outside).unwrap(), "keep"); + } + #[test] fn capability_json_has_correct_structure() { let content = generate_capability_json(); @@ -2344,6 +2834,11 @@ mod tests { assert!(content.contains("working-directory: src-tauri")); assert!(content.contains("\"src-tauri -> target\"")); assert!(!content.contains("__APP_DIR__")); + // Least-privilege token + no persisted checkout credentials. + assert!(content.contains("permissions:\n contents: read")); + assert!(content.contains("persist-credentials: false")); + // The tag-pinned action ref carries SHA-pinning guidance. + assert!(content.contains("full 40-character")); } #[test] @@ -2468,4 +2963,224 @@ mod tests { ); assert_eq!(extract_fn_name("let x = 1;"), None); } + + // ── round-4 regression tests ── + + /// Create a directory symlink, or report why the platform refused (Windows needs + /// Developer Mode or an elevated shell) so the caller can skip instead of failing. + fn try_symlink_dir(target: &Path, link: &Path) -> bool { + #[cfg(unix)] + let made = std::os::unix::fs::symlink(target, link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(target, link); + match made { + Ok(()) => true, + Err(e) => { + eprintln!( + "skipping: cannot create a directory symlink here ({e}); on Windows this \ + needs Developer Mode or an elevated shell" + ); + false + } + } + } + + fn tauri_project(dir: &Path) { + std::fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ntauri = \"2\"\n", + ) + .unwrap(); + } + + #[test] + fn init_never_writes_through_a_symlinked_directory() { + // R4-FS1: only the LAST path component was checked, so a symlinked `.github` (or + // `tests`, `capabilities`, `src`, `src-tauri`) redirected writes into another project. + for linked in [".github", "tests"] { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let outside = dir.path().join("other-project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + tauri_project(&project); + if !try_symlink_dir(&outside, &project.join(linked)) { + return; + } + let err = cmd_init(&project).expect_err("init must refuse"); + assert!(format!("{err:#}").contains("symbolic link"), "{err:#}"); + assert_eq!( + std::fs::read_dir(&outside).unwrap().count(), + 0, + "nothing may be written through the {linked} link" + ); + } + } + + #[test] + fn init_never_edits_a_manifest_behind_a_symlinked_src_tauri() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let outside = dir.path().join("other-project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + tauri_project(&outside); + let before = std::fs::read_to_string(outside.join("Cargo.toml")).unwrap(); + if !try_symlink_dir(&outside, &project.join("src-tauri")) { + return; + } + assert!(cmd_init(&project).is_err()); + assert_eq!( + std::fs::read_to_string(outside.join("Cargo.toml")).unwrap(), + before + ); + } + + #[test] + fn ensure_inside_project_accepts_plain_and_not_yet_created_paths() { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + ensure_inside_project(&root, &root.join("src").join("main.rs")).unwrap(); + ensure_inside_project(&root, &root.join(".github/workflows/victauri.yml")).unwrap(); + assert!(ensure_inside_project(&root, &root.join("../escape.txt")).is_err()); + assert!(ensure_inside_project(&root, Path::new("/elsewhere/x")).is_err()); + } + + #[test] + fn command_scan_skips_symlinked_directories() { + // R4-FS1: the scan followed directory symlinks — a loop never ended, and a link to + // `/` (or another project) was walked. + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(src.join("a.rs"), "#[tauri::command]\nfn inside() {}\n").unwrap(); + std::fs::write( + outside.join("b.rs"), + "#[tauri::command]\nfn outside_the_project() {}\n", + ) + .unwrap(); + if !try_symlink_dir(&outside, &src.join("escape")) + || !try_symlink_dir(&src, &src.join("loop")) + { + return; + } + let commands = scan_tauri_commands(&src); + assert_eq!(commands, vec!["inside".to_string()]); + } + + #[test] + fn record_output_refuses_symlinked_directories_and_files() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("project"); + let outside = dir.path().join("other"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let ok = checked_record_output(&cwd, Path::new("tests/recorded_flow.rs")).unwrap(); + assert!(ok.ends_with("recorded_flow.rs")); + if !try_symlink_dir(&outside, &cwd.join("tests")) { + return; + } + let err = checked_record_output(&cwd, Path::new("tests/recorded_flow.rs")).unwrap_err(); + assert!(format!("{err:#}").contains("symbolic link"), "{err:#}"); + // A symlinked output FILE is refused too. + std::fs::create_dir_all(cwd.join("gen")).unwrap(); + let target = outside.join("victim.rs"); + std::fs::write(&target, "keep").unwrap(); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&target, cwd.join("gen/flow.rs")).is_ok(); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(&target, cwd.join("gen/flow.rs")).is_ok(); + if linked { + let out = checked_record_output(&cwd, Path::new("gen/flow.rs")); + assert!(out.is_err() || write_regular_file(&out.unwrap(), "x").is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep"); + } + } + + #[test] + fn claude_md_points_agents_at_the_per_user_discovery_root() { + // R4-DOC1: the generated block told agents the token lives at + // `/victauri//token` — on Unix 0.9 writes to a per-user root, and the + // legacy shared root can be attacker-planted. + let content = generate_claude_md_section(); + assert!( + !content.contains("/victauri//token"), + "{content}" + ); + assert!(content.contains("XDG_RUNTIME_DIR")); + assert!(content.contains("victauri-")); + assert!(content.contains("victauri check")); + } + + #[test] + fn ghost_report_lists_confirmed_and_candidate_names() { + // Info fix: items are objects `{name, …}`; the old code read them as strings, never + // printed a name, and ignored `confirmed_ghosts` entirely. + let report = serde_json::json!({ + "confirmed_ghosts": [ + {"name": "get_widgetz", "error": "command get_widgetz not found"}, + {"name": "get_widgetz", "error": "command get_widgetz not found"} + ], + "frontend_only": [ + {"name": "set_langauge", "source": "FrontendOnly", "description": null}, + {"name": "evil\n::error::forged", "source": "FrontendOnly", "description": null} + ], + "reliability": "low" + }); + let lines = ghost_report_lines(&report).join("\n"); + assert!(lines.contains("1 confirmed"), "{lines}"); + assert!(lines.contains(" - get_widgetz"), "{lines}"); + assert!(lines.contains(" - set_langauge"), "{lines}"); + assert!(lines.contains("reliability: low"), "{lines}"); + assert!( + !lines.lines().any(|l| l.trim_start().starts_with("::")), + "page-controlled names are single-line: {lines}" + ); + assert_eq!( + ghost_report_lines(&serde_json::json!({"frontend_only": [], "confirmed_ghosts": []})), + vec![" Ghost commands: none".to_string()] + ); + // Older plugins' bare-string lists still work. + let legacy = ghost_report_lines(&serde_json::json!({"frontend_only": ["old_cmd"]})); + assert!(legacy.join("\n").contains("old_cmd")); + } + + #[test] + fn discovery_selection_errors_get_no_misleading_connection_advice() { + // R4-CLI1: two running apps printed "stale CLI / is your app running?". + let msg = connect_failure_message( + "Multiple Victauri apps are running:\n com.a (port 7373, pid 1)\n com.b (port \ + 7374, pid 2)\nSelect one with `--app ` (victauri CLI) or the \ + VICTAURI_APP env var, or pin the port with VICTAURI_PORT.", + ); + assert!(msg.contains("com.b (port 7374"), "{msg}"); + assert!(!msg.contains("Is your Tauri app running"), "{msg}"); + assert!(!msg.contains("cargo install victauri-cli"), "{msg}"); + } + + #[test] + fn capability_labels_are_printed_single_line() { + // R4-TERM1: `doctor` printed raw capability window labels. + let shown = capability_labels(&["main".to_string(), "x\n::error::forged".to_string()]); + assert_eq!(shown, "main, x\\n::error::forged"); + } + + #[test] + fn every_connecting_command_accepts_app() { + use clap::Parser; + for args in [ + &["victauri", "check", "--app", "com.a"][..], + &["victauri", "test", "--app", "com.a"], + &["victauri", "doctor", "--app", "com.a"], + &["victauri", "invoke", "cmd", "--app", "com.a"], + &["victauri", "coverage", "--app", "com.a"], + &["victauri", "record", "--app", "com.a"], + &["victauri", "bridge", "--app", "com.a"], + ] { + assert!(Cli::try_parse_from(args).is_ok(), "{args:?}"); + } + } } diff --git a/crates/victauri-cli/src/tools_fallback.json b/crates/victauri-cli/src/tools_fallback.json index 6f14b2ee..6aef3a5c 100644 --- a/crates/victauri-cli/src/tools_fallback.json +++ b/crates/victauri-cli/src/tools_fallback.json @@ -1,142 +1,142 @@ [ { - "name": "eval_js", - "description": "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically." + "description": "Animation introspection (no CDP). Reads the Web Animations API to reveal what the webview's animation engine is actually running — duration, delay, easing, iterations, keyframes, current progress, and the animating element. Standard DOM, so it works identically on WebView2/WKWebView/WebKitGTK. Actions:\n- `list`: return all running CSS animations/transitions (optionally scoped by `selector`), each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n- `scrub`: deterministically pause the target's animation and seek it to `points` evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n- `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher on `selector` (or the first animating element); then trigger the animation; then call with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, max frame gap) and declared-vs-measured duration. Works for ANY animation including JS/rAF-driven ones. `clear=true` resets recorded sessions.\nNOTE: an animation only appears while it is running or pending — trigger it (e.g. show the notification) just before calling `list`/`scrub`, or arm `sample` before triggering.", + "name": "animation" }, { - "name": "dom_snapshot", - "description": "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot)." + "description": "Get comprehensive app info: Tauri config (identifier, product name, version), app directory paths (data, config, log, local_data), process environment variables, and database files found in app directories. Provides direct backend context without going through the webview.", + "name": "app_info" }, { - "name": "find_elements", - "description": "Search for elements by text, role, test_id, CSS selector (via `css` or `selector` param), or accessible name without a full snapshot. Returns lightweight matches with ref handles." + "description": "Read application-defined backend state via a registered probe. With no `probe`, lists available probe names. With a `probe` name, runs it and returns its JSON snapshot. Probes give first-class, discoverable access to domain state (e.g. a scoring pipeline's version + stale-item count, a queue's depth, cache stats) that would otherwise need query_db + log-grepping. Probes run in the Rust process with no IPC round-trip. Apps register them via VictauriBuilder::probe(name, closure).", + "name": "app_state" }, { - "name": "invoke_command", - "description": "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering." + "description": "Run a semantic assertion: evaluate a JS expression and check the result against an expected condition. Conditions: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is. A result of `undefined` is treated as `null` (JSON has no undefined; like JS `x == null`): `exists` is false, `falsy` is true, `equals null` and `type_is \"null\"` pass.", + "name": "assert_semantic" }, { - "name": "screenshot", - "description": "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux X11/XWayland. Pure Wayland fails safely because its available fallback would capture the full desktop rather than the requested window. A hidden (non-visible) window has no on-screen surface to capture, so requesting one returns a clear error (show it first via `window` manage_action=show) rather than a stale or wrong-window image." + "description": "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.", + "name": "check_ipc_integrity" }, { - "name": "verify_state", - "description": "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches." + "description": "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.", + "name": "css" }, { - "name": "detect_ghost_commands", - "description": "Detect ghost commands (frontend calls with no backend handler) by IPC OUTCOME, not by guessing from Victauri's registry. Returns: `confirmed_ghosts` = commands invoked that NEVER returned success and errored 'not found' — real missing-handler bugs, HIGH confidence and independent of whether the app uses #[inspectable]; `verified_handlers` = count of commands that returned success at least once (they provably HAVE a handler, so they are never flagged — this is why a real command like `set_language` is no longer a false positive); `frontend_only` = the WEAKER candidate tier (invoked, never observed succeeding, NOT a Tauri/plugin framework builtin, and absent from the introspection registry) — confirm against the app's `tauri::generate_handler!` before filing; `excluded_builtins` = framework `plugin:*` commands (never app ghosts); `registry_only` = registered commands never invoked (informational). The `reliability` field describes only `frontend_only`; `confirmed_ghosts` is high-confidence regardless. Reads the JS-side IPC interception log (ACCUMULATES all session traffic). For a clean signal scope with `since_ms` (e.g. 5000) — invoke the suspect action, then call this with `since_ms` — or `logs {action:'clear'}` then exercise the app." + "description": "Detect ghost commands (frontend calls with no backend handler) by IPC OUTCOME, not by guessing from Victauri's registry. Returns: `confirmed_ghosts` = commands invoked that NEVER returned success and errored 'not found' — real missing-handler bugs, HIGH confidence and independent of whether the app uses #[inspectable]; `verified_handlers` = count of commands that returned success at least once (they provably HAVE a handler, so they are never flagged — this is why a real command like `set_language` is no longer a false positive); `frontend_only` = the WEAKER candidate tier (invoked, never observed succeeding, NOT a Tauri/plugin framework builtin, and absent from the introspection registry) — confirm against the app's `tauri::generate_handler!` before filing; `excluded_builtins` = framework `plugin:*` commands (never app ghosts); `registry_only` = registered commands never invoked (informational). The `reliability` field describes only `frontend_only`; `confirmed_ghosts` is high-confidence regardless. Reads the JS-side IPC interception log (ACCUMULATES all session traffic). For a clean signal scope with `since_ms` (e.g. 5000) — invoke the suspect action, then call this with `since_ms` — or `logs {action:'clear'}` then exercise the app.", + "name": "detect_ghost_commands" }, { - "name": "check_ipc_integrity", - "description": "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls." + "description": "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80%% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot).", + "name": "dom_snapshot" }, { - "name": "wait_for", - "description": "Wait for a condition to be met. Polls at regular intervals until satisfied or timeout. Conditions: text (text appears), text_gone (text disappears), selector (CSS selector matches), selector_gone, url (URL contains value), ipc_idle (no pending IPC calls), network_idle (no pending network requests), expression (poll a JS expression in `value` until truthy or until it equals `expected` — may `await`, e.g. await a fire-and-forget command's status), event (block until the Tauri event named in `value` fires, with `since_ms` look-back). Use expression/event to await async backend work to true completion instead of guessing with a fixed sleep." + "description": "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.", + "name": "eval_js" }, { - "name": "assert_semantic", - "description": "Run a semantic assertion: evaluate a JS expression and check the result against an expected condition. Conditions: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is." + "description": "Correlate recent activity across all layers into a coherent narrative. CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network + window events across the Rust backend and webview simultaneously.\n\nActions:\n- `summary`: High-level activity summary for the last N seconds (default 30). Counts IPC calls, DOM mutations, console entries, state changes (incl. network requests), errors. With no recording active it reads the window's live event stream (`webview_label`, default main).\n- `last_action`: Correlate the most recent burst of events into a causal timeline (e.g. 'IPC call → DOM update → console.log').\n- `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.", + "name": "explain" }, { - "name": "resolve_command", - "description": "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples." + "description": "Probe a backend command handler under failure by faulting it for chaos engineering. Simulate slow commands, backend errors, dropped responses, and corrupted data. SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), which runs below the layer Victauri can reach. Use this to test a handler's error path when YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\nActions:\n- `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms` (max 120000), `error_message`, `max_triggers`. `delay` sleeps then runs the command; `error` returns the error without running it; `drop` returns `{}` without running it; `corrupt` runs it and replaces the response with a fixed `{\"__corrupted\":true,…}` marker. Rules expire after 15 minutes.\n- `list`: List all active fault injection rules.\n- `clear`: Remove a specific fault rule (requires `command`).\n- `clear_all`: Remove all fault rules.", + "name": "fault" }, { - "name": "get_registry", - "description": "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via the #[inspectable] macro — apps that don't use it return names with null schemas; for those, use `introspect command_catalog` to recover real argument/result shapes from the live IPC log." + "description": "Search for elements by text, role, test_id, CSS selector (via `css` or `selector` param), or accessible name without a full snapshot. Returns lightweight matches with ref handles.", + "name": "find_elements" }, { - "name": "app_state", - "description": "Read application-defined backend state via a registered probe. With no `probe`, lists available probe names. With a `probe` name, runs it and returns its JSON snapshot. Probes give first-class, discoverable access to domain state (e.g. a scoring pipeline's version + stale-item count, a queue's depth, cache stats) that would otherwise need query_db + log-grepping. Probes run in the Rust process with no IPC round-trip. Apps register them via VictauriBuilder::probe(name, closure)." + "description": "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.", + "name": "get_diagnostics" }, { - "name": "get_memory_stats", - "description": "Get real-time process memory statistics from the OS (working set, page file usage). On Windows returns detailed metrics; on Linux returns virtual/resident size." + "description": "Get real-time process memory statistics from the OS (working set, page file usage). On Windows returns detailed metrics; on Linux returns virtual/resident size.", + "name": "get_memory_stats" }, { - "name": "get_plugin_info", - "description": "Inspect the Victauri plugin's own configuration: port, enabled/disabled tools, command filters, privacy settings, capacities, and version. Useful for agents to understand their capabilities before acting." + "description": "Inspect the Victauri plugin's own configuration: port, enabled/disabled tools, command filters, privacy settings, capacities, and version. Useful for agents to understand their capabilities before acting.", + "name": "get_plugin_info" }, { - "name": "get_diagnostics", - "description": "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app." + "description": "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via the #[inspectable] macro — apps that don't use it return names with null schemas; for those, use `introspect command_catalog` to recover real argument/result shapes from the live IPC log.", + "name": "get_registry" }, { - "name": "app_info", - "description": "Get comprehensive app info: Tauri config (identifier, product name, version), app directory paths (data, config, log, local_data), process environment variables, and database files found in app directories. Provides direct backend context without going through the webview." + "description": "Text and keyboard input. Actions: fill (set input value), type_text (character-by-character typing), press_key (trigger a keyboard key). Subject to privacy controls. An input the page refuses (element not fillable, covered, disabled, or ref not found) returns an error with the reason and a [hint: RETRY_LATER|CHECK_INPUT].", + "name": "input" }, { - "name": "list_app_dir", - "description": "List files in the app's data, config, log, or local_data directories. Useful for discovering databases, config files, logs, and cached data on the backend — without going through the webview." + "description": "CSS and visual inspection. Actions: get_styles (computed CSS for element), get_bounding_boxes (layout rects), highlight (debug overlay), clear_highlights, audit_accessibility (a11y audit), get_performance (timing/heap/DOM metrics).", + "name": "inspect" }, { - "name": "read_app_file", - "description": "Read a file from the app's data, config, log, or local_data directory. Returns UTF-8 text by default, or base64 for binary files. Directly reads backend files without going through the webview." + "description": "DOM element interactions. Actions: click, double_click, hover, focus, scroll_into_view, select_option. Requires ref_id from a dom_snapshot for most actions. An action the page refuses (element covered, disabled, hidden, detached, or ref not found) returns an error with the reason and a [hint: RETRY_LATER|CHECK_INPUT].", + "name": "interact" }, { - "name": "query_db", - "description": "Execute a bounded, read-only SQL query against a SQLite database in the app's data directory. The SQL goes in the `query` field (alias: `sql`). Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. CPU time, cell size, row count, and returned bytes are capped. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC." + "description": "Deep backend introspection — command profiling, IPC contract testing, coverage, startup timing, capability auditing, database diagnostics, process enumeration, and event bus monitoring. These features exploit Victauri's position inside the Rust process.\n\nActions:\n- `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n- `coverage`: Which registered commands have been called during this session.\n- `command_catalog`: Per-command argument + result SHAPES mined from the live IPC log, merged with the registry — real call/return schemas even for apps that don't use #[inspectable] (where get_registry is names-only). The highest-signal way to learn how to drive an app's commands.\n- `contract_record`: Record a command's response shape as a baseline (requires `command`).\n- `contract_check`: Check all recorded contracts for schema drift.\n- `contract_list`: List all recorded contract baselines.\n- `contract_clear`: Clear all recorded contract baselines.\n- `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n- `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n- `db_health`: Read-only SQLite diagnostics: journal mode, page stats, freelist, per-table row counts, and SQLite `quick_check` — each phase budgeted; a phase that runs out is reported (`row_counts_complete`, `integrity_check: \"not completed…\"`) instead of failing.\n- `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n- `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n- `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n- `event_bus`: List captured Tauri events + app events (auto-intercepted via listen_any — no app opt-in needed). Returns the newest events per category (default 100) so the full buffers (up to ~11k events / megabytes) never overflow the result; `count` is the true total and `truncated` flags a capped slice. Scope via the `args` object: `{\"action\":\"event_bus\",\"args\":{\"limit\":500,\"since_ms\":5000}}`.\n- `event_bus_clear`: Clear the event bus capture buffer.", + "name": "introspect" }, { - "name": "interact", - "description": "DOM element interactions. Actions: click, double_click, hover, focus, scroll_into_view, select_option. Requires ref_id from a dom_snapshot for most actions." + "description": "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Waits up to the eval timeout (30s) — pass `timeout_ms` (max 300000) for a legitimately slow command. Subject to privacy command filtering.", + "name": "invoke_command" }, { - "name": "input", - "description": "Text and keyboard input. Actions: fill (set input value), type_text (character-by-character typing), press_key (trigger a keyboard key). Subject to privacy controls." + "description": "List files in the app's data, config, log, or local_data directories. Useful for discovering databases, config files, logs, and cached data on the backend — without going through the webview.", + "name": "list_app_dir" }, { - "name": "window", - "description": "Window management. Actions: get_state (window positions/sizes/visibility), list (all window labels), manage (minimize/maximize/close/focus/show/hide/fullscreen/always_on_top), resize, move_to, set_title, introspectability (probe every window and report which Victauri can actually see — a visible window that comes back introspectable:false is almost always missing the \"victauri:default\" capability; run this FIRST when eval_js/dom_snapshot/animation return nothing for a multi-window app)." + "description": "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls), clear (DELETES the captured IPC + network logs — use for per-test isolation).", + "name": "logs" }, { - "name": "storage", - "description": "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete." + "description": "Navigation and dialog control. Actions: go_to (navigate to URL), go_back (browser back), get_history (navigation log), set_dialog_response (auto-respond to alert/confirm/prompt), get_dialog_log (captured dialog events). Subject to privacy controls for go_to and set_dialog_response.", + "name": "navigate" }, { - "name": "navigate", - "description": "Navigation and dialog control. Actions: go_to (navigate to URL), go_back (browser back), get_history (navigation log), set_dialog_response (auto-respond to alert/confirm/prompt), get_dialog_log (captured dialog events). Subject to privacy controls for go_to and set_dialog_response." + "description": "Execute a bounded, read-only SQL query against a SQLite database in the app's data directory. The SQL goes in the `query` field (alias: `sql`). Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. CPU time, cell size, row count, and returned bytes are capped. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.", + "name": "query_db" }, { - "name": "recording", - "description": "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load session from JSON), replay (re-execute recorded IPC commands and compare responses), flush (immediately drain pending events into recording without waiting for the 1-second poll)." + "description": "Read a file from the app's data, config, log, or local_data directory. Returns UTF-8 text by default, or base64 for binary files. Directly reads backend files without going through the webview.", + "name": "read_app_file" }, { - "name": "inspect", - "description": "CSS and visual inspection. Actions: get_styles (computed CSS for element), get_bounding_boxes (layout rects), highlight (debug overlay), clear_highlights, audit_accessibility (a11y audit), get_performance (timing/heap/DOM metrics)." + "description": "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load a session from JSON as the active recording; refused while one is in progress), replay (re-invoke the recorded IPC commands that succeeded with no arguments, each in the window that made it — their side effects happen again; calls that had arguments, failed, never completed, or were answered by a route rule are skipped, and webview_label limits replay to that window's calls), flush (immediately drain pending events into recording without waiting for the 1-second poll).", + "name": "recording" }, { - "name": "css", - "description": "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls." + "description": "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.", + "name": "resolve_command" }, { - "name": "route", - "description": "Network request interception (Playwright route() equivalent, no CDP). Matches webview fetch/XHR by URL and blocks, mocks, or delays them. Actions: - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). `times` limits how often it fires. Rules are page-scoped (cleared on reload). - `list`: list active rules. - `clear` (by `id`) / `clear_all`: remove rules. - `matches`: log of intercepted requests. Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the `fault` tool only affects commands you drive via `invoke_command`, not real user IPC." + "description": "Network request interception (Playwright route() equivalent, no CDP). Matches webview fetch/XHR by URL and blocks, mocks, or delays them. Actions:\n- `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n- `list`: list active rules.\n- `clear` (by `id`) / `clear_all`: remove rules.\n- `matches`: log of intercepted requests.\nNote: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.", + "name": "route" }, { - "name": "trace", - "description": "Screencast / visual trace (no CDP). Captures the window at a fixed interval into a ring buffer, forming a visual timeline that pairs with `recording` (events) and `logs` (network/console). Actions: - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set `with_events=true` to also start the event recorder. - `stop`: stop and return a summary (frame count, duration, timestamps). - `status`: active flag + buffered frame count. - `frames`: return captured frames as base64 PNGs (`limit` caps how many)." + "description": "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux X11/XWayland. Pure Wayland fails safely because its available fallback would capture the full desktop rather than the requested window. A hidden (non-visible) window has no on-screen surface to capture, so requesting one returns a clear error (show it first via `window` manage_action=show) rather than a stale or wrong-window image.", + "name": "screenshot" }, { - "name": "animation", - "description": "Animation introspection (no CDP). Reads the Web Animations API to reveal what the webview's animation engine is actually running — duration, delay, easing, iterations, keyframes, current progress, and the animating element. Standard DOM, so it works identically on WebView2/WKWebView/WebKitGTK. Actions: - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), each with declared `timing`, `computed` progress, `keyframes`, and `target`. - `scrub`: deterministically pause the target's animation and seek it to `points` evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven animations only (JS/rAF animations are not seekable — use `list`/`sample`). - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher on `selector` (or the first animating element); then trigger the animation; then call with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, max frame gap) and declared-vs-measured duration. Works for ANY animation including JS/rAF-driven ones. `clear=true` resets recorded sessions. NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the notification) just before calling `list`/`scrub`, or arm `sample` before triggering." + "description": "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.", + "name": "storage" }, { - "name": "logs", - "description": "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls)." + "description": "Screencast / visual trace (no CDP). Captures the window at a fixed interval into a ring buffer, forming a visual timeline that pairs with `recording` (events) and `logs` (network/console). Actions:\n- `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set `with_events=true` to also start the event recorder. Hidden windows are skipped (no frame), the buffer is capped at 256 MB, and an abandoned trace auto-stops after 30 minutes.\n- `stop`: stop and return a summary (frame count, duration, timestamps); also stops the recording that `with_events` started (never one started separately).\n- `status`: active flag + buffered frame count.\n- `frames`: return captured frames as base64 PNGs, newest first up to a 25 MB response (`limit` caps how many).", + "name": "trace" }, { - "name": "introspect", - "description": "Deep backend introspection — command profiling, IPC contract testing, coverage, startup timing, capability auditing, database diagnostics, process enumeration, and event bus monitoring. These features exploit Victauri's position inside the Rust process. Actions: - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter. - `coverage`: Which registered commands have been called during this session. - `command_catalog`: Per-command argument + result SHAPES mined from the live IPC log, merged with the registry — real call/return schemas even for apps that don't use #[inspectable] (where get_registry is names-only). The highest-signal way to learn how to drive an app's commands. - `contract_record`: Record a command's response shape as a baseline (requires `command`). - `contract_check`: Check all recorded contracts for schema drift. - `contract_list`: List all recorded contract baselines. - `contract_clear`: Clear all recorded contract baselines. - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown. - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions. - `db_health`: Read-only SQLite database diagnostics (journal mode, WAL presence, page stats). - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.). - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage. - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status. - `event_bus`: List captured Tauri events + app events (auto-intercepted via listen_any — no app opt-in needed). Returns the newest events per category (default 100) so the full buffers (up to ~11k events / megabytes) never overflow the result; `count` is the true total and `truncated` flags a capped slice. Scope via the `args` object: `{\"action\":\"event_bus\",\"args\":{\"limit\":500,\"since_ms\":5000}}`. - `event_bus_clear`: Clear the event bus capture buffer." + "description": "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.", + "name": "verify_state" }, { - "name": "fault", - "description": "Probe a backend command handler under failure by faulting it for chaos engineering. Simulate slow commands, backend errors, dropped responses, and corrupted data. SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), which runs below the layer Victauri can reach. Use this to test a handler's error path when YOU drive it; it does not reproduce a failure a user clicking the UI would see. Actions: - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`. - `list`: List all active fault injection rules. - `clear`: Remove a specific fault rule (requires `command`). - `clear_all`: Remove all fault rules." + "description": "Wait for a condition to be met. Polls at regular intervals until satisfied or timeout. Conditions: text (text appears), text_gone (text disappears), selector (CSS selector matches), selector_gone, url (URL contains value), ipc_idle (no pending IPC calls), network_idle (no pending network requests), expression (poll a JS expression in `value` until truthy or until it equals `expected` — may `await`, e.g. await a fire-and-forget command's status), event (block until the Tauri event named in `value` fires, with `since_ms` look-back). Use expression/event to await async backend work to true completion instead of guessing with a fixed sleep.", + "name": "wait_for" }, { - "name": "explain", - "description": "Correlate recent activity across all layers into a coherent narrative. CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network + window events across the Rust backend and webview simultaneously. Actions: - `summary`: High-level activity summary for the last N seconds (default 30). Counts IPC calls, DOM mutations, console entries, network requests, errors. - `last_action`: Correlate the most recent burst of events into a causal timeline (e.g. 'IPC call → DOM update → console.log'). - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands." + "description": "Window management. Actions: get_state (window positions/sizes/visibility), list (all window labels), manage (minimize/maximize/close/focus/show/hide/fullscreen/always_on_top), resize, move_to, set_title, introspectability (probe every window and report which Victauri can actually see — a visible window that comes back introspectable:false is almost always missing the \"victauri:default\" capability; run this FIRST when eval_js/dom_snapshot/animation return nothing for a multi-window app).", + "name": "window" } ] diff --git a/crates/victauri-cli/tests/bridge_e2e.rs b/crates/victauri-cli/tests/bridge_e2e.rs index 6fdd92a3..1c3fa9cf 100644 --- a/crates/victauri-cli/tests/bridge_e2e.rs +++ b/crates/victauri-cli/tests/bridge_e2e.rs @@ -40,6 +40,13 @@ struct Mock { /// Counts `notifications/initialized` the bridge forwards to the BACKEND after its /// handshake — proves the MCP lifecycle is completed for a stateful backend (audit M2). initialized_count: Arc, + /// The app identity `/info` reports (the bridge confirms it before binding `--app`). + identity: Arc>, +} + +/// `/info`, reporting the mock's app identity like the real plugin. +async fn info(State(s): State) -> Json { + Json(json!({"app_identifier": s.identity.lock().unwrap().clone()})) } struct ChildGuard { @@ -53,6 +60,31 @@ impl Drop for ChildGuard { } } +/// A private temp root for one test. The bridge under test resolves its discovery dir from +/// it, so the fake entry is invisible to every real `victauri bridge` on the machine — the +/// entries used to go into the real `/victauri`, where a developer's own editor session +/// discovered the mock server mid-test and offered its tools to the agent. +struct IsolatedTemp(tempfile::TempDir); + +impl IsolatedTemp { + fn new() -> Self { + Self(tempfile::tempdir().expect("create isolated temp dir")) + } + + fn discovery_dir(&self, pid: u32) -> PathBuf { + self.0.path().join("victauri").join(pid.to_string()) + } + + /// The real bridge binary, with every temp-dir source pointed at this root. + fn bridge(&self) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_victauri")); + for var in ["TMP", "TEMP", "TMPDIR", "XDG_RUNTIME_DIR"] { + cmd.env(var, self.0.path()); + } + cmd + } +} + struct DirGuard(PathBuf); impl Drop for DirGuard { @@ -122,12 +154,14 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { init_count: Arc::new(AtomicU64::new(0)), toolcall_ok: Arc::new(AtomicU64::new(0)), initialized_count: Arc::new(AtomicU64::new(0)), + identity: Arc::default(), }; // Start the mock backend on an ephemeral port. let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/mcp", post(mcp)) + .route("/info", get(info)) .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -142,8 +176,10 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { .unwrap() .as_nanos(); let ident = format!("com.test.e2e-bridge.{unique}"); + *mock.identity.lock().unwrap() = ident.clone(); let pid = std::process::id(); - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let iso = IsolatedTemp::new(); + let dir = iso.discovery_dir(pid); std::fs::create_dir_all(&dir).unwrap(); let _dir_guard = DirGuard(dir.clone()); std::fs::write(dir.join("port"), port.to_string()).unwrap(); @@ -156,7 +192,8 @@ async fn bridge_selects_by_identity_forwards_and_survives_restart() { // Spawn the real bridge binary, pinned to our app by identity. let mut child = ChildGuard { - child: Command::new(env!("CARGO_BIN_EXE_victauri")) + child: iso + .bridge() .args(["bridge", "--wait", "--app", ident.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -285,14 +322,16 @@ async fn bridge_cold_start_serves_handshake_then_goes_live_when_app_appears() { .as_nanos(); let ident = format!("com.test.coldstart.{unique}"); let pid = std::process::id(); - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let iso = IsolatedTemp::new(); + let dir = iso.discovery_dir(pid); // Ensure we truly start "app down" — no leftover discovery entry for this pid. let _ = std::fs::remove_dir_all(&dir); let _dir_guard = DirGuard(dir.clone()); // Spawn the bridge with NO backend present, pinned to our (currently-absent) app. let mut child = ChildGuard { - child: Command::new(env!("CARGO_BIN_EXE_victauri")) + child: iso + .bridge() .args(["bridge", "--app", ident.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -416,10 +455,13 @@ async fn bridge_cold_start_serves_handshake_then_goes_live_when_app_appears() { init_count: Arc::new(AtomicU64::new(0)), toolcall_ok: Arc::new(AtomicU64::new(0)), initialized_count: Arc::new(AtomicU64::new(0)), + identity: Arc::default(), }; + *mock.identity.lock().unwrap() = ident.clone(); let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/mcp", post(mcp)) + .route("/info", get(info)) .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -501,10 +543,12 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { init_count: Arc::new(AtomicU64::new(0)), toolcall_ok: Arc::new(AtomicU64::new(0)), initialized_count: Arc::new(AtomicU64::new(0)), + identity: Arc::default(), }; let app = Router::new() .route("/health", get(|| async { "ok" })) .route("/mcp", post(mcp)) + .route("/info", get(info)) .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); @@ -517,8 +561,10 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { .unwrap() .as_nanos(); let ident = format!("com.test.reresolve.{unique}"); + *mock.identity.lock().unwrap() = ident.clone(); let pid = std::process::id(); - let dir = std::env::temp_dir().join("victauri").join(pid.to_string()); + let iso = IsolatedTemp::new(); + let dir = iso.discovery_dir(pid); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let _dir_guard = DirGuard(dir.clone()); @@ -537,7 +583,8 @@ async fn bridge_reresolves_and_wont_reuse_a_stale_cached_backend() { .unwrap(); let mut child = ChildGuard { - child: Command::new(env!("CARGO_BIN_EXE_victauri")) + child: iso + .bridge() .args(["bridge", "--app", ident.as_str()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/victauri-cli/tests/bridge_protocol_e2e.rs b/crates/victauri-cli/tests/bridge_protocol_e2e.rs new file mode 100644 index 00000000..aa5cfcaf --- /dev/null +++ b/crates/victauri-cli/tests/bridge_protocol_e2e.rs @@ -0,0 +1,600 @@ +//! End-to-end tests of the real `victauri bridge` binary's JSON-RPC edge cases (round-5 audit): +//! selector normalization and message shapes the bridge must not mishandle. +//! +//! Each test runs its own mock backend, writes a discovery entry under a PRIVATE temp root +//! (the bridge's `TMP`/`TEMP`/`TMPDIR`/`XDG_RUNTIME_DIR` point there), so the tests never see — +//! or are seen by — any real Victauri app or bridge on the machine, and can run in parallel. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::Router; +use axum::routing::get; +use serde_json::{Value, json}; + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// A running bridge bound (through a real discovery entry) to a mock backend. +struct Harness { + stdin: ChildStdin, + rx: mpsc::Receiver, + stderr: Arc>>, + _child: ChildGuard, + _root: tempfile::TempDir, +} + +/// Knobs for [`Harness::start_with`]. +#[derive(Default)] +struct Opts<'a> { + /// Pass `--app `. + app_arg: bool, + /// Extra environment, applied after the Victauri variables are cleared. A value of + /// `"{port}"` is replaced with the mock backend's port. + env: &'a [(&'a str, &'a str)], + /// Extra bridge arguments. + args: &'a [&'a str], + /// The identity the backend's `/info` reports; `None` = the discovery entry's own. + info_identity: Option<&'a str>, +} + +impl Harness { + /// Serve `mcp_routes` (plus `/health`) on an ephemeral port, write a discovery entry for it + /// with a unique identity, and spawn the bridge. `app_arg` = pass `--app `; + /// `env` is applied after the Victauri selector variables are cleared. + async fn start(mcp_routes: Router, app_arg: bool, env: &[(&str, &str)]) -> Self { + Self::start_with( + mcp_routes, + Opts { + app_arg, + env, + ..Opts::default() + }, + ) + .await + } + + async fn start_with(mcp_routes: Router, opts: Opts<'_>) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let ident = format!("com.test.bridge-protocol.{unique}"); + // `/info` reports the app's identity, like the real plugin (the bridge confirms it + // before binding an `--app`, R5B-BR6). + let info = json!({ + "app_identifier": opts.info_identity.unwrap_or(ident.as_str()), + "app_product_name": "Proto", + }); + let router = mcp_routes + .route("/health", get(|| async { "ok" })) + .route("/info", get(move || async move { axum::Json(info) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let root = tempfile::tempdir().unwrap(); + let pid = std::process::id(); + let dir = root.path().join("victauri").join(pid.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for d in [root.path().join("victauri"), dir.clone()] { + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + } + std::fs::write(dir.join("port"), port.to_string()).unwrap(); + std::fs::write(dir.join("token"), "proto-token").unwrap(); + std::fs::write( + dir.join("metadata.json"), + json!({"pid": pid, "port": port, "identifier": ident, "product_name": "Proto"}) + .to_string(), + ) + .unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_victauri")); + for var in ["TMP", "TEMP", "TMPDIR", "XDG_RUNTIME_DIR"] { + cmd.env(var, root.path()); + } + for var in ["VICTAURI_APP", "VICTAURI_PORT", "VICTAURI_AUTH_TOKEN"] { + cmd.env_remove(var); + } + for (k, v) in opts.env { + let v = if *v == "{port}" { + port.to_string() + } else { + (*v).to_string() + }; + cmd.env(k, v); + } + cmd.arg("bridge"); + if opts.app_arg { + cmd.args(["--app", ident.as_str()]); + } + cmd.args(opts.args); + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn victauri bridge"); + + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr_pipe = child.stderr.take().unwrap(); + let stderr = Arc::new(Mutex::new(Vec::::new())); + let capture = Arc::clone(&stderr); + std::thread::spawn(move || { + for line in BufReader::new(stderr_pipe).lines() { + let Ok(line) = line else { break }; + capture.lock().unwrap().push(line); + } + }); + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + + let mut h = Self { + stdin, + rx, + stderr, + _child: ChildGuard(child), + _root: root, + }; + h.send(&json!({"jsonrpc":"2.0","id":"init","method":"initialize","params":{}})); + let init = h.recv_reply(); + assert_eq!(init["id"], "init", "{init}"); + h.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"})); + h + } + + fn send(&mut self, v: &Value) { + writeln!(self.stdin, "{v}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// The next line that is a reply (an object with an `id`, or a batch array), skipping the + /// poller's id-less `list_changed` notifications. + fn recv_reply(&self) -> Value { + loop { + let line = match self.rx.recv_timeout(Duration::from_secs(20)) { + Ok(l) => l, + Err(e) => { + let s = self.stderr.lock().unwrap().join("\n"); + panic!("bridge produced no reply in time: {e}; stderr:\n{s}"); + } + }; + let v: Value = serde_json::from_str(&line).expect("bridge stdout is JSON"); + if v.is_array() || v.get("id").is_some() { + return v; + } + } + } +} + +/// A stateless mock backend (like the real plugin's default): no session ids. +#[derive(Clone, Default)] +struct Backend { + tool_calls: Arc, + /// JSON arrays (batches) that reached the backend. + batches: Arc, + /// Client->server JSON-RPC responses (no `method`) that reached the backend. + responses: Arc, + /// Messages with neither a `method` nor a `result`/`error` that reached the backend. + malformed: Arc, + /// `notifications/cancelled` that reached the backend. + cancelled: Arc, + /// `slow` tool calls that have FINISHED on the backend. + slow_done: Arc, +} + +/// How long the mock's `slow` tool takes. +const SLOW_CALL: Duration = Duration::from_secs(4); + +async fn stateless_mcp( + axum::extract::State(b): axum::extract::State, + body: String, +) -> axum::response::Response { + use axum::response::IntoResponse; + let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null); + if v.is_array() { + // What rmcp 3.1.2 does with a batch (verified live): reject it, never execute it. + b.batches.fetch_add(1, Ordering::SeqCst); + return axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); + } + let id = v.get("id").cloned(); + if v.get("method").is_none() { + if v.get("result").is_some() || v.get("error").is_some() { + // A response to a server-initiated request: accepted, no body (as rmcp does). + b.responses.fetch_add(1, Ordering::SeqCst); + return axum::http::StatusCode::ACCEPTED.into_response(); + } + b.malformed.fetch_add(1, Ordering::SeqCst); + } + match v.get("method").and_then(Value::as_str) { + Some("tools/call") if v.pointer("/params/name") == Some(&json!("broken_sse")) => { + b.tool_calls.fetch_add(1, Ordering::SeqCst); + // The call is running (a progress event is streamed), then the connection dies + // before the result: the command may well have executed. + let progress = format!( + "data: {} + +", + json!({"jsonrpc":"2.0","method":"notifications/progress", + "params":{"progressToken":1,"progress":1}}) + ); + let body = futures_util::stream::unfold(0u8, move |step| { + let progress = progress.clone(); + async move { + match step { + 0 => Some((Ok::<_, std::io::Error>(progress), 1)), + 1 => { + tokio::time::sleep(Duration::from_millis(300)).await; + Some((Err(std::io::Error::other("app died mid-stream")), 2)) + } + _ => None, + } + } + }); + axum::response::Response::builder() + .header("content-type", "text/event-stream") + .body(axum::body::Body::from_stream(body)) + .unwrap() + } + Some("tools/call") if v.pointer("/params/name") == Some(&json!("slow")) => { + b.tool_calls.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(SLOW_CALL).await; + b.slow_done.fetch_add(1, Ordering::SeqCst); + axum::Json(json!({"jsonrpc":"2.0","id":id, + "result":{"content":[{"type":"text","text":"slow done"}]}})) + .into_response() + } + Some("tools/call") => { + b.tool_calls.fetch_add(1, Ordering::SeqCst); + axum::Json(json!({"jsonrpc":"2.0","id":id, + "result":{"content":[{"type":"text","text":"ok"}]}})) + .into_response() + } + Some(m) if m.starts_with("notifications/") => { + if m == "notifications/cancelled" { + b.cancelled.fetch_add(1, Ordering::SeqCst); + } + axum::http::StatusCode::ACCEPTED.into_response() + } + _ => axum::Json(json!({"jsonrpc":"2.0","id":id,"result":{}})).into_response(), + } +} + +fn backend_routes(b: &Backend) -> Router { + Router::new() + .route("/mcp", axum::routing::post(stateless_mcp)) + .with_state(b.clone()) +} + +/// R5-BR2: `VICTAURI_APP=` (set but empty, e.g. from a templated `.mcp.json`) must mean "no +/// selector", as it does for victauri-test and the watchdog — not "match an app named ''", +/// which made every call "backend not reachable" with the one running app sitting right there. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_victauri_app_env_is_treated_as_unset() { + for blank in ["", " "] { + let backend = Backend::default(); + let mut h = + Harness::start(backend_routes(&backend), false, &[("VICTAURI_APP", blank)]).await; + h.send(&json!({"jsonrpc":"2.0","id":2,"method":"tools/call", + "params":{"name":"get_plugin_info","arguments":{}}})); + let r = h.recv_reply(); + assert_eq!(r["id"], 2, "{r}"); + assert!( + r.get("result").is_some(), + "VICTAURI_APP={blank:?} must not select nothing: {r}" + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 1); + } +} + +/// R5-BR1: a JSON-RPC batch (a JSON array) is answered LOCALLY — MCP removed batching in +/// 2025-06-18 and the real server rejects it — with a JSON-RPC batch response: one -32600 error +/// per request element, carrying that element's id (notifications get none), so every pending +/// id the client holds is resolved. It is never forwarded (so never re-sent after a failure), +/// and an all-notification batch gets no reply at all, per JSON-RPC 2.0 §6. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_batch_is_rejected_locally_with_one_error_per_request() { + let backend = Backend::default(); + let mut h = Harness::start(backend_routes(&backend), true, &[]).await; + + h.send(&json!([ + {"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"eval_js","arguments":{}}}, + {"jsonrpc":"2.0","method":"notifications/progress","params":{}}, + {"jsonrpc":"2.0","id":"eleven","method":"ping"}, + 42 + ])); + let reply = h.recv_reply(); + let errs = reply + .as_array() + .unwrap_or_else(|| panic!("batch reply must be an array: {reply}")); + let ids: Vec<&Value> = errs.iter().map(|e| &e["id"]).collect(); + assert_eq!(ids, [&json!(10), &json!("eleven"), &Value::Null], "{reply}"); + for e in errs { + assert_eq!(e["jsonrpc"], "2.0"); + assert_eq!(e["error"]["code"], -32600, "{e}"); + assert!( + e["error"]["message"].as_str().unwrap().contains("batch"), + "{e}" + ); + } + + // An empty batch is itself an invalid request: one error object, id null. + h.send(&json!([])); + let empty = h.recv_reply(); + assert_eq!(empty["id"], Value::Null, "{empty}"); + assert_eq!(empty["error"]["code"], -32600, "{empty}"); + + // All notifications → no reply. The next reply seen must be the ping's. + h.send(&json!([{"jsonrpc":"2.0","method":"notifications/progress","params":{}}])); + h.send(&json!({"jsonrpc":"2.0","id":12,"method":"ping"})); + let ping = h.recv_reply(); + assert_eq!( + ping["id"], 12, + "an all-notification batch must get no reply: {ping}" + ); + + assert_eq!( + backend.batches.load(Ordering::SeqCst), + 0, + "batches are never forwarded" + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 0); +} + +/// R5-BR3: a message with an `id` but no `method` is a client->server RESPONSE (to a +/// server-initiated request such as sampling/elicitation/roots, which a stateful backend can +/// send inside an SSE stream the bridge relays). It is forwarded, but JSON-RPC never replies to +/// a response — the bridge used to answer the client's own reply with an invented error. A +/// message with neither a method nor a result/error is an Invalid Request, answered locally. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_client_response_is_forwarded_and_never_answered() { + let backend = Backend::default(); + let mut h = Harness::start(backend_routes(&backend), true, &[]).await; + + h.send(&json!({"jsonrpc":"2.0","id":"srv-1","result":{"roots":[]}})); + h.send(&json!({"jsonrpc":"2.0","id":"srv-2","error":{"code":-1,"message":"declined"}})); + // Responses are forwarded by the ordered one-way task, and nothing is written for them, so + // the ping's reply is the next one — unless the bridge answered either response. + h.send(&json!({"jsonrpc":"2.0","id":12,"method":"ping"})); + let next = h.recv_reply(); + assert_eq!( + next["id"], 12, + "a client response must never be answered: {next}" + ); + assert!( + wait_until(Duration::from_secs(10), || backend + .responses + .load(Ordering::SeqCst) + == 2) + .await, + "responses are still forwarded to the backend" + ); + + h.send(&json!({"jsonrpc":"2.0","id":13})); + let invalid = h.recv_reply(); + assert_eq!(invalid["id"], 13, "{invalid}"); + assert_eq!(invalid["error"]["code"], -32600, "{invalid}"); + assert_eq!(backend.malformed.load(Ordering::SeqCst), 0, "not forwarded"); +} + +/// R5-BR4: an SSE response that dies mid-stream (after a progress event) means the call was +/// delivered and may have run. The client must be told exactly that — and the call must not +/// be re-sent — instead of the misleading "empty or non-JSON response" (the body-read error +/// used to be swallowed by `unwrap_or_default`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_tool_call_whose_sse_stream_dies_says_it_may_have_run() { + let backend = Backend::default(); + let mut h = Harness::start(backend_routes(&backend), true, &[]).await; + + h.send(&json!({"jsonrpc":"2.0","id":20,"method":"tools/call", + "params":{"name":"broken_sse","arguments":{}}})); + let r = h.recv_reply(); + assert_eq!(r["id"], 20, "{r}"); + let msg = r["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("error reply: {r}")); + assert!( + msg.contains("NOT retried") && msg.contains("may already have taken effect"), + "must say the call may have executed: {msg}" + ); + assert!(!msg.contains("empty or non-JSON"), "{msg}"); + assert_eq!( + backend.tool_calls.load(Ordering::SeqCst), + 1, + "a possibly-executed tool call is never re-sent" + ); +} + +/// Poll `cond` until it holds or `within` elapses. +async fn wait_until(within: Duration, cond: impl Fn() -> bool) -> bool { + let deadline = std::time::Instant::now() + within; + while std::time::Instant::now() < deadline { + if cond() { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + cond() +} + +/// The next reply, waiting on the async runtime's blocking pool (the mock backend shares the +/// runtime, so the test thread must not block it). +async fn next_reply(h: &Arc>) -> (Value, std::time::Instant) { + let h = Arc::clone(h); + tokio::task::spawn_blocking(move || { + let r = h.lock().unwrap().recv_reply(); + (r, std::time::Instant::now()) + }) + .await + .unwrap() +} + +/// R5B-BR5: the stdio loop awaited each forward before reading the next line, so one long +/// `tools/call` (up to 330 s) stalled a `ping`, every parallel call, and the +/// `notifications/cancelled` meant to stop it. Requests now run concurrently. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_slow_tool_call_blocks_neither_ping_nor_cancel_nor_parallel_calls() { + let backend = Backend::default(); + let h = Harness::start(backend_routes(&backend), true, &[]).await; + let h = Arc::new(Mutex::new(h)); + let sent = std::time::Instant::now(); + { + let mut h = h.lock().unwrap(); + h.send(&json!({"jsonrpc":"2.0","id":30,"method":"tools/call", + "params":{"name":"slow","arguments":{}}})); + h.send(&json!({"jsonrpc":"2.0","id":31,"method":"tools/call", + "params":{"name":"slow","arguments":{}}})); + h.send(&json!({"jsonrpc":"2.0","id":32,"method":"ping"})); + h.send(&json!({"jsonrpc":"2.0","method":"notifications/cancelled", + "params":{"requestId":30,"reason":"user"}})); + } + + // The ping is answered at once, ahead of both slow calls. + let (ping, at) = next_reply(&h).await; + assert_eq!( + ping["id"], 32, + "ping must not queue behind a slow call: {ping}" + ); + assert!(at - sent < SLOW_CALL / 2, "ping took {:?}", at - sent); + + // The cancellation reaches the backend while the call it cancels is still running. + assert!( + wait_until(SLOW_CALL / 2, || backend.cancelled.load(Ordering::SeqCst) + == 1) + .await, + "notifications/cancelled did not reach the backend promptly" + ); + assert_eq!( + backend.slow_done.load(Ordering::SeqCst), + 0, + "the cancel must arrive while the call is in flight" + ); + + // Both slow calls ran in parallel: together they take ~one SLOW_CALL, not two. + let (a, _) = next_reply(&h).await; + let (b, done) = next_reply(&h).await; + let mut ids = [a["id"].as_i64().unwrap(), b["id"].as_i64().unwrap()]; + ids.sort_unstable(); + assert_eq!(ids, [30, 31], "{a} {b}"); + assert!( + done - sent < SLOW_CALL * 3 / 2, + "parallel calls were serialized: {:?}", + done - sent + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 2); +} + +/// R5B-BR6: `--app` binds by discovery metadata + PID liveness. After a crash the app's stale +/// entry can carry a PID that was reused by another of our processes while a DIFFERENT app +/// (auth disabled) now holds the port — the bridge then silently drove the wrong app. It now +/// confirms the identity the server itself reports on `/info` before forwarding anything. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_app_whose_info_reports_another_identity_is_never_driven() { + let backend = Backend::default(); + let mut h = Harness::start_with( + backend_routes(&backend), + Opts { + app_arg: true, + info_identity: Some("com.someone.else"), + ..Opts::default() + }, + ) + .await; + h.send(&json!({"jsonrpc":"2.0","id":40,"method":"tools/call", + "params":{"name":"invoke_command","arguments":{"command":"quit_app"}}})); + let r = h.recv_reply(); + assert_eq!(r["id"], 40, "{r}"); + let msg = r["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("must refuse, got: {r}")); + assert!( + msg.contains("com.someone.else"), + "names what it found: {msg}" + ); + assert_eq!( + backend.tool_calls.load(Ordering::SeqCst), + 0, + "nothing may be sent to a server that is not the selected app" + ); +} + +/// R5B-PORTAPP1: with `VICTAURI_PORT` set, `--app` / `VICTAURI_APP` was silently ignored — the +/// bridge drove whatever app held that port. When both are set they must agree. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn victauri_port_and_a_different_app_selector_are_refused_clearly() { + for (env, args) in [ + ( + &[("VICTAURI_PORT", "{port}")][..], + &["--app", "com.other.app"][..], + ), + ( + &[ + ("VICTAURI_PORT", "{port}"), + ("VICTAURI_APP", "com.other.app"), + ][..], + &[][..], + ), + ] { + let backend = Backend::default(); + let mut h = Harness::start_with( + backend_routes(&backend), + Opts { + env, + args, + ..Opts::default() + }, + ) + .await; + h.send(&json!({"jsonrpc":"2.0","id":50,"method":"tools/call", + "params":{"name":"get_plugin_info","arguments":{}}})); + let r = h.recv_reply(); + let msg = r["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("must refuse, got: {r}")); + assert!( + msg.contains("VICTAURI_PORT") && msg.contains("com.other.app"), + "{msg}" + ); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 0); + } + + // Agreeing selectors still work. + let backend = Backend::default(); + let mut h = Harness::start_with( + backend_routes(&backend), + Opts { + app_arg: true, + env: &[("VICTAURI_PORT", "{port}")], + ..Opts::default() + }, + ) + .await; + h.send(&json!({"jsonrpc":"2.0","id":51,"method":"tools/call", + "params":{"name":"get_plugin_info","arguments":{}}})); + let r = h.recv_reply(); + assert!(r.get("result").is_some(), "{r}"); + assert_eq!(backend.tool_calls.load(Ordering::SeqCst), 1); +} diff --git a/crates/victauri-cli/tests/cli_e2e.rs b/crates/victauri-cli/tests/cli_e2e.rs new file mode 100644 index 00000000..806b0687 --- /dev/null +++ b/crates/victauri-cli/tests/cli_e2e.rs @@ -0,0 +1,468 @@ +//! End-to-end tests of the real `victauri` CLI (check / invoke) against mock Victauri +//! servers found through REAL discovery files in a private temp root — never the machine's +//! own discovery directory, and never a real app's port. + +use std::path::PathBuf; +use std::process::{Child, Command, Output, Stdio}; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{Value, json}; + +/// A private temp root; the CLI under test resolves its discovery dir from it. +struct IsolatedTemp(tempfile::TempDir); + +impl IsolatedTemp { + fn new() -> Self { + Self(tempfile::tempdir().expect("create isolated temp dir")) + } + + /// Write a discovery entry exactly as the plugin does. + fn write_entry(&self, pid: u32, port: u16, token: &str, identifier: &str) -> PathBuf { + let dir = self.0.path().join("victauri").join(pid.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for d in [self.0.path().join("victauri"), dir.clone()] { + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + } + std::fs::write(dir.join("port"), port.to_string()).unwrap(); + std::fs::write(dir.join("token"), token).unwrap(); + std::fs::write( + dir.join("metadata.json"), + json!({"pid": pid, "port": port, "identifier": identifier, "product_name": "Mock"}) + .to_string(), + ) + .unwrap(); + dir + } + + /// The real CLI binary, with every temp-dir source pointed at this root and no + /// inherited endpoint configuration. + fn victauri(&self, args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_victauri")); + for var in ["TMP", "TEMP", "TMPDIR", "XDG_RUNTIME_DIR"] { + cmd.env(var, self.0.path()); + } + for var in ["VICTAURI_PORT", "VICTAURI_AUTH_TOKEN", "VICTAURI_APP"] { + cmd.env_remove(var); + } + cmd.args(args); + cmd + } +} + +/// A second live process of ours, so two discovery entries can both have live owners. +struct Sleeper(Child); + +impl Sleeper { + fn spawn() -> Self { + let child = if cfg!(windows) { + Command::new("ping") + .args(["-n", "120", "127.0.0.1"]) + .stdout(Stdio::null()) + .spawn() + } else { + Command::new("sleep").arg("120").spawn() + }; + Self(child.expect("spawn a sleeper process")) + } + + fn pid(&self) -> u32 { + self.0.id() + } +} + +impl Drop for Sleeper { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[derive(Clone)] +struct MockApp { + identifier: String, +} + +fn tool_result(value: &Value) -> Value { + json!({"content": [{"type": "text", "text": value.to_string()}]}) +} + +async fn mcp(State(app): State, body: String) -> Response { + let v: Value = serde_json::from_str(&body).unwrap_or_else(|_| json!({})); + let id = v.get("id").cloned(); + let result = match v["method"].as_str().unwrap_or("") { + "initialize" => json!({"protocolVersion": "2025-03-26", "capabilities": {}, + "serverInfo": {"name": "mock", "version": "0"}}), + "notifications/initialized" => return StatusCode::ACCEPTED.into_response(), + "tools/call" => { + let value = match v["params"]["name"].as_str().unwrap_or("") { + "get_plugin_info" => json!({ + "version": env!("CARGO_PKG_VERSION"), + "tools": {"total": 35, "enabled": 35}, + "uptime_secs": 5, + "app": {"identifier": app.identifier}, + }), + "get_registry" => json!([]), + "check_ipc_integrity" => json!({"healthy": true}), + // The plugin's real report shape: `confirmed_ghosts` items are + // `{name, error}`, `frontend_only` items are serialized `GhostCommand`s. + "detect_ghost_commands" => json!({ + "confirmed_ghosts": [ + {"name": "get_widgetz", "error": "command get_widgetz not found"}, + {"name": "get_widgetz", "error": "command get_widgetz not found"} + ], + "frontend_only": [ + {"name": "set_langauge", "source": "FrontendOnly", "description": null} + ], + "reliability": "low", + }), + "get_memory_stats" => json!({"working_set_bytes": 1_048_576}), + "invoke_command" => json!({"app": app.identifier}), + _ => json!({}), + }; + tool_result(&value) + } + _ => json!({}), + }; + Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response() +} + +/// Start a mock Victauri server; returns its port. +async fn start_mock(identifier: &str) -> u16 { + let state = MockApp { + identifier: identifier.to_string(), + }; + let info = json!({"app_identifier": identifier}); + let app = Router::new() + .route("/health", get(|| async { "ok" })) + .route("/info", get(move || async move { Json(info) })) + .route("/mcp", post(mcp)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + port +} + +/// Run the CLI off the async runtime (the mocks live on it). +async fn run(cmd: Command) -> Output { + let mut cmd = cmd; + tokio::task::spawn_blocking(move || cmd.output().expect("run victauri")) + .await + .unwrap() +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn check_lists_ghost_command_names_including_confirmed_ghosts() { + let iso = IsolatedTemp::new(); + let port = start_mock("com.test.ghosts").await; + iso.write_entry(std::process::id(), port, "tok", "com.test.ghosts"); + + let out = run(iso.victauri(&["check"])).await; + let err = stderr(&out); + assert!(out.status.success(), "check failed:\n{err}"); + assert!( + err.contains("get_widgetz"), + "confirmed ghosts must be listed by name:\n{err}" + ); + assert!( + err.contains("set_langauge"), + "candidate ghosts must be listed by name:\n{err}" + ); + assert_eq!( + err.matches("get_widgetz").count(), + 1, + "names are deduplicated:\n{err}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn two_running_apps_are_named_and_selectable_with_app() { + // R4-CLI1: with two apps and no VICTAURI_PORT, check/test/invoke/coverage/doctor fell back + // to (7373, no token) and printed a 401 with "stale CLI / is your app running?" advice. + let iso = IsolatedTemp::new(); + let sleeper = Sleeper::spawn(); + let port_a = start_mock("com.test.alpha").await; + let port_b = start_mock("com.test.beta").await; + iso.write_entry(std::process::id(), port_a, "tok-a", "com.test.alpha"); + iso.write_entry(sleeper.pid(), port_b, "tok-b", "com.test.beta"); + + let out = run(iso.victauri(&["check"])).await; + let err = stderr(&out); + assert!(!out.status.success(), "must refuse to guess:\n{err}"); + assert!( + err.contains(&format!("com.test.alpha (port {port_a}")), + "{err}" + ); + assert!( + err.contains(&format!("com.test.beta (port {port_b}")), + "{err}" + ); + assert!( + err.contains("--app") && err.contains("VICTAURI_APP"), + "{err}" + ); + assert!( + !err.contains("Is your Tauri app running") && !err.contains("401"), + "no misleading connection advice:\n{err}" + ); + + // `--app` selects one (exact identifier)… + let out = run(iso.victauri(&["invoke", "whoami", "--raw", "--app", "com.test.beta"])).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!(stdout(&out).contains("com.test.beta"), "{}", stdout(&out)); + + // …and so does VICTAURI_APP. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("VICTAURI_APP", "com.test.alpha"); + let out = run(cmd).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!(stdout(&out).contains("com.test.alpha"), "{}", stdout(&out)); + + // A prefix is not an identifier. + let out = run(iso.victauri(&["invoke", "whoami", "--raw", "--app", "com.test"])).await; + assert!(!out.status.success()); + assert!( + stderr(&out).contains("No running Victauri app matches"), + "{}", + stderr(&out) + ); + drop(sleeper); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_explicit_token_without_a_port_is_not_sent_to_the_default_port() { + // R4-TOK1: VICTAURI_AUTH_TOKEN with no VICTAURI_PORT was sent to whoever held 7373. + let iso = IsolatedTemp::new(); + let port = start_mock("com.test.token").await; + iso.write_entry(std::process::id(), port, "the-real-token", "com.test.token"); + + // A token matching the running app's own discovery token goes to that app. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("VICTAURI_AUTH_TOKEN", "the-real-token"); + let out = run(cmd).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!(stdout(&out).contains("com.test.token")); + + // Any other token is sent nowhere, with the fix named. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("VICTAURI_AUTH_TOKEN", "some-other-token"); + let out = run(cmd).await; + assert!(!out.status.success()); + let err = stderr(&out); + assert!(err.contains("VICTAURI_PORT"), "{err}"); +} + +#[test] +fn init_prints_a_hostile_identifier_on_one_escaped_line() { + // R4-TERM1: `victauri init` printed the raw `identifier` from tauri.conf.json — a cloned + // repo could forge a GitHub Actions annotation (`::error`) or drive the terminal (ESC). + let iso = IsolatedTemp::new(); + let project = iso.0.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ntauri = \"2\"\n", + ) + .unwrap(); + std::fs::write( + project.join("tauri.conf.json"), + json!({"identifier": "com.x\n::error file=src/main.rs::forged\u{1b}[2J"}).to_string(), + ) + .unwrap(); + let out = iso + .victauri(&["init", "--path", project.to_str().unwrap()]) + .output() + .unwrap(); + let err = stderr(&out); + assert!(out.status.success(), "{err}"); + assert!( + !err.lines().any(|l| l.trim_start().starts_with("::")), + "an identifier line became a workflow command:\n{err}" + ); + assert!( + !err.contains('\u{1b}'), + "raw ESC reached the terminal:\n{err:?}" + ); + assert!( + err.contains(r"bridge pinned to app 'com.x\n::error"), + "{err}" + ); +} + +/// R5B-DOCTOR1: `victauri doctor` printed `[FAIL]` items but always exited 0, so a CI step +/// running it could never fail. A FAIL exits 1; warnings alone do not. +#[test] +fn doctor_exits_nonzero_when_a_check_fails() { + let iso = IsolatedTemp::new(); + // No Cargo.toml here: the very first check FAILs. + let empty = iso.0.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let out = iso + .victauri(&["doctor"]) + .current_dir(&empty) + .output() + .unwrap(); + let err = stderr(&out); + assert!(err.contains("[FAIL]"), "{err}"); + assert_eq!(out.status.code(), Some(1), "a FAIL must exit 1:\n{err}"); +} + +fn write_tauri_project(dir: &std::path::Path, tauri_dep: bool) { + std::fs::create_dir_all(dir).unwrap(); + let deps = if tauri_dep { "tauri = \"2\"\n" } else { "" }; + std::fs::write( + dir.join("Cargo.toml"), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{deps}" + ), + ) + .unwrap(); + std::fs::write( + dir.join("tauri.conf.json"), + json!({"identifier": "com.test.init"}).to_string(), + ) + .unwrap(); +} + +/// R5B-INIT1: `init` canonicalizes the project root, which on Windows yields a `\?\C:\…` +/// verbatim path — and printed it that way. +#[test] +fn init_never_prints_verbatim_windows_paths() { + let iso = IsolatedTemp::new(); + let project = iso.0.path().join("project"); + // No tauri dependency → `init` warns and names the Cargo.toml path. + write_tauri_project(&project, false); + let out = iso + .victauri(&["init", "--path", project.to_str().unwrap()]) + .output() + .unwrap(); + let err = stderr(&out); + assert!(out.status.success(), "{err}"); + assert!(err.contains("Cargo.toml"), "{err}"); + assert!(!err.contains(r"\?\"), "verbatim path printed:\n{err}"); +} + +/// R5B-INIT2: with an existing `.mcp.json` that lacks Victauri, the suggested snippet must pin +/// the bridge to the app exactly like the generated file does (`--app `). +#[test] +fn init_suggestion_for_an_existing_mcp_json_pins_the_app() { + let iso = IsolatedTemp::new(); + let project = iso.0.path().join("project"); + write_tauri_project(&project, true); + std::fs::write(project.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); + let out = iso + .victauri(&["init", "--path", project.to_str().unwrap()]) + .output() + .unwrap(); + let err = stderr(&out); + assert!(out.status.success(), "{err}"); + assert!( + err.contains(r#""args": ["bridge", "--app", "com.test.init"]"#), + "the suggestion must pin the app:\n{err}" + ); +} + +/// R5B-PORTAPP1: with `VICTAURI_PORT` set, `--app` / `VICTAURI_APP` was silently ignored by +/// victauri-test's discovery (so by every CLI command but the bridge) — the command drove +/// whatever app held that port. Both set must agree; disagreement is a clear error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn victauri_port_with_a_different_app_selector_is_refused() { + let iso = IsolatedTemp::new(); + let port = start_mock("com.test.portapp").await; + iso.write_entry(std::process::id(), port, "tok", "com.test.portapp"); + + // Discovery metadata for that port disagrees with the selector. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw", "--app", "com.test.other"]); + cmd.env("VICTAURI_PORT", port.to_string()); + let out = run(cmd).await; + let err = stderr(&out); + assert!(!out.status.success(), "must refuse:\n{err}"); + assert!( + err.contains("VICTAURI_PORT") && err.contains("com.test.other"), + "{err}" + ); + assert!( + !stdout(&out).contains("com.test.portapp"), + "drove the app anyway" + ); + + // No discovery entry for the port (explicit token too): `/info` still disagrees. + let other = start_mock("com.test.unlisted").await; + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("VICTAURI_PORT", other.to_string()) + .env("VICTAURI_AUTH_TOKEN", "t") + .env("VICTAURI_APP", "com.test.portapp"); + let out = run(cmd).await; + let err = stderr(&out); + assert!(!out.status.success(), "must refuse:\n{err}"); + assert!(err.contains("com.test.unlisted"), "{err}"); + + // Agreeing selectors work. + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw", "--app", "com.test.portapp"]); + cmd.env("VICTAURI_PORT", port.to_string()); + let out = run(cmd).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!(stdout(&out).contains("com.test.portapp")); +} + +/// R5B-LINDISC1: when another user has pre-created `/victauri-` (sticky /tmp stops +/// us removing it) the app registers under `$HOME/.local/state/victauri` instead — and every +/// reader must look there. (0777 stands in for "another user's"; the WSL run used a real +/// foreign-owned directory.) +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn readers_find_an_app_registered_under_the_home_state_root() { + use std::os::unix::fs::PermissionsExt; + let iso = IsolatedTemp::new(); + let port = start_mock("com.test.homeroot").await; + // The predictable per-user temp root exists but is untrusted. + let uid = std::os::unix::fs::MetadataExt::uid(&std::fs::metadata(iso.0.path()).unwrap()); + let squatted = iso.0.path().join(format!("victauri-{uid}")); + std::fs::create_dir(&squatted).unwrap(); + std::fs::set_permissions(&squatted, std::fs::Permissions::from_mode(0o777)).unwrap(); + // The app's entry, where the plugin falls back to. + let home = iso.0.path().join("home"); + let root = home.join(".local").join("state").join("victauri"); + let dir = root.join(std::process::id().to_string()); + std::fs::create_dir_all(&dir).unwrap(); + for d in [&home, &root, &dir] { + std::fs::set_permissions(d, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write(dir.join("port"), port.to_string()).unwrap(); + std::fs::write(dir.join("token"), "tok").unwrap(); + std::fs::write( + dir.join("metadata.json"), + json!({"identifier": "com.test.homeroot"}).to_string(), + ) + .unwrap(); + + let mut cmd = iso.victauri(&["invoke", "whoami", "--raw"]); + cmd.env("HOME", &home) + .env_remove("XDG_RUNTIME_DIR") + .env_remove("XDG_STATE_HOME"); + let out = run(cmd).await; + assert!(out.status.success(), "{}", stderr(&out)); + assert!( + stdout(&out).contains("com.test.homeroot"), + "{}", + stdout(&out) + ); +} diff --git a/crates/victauri-core/LICENSE b/crates/victauri-core/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-core/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-core/README.md b/crates/victauri-core/README.md index e3481c52..683a59d5 100644 --- a/crates/victauri-core/README.md +++ b/crates/victauri-core/README.md @@ -30,17 +30,8 @@ assert_eq!(log.capacity(), 1000); // Command registry with search let registry = CommandRegistry::new(); -registry.register(CommandInfo { - name: "greet".to_string(), - plugin: None, - description: Some("Greet the user".to_string()), - args: vec![], - return_type: None, - is_async: false, - intent: None, - category: None, - examples: vec![], -}); +// `CommandInfo` is `#[non_exhaustive]`: build it with the constructor and builders. +registry.register(CommandInfo::new("greet").with_description("Greet the user")); let results = registry.search("greet"); assert_eq!(results.len(), 1); ``` diff --git a/crates/victauri-core/benches/core_benchmarks.rs b/crates/victauri-core/benches/core_benchmarks.rs index a1a02cca..50e18287 100644 --- a/crates/victauri-core/benches/core_benchmarks.rs +++ b/crates/victauri-core/benches/core_benchmarks.rs @@ -6,15 +6,15 @@ use victauri_core::registry::{CommandInfo, CommandRegistry}; use victauri_core::verification; fn make_ipc(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 32, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 32, + "main".to_string(), + )) } fn bench_event_log(c: &mut Criterion) { @@ -54,11 +54,7 @@ fn bench_event_log(c: &mut Criterion) { log.push(make_ipc(&i.to_string(), "ipc_cmd")); } for i in 0..500 { - log.push(AppEvent::StateChange { - key: format!("key_{i}"), - timestamp: Utc::now(), - caused_by: None, - }); + log.push(AppEvent::state_change(format!("key_{i}"), Utc::now(), None)); } b.iter(|| { black_box(log.ipc_calls()); @@ -165,11 +161,11 @@ fn bench_verification(c: &mut Criterion) { }); group.bench_function("evaluate_assertion", |b| { - let assertion = verification::SemanticAssertion { - label: "check_count".to_string(), - condition: verification::AssertionCondition::GreaterThan, - expected: serde_json::json!(10), - }; + let assertion = verification::SemanticAssertion::new( + "check_count".to_string(), + verification::AssertionCondition::GreaterThan, + serde_json::json!(10), + ); b.iter(|| { black_box(verification::evaluate_assertion( serde_json::json!(42), diff --git a/crates/victauri-core/src/codegen.rs b/crates/victauri-core/src/codegen.rs index 69ce6fee..9d1cf955 100644 --- a/crates/victauri-core/src/codegen.rs +++ b/crates/victauri-core/src/codegen.rs @@ -11,6 +11,7 @@ use crate::recording::RecordedSession; /// Controls whether generated code uses direct client methods or the Locator API. #[derive(Debug, Clone, Copy, Eq, PartialEq, Default)] +#[non_exhaustive] pub enum CodegenStyle { /// Generate `client.click_by_id("btn")` style calls. #[default] @@ -184,10 +185,22 @@ pub fn generate_test(session: &RecordedSession, options: &CodegenOptions) -> Str out } -/// Flattens newlines/carriage returns so a value interpolated into a `//` line -/// comment cannot break out of the comment (defense-in-depth, audit #16). +/// Makes a recorded value safe to interpolate into a `//` line comment: a newline +/// could break out of the comment (audit #16), and a control or bidi character +/// (`U+202E`, …) makes rustc reject the whole file — its +/// `text_direction_codepoint_in_comment` lint is deny-by-default — so those are +/// written as `\u{…}` escapes instead. fn sanitize_comment(s: &str) -> String { - s.replace(['\n', '\r'], " ") + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\n' | '\r' => out.push(' '), + // Quotes and backslashes are harmless in a comment; keep them readable. + '"' | '\'' | '\\' => out.push(ch), + _ => out.extend(ch.escape_debug()), + } + } + out } /// Converts arbitrary input into a safe Rust identifier for generated test names. @@ -226,16 +239,18 @@ const RUST_KEYWORDS: &[&str] = &[ ]; /// Escapes a string for embedding in a Rust string literal. +/// +/// Beyond quotes, backslashes and newlines, every control, invisible or bidi +/// character becomes a `\u{…}` escape: a raw `U+202E` in a literal makes rustc +/// reject the file (`text_direction_codepoint_in_literal` is deny-by-default), and +/// recorded page text is attacker-controlled. fn escape_rust_str(s: &str) -> String { let mut escaped = String::with_capacity(s.len()); for ch in s.chars() { match ch { - '\\' => escaped.push_str("\\\\"), - '"' => escaped.push_str("\\\""), - '\n' => escaped.push_str("\\n"), - '\r' => escaped.push_str("\\r"), - '\t' => escaped.push_str("\\t"), - other => escaped.push(other), + // `escape_debug` would write `\'`: legal inside "…" but needless noise. + '\'' => escaped.push(ch), + _ => escaped.extend(ch.escape_debug()), } } escaped @@ -291,44 +306,139 @@ fn emit_locator_interaction( } } -/// Converts a raw CSS selector into a Locator factory expression. +/// Converts a recorded selector into a Locator factory expression. fn selector_to_locator(selector: &str) -> String { - // :has-text("...") → Locator::text("...") - if let Some(start) = selector.find(":has-text(\"") { - let text_start = start + ":has-text(\"".len(); - if let Some(end) = selector[text_start..].find("\")") { - let text = escape_rust_str(&selector[text_start..text_start + end]); - return format!("Locator::text(\"{text}\")"); + let (factory, arg) = match parse_recorded_selector(selector) { + RecordedSelector::TestId(id) => ("test_id", id), + RecordedSelector::Role(role) => ("role", role), + RecordedSelector::Text(text) => ("text", text), + // `#id` stays CSS: it is valid CSS as recorded (the bridge CSS-escapes it). + RecordedSelector::Id(_) | RecordedSelector::Css => ("css", selector.to_string()), + }; + format!("Locator::{factory}(\"{}\")", escape_rust_str(&arg)) +} + +/// The shapes the bridge's `bestSelector` records (`js_bridge.rs`), parsed from the +/// start of the selector — never by searching for a marker, which a quoted value can +/// itself contain (`[data-testid="a:has-text(\"x\")"]`). +#[derive(Debug, PartialEq, Eq)] +enum RecordedSelector { + /// `[data-testid="…"]` + TestId(String), + /// `#id` — the id, CSS escapes decoded. + Id(String), + /// `[role="…"]` + Role(String), + /// `[role="…"]:has-text("…")` or `tag:has-text("…")` — the text. + Text(String), + /// Anything else: plain CSS, passed through unchanged. + Css, +} + +fn parse_recorded_selector(selector: &str) -> RecordedSelector { + if let Some(rest) = selector.strip_prefix("[data-testid=") + && let Some((id, rest)) = css_string(rest) + && rest == "]" + { + return RecordedSelector::TestId(id); + } + if let Some(rest) = selector.strip_prefix('#') + && let Some(id) = css_ident(rest) + { + return RecordedSelector::Id(id); + } + if let Some(rest) = selector.strip_prefix("[role=") + && let Some((role, rest)) = css_string(rest) + && let Some(rest) = rest.strip_prefix(']') + { + if rest.is_empty() { + return RecordedSelector::Role(role); + } + if let Some(text) = has_text_suffix(rest) { + return RecordedSelector::Text(text); } + return RecordedSelector::Css; } - - // #id → Locator::css("#id") - if selector.starts_with('#') && !selector[1..].contains(' ') { - let escaped = escape_rust_str(selector); - return format!("Locator::css(\"{escaped}\")"); + let tag_len = selector + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) + .unwrap_or(selector.len()); + if tag_len > 0 + && let Some(text) = has_text_suffix(&selector[tag_len..]) + { + return RecordedSelector::Text(text); } + RecordedSelector::Css +} - // [data-testid="..."] → Locator::test_id("...") - if let Some(start) = selector.find("[data-testid=\"") { - let id_start = start + "[data-testid=\"".len(); - if let Some(end) = selector[id_start..].find("\"]") { - let id = escape_rust_str(&selector[id_start..id_start + end]); - return format!("Locator::test_id(\"{id}\")"); +/// `:has-text("…")` spanning the whole of `s` → the decoded text. +fn has_text_suffix(s: &str) -> Option { + let (text, rest) = css_string(s.strip_prefix(":has-text(")?)?; + (rest == ")").then_some(text) +} + +/// Decodes a CSS double-quoted string at the start of `s`; returns it and the rest. +fn css_string(s: &str) -> Option<(String, &str)> { + let body = s.strip_prefix('"')?; + let mut out = String::new(); + let mut chars = body.char_indices(); + while let Some((i, c)) = chars.next() { + match c { + '"' => return Some((out, &body[i + 1..])), + '\\' => match css_escape(&mut chars) { + Some(Some(decoded)) => out.push(decoded), + Some(None) => {} // escaped newline: a line continuation + None => return None, + }, + _ => out.push(c), } } + None +} - // [role="..."] → Locator::role("...") - if let Some(start) = selector.find("[role=\"") { - let role_start = start + "[role=\"".len(); - if let Some(end) = selector[role_start..].find("\"]") { - let role = escape_rust_str(&selector[role_start..role_start + end]); - return format!("Locator::role(\"{role}\")"); +/// Decodes a CSS identifier spanning all of `s` (as `CSS.escape` writes one). +fn css_ident(s: &str) -> Option { + let mut out = String::new(); + let mut chars = s.char_indices(); + while let Some((_, c)) = chars.next() { + match c { + '\\' => out.push(css_escape(&mut chars)??), + c if c.is_alphanumeric() || c == '-' || c == '_' || !c.is_ascii() => out.push(c), + _ => return None, } } + (!out.is_empty()).then_some(out) +} - // Fallback: Locator::css("...") - let escaped = escape_rust_str(selector); - format!("Locator::css(\"{escaped}\")") +/// Decodes one CSS escape after its backslash: `Some(Some(c))` for a character, +/// `Some(None)` for an escaped newline, `None` for a backslash at the end. +fn css_escape(chars: &mut std::str::CharIndices<'_>) -> Option> { + let (_, first) = chars.next()?; + if first == '\n' { + return Some(None); + } + let Some(mut code) = first.to_digit(16) else { + return Some(Some(first)); + }; + // Up to six hex digits, then one optional whitespace terminator. + for _ in 1..6 { + let mut peek = chars.clone(); + match peek.next() { + Some((_, d)) if d.is_ascii_hexdigit() => { + code = code * 16 + d.to_digit(16).unwrap_or(0); + *chars = peek; + } + _ => break, + } + } + let mut peek = chars.clone(); + if matches!(peek.next(), Some((_, ' ' | '\t' | '\n'))) { + *chars = peek; + } + Some(Some( + char::from_u32(code) + .filter(|&c| c != '\0') + .unwrap_or(char::REPLACEMENT_CHARACTER), + )) } /// Resolved selector form for emitting idiomatic `VictauriClient` calls. @@ -337,32 +447,23 @@ fn selector_to_locator(selector: &str) -> String { /// to the high-level convenience methods on `VictauriClient` (`click_by_id`, /// `click_by_text`, etc.) so generated tests read naturally. enum ResolvedSelector { - /// Selector started with `#` — strip the hash and use `*_by_id`. + /// A `#id` selector — the decoded id, for `*_by_id`. ById(String), - /// Selector contained `:has-text("...")` — extract the text and use `*_by_text`. + /// A `:has-text("...")` selector — the decoded text, for `*_by_text`. ByText(String), /// Everything else — pass the raw selector through. Raw(String), } -/// Classifies a raw CSS selector into the most idiomatic `VictauriClient` form. +/// Classifies a recorded selector into the most idiomatic `VictauriClient` form. fn resolve_selector(selector: &str) -> ResolvedSelector { - // Pattern 2: contains `:has-text("...")` — extract the quoted text. - if let Some(start) = selector.find(":has-text(\"") { - let text_start = start + ":has-text(\"".len(); - if let Some(end) = selector[text_start..].find("\")") { - let text = &selector[text_start..text_start + end]; - return ResolvedSelector::ByText(text.to_string()); + match parse_recorded_selector(selector) { + RecordedSelector::Id(id) => ResolvedSelector::ById(id), + RecordedSelector::Text(text) => ResolvedSelector::ByText(text), + RecordedSelector::TestId(_) | RecordedSelector::Role(_) | RecordedSelector::Css => { + ResolvedSelector::Raw(selector.to_string()) } } - - // Pattern 1: starts with `#` (simple ID selector, no combinators). - if selector.starts_with('#') && !selector[1..].contains(' ') { - let id = &selector[1..]; - return ResolvedSelector::ById(id.to_string()); - } - - ResolvedSelector::Raw(selector.to_string()) } /// Emits a single DOM interaction as a `VictauriClient` method call. @@ -472,28 +573,18 @@ fn emit_resolved_select(out: &mut String, resolved: &ResolvedSelector, val: &str /// Emits a resolved `scroll_to` call. /// -/// For `Raw` selectors, emits `scroll_to_by_selector`. For `ById`, emits -/// `scroll_to_by_id`. For `ByText`, falls back to `scroll_to_by_selector` -/// with the original selector text (scroll has no text variant). +/// Emits `scroll_to_by_id` / `scroll_to_by_text` / `scroll_to_by_selector`. +/// (Text is never passed as a CSS selector: it is not one.) fn emit_resolved_scroll(out: &mut String, resolved: &ResolvedSelector) { - match resolved { - ResolvedSelector::ById(id) => { - let escaped = escape_rust_str(id); - out.push_str(&format!( - " client.scroll_to_by_id(\"{escaped}\").await.unwrap();\n" - )); - } - ResolvedSelector::ByText(_) | ResolvedSelector::Raw(_) => { - let sel = match resolved { - ResolvedSelector::ByText(t) => escape_rust_str(t), - ResolvedSelector::Raw(s) => escape_rust_str(s), - _ => unreachable!(), - }; - out.push_str(&format!( - " client.scroll_to_by_selector(\"{sel}\").await.unwrap();\n" - )); - } - } + let (method, arg) = match resolved { + ResolvedSelector::ById(id) => ("scroll_to_by_id", id), + ResolvedSelector::ByText(text) => ("scroll_to_by_text", text), + ResolvedSelector::Raw(sel) => ("scroll_to_by_selector", sel), + }; + let escaped = escape_rust_str(arg); + out.push_str(&format!( + " client.{method}(\"{escaped}\").await.unwrap();\n" + )); } #[cfg(test)] @@ -591,6 +682,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!(true)), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let code = generate_test_default(&session); @@ -611,6 +703,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!({})), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let code = generate_test_default(&session); @@ -631,6 +724,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!(true)), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let opts = CodegenOptions { @@ -1032,6 +1126,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!({"saved": true})), arg_size_bytes: 42, webview_label: "main".to_string(), + mocked: false, }), }, // 4: StateChange caused by save_draft @@ -1224,6 +1319,7 @@ mod tests { result: IpcResult::Ok(serde_json::json!(true)), arg_size_bytes: 0, webview_label: "main".to_string(), + mocked: false, }), }]); let opts = CodegenOptions { diff --git a/crates/victauri-core/src/event.rs b/crates/victauri-core/src/event.rs index c6d9f90c..a817097a 100644 --- a/crates/victauri-core/src/event.rs +++ b/crates/victauri-core/src/event.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; /// A single Tauri IPC call with timing, result, and source webview. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct IpcCall { /// Unique call identifier for correlation. pub id: String, @@ -24,6 +25,11 @@ pub struct IpcCall { pub arg_size_bytes: usize, /// Label of the webview that initiated the call. pub webview_label: String, + /// The call never reached the backend: a `route` rule fulfilled or blocked it in the + /// webview. `recording replay` skips such calls — replaying a mocked call would turn a + /// fake success into a real invocation. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub mocked: bool, } /// Outcome of an IPC call: pending, success with a JSON value, or error. @@ -38,6 +44,59 @@ pub enum IpcResult { Err(String), } +impl IpcCall { + /// Creates an IPC call record. + /// + /// `IpcCall` is `#[non_exhaustive]`, so code outside this crate (bridges, + /// mocks, tests) builds it through this constructor rather than a struct + /// literal; fields added later get a default here instead of breaking callers. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{IpcCall, IpcResult}; + /// + /// let call = IpcCall::new( + /// "c1", + /// "greet", + /// chrono::Utc::now(), + /// IpcResult::Pending, + /// None, + /// 0, + /// "main", + /// ); + /// assert_eq!(call.command, "greet"); + /// ``` + #[must_use] + pub fn new( + id: impl Into, + command: impl Into, + timestamp: DateTime, + result: IpcResult, + duration_ms: Option, + arg_size_bytes: usize, + webview_label: impl Into, + ) -> Self { + Self { + id: id.into(), + command: command.into(), + timestamp, + duration_ms, + result, + arg_size_bytes, + webview_label: webview_label.into(), + mocked: false, + } + } + + /// Mark the call as answered by a route rule instead of the backend. + #[must_use] + pub fn with_mocked(mut self, mocked: bool) -> Self { + self.mocked = mocked; + self + } +} + impl fmt::Display for IpcResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -62,6 +121,7 @@ impl From for AppEvent { /// The kind of user interaction captured from the DOM. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub enum InteractionKind { /// Mouse click on an element. Click, @@ -101,6 +161,7 @@ pub enum AppEvent { /// An IPC call between webview and Rust backend. Ipc(IpcCall), /// A change to application state in the backend. + #[non_exhaustive] StateChange { /// State key that changed. key: String, @@ -110,6 +171,7 @@ pub enum AppEvent { caused_by: Option, }, /// A batch of DOM mutations observed in a webview. + #[non_exhaustive] DomMutation { /// Webview where the mutations were observed. webview_label: String, @@ -119,6 +181,7 @@ pub enum AppEvent { mutation_count: u32, }, /// A user interaction captured from the DOM during recording. + #[non_exhaustive] DomInteraction { /// What kind of interaction occurred. action: InteractionKind, @@ -132,6 +195,7 @@ pub enum AppEvent { webview_label: String, }, /// A native window lifecycle event (e.g. focus, resize, close). + #[non_exhaustive] WindowEvent { /// Tauri window label that emitted the event. label: String, @@ -141,6 +205,7 @@ pub enum AppEvent { timestamp: DateTime, }, /// A console log/warn/error message captured from the webview. + #[non_exhaustive] Console { /// Severity level: "log", "warn", or "error". level: String, @@ -152,6 +217,89 @@ pub enum AppEvent { } impl AppEvent { + /// Builds an [`AppEvent::StateChange`]. + #[must_use] + pub fn state_change( + key: impl Into, + timestamp: DateTime, + caused_by: Option, + ) -> Self { + Self::StateChange { + key: key.into(), + timestamp, + caused_by, + } + } + + /// Builds an [`AppEvent::DomMutation`]. + #[must_use] + pub fn dom_mutation( + webview_label: impl Into, + timestamp: DateTime, + mutation_count: u32, + ) -> Self { + Self::DomMutation { + webview_label: webview_label.into(), + timestamp, + mutation_count, + } + } + + /// Builds an [`AppEvent::DomInteraction`]. + #[must_use] + pub fn dom_interaction( + action: InteractionKind, + selector: impl Into, + value: Option, + timestamp: DateTime, + webview_label: impl Into, + ) -> Self { + Self::DomInteraction { + action, + selector: selector.into(), + value, + timestamp, + webview_label: webview_label.into(), + } + } + + /// Builds an [`AppEvent::WindowEvent`]. + #[must_use] + pub fn window_event( + label: impl Into, + event: impl Into, + timestamp: DateTime, + ) -> Self { + Self::WindowEvent { + label: label.into(), + event: event.into(), + timestamp, + } + } + + /// Builds an [`AppEvent::Console`]. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::AppEvent; + /// + /// let ev = AppEvent::console("warn", "deprecated", chrono::Utc::now()); + /// assert!(matches!(ev, AppEvent::Console { .. })); + /// ``` + #[must_use] + pub fn console( + level: impl Into, + message: impl Into, + timestamp: DateTime, + ) -> Self { + Self::Console { + level: level.into(), + message: message.into(), + timestamp, + } + } + /// Returns the timestamp of this event, regardless of variant. #[must_use] pub fn timestamp(&self) -> DateTime { @@ -217,11 +365,7 @@ impl EventLog { /// use chrono::Utc; /// /// let log = EventLog::new(100); - /// log.push(AppEvent::StateChange { - /// key: "theme".to_string(), - /// timestamp: Utc::now(), - /// caused_by: None, - /// }); + /// log.push(AppEvent::state_change("theme", Utc::now(), None)); /// assert_eq!(log.len(), 1); /// assert_eq!(log.snapshot().len(), 1); /// ``` @@ -272,15 +416,15 @@ impl EventLog { /// use chrono::Utc; /// /// let log = EventLog::new(100); - /// log.push(AppEvent::Ipc(IpcCall { - /// id: "c1".to_string(), - /// command: "greet".to_string(), - /// timestamp: Utc::now(), - /// duration_ms: Some(5), - /// result: IpcResult::Ok(serde_json::json!("hi")), - /// arg_size_bytes: 0, - /// webview_label: "main".to_string(), - /// })); + /// log.push(AppEvent::Ipc(IpcCall::new( + /// "c1", + /// "greet", + /// Utc::now(), + /// IpcResult::Ok(serde_json::json!("hi")), + /// Some(5), + /// 0, + /// "main", + /// ))); /// assert_eq!(log.ipc_calls().len(), 1); /// ``` #[must_use] @@ -294,6 +438,55 @@ impl EventLog { .collect() } + /// Returns the newest `limit` IPC calls, oldest first. + /// + /// Unlike [`ipc_calls`](Self::ipc_calls) this clones only the calls it returns (a call + /// carries its whole request/response body), so its cost is bounded by `limit`, not by the + /// size of the log. + #[must_use] + pub fn recent_ipc_calls(&self, limit: usize) -> Vec { + let events = crate::acquire_lock(&self.events, "EventLog"); + let mut calls: Vec = events + .iter() + .rev() + .filter_map(|e| match e { + AppEvent::Ipc(call) => Some(call), + _ => None, + }) + .take(limit) + .cloned() + .collect(); + drop(events); + calls.reverse(); + calls + } + + /// Returns the distinct command names of the IPC calls in the log, without cloning the + /// calls themselves. + #[must_use] + pub fn ipc_command_names(&self) -> std::collections::BTreeSet { + let events = crate::acquire_lock(&self.events, "EventLog"); + let mut names = std::collections::BTreeSet::new(); + for event in events.iter() { + if let AppEvent::Ipc(call) = event + && !names.contains(call.command.as_str()) + { + names.insert(call.command.clone()); + } + } + names + } + + /// Calls `f` on every IPC call in the log, in order, under the log's lock (so `f` must be + /// quick and must not touch the log). Nothing is cloned. + pub(crate) fn for_each_ipc_call(&self, mut f: impl FnMut(&IpcCall)) { + for event in crate::acquire_lock(&self.events, "EventLog").iter() { + if let AppEvent::Ipc(call) = event { + f(call); + } + } + } + /// Returns IPC calls with a timestamp at or after the given time. #[must_use] pub fn ipc_calls_since(&self, timestamp: DateTime) -> Vec { diff --git a/crates/victauri-core/src/lib.rs b/crates/victauri-core/src/lib.rs index 9272b642..cbdfb205 100644 --- a/crates/victauri-core/src/lib.rs +++ b/crates/victauri-core/src/lib.rs @@ -11,10 +11,13 @@ pub extern crate inventory; pub mod codegen; pub mod error; pub mod event; +// Shared plumbing for the plugin/CLI crates — not part of the supported API. #[cfg(feature = "middleware")] +#[doc(hidden)] pub mod middleware; pub mod recording; pub mod registry; +#[doc(hidden)] pub mod security; pub mod snapshot; pub mod types; @@ -40,6 +43,7 @@ pub use verification::{ /// /// Victauri's mutex-protected data is append-only logs and registries where /// stale data is preferable to crashing the testing framework. +#[doc(hidden)] pub fn acquire_lock<'a, T>( mutex: &'a std::sync::Mutex, context: &str, @@ -51,6 +55,7 @@ pub fn acquire_lock<'a, T>( } /// Acquire a read lock on an `RwLock`, recovering from poisoning. +#[doc(hidden)] pub fn acquire_read<'a, T>( lock: &'a std::sync::RwLock, context: &str, @@ -62,6 +67,7 @@ pub fn acquire_read<'a, T>( } /// Acquire a write lock on an `RwLock`, recovering from poisoning. +#[doc(hidden)] pub fn acquire_write<'a, T>( lock: &'a std::sync::RwLock, context: &str, @@ -71,3 +77,10 @@ pub fn acquire_write<'a, T>( poisoned.into_inner() }) } + +/// Compiles the README's examples as doctests, so the crates.io landing page +/// cannot drift from the API again (its `CommandInfo` struct literal outlived the +/// 0.9 `#[non_exhaustive]` change). +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +pub struct ReadmeDoctests; diff --git a/crates/victauri-core/src/middleware.rs b/crates/victauri-core/src/middleware.rs index eff726c6..f0e3247c 100644 --- a/crates/victauri-core/src/middleware.rs +++ b/crates/victauri-core/src/middleware.rs @@ -40,28 +40,26 @@ pub async fn require_auth( return Ok(next.run(request).await); }; - let provided = request - .headers() + if bearer_matches(request.headers(), expected) { + Ok(next.run(request).await) + } else { + tracing::warn!("Victauri: rejected request — invalid or missing auth token"); + Err(StatusCode::UNAUTHORIZED) + } +} + +/// Whether `headers` carry `Authorization: Bearer ` (scheme matched +/// case-insensitively per RFC 7235, token compared in constant time). +#[must_use] +pub fn bearer_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { + headers .get("authorization") .and_then(|v| v.to_str().ok()) - .and_then(|v| { - let lower = v.to_lowercase(); - if lower.starts_with("bearer ") { - Some(v[BEARER_PREFIX_LEN..].to_string()) - } else { - None - } - }); - - match provided { - Some(ref token) if constant_time_eq(token.as_bytes(), expected.as_bytes()) => { - Ok(next.run(request).await) - } - _ => { - tracing::warn!("Victauri: rejected request — invalid or missing auth token"); - Err(StatusCode::UNAUTHORIZED) - } - } + .filter(|v| { + v.get(..BEARER_PREFIX_LEN) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("bearer ")) + }) + .is_some_and(|v| constant_time_eq(&v.as_bytes()[BEARER_PREFIX_LEN..], expected.as_bytes())) } /// Create a rate limiter with the default capacity of @@ -123,22 +121,35 @@ pub async fn dns_rebinding_guard(request: Request, next: Next) -> Result` sends no `Origin` and used to drain the shared +/// bucket until the agent was answered 429). /// /// # Errors /// -/// Returns [`StatusCode::FORBIDDEN`] if the `Origin` header is present and does -/// not match a localhost or `tauri://` origin. +/// Returns [`StatusCode::FORBIDDEN`] if the request carries a `Sec-Fetch-Site` header other +/// than `none` (every modern browser marks page-initiated requests; `none` is a user +/// navigation), or an `Origin` header that is unreadable or not a localhost origin. pub async fn origin_guard(request: Request, next: Next) -> Result { - if let Some(origin) = request - .headers() - .get("origin") - .and_then(|v| v.to_str().ok()) - && !is_allowed_origin(origin) + if let Some(site) = request.headers().get("sec-fetch-site") + && site.as_bytes() != b"none" { - tracing::warn!("Cross-origin request blocked: Origin={origin}"); + tracing::warn!("Browser-initiated request blocked: Sec-Fetch-Site={site:?}"); return Err(StatusCode::FORBIDDEN); } + if let Some(origin) = request.headers().get("origin") { + // An Origin that is not visible ASCII cannot be judged, so it is refused rather than + // treated as absent (absent means "not a browser"). + match origin.to_str() { + Ok(origin) if is_allowed_origin(origin) => {} + _ => { + tracing::warn!("Cross-origin request blocked: Origin={origin:?}"); + return Err(StatusCode::FORBIDDEN); + } + } + } Ok(next.run(request).await) } @@ -159,10 +170,8 @@ pub async fn security_headers(request: Request, next: Next) -> Response { axum::http::header::HeaderName::from_static("x-frame-options"), axum::http::HeaderValue::from_static("DENY"), ); - headers.insert( - axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, - axum::http::HeaderValue::from_static("null"), - ); + // No `Access-Control-Allow-Origin` at all: `null` is not "deny" — it GRANTS the opaque + // `null` origin (sandboxed iframes, `data:` / `file:` documents). headers.insert( axum::http::header::HeaderName::from_static("content-security-policy"), axum::http::HeaderValue::from_static("default-src 'none'"), diff --git a/crates/victauri-core/src/recording.rs b/crates/victauri-core/src/recording.rs index 7568e75b..87e727c2 100644 --- a/crates/victauri-core/src/recording.rs +++ b/crates/victauri-core/src/recording.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use crate::error::VictauriError; @@ -14,6 +15,7 @@ const DEFAULT_MAX_EVENTS: usize = 50_000; /// A snapshot of application state taken at a specific point during recording. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct StateCheckpoint { /// Unique identifier for this checkpoint. pub id: String, @@ -29,6 +31,7 @@ pub struct StateCheckpoint { /// A complete recorded session with events and state checkpoints. Serializable for export/import. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct RecordedSession { /// Unique session identifier (UUID). pub id: String, @@ -42,6 +45,7 @@ pub struct RecordedSession { /// A single event captured during a recording session, with its sequence index. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct RecordedEvent { /// Monotonically increasing sequence number within the recording session. pub index: usize, @@ -51,6 +55,48 @@ pub struct RecordedEvent { pub event: AppEvent, } +impl RecordedSession { + /// Creates a recorded session from its parts (e.g. when importing a + /// session built outside a live [`EventRecorder`]). + #[must_use] + pub fn new( + id: impl Into, + started_at: DateTime, + events: Vec, + checkpoints: Vec, + ) -> Self { + Self { + id: id.into(), + started_at, + events, + checkpoints, + } + } +} + +impl RecordedEvent { + /// Creates a recorded event with the given sequence index. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{AppEvent, RecordedEvent, RecordedSession}; + /// + /// let now = chrono::Utc::now(); + /// let ev = RecordedEvent::new(0, now, AppEvent::window_event("main", "focus", now)); + /// let session = RecordedSession::new("s1", now, vec![ev], vec![]); + /// assert_eq!(session.events.len(), 1); + /// ``` + #[must_use] + pub fn new(index: usize, timestamp: DateTime, event: AppEvent) -> Self { + Self { + index, + timestamp, + event, + } + } +} + /// Thread-safe session recorder for time-travel debugging. Records events and /// state checkpoints during a recording session. Only one session can be active at a time. #[derive(Debug, Clone)] @@ -58,11 +104,16 @@ pub struct EventRecorder { recording: Arc>>, last_session: Arc>>, max_events: usize, + /// Source of recording generations: every `start`/`import` takes a fresh one, so a + /// caller holding an old generation can never touch a later recording — even one that + /// reuses the same (caller-chosen) session id. + generations: Arc, } #[derive(Debug, Clone)] struct ActiveRecording { session_id: String, + generation: u64, started_at: DateTime, events: VecDeque, checkpoints: VecDeque, @@ -87,9 +138,14 @@ impl EventRecorder { recording: Arc::new(Mutex::new(None)), last_session: Arc::new(Mutex::new(None)), max_events, + generations: Arc::new(AtomicU64::new(0)), } } + fn next_generation(&self) -> u64 { + self.generations.fetch_add(1, Ordering::Relaxed) + 1 + } + /// Starts a new recording session; returns `Err` if one is already active. /// /// # Errors @@ -106,12 +162,24 @@ impl EventRecorder { /// assert!(recorder.is_recording()); /// ``` pub fn start(&self, session_id: String) -> crate::error::Result<()> { + self.start_session(session_id).map(|_| ()) + } + + /// [`start`](Self::start), returning the new recording's generation — the token for + /// [`record_event_if`](Self::record_event_if) and [`stop_if_generation`](Self::stop_if_generation). + /// + /// # Errors + /// + /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is already in progress. + pub fn start_session(&self, session_id: String) -> crate::error::Result { let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); if rec.is_some() { return Err(VictauriError::RecordingAlreadyActive); } + let generation = self.next_generation(); *rec = Some(ActiveRecording { session_id, + generation, started_at: Utc::now(), events: VecDeque::new(), checkpoints: VecDeque::new(), @@ -119,7 +187,7 @@ impl EventRecorder { max_events: self.max_events, max_checkpoints: DEFAULT_MAX_CHECKPOINTS, }); - Ok(()) + Ok(generation) } /// Stops the active recording and returns the completed session, or None if not recording. @@ -138,6 +206,12 @@ impl EventRecorder { #[must_use] pub fn stop(&self) -> Option { let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + self.finish(&mut rec) + } + + /// Take the active recording out of `rec` (whose lock the caller holds) and keep it as the + /// last session. + fn finish(&self, rec: &mut Option) -> Option { rec.take().map(|r| { let session = RecordedSession { id: r.session_id, @@ -157,27 +231,55 @@ impl EventRecorder { crate::acquire_lock(&self.recording, "EventRecorder").is_some() } + /// Generation of the active recording, or `None` if not recording. + #[must_use] + pub fn generation(&self) -> Option { + crate::acquire_lock(&self.recording, "EventRecorder") + .as_ref() + .map(|r| r.generation) + } + /// Appends an event to the active recording, evicting the oldest if at capacity. pub fn record_event(&self, event: AppEvent) { let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); if let Some(ref mut active) = *rec { - let timestamp = event.timestamp(); - let index = active.event_counter; - // Saturating: an imported session can seed event_counter at usize::MAX - // (its index is attacker-controlled); a bare `+= 1` would then panic in - // debug / wrap in release on the next auto-captured event (audit #18). - active.event_counter = active.event_counter.saturating_add(1); - - if active.events.len() >= active.max_events { - active.events.pop_front(); + Self::append(active, event); + } + } + + /// [`record_event`](Self::record_event), but only into the recording of `generation`. + /// A reader that captured the generation before a slow read (the webview drain) must not + /// append what it read into a recording started after it — those events predate it. + /// Returns whether the event was recorded. + #[must_use] + pub fn record_event_if(&self, generation: u64, event: AppEvent) -> bool { + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + match rec.as_mut() { + Some(active) if active.generation == generation => { + Self::append(active, event); + true } + _ => false, + } + } - active.events.push_back(RecordedEvent { - index, - timestamp, - event, - }); + fn append(active: &mut ActiveRecording, event: AppEvent) { + let timestamp = event.timestamp(); + let index = active.event_counter; + // Saturating: an imported session can seed event_counter at usize::MAX + // (its index is attacker-controlled); a bare `+= 1` would then panic in + // debug / wrap in release on the next auto-captured event (audit #18). + active.event_counter = active.event_counter.saturating_add(1); + + if active.events.len() >= active.max_events { + active.events.pop_front(); } + + active.events.push_back(RecordedEvent { + index, + timestamp, + event, + }); } /// Creates a named state checkpoint at the current event index; returns `Err` if not recording. @@ -210,6 +312,43 @@ impl EventRecorder { } } + /// Session id of the active recording, or `None` if not recording. + #[must_use] + pub fn active_session_id(&self) -> Option { + crate::acquire_lock(&self.recording, "EventRecorder") + .as_ref() + .map(|r| r.session_id.clone()) + } + + /// Stop the active recording only if it is the session `session_id` (a caller that started + /// a recording must not stop a different one someone else started after it ended). + /// + /// The id check and the stop happen under one lock: checked and stopped separately, a + /// stop + start by someone else in between made this stop the OTHER recording. Session ids + /// are caller-chosen and may repeat, so an owner that must never stop a later recording + /// with the same id should hold its generation and use + /// [`stop_if_generation`](Self::stop_if_generation). + #[must_use] + pub fn stop_if_session(&self, session_id: &str) -> Option { + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + if rec.as_ref().is_some_and(|r| r.session_id == session_id) { + self.finish(&mut rec) + } else { + None + } + } + + /// Stop the active recording only if it is the one of `generation` (atomically). + #[must_use] + pub fn stop_if_generation(&self, generation: u64) -> Option { + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + if rec.as_ref().is_some_and(|r| r.generation == generation) { + self.finish(&mut rec) + } else { + None + } + } + /// When the active recording started, or `None` if not recording. #[must_use] pub fn started_at(&self) -> Option> { @@ -393,6 +532,29 @@ impl EventRecorder { /// `max_events`/`max_checkpoints` (audit #19), and a crafted event index of /// `usize::MAX` cannot overflow the counter (audit #18). pub fn import(&self, session: RecordedSession) { + let active = self.imported(session); + *crate::acquire_lock(&self.recording, "EventRecorder") = Some(active); + } + + /// [`import`](Self::import), but only if no recording is active — checked and replaced under + /// one lock, so a recording started concurrently is never silently discarded. Returns the + /// imported recording's generation. + /// + /// # Errors + /// + /// Returns [`VictauriError::RecordingAlreadyActive`] if a session is in progress. + pub fn import_if_idle(&self, session: RecordedSession) -> crate::error::Result { + let active = self.imported(session); + let generation = active.generation; + let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); + if rec.is_some() { + return Err(VictauriError::RecordingAlreadyActive); + } + *rec = Some(active); + Ok(generation) + } + + fn imported(&self, session: RecordedSession) -> ActiveRecording { let max_events = self.max_events; let max_checkpoints = DEFAULT_MAX_CHECKPOINTS; @@ -409,16 +571,16 @@ impl EventRecorder { let event_counter = events.back().map_or(0, |e| e.index.saturating_add(1)); - let mut rec = crate::acquire_lock(&self.recording, "EventRecorder"); - *rec = Some(ActiveRecording { + ActiveRecording { session_id: session.id, + generation: self.next_generation(), started_at: session.started_at, events, checkpoints, event_counter, max_events, max_checkpoints, - }); + } } /// Extracts IPC calls in order from the active recording or last stopped session for replay. @@ -455,3 +617,82 @@ impl Default for EventRecorder { Self::new(DEFAULT_MAX_EVENTS) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn console(msg: &str) -> AppEvent { + AppEvent::console("log".to_string(), msg.to_string(), Utc::now()) + } + + // C15a: the id check and the stop must be one step. With the check and the stop under + // separate locks, a stop+start by someone else in between made `stop_if_session("x")` + // stop (and return) the OTHER recording. + #[test] + fn stop_if_session_never_stops_a_different_session() { + let rec = EventRecorder::new(100); + let racer = rec.clone(); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop2 = Arc::clone(&stop); + let t = std::thread::spawn(move || { + while !stop2.load(std::sync::atomic::Ordering::Relaxed) { + let _ = racer.stop(); + let _ = racer.start("other".to_string()); + } + }); + let mut wrong = 0; + for _ in 0..200_000 { + let _ = rec.start("mine".to_string()); + if let Some(s) = rec.stop_if_session("mine") + && s.id != "mine" + { + wrong += 1; + } + } + stop.store(true, std::sync::atomic::Ordering::Relaxed); + t.join().unwrap(); + assert_eq!(wrong, 0, "stop_if_session stopped someone else's recording"); + } + + // C15a (ABA): session ids are caller-chosen and may repeat, so ownership is by generation. + #[test] + fn stale_generation_cannot_stop_a_later_recording_with_the_same_id() { + let rec = EventRecorder::new(100); + let old = rec.start_session("s".to_string()).unwrap(); + let _ = rec.stop(); + let new = rec.start_session("s".to_string()).unwrap(); + assert_ne!(old, new); + assert!(rec.stop_if_generation(old).is_none()); + assert!(rec.is_recording()); + assert_eq!(rec.stop_if_generation(new).unwrap().id, "s"); + } + + // C6: a reader holding an old generation must not append into a newer recording. + #[test] + fn record_event_if_rejects_a_stale_generation() { + let rec = EventRecorder::new(100); + let a = rec.start_session("A".to_string()).unwrap(); + assert!(rec.record_event_if(a, console("during A"))); + let _ = rec.stop(); + let b = rec.start_session("B".to_string()).unwrap(); + assert!(!rec.record_event_if(a, console("late A read"))); + assert!(rec.record_event_if(b, console("during B"))); + assert_eq!(rec.event_count(), 1); + assert_eq!(rec.generation(), Some(b)); + } + + // C15b: import must not replace a recording that became active after an is_recording check. + #[test] + fn import_if_idle_refuses_while_recording() { + let rec = EventRecorder::new(100); + rec.start("live".to_string()).unwrap(); + let session = RecordedSession::new("imported", Utc::now(), vec![], vec![]); + assert!(rec.import_if_idle(session.clone()).is_err()); + assert_eq!(rec.active_session_id().as_deref(), Some("live")); + let _ = rec.stop(); + let g = rec.import_if_idle(session).unwrap(); + assert_eq!(rec.generation(), Some(g)); + assert_eq!(rec.active_session_id().as_deref(), Some("imported")); + } +} diff --git a/crates/victauri-core/src/registry.rs b/crates/victauri-core/src/registry.rs index 735f0d28..59ddd8b7 100644 --- a/crates/victauri-core/src/registry.rs +++ b/crates/victauri-core/src/registry.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; /// Metadata for a registered Tauri command, including intent and schema information. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct CommandInfo { /// Fully qualified command name (e.g. "`get_settings`"). pub name: String, @@ -32,9 +33,16 @@ pub struct CommandInfo { /// Schema for a single argument of a registered command. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct CommandArg { /// Argument name as declared in the Rust function signature. pub name: String, + /// The key the frontend must use for this argument in `invoke(cmd, { key })`, + /// when it differs from [`name`](Self::name). Tauri renames arguments to + /// camelCase by default (`size_kb` → `sizeKb`), so passing `name` would fail + /// with "missing required key". `None` means the key equals `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, /// Rust type name (e.g. "String", "`Option`"). pub type_name: String, /// Whether the argument must be provided (not `Option`). @@ -99,6 +107,111 @@ impl CommandInfo { self.category = Some(category.into()); self } + + /// Sets the plugin namespace. + #[must_use] + pub fn with_plugin(mut self, plugin: impl Into) -> Self { + self.plugin = Some(plugin.into()); + self + } + + /// Replaces the argument list. + #[must_use] + pub fn with_args(mut self, args: Vec) -> Self { + self.args = args; + self + } + + /// Appends one argument. + #[must_use] + pub fn with_arg(mut self, arg: CommandArg) -> Self { + self.args.push(arg); + self + } + + /// Sets the Rust return type string. + #[must_use] + pub fn with_return_type(mut self, return_type: impl Into) -> Self { + self.return_type = Some(return_type.into()); + self + } + + /// Sets whether the command handler is async. + #[must_use] + pub fn with_async(mut self, is_async: bool) -> Self { + self.is_async = is_async; + self + } + + /// Replaces the example natural-language queries. + #[must_use] + pub fn with_examples(mut self, examples: Vec) -> Self { + self.examples = examples; + self + } + + /// Appends one example natural-language query. + #[must_use] + pub fn with_example(mut self, example: impl Into) -> Self { + self.examples.push(example.into()); + self + } +} + +impl CommandArg { + /// Creates an argument schema with no JSON Schema attached. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{CommandArg, CommandInfo}; + /// + /// let cmd = CommandInfo::new("greet") + /// .with_arg(CommandArg::new("name", "String", true)) + /// .with_return_type("String"); + /// assert_eq!(cmd.args[0].name, "name"); + /// assert!(cmd.args[0].required); + /// ``` + #[must_use] + pub fn new(name: impl Into, type_name: impl Into, required: bool) -> Self { + Self { + name: name.into(), + key: None, + type_name: type_name.into(), + required, + schema: None, + } + } + + /// Sets the IPC key the frontend passes this argument under, when it differs + /// from the Rust name (see [`key`](Self::key)). + /// + /// ``` + /// use victauri_core::CommandArg; + /// + /// let arg = CommandArg::new("size_kb", "usize", true).with_key("sizeKb"); + /// assert_eq!(arg.invoke_key(), "sizeKb"); + /// assert_eq!(CommandArg::new("name", "String", true).invoke_key(), "name"); + /// ``` + #[must_use] + pub fn with_key(mut self, key: impl Into) -> Self { + let key = key.into(); + self.key = (key != self.name).then_some(key); + self + } + + /// The key to pass this argument under in an IPC call. + #[must_use] + pub fn invoke_key(&self) -> &str { + self.key.as_deref().unwrap_or(&self.name) + } + + /// Attaches a JSON Schema describing the argument's expected shape. + #[must_use] + pub fn with_schema(mut self, schema: serde_json::Value) -> Self { + self.schema = Some(schema); + self + } } /// Thread-safe registry of known Tauri commands, indexed by name. @@ -297,6 +410,7 @@ impl Default for CommandRegistry { /// A command paired with its relevance score from natural-language resolution. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct ScoredCommand { /// The matched command metadata. pub command: CommandInfo, diff --git a/crates/victauri-core/src/security.rs b/crates/victauri-core/src/security.rs index 0ded3f7b..139323ab 100644 --- a/crates/victauri-core/src/security.rs +++ b/crates/victauri-core/src/security.rs @@ -166,22 +166,22 @@ pub fn is_localhost_host(host: &str) -> bool { } fn valid_port(port: &str) -> bool { - !port.is_empty() && port.parse::().is_ok() + // Digits only: `u16::from_str` also accepts a leading `+` (`localhost:+7373`). + !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && port.parse::().is_ok() } // ── Origin validation (cross-origin guard) ─────────────────────────────── -/// Returns `true` if `origin` (from the HTTP `Origin` header) is a -/// localhost origin, a `tauri://` origin, or absent. +/// Returns `true` if `origin` (from the HTTP `Origin` header) is an `http`/`https` +/// localhost origin. /// /// Uses [`url::Url::parse`] internally so that subdomain-smuggling attacks /// like `localhost.evil.com` are caught by comparing the **parsed host** -/// rather than doing prefix matching. +/// rather than doing prefix matching. `tauri://` origins are refused: the app's own +/// webview talks to Victauri over IPC, never HTTP, and the prefix check accepted +/// `tauri://` with ANY host. #[must_use] pub fn is_allowed_origin(origin: &str) -> bool { - if origin.starts_with("tauri://") { - return true; - } let Ok(parsed) = url::Url::parse(origin) else { return false; }; @@ -309,6 +309,8 @@ mod tests { #[test] fn rate_limiter_concurrent() { + // Started before the limiter: the refill clock can only have run for less than this. + let started = std::time::Instant::now(); let limiter = std::sync::Arc::new(RateLimiter::new(1000)); let mut handles = vec![]; for _ in 0..10 { @@ -328,7 +330,15 @@ mod tests { total >= 1000, "should dispense at least the initial budget, got {total}" ); - assert!(total <= 1200, "refill overshoot too high, got {total}"); + // The limiter refills 1,000 tokens/s (1 per elapsed ms), so the most it can hand out is + // the initial 1,000 plus 1 per millisecond the threads ran. A fixed ceiling (1,200) + // failed on a loaded machine where the loop simply took longer; anything above this + // bound is a real refill double-count. + let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + assert!( + total <= 1000 + elapsed_ms + 1, + "refill overshoot: {total} tokens in {elapsed_ms} ms" + ); } // is_localhost_host @@ -401,8 +411,22 @@ mod tests { assert!(is_allowed_origin("http://127.0.0.1:8080")); assert!(is_allowed_origin("http://[::1]")); assert!(is_allowed_origin("http://[::1]:7373")); - assert!(is_allowed_origin("tauri://localhost")); - assert!(is_allowed_origin("tauri://some-app")); + } + + #[test] + fn origin_refuses_tauri_scheme() { + assert!(!is_allowed_origin("tauri://localhost")); + assert!(!is_allowed_origin("tauri://some-app")); + assert!(!is_allowed_origin("tauri://evil.com")); + } + + #[test] + fn host_port_must_be_plain_digits() { + assert!(is_localhost_host("localhost:7373")); + assert!(is_localhost_host("[::1]:7373")); + assert!(!is_localhost_host("localhost:+7373")); + assert!(!is_localhost_host("127.0.0.1:+80")); + assert!(!is_localhost_host("[::1]:+7373")); } #[test] diff --git a/crates/victauri-core/src/snapshot.rs b/crates/victauri-core/src/snapshot.rs index 7a86cb81..bc87cf67 100644 --- a/crates/victauri-core/src/snapshot.rs +++ b/crates/victauri-core/src/snapshot.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; /// Current state of a Tauri window including geometry, visibility, and loaded URL. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct WindowState { /// Tauri window label (e.g. "main", "notification"). pub label: String, @@ -29,6 +30,7 @@ pub struct WindowState { /// A point-in-time snapshot of the DOM accessible tree from a specific webview. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct DomSnapshot { /// Label of the webview this snapshot was taken from. pub webview_label: String, @@ -40,6 +42,7 @@ pub struct DomSnapshot { /// A single element in the accessible DOM tree with semantic metadata and ref handle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct DomElement { /// Unique ref handle for this element (e.g. "e3"), used to target interactions. pub ref_id: String, @@ -69,6 +72,7 @@ pub struct DomElement { /// Pixel-level bounding rectangle of a DOM element relative to the viewport. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ElementBounds { /// Left edge offset from viewport origin. pub x: f64, @@ -80,33 +84,127 @@ pub struct ElementBounds { pub height: f64, } +impl WindowState { + /// Creates a window state for `label` with every other field zeroed + /// (empty title/URL, not visible/focused/maximized/minimized/fullscreen, + /// position `(0, 0)`, size `(0, 0)`). Chain the `with_*` setters to fill it in. + /// + /// `WindowState` is `#[non_exhaustive]`; `victauri_plugin::bridge::WebviewBridge` + /// implementors and mock bridges outside this crate build it through this constructor. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::WindowState; + /// + /// let w = WindowState::new("main") + /// .with_title("My App") + /// .with_url("http://localhost/") + /// .with_visible(true) + /// .with_size(800, 600); + /// assert_eq!(w.label, "main"); + /// assert_eq!(w.size, (800, 600)); + /// assert!(!w.focused); + /// ``` + #[must_use] + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + title: String::new(), + url: String::new(), + visible: false, + focused: false, + maximized: false, + minimized: false, + fullscreen: false, + position: (0, 0), + size: (0, 0), + } + } + + /// Sets the window title. + #[must_use] + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Sets the URL loaded in the webview. + #[must_use] + pub fn with_url(mut self, url: impl Into) -> Self { + self.url = url.into(); + self + } + + /// Sets whether the window is visible. + #[must_use] + pub fn with_visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Sets whether the window has input focus. + #[must_use] + pub fn with_focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + /// Sets whether the window is maximized. + #[must_use] + pub fn with_maximized(mut self, maximized: bool) -> Self { + self.maximized = maximized; + self + } + + /// Sets whether the window is minimized. + #[must_use] + pub fn with_minimized(mut self, minimized: bool) -> Self { + self.minimized = minimized; + self + } + + /// Sets whether the window is fullscreen. + #[must_use] + pub fn with_fullscreen(mut self, fullscreen: bool) -> Self { + self.fullscreen = fullscreen; + self + } + + /// Sets the window position in screen coordinates. + #[must_use] + pub fn with_position(mut self, x: i32, y: i32) -> Self { + self.position = (x, y); + self + } + + /// Sets the window size in pixels. + #[must_use] + pub fn with_size(mut self, width: u32, height: u32) -> Self { + self.size = (width, height); + self + } +} + impl DomSnapshot { /// Renders the snapshot as indented accessible text (roles, names, and ref handles). /// /// # Examples /// /// ``` - /// use victauri_core::{DomSnapshot, DomElement}; - /// use std::collections::BTreeMap; + /// use victauri_core::DomSnapshot; /// - /// let snapshot = DomSnapshot { - /// webview_label: "main".to_string(), - /// elements: vec![DomElement { - /// ref_id: "e1".to_string(), - /// tag: "button".to_string(), - /// role: Some("button".to_string()), - /// name: Some("Submit".to_string()), - /// text: None, - /// value: None, - /// enabled: true, - /// visible: true, - /// focusable: true, - /// bounds: None, - /// children: vec![], - /// attributes: BTreeMap::new(), + /// // Snapshots are produced by the webview bridge; here one is parsed from JSON. + /// let snapshot: DomSnapshot = serde_json::from_value(serde_json::json!({ + /// "webview_label": "main", + /// "elements": [{ + /// "ref_id": "e1", "tag": "button", "role": "button", "name": "Submit", + /// "text": null, "value": null, "enabled": true, "visible": true, + /// "focusable": true, "bounds": null, "children": [], "attributes": {} /// }], - /// ref_map: BTreeMap::new(), - /// }; + /// "ref_map": {} + /// })) + /// .unwrap(); /// let text = snapshot.to_accessible_text(0); /// assert!(text.contains("button")); /// assert!(text.contains("Submit")); diff --git a/crates/victauri-core/src/types.rs b/crates/victauri-core/src/types.rs index b63b2675..0915fbf8 100644 --- a/crates/victauri-core/src/types.rs +++ b/crates/victauri-core/src/types.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; /// A short-lived handle to a DOM element, identified by a semantic ref rather than a CSS selector. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct RefHandle { /// Unique ref identifier (e.g. "e3") assigned during DOM snapshot. pub id: String, @@ -19,6 +20,7 @@ pub struct RefHandle { /// Memory usage delta measured before and after a command execution. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct MemoryDelta { /// Allocated bytes before the command ran. pub before_bytes: i64, @@ -35,18 +37,14 @@ pub struct MemoryDelta { /// # Examples /// /// ``` -/// use victauri_core::VerificationResult; +/// use victauri_core::verify_state; /// use serde_json::json; /// -/// let result = VerificationResult { -/// passed: true, -/// frontend_state: json!({"count": 1}), -/// backend_state: json!({"count": 1}), -/// divergences: vec![], -/// }; +/// let result = verify_state(json!({"count": 1}), json!({"count": 1})); /// assert!(result.passed); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct VerificationResult { /// True if no divergences were found between frontend and backend state. pub passed: bool, @@ -60,6 +58,7 @@ pub struct VerificationResult { /// A single mismatch between frontend and backend state at a specific JSON path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Divergence { /// Dot-separated JSON path where the mismatch occurs (e.g. "settings.theme"). pub path: String, @@ -107,30 +106,13 @@ impl fmt::Display for Divergence { /// # Examples /// /// ``` -/// use victauri_core::{VerificationResult, Divergence, DivergenceSeverity}; +/// use victauri_core::verify_state; /// use serde_json::json; /// -/// let passed = VerificationResult { -/// passed: true, -/// frontend_state: json!({}), -/// backend_state: json!({}), -/// divergences: vec![], -/// }; +/// let passed = verify_state(json!({}), json!({})); /// assert_eq!(passed.to_string(), "verification passed"); /// -/// let failed = VerificationResult { -/// passed: false, -/// frontend_state: json!({"a": 1}), -/// backend_state: json!({"a": 2}), -/// divergences: vec![ -/// Divergence { -/// path: "a".to_string(), -/// frontend_value: json!(1), -/// backend_value: json!(2), -/// severity: DivergenceSeverity::Error, -/// }, -/// ], -/// }; +/// let failed = verify_state(json!({"a": 1}), json!({"a": 2})); /// assert_eq!(failed.to_string(), "verification failed: 1 divergence(s)"); /// ``` impl fmt::Display for VerificationResult { diff --git a/crates/victauri-core/src/verification.rs b/crates/victauri-core/src/verification.rs index 70505a60..d105b787 100644 --- a/crates/victauri-core/src/verification.rs +++ b/crates/victauri-core/src/verification.rs @@ -187,6 +187,7 @@ fn classify_severity( /// with a `reliability` signal for exactly this reason. `registry_only` is purely /// informational (registered handlers never invoked during the observation window). #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct GhostCommandReport { /// Commands invoked from the frontend but absent from Victauri's introspection /// registry. A *candidate* ghost — only a real ghost if the registry is complete. @@ -201,6 +202,7 @@ pub struct GhostCommandReport { /// A command that exists on only one side of the frontend/backend boundary. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct GhostCommand { /// Command name as invoked or registered. pub name: String, @@ -241,20 +243,13 @@ impl fmt::Display for GhostCommand { /// # Examples /// /// ``` -/// use victauri_core::{GhostCommandReport, GhostCommand, GhostSource}; +/// use victauri_core::{CommandInfo, CommandRegistry, detect_ghost_commands}; /// -/// let report = GhostCommandReport { -/// frontend_only: vec![ -/// GhostCommand { -/// name: "delete".to_string(), -/// source: GhostSource::FrontendOnly, -/// description: None, -/// }, -/// ], -/// registry_only: vec![], -/// total_frontend_commands: 3, -/// total_registry_commands: 2, -/// }; +/// let registry = CommandRegistry::new(); +/// registry.register(CommandInfo::new("a")); +/// registry.register(CommandInfo::new("b")); +/// let frontend = ["a", "b", "delete"].map(String::from); +/// let report = detect_ghost_commands(&frontend, ®istry); /// assert_eq!( /// report.to_string(), /// "1 ghost command(s) (3 frontend, 2 registry)" @@ -275,30 +270,24 @@ impl fmt::Display for GhostCommandReport { /// /// ``` /// use victauri_core::verification::IpcIntegrityReport; +/// use serde_json::json; /// -/// let healthy = IpcIntegrityReport { -/// total_calls: 10, -/// completed: 10, -/// pending: 0, -/// errored: 0, -/// stale_calls: vec![], -/// error_calls: vec![], -/// healthy: true, -/// }; +/// // Reports come from `check_ipc_integrity`; parsed from JSON here to show both states. +/// let healthy: IpcIntegrityReport = serde_json::from_value(json!({ +/// "total_calls": 10, "completed": 10, "pending": 0, "errored": 0, +/// "stale_calls": [], "error_calls": [], "healthy": true +/// })) +/// .unwrap(); /// assert_eq!( /// healthy.to_string(), /// "IPC healthy: 10/10 completed" /// ); /// -/// let unhealthy = IpcIntegrityReport { -/// total_calls: 10, -/// completed: 7, -/// pending: 2, -/// errored: 1, -/// stale_calls: vec![], -/// error_calls: vec![], -/// healthy: false, -/// }; +/// let unhealthy: IpcIntegrityReport = serde_json::from_value(json!({ +/// "total_calls": 10, "completed": 7, "pending": 2, "errored": 1, +/// "stale_calls": [], "error_calls": [], "healthy": false +/// })) +/// .unwrap(); /// assert_eq!( /// unhealthy.to_string(), /// "IPC unhealthy: 0 stale, 1 errored of 10 calls" @@ -398,6 +387,7 @@ pub fn detect_ghost_commands( /// Summary of IPC round-trip health: completed, pending, errored, and stale calls. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct IpcIntegrityReport { /// Total number of IPC calls analyzed. pub total_calls: usize, @@ -417,6 +407,7 @@ pub struct IpcIntegrityReport { /// An IPC call that has been pending longer than the staleness threshold. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct StaleCall { /// Unique call identifier. pub id: String, @@ -432,6 +423,7 @@ pub struct StaleCall { /// An IPC call that returned an error result. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ErrorCall { /// Unique call identifier. pub id: String, @@ -461,15 +453,17 @@ pub struct ErrorCall { #[must_use] pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport { let now = Utc::now(); - let calls = event_log.ipc_calls(); - let total_calls = calls.len(); + let mut total_calls = 0usize; let mut completed = 0usize; let mut pending = 0usize; let mut errored = 0usize; let mut stale_calls = Vec::new(); let mut error_calls = Vec::new(); - for call in &calls { + // Scanned in place: cloning every call (each carries its whole request/response body) just + // to count them cost a full copy of the log per check. + event_log.for_each_ipc_call(|call| { + total_calls += 1; match &call.result { IpcResult::Ok(_) => completed += 1, IpcResult::Pending => { @@ -496,7 +490,7 @@ pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> Ipc }); } } - } + }); let healthy = stale_calls.is_empty() && errored == 0; @@ -579,6 +573,7 @@ impl std::fmt::Display for AssertionCondition { /// A declarative assertion to evaluate against a runtime value (e.g. "equals", "truthy"). #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct SemanticAssertion { /// Human-readable label describing what is being asserted. pub label: String, @@ -588,8 +583,34 @@ pub struct SemanticAssertion { pub expected: serde_json::Value, } +impl SemanticAssertion { + /// Creates an assertion with the given label, condition, and expected value. + /// + /// # Examples + /// + /// ``` + /// use victauri_core::{AssertionCondition, SemanticAssertion}; + /// + /// let a = SemanticAssertion::new("count", AssertionCondition::Equals, serde_json::json!(3)); + /// assert_eq!(a.label, "count"); + /// ``` + #[must_use] + pub fn new( + label: impl Into, + condition: AssertionCondition, + expected: serde_json::Value, + ) -> Self { + Self { + label: label.into(), + condition, + expected, + } + } +} + /// Outcome of evaluating a semantic assertion against an actual value. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct AssertionResult { /// Label from the original assertion. pub label: String, @@ -627,11 +648,7 @@ fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool { /// use victauri_core::verification::{evaluate_assertion, AssertionCondition, SemanticAssertion}; /// use serde_json::json; /// -/// let assertion = SemanticAssertion { -/// label: "check count".to_string(), -/// condition: AssertionCondition::Equals, -/// expected: json!(42), -/// }; +/// let assertion = SemanticAssertion::new("check count", AssertionCondition::Equals, json!(42)); /// let result = evaluate_assertion(json!(42), &assertion); /// assert!(result.passed); /// ``` diff --git a/crates/victauri-core/tests/codegen_compile_test.rs b/crates/victauri-core/tests/codegen_compile_test.rs index b4c27e73..ef71b284 100644 --- a/crates/victauri-core/tests/codegen_compile_test.rs +++ b/crates/victauri-core/tests/codegen_compile_test.rs @@ -11,12 +11,7 @@ use victauri_core::recording::{RecordedEvent, RecordedSession}; use victauri_core::{generate_test, generate_test_default}; fn make_session(events: Vec) -> RecordedSession { - RecordedSession { - id: "codegen-drift-test".to_string(), - started_at: Utc::now(), - events, - checkpoints: vec![], - } + RecordedSession::new("codegen-drift-test".to_string(), Utc::now(), events, vec![]) } fn interaction( @@ -27,17 +22,17 @@ fn interaction( offset_ms: i64, ) -> RecordedEvent { let ts = Utc::now() + Duration::milliseconds(offset_ms); - RecordedEvent { + RecordedEvent::new( index, - timestamp: ts, - event: AppEvent::DomInteraction { + ts, + AppEvent::dom_interaction( action, - selector: selector.to_string(), - value: value.map(String::from), - timestamp: ts, - webview_label: "main".to_string(), - }, - } + selector.to_string(), + value.map(String::from), + ts, + "main".to_string(), + ), + ) } /// The canonical set of `VictauriClient` method names. If the client adds or @@ -63,6 +58,7 @@ const VALID_CLIENT_METHODS: &[&str] = &[ "select_option_by_id", "select_by_id", "scroll_to_by_id", + "scroll_to_by_text", // By-text variants "click_by_text", "double_click_by_text", diff --git a/crates/victauri-core/tests/codegen_hostile_input_test.rs b/crates/victauri-core/tests/codegen_hostile_input_test.rs new file mode 100644 index 00000000..aabaec05 --- /dev/null +++ b/crates/victauri-core/tests/codegen_hostile_input_test.rs @@ -0,0 +1,168 @@ +//! `victauri record` turns recorded page activity — attacker-controllable text — +//! into Rust source. Whatever the page recorded, the generated file must be valid +//! Rust that rustc accepts and that says what the recording said. + +use chrono::{Duration, Utc}; +use victauri_core::codegen::{CodegenOptions, CodegenStyle}; +use victauri_core::event::{AppEvent, InteractionKind, IpcCall, IpcResult}; +use victauri_core::generate_test; +use victauri_core::recording::{RecordedEvent, RecordedSession}; + +/// Characters rustc rejects outright in comments and literals +/// (`text_direction_codepoint_in_{comment,literal}` are deny-by-default). +const BIDI: &[char] = &[ + '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{202e}', '\u{2066}', '\u{2067}', '\u{2068}', + '\u{2069}', +]; + +fn interaction(action: InteractionKind, selector: &str, value: Option<&str>) -> RecordedEvent { + let ts = Utc::now() + Duration::milliseconds(1); + RecordedEvent::new( + 0, + ts, + AppEvent::dom_interaction( + action, + selector.to_string(), + value.map(String::from), + ts, + "main".to_string(), + ), + ) +} + +fn session(id: &str, events: Vec) -> RecordedSession { + RecordedSession::new(id.to_string(), Utc::now(), events, vec![]) +} + +fn both_styles(s: &RecordedSession) -> Vec { + [CodegenStyle::Direct, CodegenStyle::Locator] + .into_iter() + .map(|style| { + let options = CodegenOptions { + style, + emit_ipc_assert_calls: true, + ..CodegenOptions::default() + }; + generate_test(s, &options) + }) + .collect() +} + +fn assert_clean(code: &str) { + assert!( + !code + .chars() + .any(|c| BIDI.contains(&c) || (c.is_control() && c != '\n')), + "generated code carries a raw control/bidi character:\n{code}" + ); +} + +#[test] +fn bidi_and_control_characters_are_escaped_everywhere() { + let evil = "ok\u{202e}gnp.exe\u{2066}\u{0}\u{7}"; + let ipc = IpcCall::new( + "c1", + format!("save{evil}"), + Utc::now(), + IpcResult::Ok(serde_json::json!(null)), + None, + 0, + "main", + ); + let s = session( + &format!("sess{evil}"), + vec![ + interaction(InteractionKind::Click, &format!("#btn{evil}"), None), + interaction( + InteractionKind::Fill, + &format!("button:has-text(\"{evil}\")"), + Some(evil), + ), + interaction(InteractionKind::Navigate, "", Some(evil)), + RecordedEvent::new(1, Utc::now(), AppEvent::Ipc(ipc)), + ], + ); + for code in both_styles(&s) { + assert_clean(&code); + assert!(code.contains("\\u{202e}"), "escape is visible:\n{code}"); + } +} + +#[test] +fn a_quoted_value_cannot_masquerade_as_a_has_text_selector() { + // The test id itself contains the `:has-text("` marker the old parser searched for. + let s = session( + "s", + vec![interaction( + InteractionKind::Click, + r#"[data-testid="a:has-text(\"x\")"]"#, + None, + )], + ); + let direct = &both_styles(&s)[0]; + assert!( + direct.contains(r#"click_by_selector("[data-testid=\"a:has-text(\\\"x\\\")\"]")"#), + "{direct}" + ); + let locator = &both_styles(&s)[1]; + assert!( + locator.contains(r#"Locator::test_id("a:has-text(\"x\")")"#), + "{locator}" + ); +} + +#[test] +fn only_a_whole_id_selector_becomes_by_id() { + for (selector, expected) in [ + ("#save", r#"click_by_id("save")"#), + // CSS.escape'd React useId id: decoded for the id lookup. + (r"#\:r0\:", r#"click_by_id(":r0:")"#), + (r"#\31 23", r#"click_by_id("123")"#), + // Compound selectors are not ids: `#a.b` is id "a" AND class "b". + ("#a.b", r##"click_by_selector("#a.b")"##), + ("#a>b", r##"click_by_selector("#a>b")"##), + ("#a:hover", r##"click_by_selector("#a:hover")"##), + ] { + let s = session( + "s", + vec![interaction(InteractionKind::Click, selector, None)], + ); + let direct = &both_styles(&s)[0]; + assert!(direct.contains(expected), "{selector}:\n{direct}"); + } +} + +#[test] +fn escaped_text_is_decoded_and_re_escaped() { + // bestSelector writes CSS strings: `\"`, `\\`, and hex escapes for controls. + let s = session( + "s", + vec![interaction( + InteractionKind::Click, + r#"[role="tab"]:has-text("say \"hi\"\a back\\slash")"#, + None, + )], + ); + let direct = &both_styles(&s)[0]; + assert!( + direct.contains(r#"click_by_text("say \"hi\"\nback\\slash")"#), + "{direct}" + ); +} + +#[test] +fn scrolling_to_text_uses_the_text_lookup_not_a_css_selector() { + let s = session( + "s", + vec![interaction( + InteractionKind::Scroll, + r#"h2:has-text("Pricing")"#, + None, + )], + ); + let direct = &both_styles(&s)[0]; + assert!( + direct.contains(r#"scroll_to_by_text("Pricing")"#), + "{direct}" + ); +} diff --git a/crates/victauri-core/tests/core_tests.rs b/crates/victauri-core/tests/core_tests.rs index 86cce57c..7203a91b 100644 --- a/crates/victauri-core/tests/core_tests.rs +++ b/crates/victauri-core/tests/core_tests.rs @@ -3,20 +3,32 @@ use std::collections::BTreeMap; use std::sync::Arc; use victauri_core::*; +/// Builds a `#[non_exhaustive]` output type (e.g. `DomSnapshot`, `VerificationResult`) +/// from named fields. These types are produced by victauri, never constructed by +/// consumers, so outside the crate the only way to fabricate one is through serde — +/// exactly how a client receives them. +macro_rules! lit { + ($t:path { $($f:ident : $v:expr),* $(,)? }) => {{ + let mut map = serde_json::Map::new(); + $( map.insert(stringify!($f).to_owned(), serde_json::to_value($v).expect("serialize field")); )* + serde_json::from_value::<$t>(serde_json::Value::Object(map)).expect("build literal") + }}; +} + #[test] fn event_log_push_and_snapshot() { let log = EventLog::new(100); assert!(log.is_empty()); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "test_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!(42)), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "test_cmd".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!(42)), + Some(5), + 10, + "main".to_string(), + ))); assert_eq!(log.len(), 1); assert!(!log.is_empty()); @@ -37,15 +49,15 @@ fn event_log_ring_buffer_eviction() { let log = EventLog::new(3); for i in 0..5 { - log.push(AppEvent::Ipc(IpcCall { - id: i.to_string(), - command: format!("cmd_{i}"), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + i.to_string(), + format!("cmd_{i}"), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); } assert_eq!(log.len(), 3); @@ -59,31 +71,27 @@ fn event_log_ring_buffer_eviction() { fn event_log_ipc_calls_filter() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - - log.push(AppEvent::StateChange { - key: "user".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: Some(10), - result: event::IpcResult::Ok(serde_json::json!("data")), - arg_size_bytes: 5, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + + log.push(AppEvent::state_change("user".to_string(), Utc::now(), None)); + + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("data")), + Some(10), + 5, + "main".to_string(), + ))); assert_eq!(log.len(), 3); let ipc_only = log.ipc_calls(); @@ -95,11 +103,11 @@ fn event_log_ipc_calls_filter() { #[test] fn event_log_clear() { let log = EventLog::new(100); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: Utc::now(), - }); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + Utc::now(), + )); assert_eq!(log.len(), 1); log.clear(); assert!(log.is_empty()); @@ -111,12 +119,11 @@ fn command_registry_register_and_list() { assert_eq!(registry.count(), 0); let mut cmd = CommandInfo::new("save_file").with_description("Save a file to disk"); - cmd.args = vec![CommandArg { - name: "path".to_string(), - type_name: "String".to_string(), - required: true, - schema: None, - }]; + cmd.args = vec![CommandArg::new( + "path".to_string(), + "String".to_string(), + true, + )]; cmd.return_type = Some("Result<(), String>".to_string()); cmd.is_async = true; registry.register(cmd); @@ -151,53 +158,53 @@ fn command_registry_search() { #[test] fn dom_snapshot_accessible_text() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("main".to_string()), name: Some("Content".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, + bounds: Option::<()>::None, children: vec![ - DomElement { + lit!(DomElement { ref_id: "e1".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Submit".to_string()), text: Some("Submit".to_string()), - value: None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e2".to_string(), tag: "input".to_string(), role: Some("textbox".to_string()), name: Some("Email".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), ], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.contains("main \"Content\"")); @@ -207,24 +214,24 @@ fn dom_snapshot_accessible_text() { #[test] fn dom_snapshot_hides_invisible() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), - role: None, - name: None, - text: None, - value: None, + role: Option::<()>::None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: false, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.is_empty()); @@ -232,18 +239,16 @@ fn dom_snapshot_hides_invisible() { #[test] fn window_state_serialization() { - let state = WindowState { - label: "main".to_string(), - title: "My App".to_string(), - url: "tauri://localhost".to_string(), - visible: true, - focused: true, - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 200), - size: (800, 600), - }; + let state = WindowState::new("main".to_string()) + .with_title("My App".to_string()) + .with_url("tauri://localhost".to_string()) + .with_visible(true) + .with_focused(true) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 200) + .with_size(800, 600); let json = serde_json::to_string(&state).unwrap(); let deserialized: WindowState = serde_json::from_str(&json).unwrap(); @@ -256,17 +261,17 @@ fn window_state_serialization() { fn verification_result_with_divergences() { use victauri_core::types::{Divergence, DivergenceSeverity, VerificationResult}; - let result = VerificationResult { + let result = lit!(VerificationResult { passed: false, frontend_state: serde_json::json!({"count": 5}), backend_state: serde_json::json!({"count": 3}), - divergences: vec![Divergence { + divergences: vec![lit!(Divergence { path: "count".to_string(), frontend_value: serde_json::json!(5), backend_value: serde_json::json!(3), severity: DivergenceSeverity::Error, - }], - }; + })], + }); assert!(!result.passed); assert_eq!(result.divergences.len(), 1); @@ -438,24 +443,24 @@ fn ghost_commands_bidirectional() { #[test] fn ipc_integrity_healthy() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: Some(3), - result: event::IpcResult::Ok(serde_json::json!("data")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("data")), + Some(3), + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(report.healthy); @@ -470,15 +475,15 @@ fn ipc_integrity_healthy() { #[test] fn ipc_integrity_with_errors() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Err("permission denied".to_string()), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Err("permission denied".to_string()), + Some(5), + 10, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(!report.healthy); @@ -492,15 +497,15 @@ fn ipc_integrity_with_errors() { fn ipc_integrity_stale_pending() { let log = EventLog::new(100); let old_timestamp = Utc::now() - chrono::Duration::seconds(10); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "slow_cmd".to_string(), - timestamp: old_timestamp, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "slow_cmd".to_string(), + old_timestamp, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(!report.healthy); @@ -513,15 +518,15 @@ fn ipc_integrity_stale_pending() { #[test] fn ipc_integrity_recent_pending_not_stale() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "fast_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "fast_cmd".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(report.healthy); @@ -534,33 +539,33 @@ fn ipc_integrity_mixed_status() { let log = EventLog::new(100); let old = Utc::now() - chrono::Duration::seconds(30); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "ok_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!(null)), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "stuck_cmd".to_string(), - timestamp: old, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::Ipc(IpcCall { - id: "3".to_string(), - command: "err_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(2), - result: event::IpcResult::Err("boom".to_string()), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "ok_cmd".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!(null)), + Some(1), + 0, + "main".to_string(), + ))); + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "stuck_cmd".to_string(), + old, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + log.push(AppEvent::Ipc(IpcCall::new( + "3".to_string(), + "err_cmd".to_string(), + Utc::now(), + event::IpcResult::Err("boom".to_string()), + Some(2), + 0, + "main".to_string(), + ))); let report = victauri_core::check_ipc_integrity(&log, 5000); assert!(!report.healthy); @@ -758,11 +763,11 @@ fn resolve_scores_normalized_across_query_lengths() { #[test] fn semantic_assertion_equals() { - let assertion = victauri_core::SemanticAssertion { - label: "count is 5".to_string(), - condition: victauri_core::AssertionCondition::Equals, - expected: serde_json::json!(5), - }; + let assertion = victauri_core::SemanticAssertion::new( + "count is 5".to_string(), + victauri_core::AssertionCondition::Equals, + serde_json::json!(5), + ); let pass = victauri_core::evaluate_assertion(serde_json::json!(5), &assertion); assert!(pass.passed); @@ -775,22 +780,22 @@ fn semantic_assertion_equals() { #[test] fn semantic_assertion_truthy_falsy() { - let truthy = victauri_core::SemanticAssertion { - label: "value is truthy".to_string(), - condition: victauri_core::AssertionCondition::Truthy, - expected: serde_json::Value::Null, - }; + let truthy = victauri_core::SemanticAssertion::new( + "value is truthy".to_string(), + victauri_core::AssertionCondition::Truthy, + serde_json::Value::Null, + ); assert!(victauri_core::evaluate_assertion(serde_json::json!(true), &truthy).passed); assert!(victauri_core::evaluate_assertion(serde_json::json!("hello"), &truthy).passed); assert!(victauri_core::evaluate_assertion(serde_json::json!(42), &truthy).passed); assert!(!victauri_core::evaluate_assertion(serde_json::Value::Null, &truthy).passed); - let falsy = victauri_core::SemanticAssertion { - label: "value is falsy".to_string(), - condition: victauri_core::AssertionCondition::Falsy, - expected: serde_json::Value::Null, - }; + let falsy = victauri_core::SemanticAssertion::new( + "value is falsy".to_string(), + victauri_core::AssertionCondition::Falsy, + serde_json::Value::Null, + ); assert!(victauri_core::evaluate_assertion(serde_json::Value::Null, &falsy).passed); assert!(victauri_core::evaluate_assertion(serde_json::json!(false), &falsy).passed); @@ -800,11 +805,11 @@ fn semantic_assertion_truthy_falsy() { #[test] fn semantic_assertion_contains() { - let assertion = victauri_core::SemanticAssertion { - label: "string contains hello".to_string(), - condition: victauri_core::AssertionCondition::Contains, - expected: serde_json::json!("hello"), - }; + let assertion = victauri_core::SemanticAssertion::new( + "string contains hello".to_string(), + victauri_core::AssertionCondition::Contains, + serde_json::json!("hello"), + ); assert!( victauri_core::evaluate_assertion(serde_json::json!("say hello world"), &assertion).passed @@ -814,20 +819,20 @@ fn semantic_assertion_contains() { #[test] fn semantic_assertion_comparisons() { - let gt = victauri_core::SemanticAssertion { - label: "greater than 10".to_string(), - condition: victauri_core::AssertionCondition::GreaterThan, - expected: serde_json::json!(10), - }; + let gt = victauri_core::SemanticAssertion::new( + "greater than 10".to_string(), + victauri_core::AssertionCondition::GreaterThan, + serde_json::json!(10), + ); assert!(victauri_core::evaluate_assertion(serde_json::json!(15), >).passed); assert!(!victauri_core::evaluate_assertion(serde_json::json!(5), >).passed); - let lt = victauri_core::SemanticAssertion { - label: "less than 10".to_string(), - condition: victauri_core::AssertionCondition::LessThan, - expected: serde_json::json!(10), - }; + let lt = victauri_core::SemanticAssertion::new( + "less than 10".to_string(), + victauri_core::AssertionCondition::LessThan, + serde_json::json!(10), + ); assert!(victauri_core::evaluate_assertion(serde_json::json!(5), <).passed); assert!(!victauri_core::evaluate_assertion(serde_json::json!(15), <).passed); @@ -835,11 +840,11 @@ fn semantic_assertion_comparisons() { #[test] fn semantic_assertion_type_is() { - let assertion = victauri_core::SemanticAssertion { - label: "is a string".to_string(), - condition: victauri_core::AssertionCondition::TypeIs, - expected: serde_json::json!("string"), - }; + let assertion = victauri_core::SemanticAssertion::new( + "is a string".to_string(), + victauri_core::AssertionCondition::TypeIs, + serde_json::json!("string"), + ); assert!(victauri_core::evaluate_assertion(serde_json::json!("hello"), &assertion).passed); assert!(!victauri_core::evaluate_assertion(serde_json::json!(42), &assertion).passed); @@ -847,11 +852,11 @@ fn semantic_assertion_type_is() { #[test] fn semantic_assertion_exists() { - let assertion = victauri_core::SemanticAssertion { - label: "value exists".to_string(), - condition: victauri_core::AssertionCondition::Exists, - expected: serde_json::Value::Null, - }; + let assertion = victauri_core::SemanticAssertion::new( + "value exists".to_string(), + victauri_core::AssertionCondition::Exists, + serde_json::Value::Null, + ); assert!(victauri_core::evaluate_assertion(serde_json::json!("something"), &assertion).passed); assert!(!victauri_core::evaluate_assertion(serde_json::Value::Null, &assertion).passed); @@ -879,21 +884,21 @@ fn recorder_record_events() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); - - recorder.record_event(AppEvent::StateChange { - key: "user".to_string(), - timestamp: Utc::now(), - caused_by: Some("save".to_string()), - }); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); + + recorder.record_event(AppEvent::state_change( + "user".to_string(), + Utc::now(), + Some("save".to_string()), + )); assert_eq!(recorder.event_count(), 2); @@ -914,15 +919,15 @@ fn recorder_checkpoints() { recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); recorder .checkpoint( @@ -932,15 +937,15 @@ fn recorder_checkpoints() { ) .unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); recorder .checkpoint( @@ -966,15 +971,15 @@ fn recorder_events_since() { recorder.start("s1".to_string()).unwrap(); for i in 0..5 { - recorder.record_event(AppEvent::Ipc(IpcCall { - id: i.to_string(), - command: format!("cmd_{i}"), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + i.to_string(), + format!("cmd_{i}"), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); } let since_3 = recorder.events_since(3); @@ -988,39 +993,39 @@ fn recorder_events_between_checkpoints() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "0".to_string(), - command: "before".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "0".to_string(), + "before".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); recorder .checkpoint("cp1".to_string(), None, serde_json::json!(null)) .unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "between".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "between2".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "between".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "between2".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); recorder .checkpoint("cp2".to_string(), None, serde_json::json!(null)) @@ -1041,31 +1046,27 @@ fn recorder_ipc_replay_sequence() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - })); - - recorder.record_event(AppEvent::StateChange { - key: "user".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - - recorder.record_event(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "load".to_string(), - timestamp: Utc::now(), - duration_ms: Some(3), - result: event::IpcResult::Ok(serde_json::json!("data")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "save".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ))); + + recorder.record_event(AppEvent::state_change("user".to_string(), Utc::now(), None)); + + recorder.record_event(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "load".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("data")), + Some(3), + 0, + "main".to_string(), + ))); let replay = recorder.ipc_replay_sequence(); assert_eq!(replay.len(), 2); @@ -1087,11 +1088,7 @@ fn recorder_not_recording_returns_empty() { fn recorder_export_does_not_stop_recording() { let recorder = EventRecorder::new(1000); recorder.start("s1".to_string()).unwrap(); - recorder.record_event(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); + recorder.record_event(AppEvent::state_change("k".to_string(), Utc::now(), None)); let exported = recorder.export(); assert!(exported.is_some()); @@ -1105,11 +1102,7 @@ fn recorder_export_does_not_stop_recording() { ); assert_eq!(recorder.event_count(), 1); - recorder.record_event(AppEvent::StateChange { - key: "k2".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); + recorder.record_event(AppEvent::state_change("k2".to_string(), Utc::now(), None)); assert_eq!(recorder.event_count(), 2); } @@ -1123,37 +1116,25 @@ fn recorder_export_returns_none_when_not_recording() { fn recorder_import_replaces_active_recording() { let recorder = EventRecorder::new(1000); recorder.start("original".to_string()).unwrap(); - recorder.record_event(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - - let session = victauri_core::RecordedSession { - id: "imported".to_string(), - started_at: Utc::now(), - events: vec![ - victauri_core::RecordedEvent { - index: 0, - timestamp: Utc::now(), - event: AppEvent::StateChange { - key: "a".to_string(), - timestamp: Utc::now(), - caused_by: None, - }, - }, - victauri_core::RecordedEvent { - index: 1, - timestamp: Utc::now(), - event: AppEvent::StateChange { - key: "b".to_string(), - timestamp: Utc::now(), - caused_by: None, - }, - }, + recorder.record_event(AppEvent::state_change("k".to_string(), Utc::now(), None)); + + let session = victauri_core::RecordedSession::new( + "imported".to_string(), + Utc::now(), + vec![ + victauri_core::RecordedEvent::new( + 0, + Utc::now(), + AppEvent::state_change("a".to_string(), Utc::now(), None), + ), + victauri_core::RecordedEvent::new( + 1, + Utc::now(), + AppEvent::state_change("b".to_string(), Utc::now(), None), + ), ], - checkpoints: vec![], - }; + vec![], + ); recorder.import(session); assert!(recorder.is_recording()); @@ -1169,12 +1150,8 @@ fn recorder_import_when_not_recording() { let recorder = EventRecorder::new(1000); assert!(!recorder.is_recording()); - let session = victauri_core::RecordedSession { - id: "fresh".to_string(), - started_at: Utc::now(), - events: vec![], - checkpoints: vec![], - }; + let session = + victauri_core::RecordedSession::new("fresh".to_string(), Utc::now(), vec![], vec![]); recorder.import(session); assert!(recorder.is_recording()); @@ -1184,16 +1161,16 @@ fn recorder_import_when_not_recording() { #[test] fn truthy_falsy_are_never_both_true() { use victauri_core::verification; - let truthy = verification::SemanticAssertion { - label: "t".to_string(), - condition: verification::AssertionCondition::Truthy, - expected: serde_json::Value::Null, - }; - let falsy = verification::SemanticAssertion { - label: "f".to_string(), - condition: verification::AssertionCondition::Falsy, - expected: serde_json::Value::Null, - }; + let truthy = verification::SemanticAssertion::new( + "t".to_string(), + verification::AssertionCondition::Truthy, + serde_json::Value::Null, + ); + let falsy = verification::SemanticAssertion::new( + "f".to_string(), + verification::AssertionCondition::Falsy, + serde_json::Value::Null, + ); let test_values = vec![ serde_json::json!(null), serde_json::json!(true), @@ -1227,15 +1204,15 @@ mod adversarial { use std::thread; fn make_ipc(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } // ── Mutex poisoning recovery ──────────────────────────────────────── @@ -1475,11 +1452,11 @@ mod adversarial { #[test] fn assertion_truthy_edge_cases() { - let truthy = verification::SemanticAssertion { - label: "t".to_string(), - condition: verification::AssertionCondition::Truthy, - expected: serde_json::json!(null), - }; + let truthy = verification::SemanticAssertion::new( + "t".to_string(), + verification::AssertionCondition::Truthy, + serde_json::json!(null), + ); // 0 is falsy (JS semantics — Victauri evaluates JS expressions) assert!(!verification::evaluate_assertion(serde_json::json!(0), &truthy).passed); // Non-zero numbers are truthy @@ -1496,11 +1473,11 @@ mod adversarial { #[test] fn assertion_contains_in_array() { - let assertion = verification::SemanticAssertion { - label: "arr".to_string(), - condition: verification::AssertionCondition::Contains, - expected: serde_json::json!(2), - }; + let assertion = verification::SemanticAssertion::new( + "arr".to_string(), + verification::AssertionCondition::Contains, + serde_json::json!(2), + ); assert!(verification::evaluate_assertion(serde_json::json!([1, 2, 3]), &assertion).passed); assert!(!verification::evaluate_assertion(serde_json::json!([1, 3, 5]), &assertion).passed); } @@ -1518,15 +1495,15 @@ mod adversarial { #[test] fn ipc_integrity_detects_errors() { let log = EventLog::new(100); - log.push(AppEvent::Ipc(IpcCall { - id: "err1".to_string(), - command: "broken_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Err("something failed".to_string()), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "err1".to_string(), + "broken_cmd".to_string(), + Utc::now(), + event::IpcResult::Err("something failed".to_string()), + None, + 0, + "main".to_string(), + ))); let report = check_ipc_integrity(&log, 5000); assert!(!report.healthy); assert_eq!(report.errored, 1); @@ -1685,26 +1662,26 @@ mod adversarial { let log = EventLog::new(100); let t1 = Utc::now(); std::thread::sleep(std::time::Duration::from_millis(10)); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "old".to_string(), - timestamp: t1, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "old".to_string(), + t1, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let t2 = Utc::now(); std::thread::sleep(std::time::Duration::from_millis(10)); - log.push(AppEvent::Ipc(IpcCall { - id: "2".to_string(), - command: "new".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "2".to_string(), + "new".to_string(), + Utc::now(), + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); let since = log.since(t2); assert_eq!(since.len(), 1); @@ -1716,30 +1693,22 @@ mod adversarial { let past = Utc::now() - chrono::Duration::seconds(10); let future = Utc::now() + chrono::Duration::seconds(10); - log.push(AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "test".to_string(), - timestamp: past, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: future, - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: past, - mutation_count: 1, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: future, - }); + log.push(AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "test".to_string(), + past, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + log.push(AppEvent::state_change("k".to_string(), future, None)); + log.push(AppEvent::dom_mutation("main".to_string(), past, 1)); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + future, + )); let since = log.since(Utc::now()); assert_eq!(since.len(), 2); // only the future ones @@ -1789,29 +1758,25 @@ mod adversarial { let log = EventLog::new(100); let past = Utc::now() - chrono::Duration::seconds(10); - log.push(AppEvent::Ipc(IpcCall { - id: "old".to_string(), - command: "old_cmd".to_string(), - timestamp: past, - duration_ms: None, - result: event::IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - log.push(AppEvent::Ipc(IpcCall { - id: "new".to_string(), - command: "new_cmd".to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - })); + log.push(AppEvent::Ipc(IpcCall::new( + "old".to_string(), + "old_cmd".to_string(), + past, + event::IpcResult::Pending, + None, + 0, + "main".to_string(), + ))); + log.push(AppEvent::state_change("k".to_string(), Utc::now(), None)); + log.push(AppEvent::Ipc(IpcCall::new( + "new".to_string(), + "new_cmd".to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + ))); let calls = log.ipc_calls_since(Utc::now() - chrono::Duration::seconds(1)); assert_eq!(calls.len(), 1); @@ -1822,27 +1787,27 @@ mod adversarial { // ── Additional EventLog tests ───────────────────────────────────────────── fn make_ipc_at(id: &str, cmd: &str, ts: DateTime) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: ts, - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + ts, + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } fn make_ipc_simple(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: event::IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + event::IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } #[test] @@ -1970,38 +1935,22 @@ fn event_log_since_filters_all_event_types() { let cutoff = Utc::now(); log.push(make_ipc_at("1", "old_ipc", t_old)); - log.push(AppEvent::StateChange { - key: "old_state".to_string(), - timestamp: t_old, - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: t_old, - mutation_count: 5, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "old_focus".to_string(), - timestamp: t_old, - }); + log.push(AppEvent::state_change("old_state".to_string(), t_old, None)); + log.push(AppEvent::dom_mutation("main".to_string(), t_old, 5)); + log.push(AppEvent::window_event( + "main".to_string(), + "old_focus".to_string(), + t_old, + )); log.push(make_ipc_at("2", "new_ipc", t_new)); - log.push(AppEvent::StateChange { - key: "new_state".to_string(), - timestamp: t_new, - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: t_new, - mutation_count: 10, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "new_focus".to_string(), - timestamp: t_new, - }); + log.push(AppEvent::state_change("new_state".to_string(), t_new, None)); + log.push(AppEvent::dom_mutation("main".to_string(), t_new, 10)); + log.push(AppEvent::window_event( + "main".to_string(), + "new_focus".to_string(), + t_new, + )); let recent = log.since(cutoff); assert_eq!(recent.len(), 4); @@ -2028,21 +1977,17 @@ fn event_log_ipc_calls_returns_only_ipc_events() { let log = EventLog::new(100); log.push(make_ipc_simple("1", "save")); - log.push(AppEvent::StateChange { - key: "data".to_string(), - timestamp: Utc::now(), - caused_by: Some("save".to_string()), - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: 3, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "resize".to_string(), - timestamp: Utc::now(), - }); + log.push(AppEvent::state_change( + "data".to_string(), + Utc::now(), + Some("save".to_string()), + )); + log.push(AppEvent::dom_mutation("main".to_string(), Utc::now(), 3)); + log.push(AppEvent::window_event( + "main".to_string(), + "resize".to_string(), + Utc::now(), + )); log.push(make_ipc_simple("2", "load")); assert_eq!(log.len(), 5); @@ -2057,21 +2002,13 @@ fn event_log_ipc_calls_returns_only_ipc_events() { fn event_log_ipc_calls_empty_when_no_ipc_events() { let log = EventLog::new(100); - log.push(AppEvent::StateChange { - key: "data".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: 1, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: Utc::now(), - }); + log.push(AppEvent::state_change("data".to_string(), Utc::now(), None)); + log.push(AppEvent::dom_mutation("main".to_string(), Utc::now(), 1)); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + Utc::now(), + )); assert_eq!(log.len(), 3); assert!(log.ipc_calls().is_empty()); @@ -2085,11 +2022,7 @@ fn event_log_ipc_calls_since_filters_by_timestamp() { let cutoff = Utc::now() - chrono::Duration::seconds(30); log.push(make_ipc_at("1", "old_cmd", t_old)); - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: t_new, - caused_by: None, - }); + log.push(AppEvent::state_change("k".to_string(), t_new, None)); log.push(make_ipc_at("2", "new_cmd", t_new)); let calls = log.ipc_calls_since(cutoff); @@ -2103,16 +2036,12 @@ fn event_log_ipc_calls_since_excludes_non_ipc() { let ts = Utc::now(); // Add non-IPC events with recent timestamps - log.push(AppEvent::StateChange { - key: "k".to_string(), - timestamp: ts, - caused_by: None, - }); - log.push(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: ts, - }); + log.push(AppEvent::state_change("k".to_string(), ts, None)); + log.push(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + ts, + )); let calls = log.ipc_calls_since(ts - chrono::Duration::seconds(1)); assert!(calls.is_empty()); @@ -2209,16 +2138,12 @@ fn event_log_ring_buffer_eviction_mixed_event_types() { let log = EventLog::new(3); log.push(make_ipc_simple("1", "ipc_1")); - log.push(AppEvent::StateChange { - key: "state_1".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); - log.push(AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: 1, - }); + log.push(AppEvent::state_change( + "state_1".to_string(), + Utc::now(), + None, + )); + log.push(AppEvent::dom_mutation("main".to_string(), Utc::now(), 1)); // At capacity. Push one more IPC to evict ipc_1. log.push(make_ipc_simple("2", "ipc_2")); @@ -2421,11 +2346,7 @@ fn recorder_event_count_increments() { recorder.record_event(make_ipc_simple("2", "cmd2")); assert_eq!(recorder.event_count(), 2); - recorder.record_event(AppEvent::StateChange { - key: "k".to_string(), - timestamp: Utc::now(), - caused_by: None, - }); + recorder.record_event(AppEvent::state_change("k".to_string(), Utc::now(), None)); assert_eq!(recorder.event_count(), 3); } @@ -2473,50 +2394,50 @@ fn recorder_is_recording_lifecycle() { #[test] fn dom_snapshot_accessible_text_nested_indentation() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("navigation".to_string()), name: Some("Nav".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![DomElement { + bounds: Option::<()>::None, + children: vec![lit!(DomElement { ref_id: "e1".to_string(), tag: "ul".to_string(), role: Some("list".to_string()), - name: None, - text: None, - value: None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![DomElement { + bounds: Option::<()>::None, + children: vec![lit!(DomElement { ref_id: "e2".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Home".to_string()), text: Some("Home".to_string()), - value: None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - attributes: BTreeMap::new(), - }], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + attributes: BTreeMap::::new(), + })], + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); @@ -2530,53 +2451,53 @@ fn dom_snapshot_accessible_text_nested_indentation() { #[test] fn dom_snapshot_accessible_text_skips_invisible_children() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), - role: None, - name: None, - text: None, - value: None, + role: Option::<()>::None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, + bounds: Option::<()>::None, children: vec![ - DomElement { + lit!(DomElement { ref_id: "e1".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Visible".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e2".to_string(), tag: "button".to_string(), role: Some("button".to_string()), name: Some("Hidden".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: false, focusable: true, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), ], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.contains("Visible")); @@ -2585,54 +2506,54 @@ fn dom_snapshot_accessible_text_skips_invisible_children() { #[test] fn dom_snapshot_accessible_text_ref_on_focusable_and_input() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![ - DomElement { + lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), - role: None, + role: Option::<()>::None, name: Some("Container".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, - focusable: false, // not focusable, not button/input => no ref - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + focusable: false, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e1".to_string(), tag: "input".to_string(), role: Some("textbox".to_string()), name: Some("Name".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, - focusable: false, // input tag => ref regardless of focusable - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, - DomElement { + focusable: false, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), + lit!(DomElement { ref_id: "e2".to_string(), tag: "span".to_string(), - role: None, + role: Option::<()>::None, name: Some("Label".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, - focusable: true, // focusable => ref - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }, + focusable: true, + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }), ], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); @@ -2649,24 +2570,24 @@ fn dom_snapshot_accessible_text_ref_on_focusable_and_input() { #[test] fn dom_snapshot_accessible_text_custom_starting_indent() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("main".to_string()), - name: None, - text: None, - value: None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); // Start at indent 3 => 6 spaces prefix let text = snapshot.to_accessible_text(3); @@ -2675,11 +2596,11 @@ fn dom_snapshot_accessible_text_custom_starting_indent() { #[test] fn dom_snapshot_accessible_text_empty_elements() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![], - ref_map: BTreeMap::new(), - }; + elements: Vec::<()>::new(), + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.is_empty()); @@ -2687,25 +2608,45 @@ fn dom_snapshot_accessible_text_empty_elements() { #[test] fn dom_snapshot_accessible_text_uses_tag_when_no_role() { - let snapshot = DomSnapshot { + let snapshot = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "section".to_string(), - role: None, // no role => falls back to tag name + role: Option::<()>::None, name: Some("Content".to_string()), - text: None, - value: None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - }], - ref_map: BTreeMap::new(), - }; + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + })], + ref_map: BTreeMap::::new(), + }); let text = snapshot.to_accessible_text(0); assert!(text.contains("- section \"Content\"")); } + +/// R5B-PAGEQ1: page-callable `victauri_get_ipc_log` / `victauri_detect_ghost_commands` cloned +/// every call in the log (bodies included) on the UI thread. The bounded accessors return the +/// same data as the full clone for what they cover. +#[test] +fn recent_ipc_calls_and_command_names_match_the_full_log() { + let log = EventLog::new(1000); + for i in 0..50 { + log.push(make_ipc_simple(&format!("c{i}"), &format!("cmd{}", i % 3))); + log.push(AppEvent::state_change(format!("k{i}"), Utc::now(), None)); + } + let all = log.ipc_calls(); + let recent = log.recent_ipc_calls(10); + assert_eq!(recent.len(), 10); + assert_eq!(recent, all[40..].to_vec(), "newest 10, oldest first"); + assert_eq!(log.recent_ipc_calls(1000), all); + assert!(log.recent_ipc_calls(0).is_empty()); + let names: Vec = log.ipc_command_names().into_iter().collect(); + assert_eq!(names, vec!["cmd0", "cmd1", "cmd2"]); +} diff --git a/crates/victauri-core/tests/deep_adversarial_tests.rs b/crates/victauri-core/tests/deep_adversarial_tests.rs index ef74a1ba..785e91b3 100644 --- a/crates/victauri-core/tests/deep_adversarial_tests.rs +++ b/crates/victauri-core/tests/deep_adversarial_tests.rs @@ -8,91 +8,91 @@ use victauri_core::types::DivergenceSeverity; use victauri_core::verification::AssertionCondition; use victauri_core::*; +/// Builds a `#[non_exhaustive]` output type (e.g. `DomSnapshot`, `VerificationResult`) +/// from named fields. These types are produced by victauri, never constructed by +/// consumers, so outside the crate the only way to fabricate one is through serde — +/// exactly how a client receives them. +macro_rules! lit { + ($t:path { $($f:ident : $v:expr),* $(,)? }) => {{ + let mut map = serde_json::Map::new(); + $( map.insert(stringify!($f).to_owned(), serde_json::to_value($v).expect("serialize field")); )* + serde_json::from_value::<$t>(serde_json::Value::Object(map)).expect("build literal") + }}; +} + fn ipc(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Ok(serde_json::json!("ok")), + Some(1), + 0, + "main".to_string(), + )) } fn ipc_pending(id: &str, cmd: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Pending, + None, + 0, + "main".to_string(), + )) } fn ipc_err(id: &str, cmd: &str, err: &str) -> AppEvent { - AppEvent::Ipc(IpcCall { - id: id.to_string(), - command: cmd.to_string(), - timestamp: Utc::now(), - duration_ms: Some(1), - result: IpcResult::Err(err.to_string()), - arg_size_bytes: 0, - webview_label: "main".to_string(), - }) + AppEvent::Ipc(IpcCall::new( + id.to_string(), + cmd.to_string(), + Utc::now(), + IpcResult::Err(err.to_string()), + Some(1), + 0, + "main".to_string(), + )) } fn state_change(key: &str) -> AppEvent { - AppEvent::StateChange { - key: key.to_string(), - timestamp: Utc::now(), - caused_by: None, - } + AppEvent::state_change(key.to_string(), Utc::now(), None) } fn dom_mutation(count: u32) -> AppEvent { - AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: Utc::now(), - mutation_count: count, - } + AppEvent::dom_mutation("main".to_string(), Utc::now(), count) } fn window_event(label: &str, event: &str) -> AppEvent { - AppEvent::WindowEvent { - label: label.to_string(), - event: event.to_string(), - timestamp: Utc::now(), - } + AppEvent::window_event(label.to_string(), event.to_string(), Utc::now()) } fn dom_interaction(action: InteractionKind, selector: &str) -> AppEvent { - AppEvent::DomInteraction { + AppEvent::dom_interaction( action, - selector: selector.to_string(), - value: None, - timestamp: Utc::now(), - webview_label: "main".to_string(), - } + selector.to_string(), + None, + Utc::now(), + "main".to_string(), + ) } fn element(ref_id: &str, tag: &str) -> DomElement { - DomElement { + lit!(DomElement { ref_id: ref_id.to_string(), tag: tag.to_string(), - role: None, - name: None, - text: None, - value: None, + role: Option::<()>::None, + name: Option::<()>::None, + text: Option::<()>::None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: None, - children: vec![], - attributes: BTreeMap::new(), - } + bounds: Option::<()>::None, + children: Vec::<()>::new(), + attributes: BTreeMap::::new(), + }) } fn assertion( @@ -100,11 +100,7 @@ fn assertion( cond: AssertionCondition, expected: serde_json::Value, ) -> SemanticAssertion { - SemanticAssertion { - label: label.to_string(), - condition: cond, - expected, - } + SemanticAssertion::new(label.to_string(), cond, expected) } // ── Group 1: EventLog Ring Buffer Adversarial ──────────────────────────── @@ -253,11 +249,7 @@ fn eventlog_snapshot_range_after_wraparound() { fn eventlog_since_returns_events_at_exact_timestamp() { let ts = Utc::now(); let log = EventLog::new(100); - log.push(AppEvent::StateChange { - key: "exact".to_string(), - timestamp: ts, - caused_by: None, - }); + log.push(AppEvent::state_change("exact".to_string(), ts, None)); let result = log.since(ts); assert_eq!(result.len(), 1); } @@ -427,18 +419,9 @@ fn registry_command_info_all_optional_fields() { .with_category("admin"); cmd.plugin = Some("my_plugin".to_string()); cmd.args = vec![ - CommandArg { - name: "arg1".to_string(), - type_name: "String".to_string(), - required: true, - schema: Some(serde_json::json!({"type": "string"})), - }, - CommandArg { - name: "arg2".to_string(), - type_name: "Option".to_string(), - required: false, - schema: None, - }, + CommandArg::new("arg1".to_string(), "String".to_string(), true) + .with_schema(serde_json::json!({"type": "string"})), + CommandArg::new("arg2".to_string(), "Option".to_string(), false), ]; cmd.return_type = Some("Result<(), Error>".to_string()); cmd.is_async = true; @@ -593,12 +576,7 @@ fn recorder_export_json_roundtrip() { #[test] fn recorder_import_invalid_session_empty() { let rec = EventRecorder::new(100); - let session = RecordedSession { - id: "empty".to_string(), - started_at: Utc::now(), - events: vec![], - checkpoints: vec![], - }; + let session = RecordedSession::new("empty".to_string(), Utc::now(), vec![], vec![]); rec.import(session); assert!(rec.is_recording()); assert_eq!(rec.event_count(), 0); @@ -732,28 +710,16 @@ fn recorder_import_replaces_active() { let rec = EventRecorder::new(100); rec.start("original".to_string()).unwrap(); rec.record_event(ipc("1", "orig_cmd")); - let session = RecordedSession { - id: "imported".to_string(), - started_at: Utc::now(), - events: vec![ - RecordedEvent { - index: 0, - timestamp: Utc::now(), - event: ipc("10", "imp_a"), - }, - RecordedEvent { - index: 1, - timestamp: Utc::now(), - event: ipc("11", "imp_b"), - }, - RecordedEvent { - index: 2, - timestamp: Utc::now(), - event: ipc("12", "imp_c"), - }, + let session = RecordedSession::new( + "imported".to_string(), + Utc::now(), + vec![ + RecordedEvent::new(0, Utc::now(), ipc("10", "imp_a")), + RecordedEvent::new(1, Utc::now(), ipc("11", "imp_b")), + RecordedEvent::new(2, Utc::now(), ipc("12", "imp_c")), ], - checkpoints: vec![], - }; + vec![], + ); rec.import(session); assert_eq!(rec.event_count(), 3); let stopped = rec.stop().unwrap(); @@ -805,11 +771,14 @@ fn dom_deep_nesting_is_depth_bounded() { el } // 1000 levels > the 256 DOM cap (so the guard fires) but is safe to drop. - let snap = DomSnapshot { + // (Built empty then filled: serde can't round-trip 1000 levels, which is + // the very depth this test is about.) + let mut snap = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![build_nested(1000)], - ref_map: BTreeMap::new(), - }; + elements: Vec::<()>::new(), + ref_map: BTreeMap::::new(), + }); + snap.elements = vec![build_nested(1000)]; let text = snap.to_accessible_text(0); // guard prevents recursing past the cap assert!( text.contains("max DOM depth"), @@ -835,11 +804,11 @@ fn dom_wide_tree_1000_children() { let mut root = element("root", "div"); root.visible = true; root.children = children; - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![root], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(text.contains("Child 0")); assert!(text.contains("Child 999")); @@ -847,17 +816,17 @@ fn dom_wide_tree_1000_children() { #[test] fn dom_empty_tree() { - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![], - ref_map: BTreeMap::new(), - }; + elements: Vec::<()>::new(), + ref_map: BTreeMap::::new(), + }); assert!(snap.to_accessible_text(0).is_empty()); } #[test] fn dom_element_all_optional_fields() { - let el = DomElement { + let el = lit!(DomElement { ref_id: "e1".to_string(), tag: "input".to_string(), role: Some("textbox".to_string()), @@ -867,25 +836,25 @@ fn dom_element_all_optional_fields() { enabled: true, visible: true, focusable: true, - bounds: Some(snapshot::ElementBounds { + bounds: Some(lit!(snapshot::ElementBounds { x: 10.0, y: 20.0, width: 200.0, height: 30.0, - }), - children: vec![], + })), + children: Vec::<()>::new(), attributes: { let mut m = BTreeMap::new(); m.insert("type".to_string(), "email".to_string()); m.insert("placeholder".to_string(), "Enter email".to_string()); m }, - }; - let snap = DomSnapshot { + }); + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![el], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(text.contains("textbox")); assert!(text.contains("Email")); @@ -895,11 +864,11 @@ fn dom_element_all_optional_fields() { #[test] fn dom_element_minimal() { let el = element("e0", "div"); - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![el], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(text.contains("div")); } @@ -917,11 +886,11 @@ fn dom_ref_handle_uniqueness() { let mut root = element("root", "div"); root.visible = true; root.children = children; - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![root], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); for i in 0..100 { assert!(text.contains(&format!("[ref=e{i}]"))); @@ -930,24 +899,24 @@ fn dom_ref_handle_uniqueness() { #[test] fn dom_serde_roundtrip() { - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), - elements: vec![DomElement { + elements: vec![lit!(DomElement { ref_id: "e0".to_string(), tag: "div".to_string(), role: Some("main".to_string()), name: Some("Content".to_string()), text: Some("Hello".to_string()), - value: None, + value: Option::<()>::None, enabled: true, visible: true, focusable: false, - bounds: Some(snapshot::ElementBounds { + bounds: Some(lit!(snapshot::ElementBounds { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0, - }), + })), children: vec![{ let mut child = element("e1", "button"); child.role = Some("button".to_string()); @@ -956,15 +925,15 @@ fn dom_serde_roundtrip() { child.visible = true; child }], - attributes: BTreeMap::new(), - }], + attributes: BTreeMap::::new(), + })], ref_map: { let mut m = BTreeMap::new(); m.insert("e0".to_string(), "div.main".to_string()); m.insert("e1".to_string(), "button.click".to_string()); m }, - }; + }); let json = serde_json::to_string(&snap).unwrap(); let roundtripped: DomSnapshot = serde_json::from_str(&json).unwrap(); assert_eq!(roundtripped.webview_label, "main"); @@ -981,23 +950,23 @@ fn dom_invisible_parent_hides_subtree() { child.visible = true; child.name = Some("Hidden button".to_string()); parent.children = vec![child]; - let snap = DomSnapshot { + let snap = lit!(DomSnapshot { webview_label: "main".to_string(), elements: vec![parent], - ref_map: BTreeMap::new(), - }; + ref_map: BTreeMap::::new(), + }); let text = snap.to_accessible_text(0); assert!(!text.contains("Hidden button")); } #[test] fn dom_element_bounds_serde() { - let bounds = snapshot::ElementBounds { + let bounds = lit!(snapshot::ElementBounds { x: -10.5, y: 0.0, width: 100.123, height: 50.999, - }; + }); let json = serde_json::to_string(&bounds).unwrap(); let rt: snapshot::ElementBounds = serde_json::from_str(&json).unwrap(); assert_eq!(rt.x, -10.5); @@ -1264,18 +1233,16 @@ fn assertion_truthy_empty_object_is_truthy() { #[test] fn window_state_serde_roundtrip() { - let state = WindowState { - label: "main".to_string(), - title: "Test App".to_string(), - url: "https://example.com".to_string(), - visible: true, - focused: false, - maximized: true, - minimized: false, - fullscreen: false, - position: (100, 200), - size: (1920, 1080), - }; + let state = WindowState::new("main".to_string()) + .with_title("Test App".to_string()) + .with_url("https://example.com".to_string()) + .with_visible(true) + .with_focused(false) + .with_maximized(true) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 200) + .with_size(1920, 1080); let json = serde_json::to_string(&state).unwrap(); let rt: WindowState = serde_json::from_str(&json).unwrap(); assert_eq!(rt, state); @@ -1283,18 +1250,16 @@ fn window_state_serde_roundtrip() { #[test] fn window_state_extreme_values() { - let state = WindowState { - label: "".to_string(), - title: "".to_string(), - url: "".to_string(), - visible: false, - focused: false, - maximized: false, - minimized: false, - fullscreen: false, - position: (i32::MIN, i32::MAX), - size: (0, u32::MAX), - }; + let state = WindowState::new("".to_string()) + .with_title("".to_string()) + .with_url("".to_string()) + .with_visible(false) + .with_focused(false) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(i32::MIN, i32::MAX) + .with_size(0, u32::MAX); let json = serde_json::to_string(&state).unwrap(); let rt: WindowState = serde_json::from_str(&json).unwrap(); assert_eq!(rt.position, (i32::MIN, i32::MAX)); @@ -1303,20 +1268,20 @@ fn window_state_extreme_values() { #[test] fn ghost_command_report_serde_roundtrip() { - let report = GhostCommandReport { - frontend_only: vec![GhostCommand { + let report = lit!(GhostCommandReport { + frontend_only: vec![lit!(GhostCommand { name: "phantom_cmd".to_string(), source: GhostSource::FrontendOnly, description: Some("A ghost".to_string()), - }], - registry_only: vec![GhostCommand { + })], + registry_only: vec![lit!(GhostCommand { name: "unused_backend".to_string(), source: GhostSource::RegistryOnly, - description: None, - }], + description: Option::<()>::None, + })], total_frontend_commands: 5, total_registry_commands: 3, - }; + }); let json = serde_json::to_string(&report).unwrap(); let rt: GhostCommandReport = serde_json::from_str(&json).unwrap(); assert_eq!(rt, report); @@ -1324,27 +1289,27 @@ fn ghost_command_report_serde_roundtrip() { #[test] fn ipc_integrity_report_serde_roundtrip() { - let report = verification::IpcIntegrityReport { + let report = lit!(verification::IpcIntegrityReport { total_calls: 100, completed: 90, pending: 5, errored: 5, - stale_calls: vec![verification::StaleCall { + stale_calls: vec![lit!(verification::StaleCall { id: "s1".to_string(), command: "slow_cmd".to_string(), timestamp: Utc::now(), age_ms: 10000, webview_label: "main".to_string(), - }], - error_calls: vec![verification::ErrorCall { + })], + error_calls: vec![lit!(verification::ErrorCall { id: "e1".to_string(), command: "bad_cmd".to_string(), timestamp: Utc::now(), error: "boom".to_string(), webview_label: "main".to_string(), - }], + })], healthy: false, - }; + }); let json = serde_json::to_string(&report).unwrap(); let rt: verification::IpcIntegrityReport = serde_json::from_str(&json).unwrap(); assert_eq!(rt.total_calls, 100); @@ -1355,29 +1320,21 @@ fn ipc_integrity_report_serde_roundtrip() { #[test] fn recorded_session_serde_roundtrip() { - let session = RecordedSession { - id: "test-session".to_string(), - started_at: Utc::now(), - events: vec![ - RecordedEvent { - index: 0, - timestamp: Utc::now(), - event: ipc("1", "cmd_a"), - }, - RecordedEvent { - index: 1, - timestamp: Utc::now(), - event: state_change("k"), - }, + let session = RecordedSession::new( + "test-session".to_string(), + Utc::now(), + vec![ + RecordedEvent::new(0, Utc::now(), ipc("1", "cmd_a")), + RecordedEvent::new(1, Utc::now(), state_change("k")), ], - checkpoints: vec![StateCheckpoint { + vec![lit!(StateCheckpoint { id: "cp1".to_string(), label: Some("Midpoint".to_string()), timestamp: Utc::now(), state: serde_json::json!({"count": 42}), event_index: 1, - }], - }; + })], + ); let json = serde_json::to_string(&session).unwrap(); let rt: RecordedSession = serde_json::from_str(&json).unwrap(); assert_eq!(rt.id, "test-session"); @@ -1388,17 +1345,17 @@ fn recorded_session_serde_roundtrip() { #[test] fn verification_result_serde_roundtrip() { - let result = VerificationResult { + let result = lit!(VerificationResult { passed: false, frontend_state: serde_json::json!({"x": 1}), backend_state: serde_json::json!({"x": 2}), - divergences: vec![Divergence { + divergences: vec![lit!(Divergence { path: "x".to_string(), frontend_value: serde_json::json!(1), backend_value: serde_json::json!(2), severity: DivergenceSeverity::Error, - }], - }; + })], + }); let json = serde_json::to_string(&result).unwrap(); let rt: VerificationResult = serde_json::from_str(&json).unwrap(); assert_eq!(rt, result); @@ -1406,11 +1363,11 @@ fn verification_result_serde_roundtrip() { #[test] fn semantic_assertion_serde_roundtrip() { - let a = SemanticAssertion { - label: "count check".to_string(), - condition: AssertionCondition::GreaterThan, - expected: serde_json::json!(10), - }; + let a = SemanticAssertion::new( + "count check".to_string(), + AssertionCondition::GreaterThan, + serde_json::json!(10), + ); let json = serde_json::to_string(&a).unwrap(); let rt: SemanticAssertion = serde_json::from_str(&json).unwrap(); assert_eq!(rt, a); @@ -1418,13 +1375,13 @@ fn semantic_assertion_serde_roundtrip() { #[test] fn assertion_result_serde_roundtrip() { - let result = AssertionResult { + let result = lit!(AssertionResult { label: "test".to_string(), passed: false, actual: serde_json::json!(5), expected: serde_json::json!(10), message: Some("failed".to_string()), - }; + }); let json = serde_json::to_string(&result).unwrap(); let rt: AssertionResult = serde_json::from_str(&json).unwrap(); assert_eq!(rt, result); @@ -1462,12 +1419,12 @@ fn app_event_all_variants_serde() { #[test] fn ref_handle_serde_roundtrip() { - let handle = RefHandle { + let handle = lit!(RefHandle { id: "e5".to_string(), selector: "button.submit".to_string(), role: Some("button".to_string()), name: Some("Submit".to_string()), - }; + }); let json = serde_json::to_string(&handle).unwrap(); let rt: RefHandle = serde_json::from_str(&json).unwrap(); assert_eq!(rt, handle); @@ -1475,12 +1432,12 @@ fn ref_handle_serde_roundtrip() { #[test] fn memory_delta_serde_roundtrip() { - let delta = types::MemoryDelta { + let delta = lit!(types::MemoryDelta { before_bytes: 1024, after_bytes: 2048, delta_bytes: 1024, command: "allocate".to_string(), - }; + }); let json = serde_json::to_string(&delta).unwrap(); let rt: types::MemoryDelta = serde_json::from_str(&json).unwrap(); assert_eq!(rt, delta); @@ -1698,43 +1655,43 @@ fn concurrent_eventlog_clear_during_push() { #[test] fn display_verification_result_passed() { - let result = VerificationResult { + let result = lit!(VerificationResult { passed: true, frontend_state: serde_json::json!({}), backend_state: serde_json::json!({}), - divergences: vec![], - }; + divergences: Vec::<()>::new(), + }); assert_eq!(result.to_string(), "verification passed"); } #[test] fn display_verification_result_failed() { - let result = VerificationResult { + let result = lit!(VerificationResult { passed: false, frontend_state: serde_json::json!({}), backend_state: serde_json::json!({}), - divergences: vec![Divergence { + divergences: vec![lit!(Divergence { path: "x".to_string(), frontend_value: serde_json::json!(1), backend_value: serde_json::json!(2), severity: DivergenceSeverity::Error, - }], - }; + })], + }); assert_eq!(result.to_string(), "verification failed: 1 divergence(s)"); } #[test] fn display_ghost_command_report() { - let report = GhostCommandReport { - frontend_only: vec![GhostCommand { + let report = lit!(GhostCommandReport { + frontend_only: vec![lit!(GhostCommand { name: "phantom".to_string(), source: GhostSource::FrontendOnly, - description: None, - }], - registry_only: vec![], + description: Option::<()>::None, + })], + registry_only: Vec::<()>::new(), total_frontend_commands: 2, total_registry_commands: 1, - }; + }); assert_eq!( report.to_string(), "1 ghost command(s) (2 frontend, 1 registry)" @@ -1743,29 +1700,29 @@ fn display_ghost_command_report() { #[test] fn display_ipc_integrity_healthy() { - let report = verification::IpcIntegrityReport { + let report = lit!(verification::IpcIntegrityReport { total_calls: 10, completed: 10, pending: 0, errored: 0, - stale_calls: vec![], - error_calls: vec![], + stale_calls: Vec::<()>::new(), + error_calls: Vec::<()>::new(), healthy: true, - }; + }); assert_eq!(report.to_string(), "IPC healthy: 10/10 completed"); } #[test] fn display_ipc_integrity_unhealthy() { - let report = verification::IpcIntegrityReport { + let report = lit!(verification::IpcIntegrityReport { total_calls: 10, completed: 7, pending: 2, errored: 1, - stale_calls: vec![], - error_calls: vec![], + stale_calls: Vec::<()>::new(), + error_calls: Vec::<()>::new(), healthy: false, - }; + }); assert!(report.to_string().contains("unhealthy")); } @@ -1794,15 +1751,15 @@ fn display_ipc_result() { #[test] fn display_ipc_call() { - let call = IpcCall { - id: "42".to_string(), - command: "save".to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), - result: IpcResult::Ok(serde_json::json!("ok")), - arg_size_bytes: 10, - webview_label: "main".to_string(), - }; + let call = IpcCall::new( + "42".to_string(), + "save".to_string(), + Utc::now(), + IpcResult::Ok(serde_json::json!("ok")), + Some(5), + 10, + "main".to_string(), + ); let s = call.to_string(); assert!(s.contains("save")); assert!(s.contains("[42]")); @@ -1810,10 +1767,10 @@ fn display_ipc_call() { #[test] fn display_scored_command() { - let sc = ScoredCommand { + let sc = lit!(ScoredCommand { command: CommandInfo::new("test_cmd"), score: 5.75, - }; + }); let s = sc.to_string(); assert!(s.contains("test_cmd")); assert!(s.contains("5.75")); @@ -1977,15 +1934,15 @@ fn error_display_messages() { #[test] fn ipc_call_into_app_event() { - let call = IpcCall { - id: "1".to_string(), - command: "test".to_string(), - timestamp: Utc::now(), - duration_ms: None, - result: IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - }; + let call = IpcCall::new( + "1".to_string(), + "test".to_string(), + Utc::now(), + IpcResult::Pending, + None, + 0, + "main".to_string(), + ); let event: AppEvent = call.into(); assert!(matches!(event, AppEvent::Ipc(_))); } @@ -1994,37 +1951,25 @@ fn ipc_call_into_app_event() { fn app_event_timestamp_all_variants() { let ts = Utc::now(); let events = vec![ - AppEvent::Ipc(IpcCall { - id: "1".to_string(), - command: "cmd".to_string(), - timestamp: ts, - duration_ms: None, - result: IpcResult::Pending, - arg_size_bytes: 0, - webview_label: "main".to_string(), - }), - AppEvent::StateChange { - key: "k".to_string(), - timestamp: ts, - caused_by: None, - }, - AppEvent::DomMutation { - webview_label: "main".to_string(), - timestamp: ts, - mutation_count: 1, - }, - AppEvent::DomInteraction { - action: InteractionKind::Click, - selector: "#x".to_string(), - value: None, - timestamp: ts, - webview_label: "main".to_string(), - }, - AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: ts, - }, + AppEvent::Ipc(IpcCall::new( + "1".to_string(), + "cmd".to_string(), + ts, + IpcResult::Pending, + None, + 0, + "main".to_string(), + )), + AppEvent::state_change("k".to_string(), ts, None), + AppEvent::dom_mutation("main".to_string(), ts, 1), + AppEvent::dom_interaction( + InteractionKind::Click, + "#x".to_string(), + None, + ts, + "main".to_string(), + ), + AppEvent::window_event("main".to_string(), "focus".to_string(), ts), ]; for event in &events { assert_eq!(event.timestamp(), ts); @@ -2097,16 +2042,12 @@ fn record_event_after_max_index_import_does_not_panic() { // debug / wrap in release (audit #18 — the saturating_add belongs in // record_event, not only in import). let rec = EventRecorder::new(100); - let session = RecordedSession { - id: "evil".to_string(), - started_at: Utc::now(), - events: vec![RecordedEvent { - index: usize::MAX, - timestamp: Utc::now(), - event: ipc("m", "cmd"), - }], - checkpoints: vec![], - }; + let session = RecordedSession::new( + "evil".to_string(), + Utc::now(), + vec![RecordedEvent::new(usize::MAX, Utc::now(), ipc("m", "cmd"))], + vec![], + ); rec.import(session); rec.record_event(ipc("after", "cmd")); // must not panic assert!(rec.event_count() >= 1); diff --git a/crates/victauri-macros/Cargo.toml b/crates/victauri-macros/Cargo.toml index 4d85719f..b5b22206 100644 --- a/crates/victauri-macros/Cargo.toml +++ b/crates/victauri-macros/Cargo.toml @@ -24,6 +24,7 @@ proc-macro = true syn = { workspace = true } quote = { workspace = true } proc-macro2 = { workspace = true } +heck = { workspace = true } [dev-dependencies] victauri-core = { workspace = true } diff --git a/crates/victauri-macros/LICENSE b/crates/victauri-macros/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-macros/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-macros/README.md b/crates/victauri-macros/README.md index e15f2b79..824fb6b3 100644 --- a/crates/victauri-macros/README.md +++ b/crates/victauri-macros/README.md @@ -13,8 +13,8 @@ depend on `victauri-core` directly (whether you import the macro as ```toml [dependencies] -victauri-plugin = "0.8" -victauri-core = "0.8" +victauri-plugin = "0.9" +victauri-core = "0.9" ``` ```rust diff --git a/crates/victauri-macros/src/lib.rs b/crates/victauri-macros/src/lib.rs index 8f11ff77..7f80063e 100644 --- a/crates/victauri-macros/src/lib.rs +++ b/crates/victauri-macros/src/lib.rs @@ -2,8 +2,10 @@ //! Procedural macros for Victauri — currently provides the `#[inspectable]` attribute //! for auto-registering Tauri commands with the MCP introspection layer. +use heck::{ToLowerCamelCase, ToSnakeCase}; use proc_macro::TokenStream; use quote::quote; +use syn::ext::IdentExt; use syn::{ItemFn, parse_macro_input}; /// Marks a `#[tauri::command]` as inspectable by Victauri. @@ -23,6 +25,17 @@ use syn::{ItemFn, parse_macro_input}; /// `E0433: failed to resolve: use of undeclared crate victauri_core`. /// `victauri init` adds the dependency automatically. /// +/// # Argument keys +/// +/// Tauri renames command arguments to camelCase by default: a `size_kb` argument is +/// invoked as `invoke("cmd", { sizeKb })`. Each registered argument records that IPC +/// key (`CommandArg::key`), so an agent reading the registry passes the key the +/// command actually accepts. `#[tauri::command]` consumes its own attribute before +/// this macro runs, so when the command uses `rename_all = "snake_case"` or +/// `rename = "..."`, repeat it here — `#[inspectable(rename_all = "snake_case")]`, +/// `#[inspectable(rename = "...")]`. (Written *above* `#[tauri::command(...)]`, +/// `#[inspectable]` reads those options itself.) +/// /// # Example /// /// ```rust,ignore @@ -42,44 +55,47 @@ pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream { Ok(a) => a, Err(e) => return e.to_compile_error().into(), }; + let command_options = tauri_command_options(&input.attrs); let fn_name = &input.sig.ident; - let fn_name_str = fn_name.to_string(); - let schema_fn_name = syn::Ident::new(&format!("{fn_name_str}__schema"), fn_name.span()); + let schema_fn_name = syn::Ident::new(&format!("{fn_name}__schema"), fn_name.span()); + // The name the frontend invokes: Tauri's `rename`, else the bare identifier + // (`r#type` is invoked as `type`). + let command_name = attrs + .rename + .clone() + .or(command_options.rename) + .unwrap_or_else(|| fn_name.unraw().to_string()); let is_async = input.sig.asyncness.is_some(); let description = attrs .description - .unwrap_or_else(|| fn_name_str.replace('_', " ")); + .unwrap_or_else(|| command_name.replace('_', " ")); + let snake_case = attrs + .snake_case + .or(command_options.snake_case) + .unwrap_or(false); let args_info = extract_args(&input.sig); let arg_tokens: Vec<_> = args_info .iter() .map(|(name, type_str, required)| { + let key = invoke_key(name, snake_case); quote! { - victauri_core::registry::CommandArg { - name: #name.to_string(), - type_name: #type_str.to_string(), - required: #required, - schema: None, - } + victauri_core::registry::CommandArg::new(#name, #type_str, #required) + .with_key(#key) } }) .collect(); let return_type = extract_return_type(&input.sig); - let intent_token = if let Some(i) = &attrs.intent { - quote! { Some(#i.to_string()) } - } else { - quote! { None } - }; + let intent_token = attrs.intent.as_ref().map(|i| quote! { .with_intent(#i) }); - let category_token = if let Some(c) = &attrs.category { - quote! { Some(#c.to_string()) } - } else { - quote! { None } - }; + let category_token = attrs + .category + .as_ref() + .map(|c| quote! { .with_category(#c) }); let example_tokens: Vec<_> = attrs .examples @@ -92,17 +108,17 @@ pub fn inspectable(attr: TokenStream, item: TokenStream) -> TokenStream { #[allow(dead_code, non_snake_case)] fn #schema_fn_name() -> victauri_core::registry::CommandInfo { - victauri_core::registry::CommandInfo { - name: #fn_name_str.to_string(), - plugin: None, - description: Some(#description.to_string()), - args: vec![#(#arg_tokens),*], - return_type: Some(#return_type.to_string()), - is_async: #is_async, - intent: #intent_token, - category: #category_token, - examples: vec![#(#example_tokens),*], - } + // Built via constructors, not a struct literal: `CommandInfo` and + // `CommandArg` are `#[non_exhaustive]`, and this code expands in the + // user's crate. + victauri_core::registry::CommandInfo::new(#command_name) + .with_description(#description) + .with_args(vec![#(#arg_tokens),*]) + .with_return_type(#return_type) + .with_async(#is_async) + #intent_token + #category_token + .with_examples(vec![#(#example_tokens),*]) } victauri_core::inventory::submit! { @@ -118,6 +134,64 @@ struct InspectableAttrs { intent: Option, category: Option, examples: Vec, + /// `rename = "..."`: the name the command is invoked by. + rename: Option, + /// `rename_all = "snake_case" | "camelCase"`: the argument key case. + snake_case: Option, +} + +/// The options of a `#[tauri::command(...)]` still present on the function, which +/// happens only when `#[inspectable]` is written above it. +#[derive(Default)] +struct CommandOptions { + rename: Option, + snake_case: Option, +} + +fn parse_rename_all(lit: &syn::LitStr) -> syn::Result { + match lit.value().as_str() { + "snake_case" => Ok(true), + "camelCase" => Ok(false), + _ => Err(syn::Error::new( + lit.span(), + "expected \"camelCase\" or \"snake_case\"", + )), + } +} + +fn tauri_command_options(fn_attrs: &[syn::Attribute]) -> CommandOptions { + let mut out = CommandOptions::default(); + for attr in fn_attrs { + let path = attr.path(); + let is_command = path.segments.last().is_some_and(|s| s.ident == "command") + && (path.segments.len() == 1 || path.segments[0].ident == "tauri"); + if !is_command || !matches!(attr.meta, syn::Meta::List(_)) { + continue; + } + // Best effort: Tauri validates its own attribute (and `async` is a keyword + // `parse_nested_meta` refuses), so a parse failure here is never an error. + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + out.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?); + } else if meta.path.is_ident("rename") { + out.rename = Some(meta.value()?.parse::()?.value()); + } else if meta.input.peek(syn::Token![=]) { + // Other options (`root = "..."`) are Tauri's business. + let _: syn::Expr = meta.value()?.parse()?; + } + Ok(()) + }); + } + out +} + +/// The IPC key Tauri derives for an argument name (tauri-macros `ArgumentCase`). +fn invoke_key(name: &str, snake_case: bool) -> String { + if snake_case { + name.to_snake_case() + } else { + name.to_lower_camel_case() + } } fn parse_attrs(attr: TokenStream) -> syn::Result { @@ -126,6 +200,8 @@ fn parse_attrs(attr: TokenStream) -> syn::Result { intent: None, category: None, examples: Vec::new(), + rename: None, + snake_case: None, }; let parser = syn::meta::parser(|meta| { @@ -139,6 +215,10 @@ fn parse_attrs(attr: TokenStream) -> syn::Result { attrs .examples .push(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("rename") { + attrs.rename = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("rename_all") { + attrs.snake_case = Some(parse_rename_all(&meta.value()?.parse()?)?); } else { return Err(meta.error("unknown #[inspectable] attribute")); } @@ -155,15 +235,15 @@ fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> { .filter_map(|arg| { if let syn::FnArg::Typed(pat_type) = arg { let name = match &*pat_type.pat { - syn::Pat::Ident(ident) => ident.ident.to_string(), + syn::Pat::Ident(ident) => ident.ident.unraw().to_string(), _ => return None, }; let ty = &*pat_type.ty; - let type_str = quote!(#ty).to_string(); - if is_tauri_framework_type(&type_str) { + if is_tauri_framework_type(ty) { return None; } + let type_str = quote!(#ty).to_string(); let is_option = type_str.starts_with("Option") || type_str.starts_with("Option <") @@ -178,17 +258,34 @@ fn extract_args(sig: &syn::Signature) -> Vec<(String, String, bool)> { .collect() } -fn is_tauri_framework_type(type_str: &str) -> bool { - const FRAMEWORK_TYPES: &[&str] = &["AppHandle", "State", "Window", "Webview", "WebviewWindow"]; - let last_segment = type_str - .rsplit("::") - .next() - .unwrap_or(type_str) - .split('<') - .next() - .unwrap_or(type_str) - .trim(); - FRAMEWORK_TYPES.contains(&last_segment) +/// Whether Tauri injects this argument itself (the `CommandArg` impls that do not +/// read the invoke payload), so it never appears in the frontend's arguments. +fn is_tauri_framework_type(ty: &syn::Type) -> bool { + const FRAMEWORK_TYPES: &[&str] = &[ + "AppHandle", + "State", + "Window", + "Webview", + "WebviewWindow", + "Request", + "CommandScope", + "GlobalScope", + ]; + let ty = match ty { + syn::Type::Reference(r) => &*r.elem, + syn::Type::Paren(p) => &*p.elem, + syn::Type::Group(g) => &*g.elem, + other => other, + }; + // Judge by the path's LAST segment, never by splitting its tokens as a string: + // `State<'_, my::Pool>` has a `::` inside its generics. + let syn::Type::Path(path) = ty else { + return false; + }; + path.path + .segments + .last() + .is_some_and(|seg| FRAMEWORK_TYPES.iter().any(|t| seg.ident == t)) } fn extract_return_type(sig: &syn::Signature) -> String { @@ -197,3 +294,46 @@ fn extract_return_type(sig: &syn::Signature) -> String { syn::ReturnType::Type(_, ty) => quote!(#ty).to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn fn_attrs(src: &str) -> Vec { + syn::parse_str::(src).unwrap().attrs + } + + #[test] + fn reads_rename_options_from_a_tauri_command_attribute() { + let o = tauri_command_options(&fn_attrs( + "#[tauri::command(rename_all = \"snake_case\", rename = \"x\")] fn f() {}", + )); + assert_eq!(o.snake_case, Some(true)); + assert_eq!(o.rename.as_deref(), Some("x")); + } + + #[test] + fn tolerates_the_rest_of_tauris_attribute_grammar() { + for src in [ + "#[tauri::command(async)] fn f() {}", + "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}", + "#[tauri::command] fn f() {}", + "#[other::command(rename = \"no\")] fn f() {}", + ] { + let o = tauri_command_options(&fn_attrs(src)); + assert!(o.rename.is_none() || src.contains("root"), "{src}"); + } + let o = tauri_command_options(&fn_attrs( + "#[command(root = \"crate\", rename_all = \"snake_case\")] fn f() {}", + )); + assert_eq!(o.snake_case, Some(true)); + } + + #[test] + fn keys_match_tauri_argument_case() { + assert_eq!(invoke_key("size_kb", false), "sizeKb"); + assert_eq!(invoke_key("a1_b", false), "a1B"); + assert_eq!(invoke_key("name", false), "name"); + assert_eq!(invoke_key("size_kb", true), "size_kb"); + } +} diff --git a/crates/victauri-macros/tests/macro_tests.rs b/crates/victauri-macros/tests/macro_tests.rs index 5cae0ab8..69f73cc8 100644 --- a/crates/victauri-macros/tests/macro_tests.rs +++ b/crates/victauri-macros/tests/macro_tests.rs @@ -151,3 +151,68 @@ fn registry_from_auto_discovery() { "registry should contain load_user" ); } + +// ── IPC argument keys (audit: the registry advertised Rust names, but Tauri invokes +// commands with camelCase keys by default, so an agent following it failed) ── + +#[victauri_macros::inspectable] +fn large_payload(size_kb: usize, r#type: String) -> String { + let _ = (size_kb, r#type); + String::new() +} + +#[victauri_macros::inspectable(rename_all = "snake_case")] +fn snake_keys(size_kb: usize) -> usize { + size_kb +} + +#[victauri_macros::inspectable(rename = "renamed_cmd")] +fn original_name(user_id: u64) -> u64 { + user_id +} + +/// A path-qualified `State` whose generics contain `::` is still an injected arg. +#[victauri_macros::inspectable] +fn with_injected( + app: fake_tauri_types::AppHandle, + state: fake_tauri_types::State>, + window: &fake_tauri_types::Window, + page_size: u32, +) -> u32 { + let _ = (app, state, window); + page_size +} + +#[test] +fn arg_keys_follow_tauris_camel_case_default() { + let info = large_payload__schema(); + assert_eq!(info.args[0].name, "size_kb"); + assert_eq!(info.args[0].invoke_key(), "sizeKb"); + // Raw identifiers are invoked without the `r#` prefix. + assert_eq!(info.args[1].name, "type"); + assert_eq!(info.args[1].key, None); + // Unchanged keys stay implicit (`key: None`), as for single-word args. + assert_eq!(save_api_key__schema().args[0].key, None); +} + +#[test] +fn rename_all_snake_case_keeps_rust_names() { + let info = snake_keys__schema(); + assert_eq!(info.args[0].invoke_key(), "size_kb"); + assert_eq!(info.args[0].key, None); +} + +#[test] +fn rename_sets_the_invoked_command_name() { + let info = original_name__schema(); + assert_eq!(info.name, "renamed_cmd"); + assert_eq!(info.args[0].invoke_key(), "userId"); +} + +#[test] +fn injected_framework_args_are_skipped_even_with_paths_in_generics() { + let info = with_injected__schema(); + let names: Vec<&str> = info.args.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["page_size"]); + assert_eq!(info.args[0].invoke_key(), "pageSize"); +} diff --git a/crates/victauri-plugin/Cargo.toml b/crates/victauri-plugin/Cargo.toml index e2f3b70e..d9d9bb5f 100644 --- a/crates/victauri-plugin/Cargo.toml +++ b/crates/victauri-plugin/Cargo.toml @@ -40,11 +40,20 @@ regex = "1" url = { workspace = true } flate2 = "1" tower = { version = "0.5", features = ["limit"] } +# Serve loop with a header-read timer and a connection cap (axum::serve sets neither). +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["server-auto", "tokio", "service", "http1"] } rusqlite = { workspace = true, optional = true } [features] -default = ["sqlite"] +default = ["sqlite", "bundled-sqlite"] +# `query_db` / `introspect db_health`. sqlite = ["dep:rusqlite"] +# Compile SQLite into the binary (rusqlite `bundled`). Because cargo unifies the plugin's +# rusqlite with the app's, this also switches the APP's rusqlite to bundled SQLite. An app that +# links a system SQLite or SQLCipher keeps its own with +# `default-features = false, features = ["sqlite"]`. +bundled-sqlite = ["sqlite", "rusqlite?/bundled"] [build-dependencies] tauri-plugin = { version = "2", features = ["build"] } diff --git a/crates/victauri-plugin/LICENSE b/crates/victauri-plugin/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-plugin/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-plugin/README.md b/crates/victauri-plugin/README.md index 497a21a6..23efd477 100644 --- a/crates/victauri-plugin/README.md +++ b/crates/victauri-plugin/README.md @@ -8,7 +8,7 @@ Add the dependency: ```toml [dependencies] -victauri-plugin = "0.8" +victauri-plugin = "0.9" ``` Wire it into your Tauri app: @@ -47,9 +47,28 @@ victauri_plugin::VictauriBuilder::new() .expect("valid config") ``` +## Cargo features + +| Feature | Default | What it does | +|---|---|---| +| `sqlite` | on | `query_db` and `introspect db_health` (via rusqlite). Without it both tools return a clear "not compiled in" error. | +| `bundled-sqlite` | on | Compiles SQLite into the binary (rusqlite's `bundled`). Implies `sqlite`. | + +The plugin accepts rusqlite `>=0.32, <0.41`, so cargo unifies it with your app's own rusqlite — +which means `bundled-sqlite` **also switches your app's rusqlite to the bundled SQLite**. If your +app links a system SQLite or SQLCipher, keep yours by turning the default features off and +enabling only `sqlite`: + +```toml +[dependencies] +victauri-plugin = { version = "0.9", default-features = false, features = ["sqlite"] } +``` + +(With `default-features = false` and no `sqlite`, the plugin has no rusqlite dependency at all.) + ## Tools -35 MCP tools across three layers -- webview, IPC, and Rust backend (full catalog in the [tools reference](https://4da-systems.github.io/victauri/tools-reference.html)): +35 MCP tools across three layers -- webview, IPC, and Rust backend (full catalog in the [tools reference](https://4da-systems.github.io/Victauri/tools-reference.html)): ### Backend (direct Rust access, no webview needed) diff --git a/crates/victauri-plugin/permissions/autogenerated/reference.md b/crates/victauri-plugin/permissions/autogenerated/reference.md index 1bd356b3..a2abc6bd 100644 --- a/crates/victauri-plugin/permissions/autogenerated/reference.md +++ b/crates/victauri-plugin/permissions/autogenerated/reference.md @@ -1,17 +1,15 @@ ## Default Permission -Default permissions for the Victauri plugin — allows all introspection commands. +Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs (the eval callback) plus the synchronous, read-only introspection commands. `allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async commands) are NOT included — grant them explicitly if page code needs them. #### This default permission set includes the following: -- `allow-victauri-eval-js` - `allow-victauri-eval-callback` - `allow-victauri-get-window-state` - `allow-victauri-list-windows` - `allow-victauri-get-ipc-log` - `allow-victauri-get-registry` - `allow-victauri-get-memory-stats` -- `allow-victauri-dom-snapshot` - `allow-victauri-verify-state` - `allow-victauri-detect-ghost-commands` - `allow-victauri-check-ipc-integrity` diff --git a/crates/victauri-plugin/permissions/default.toml b/crates/victauri-plugin/permissions/default.toml index 01b99d9b..ba5f6344 100644 --- a/crates/victauri-plugin/permissions/default.toml +++ b/crates/victauri-plugin/permissions/default.toml @@ -1,14 +1,17 @@ [default] -description = "Default permissions for the Victauri plugin — allows all introspection commands." +description = """ +Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs \ +(the eval callback) plus the synchronous, read-only introspection commands. \ +`allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async \ +commands) are NOT included — grant them explicitly if page code needs them.\ +""" permissions = [ - "allow-victauri-eval-js", "allow-victauri-eval-callback", "allow-victauri-get-window-state", "allow-victauri-list-windows", "allow-victauri-get-ipc-log", "allow-victauri-get-registry", "allow-victauri-get-memory-stats", - "allow-victauri-dom-snapshot", "allow-victauri-verify-state", "allow-victauri-detect-ghost-commands", "allow-victauri-check-ipc-integrity", diff --git a/crates/victauri-plugin/permissions/schemas/schema.json b/crates/victauri-plugin/permissions/schemas/schema.json index 0ed7ba5c..43908ee0 100644 --- a/crates/victauri-plugin/permissions/schemas/schema.json +++ b/crates/victauri-plugin/permissions/schemas/schema.json @@ -427,10 +427,10 @@ "markdownDescription": "Denies the victauri_verify_state command without any pre-configured scope." }, { - "description": "Default permissions for the Victauri plugin — allows all introspection commands.\n#### This default permission set includes:\n\n- `allow-victauri-eval-js`\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-dom-snapshot`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`", + "description": "Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs (the eval callback) plus the synchronous, read-only introspection commands. `allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async commands) are NOT included — grant them explicitly if page code needs them.\n#### This default permission set includes:\n\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the Victauri plugin — allows all introspection commands.\n#### This default permission set includes:\n\n- `allow-victauri-eval-js`\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-dom-snapshot`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`" + "markdownDescription": "Default permissions for the Victauri plugin — the commands Victauri's own injected bridge needs (the eval callback) plus the synchronous, read-only introspection commands. `allow-victauri-eval-js` and `allow-victauri-dom-snapshot` (page-callable eval / snapshot; async commands) are NOT included — grant them explicitly if page code needs them.\n#### This default permission set includes:\n\n- `allow-victauri-eval-callback`\n- `allow-victauri-get-window-state`\n- `allow-victauri-list-windows`\n- `allow-victauri-get-ipc-log`\n- `allow-victauri-get-registry`\n- `allow-victauri-get-memory-stats`\n- `allow-victauri-verify-state`\n- `allow-victauri-detect-ghost-commands`\n- `allow-victauri-check-ipc-integrity`" } ] } diff --git a/crates/victauri-plugin/proptest-regressions/mcp/mod.txt b/crates/victauri-plugin/proptest-regressions/mcp/mod.txt new file mode 100644 index 00000000..13f60394 --- /dev/null +++ b/crates/victauri-plugin/proptest-regressions/mcp/mod.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 667b3479c4c3cb88385986b82c5c7d423a6a8f4f62fef857fbb07527e2fd20e4 # shrinks to s = "``¡" diff --git a/crates/victauri-plugin/src/auth.rs b/crates/victauri-plugin/src/auth.rs index 8a65d5cf..ff6b0788 100644 --- a/crates/victauri-plugin/src/auth.rs +++ b/crates/victauri-plugin/src/auth.rs @@ -63,6 +63,8 @@ mod tests { #[test] fn rate_limiter_concurrent_acquire() { + // Started before the limiter: the refill clock can only have run for less than this. + let started = std::time::Instant::now(); let limiter = Arc::new(RateLimiterState::new(1000)); let mut handles = vec![]; for _ in 0..10 { @@ -82,7 +84,15 @@ mod tests { total >= 1000, "should dispense at least the initial budget, got {total}" ); - assert!(total <= 1200, "refill overshoot too high, got {total}"); + // The limiter refills 1,000 tokens/s (1 per elapsed ms), so the most it can hand out is + // the initial 1,000 plus 1 per millisecond the threads ran. A fixed ceiling (1,200) + // failed on a loaded machine where the loop simply took longer; anything above this + // bound is a real refill double-count. + let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + assert!( + total <= 1000 + elapsed_ms + 1, + "refill overshoot: {total} tokens in {elapsed_ms} ms" + ); } #[test] @@ -225,14 +235,74 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); } + /// Audit I3: `tauri://` was accepted by prefix, for any host. No caller needs it (the + /// app's webview reaches Victauri over IPC), so it is refused. #[tokio::test] - async fn origin_allows_tauri_scheme() { + async fn origin_blocks_tauri_scheme() { + for origin in ["tauri://localhost", "tauri://evil.com"] { + let app = origin_router(); + let resp = app.oneshot(origin_request(Some(origin))).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{origin}"); + } + } + + /// Audit I1: an Origin that is not visible ASCII used to be treated as ABSENT (allowed). + #[tokio::test] + async fn origin_blocks_unreadable_origin() { let app = origin_router(); - let resp = app - .oneshot(origin_request(Some("tauri://localhost"))) - .await + let req = axum::extract::Request::builder() + .uri("/test") + .header( + "origin", + axum::http::HeaderValue::from_bytes(b"http://\xe9vil.com").unwrap(), + ) + .body(Body::empty()) .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + app.oneshot(req).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + } + + /// Audit N3: a page can make a browser send `GET /health` with no Origin header + /// (``); `Sec-Fetch-Site` marks it. It is refused BEFORE the rate limiter, so it + /// spends no token. + #[tokio::test] + async fn browser_initiated_requests_are_refused_without_spending_a_token() { + let limiter = Arc::new(RateLimiterState::new(5)); + let app = Router::new() + .route("/test", get(ok_handler)) + .layer(middleware::from_fn_with_state(limiter.clone(), rate_limit)) + .layer(middleware::from_fn(origin_guard)); + for site in ["cross-site", "same-site", "same-origin"] { + let req = axum::extract::Request::builder() + .uri("/test") + .header("sec-fetch-site", site) + .body(Body::empty()) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{site}"); + } + assert_eq!( + limiter.current_tokens(), + 5, + "a refused request spent a token" + ); + // A user navigation (`none`) and a non-browser client (no header) pass. + let req = axum::extract::Request::builder() + .uri("/test") + .header("sec-fetch-site", "none") + .body(Body::empty()) + .unwrap(); + assert_eq!( + app.clone().oneshot(req).await.unwrap().status(), + StatusCode::OK + ); + let req = axum::extract::Request::builder() + .uri("/test") + .body(Body::empty()) + .unwrap(); + assert_eq!(app.oneshot(req).await.unwrap().status(), StatusCode::OK); } #[tokio::test] @@ -483,8 +553,8 @@ mod tests { assert!(is_allowed_origin("https://127.0.0.1")); assert!(is_allowed_origin("http://[::1]")); assert!(is_allowed_origin("http://[::1]:7373")); - assert!(is_allowed_origin("tauri://localhost")); - assert!(is_allowed_origin("tauri://some-app")); + assert!(!is_allowed_origin("tauri://localhost")); + assert!(!is_allowed_origin("tauri://some-app")); } #[test] @@ -545,6 +615,8 @@ mod tests { // ── Security headers: CORS + CSP tests ─────────────────────────────── + /// Audit I2: `Access-Control-Allow-Origin: null` GRANTS the opaque `null` origin + /// (sandboxed iframes, `data:` documents); denying CORS means sending no ACAO at all. #[tokio::test] async fn security_headers_cors_deny() { let app = security_headers_router(); @@ -553,10 +625,7 @@ mod tests { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!( - resp.headers().get("access-control-allow-origin").unwrap(), - "null" - ); + assert!(resp.headers().get("access-control-allow-origin").is_none()); } #[tokio::test] diff --git a/crates/victauri-plugin/src/bridge.rs b/crates/victauri-plugin/src/bridge.rs index 943a16c7..04bdea21 100644 --- a/crates/victauri-plugin/src/bridge.rs +++ b/crates/victauri-plugin/src/bridge.rs @@ -4,6 +4,12 @@ use victauri_core::WindowState; /// Runtime-erased interface for webview and backend access, allowing the MCP /// server to interact with Tauri windows and the application backend without /// generic parameters. +/// +/// Victauri implements this for `tauri::AppHandle`; it is public so tests (and embedders) can +/// supply a mock. **Stability contract:** any method added to this trait in a future release +/// will have a default implementation, so implementing it does not pin you to an exact +/// version. [`WindowState`] is `#[non_exhaustive]` — build one with +/// `WindowState::new(label).with_*(..)`. pub trait WebviewBridge: Send + Sync { /// Execute JavaScript in the target webview (defaults to "main" or first visible window). /// @@ -13,8 +19,41 @@ pub trait WebviewBridge: Send + Sync { fn eval_webview(&self, label: Option<&str>, script: &str) -> Result<(), String>; /// Retrieve the state of one or all windows (position, size, visibility, focus, URL). fn get_window_states(&self, label: Option<&str>) -> Vec; + /// Like [`get_window_states`](Self::get_window_states), but distinguishes "could not ask" + /// (the UI thread did not answer in time) from "no such window". A caller that reports a + /// window as missing, or a list as empty, must use this. + /// + /// # Errors + /// + /// Returns an error string if the window states could not be obtained. + fn try_get_window_states(&self, label: Option<&str>) -> Result, String> { + Ok(self.get_window_states(label)) + } /// Return the labels of all open webview windows. fn list_window_labels(&self) -> Vec; + /// Like [`list_window_labels`](Self::list_window_labels), but distinguishes "could not + /// ask" (e.g. the UI thread did not answer in time) from "there are no windows". A caller + /// deciding that a window is GONE must use this — an empty list from a wedged UI is not + /// evidence of anything. + /// + /// # Errors + /// + /// Returns an error string if the window list could not be obtained. + fn try_list_window_labels(&self) -> Result, String> { + Ok(self.list_window_labels()) + } + /// Like [`eval_webview`](Self::eval_webview), but returns the label of the window the + /// script was actually delivered to (for `None`, the resolved default window). The default + /// implementation returns the requested label, or an EMPTY string when it cannot know which + /// window a `None` label resolved to — callers must treat empty as "unknown". + /// + /// # Errors + /// + /// Returns an error string if no matching window is found or the eval fails. + fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result { + self.eval_webview(label, script)?; + Ok(label.map(str::to_string).unwrap_or_default()) + } /// Return the platform-native window handle for screenshot capture. /// Windows: `HWND`, macOS: `CGWindowID` (window number), Linux: `X11` window ID. /// @@ -149,14 +188,41 @@ fn find_window<'a, R: Runtime>( .ok_or_else(|| format!("window not found: {l}")), None => windows .get("main") - .or_else(|| windows.values().find(|w| w.is_visible().unwrap_or(false))) - .or_else(|| windows.values().next()) + // Deterministic fallbacks: `webview_windows()` is a HashMap whose iteration order + // differs between calls, so "first visible" must be chosen by a stable key or two + // consecutive calls can pick different windows. + .or_else(|| { + let mut visible: Vec<_> = windows + .iter() + .filter(|(_, w)| w.is_visible().unwrap_or(false)) + .collect(); + visible.sort_by(|a, b| a.0.cmp(b.0)); + visible.first().map(|(_, w)| *w) + }) + .or_else(|| windows.iter().min_by(|a, b| a.0.cmp(b.0)).map(|(_, w)| w)) .ok_or_else(|| "no window available".to_string()), } } +/// The label of the window a `None` label resolves to (see `find_window`). MAIN THREAD ONLY: +/// it lists the webview windows, which clones their handles (see [`on_main`]) — for a sync +/// Tauri command such as the eval callback. +pub(crate) fn default_window_label(app: &tauri::AppHandle) -> Option { + let windows = app.webview_windows(); + find_window(&windows, None) + .ok() + .map(|window| window.label().to_string()) +} + /// Serializes Victauri's main-thread round trips. /// +/// **Correction (0.9.0): the heap corruption described below was NOT caused by concurrent round +/// trips as such.** Its real cause was the `AppHandle` clone each round trip made on the calling +/// thread (see [`on_main`]) — cloning or dropping any Tauri handle off the main thread races a +/// non-atomic `Rc` inside tao on Linux. Serializing only limited how many threads cloned at once, +/// which is why it reduced the crash without removing it. The lock stays: it bounds how much +/// Victauri can queue onto the UI thread and is measured-safe. The original 0.8.8 notes follow. +/// /// Concurrent `run_on_main_thread` round trips corrupt the process heap on Linux/WebKitGTK — /// glibc aborts the app with `malloc(): unaligned tcache chunk detected` or `corrupted /// double-linked list`. Measured on Ubuntu 24.04 + `WebKitGTK` 2.52, three concurrent @@ -177,9 +243,152 @@ fn find_window<'a, R: Runtime>( /// Serializing costs effectively nothing: the closures already execute one at a time on the /// single main thread, so this only stops several round trips being in flight *around* it. /// -/// NOTE: the lock is not reentrant. No `on_main` closure may itself call `on_main` — on the main -/// thread `run_on_main_thread` runs inline, so that would self-deadlock. -static MAIN_DISPATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// The lock is only taken OFF the main thread: a caller already on the main thread (including +/// an `on_main` closure that calls back into the bridge) runs inline without it — see `on_main`. +static MAIN_DISPATCH_LOCK: DispatchGate = DispatchGate::new(); + +/// The serialization state behind [`MAIN_DISPATCH_LOCK`] (a separate type so tests can use +/// their own gate instead of the process-wide one). +/// +/// The lock alone is not enough: a caller whose closure STARTED but outlived its timeout + grace +/// returns "outcome unknown" and releases the lock while that closure is still executing on the +/// main thread. `in_flight` counts dispatched closures that have reached the main thread and not +/// yet finished, and the next lock holder also waits (within its own deadline) for it to reach +/// zero — so there is still never more than one round trip in flight (R4-LOCK1). +struct DispatchGate { + lock: std::sync::Mutex<()>, + in_flight: std::sync::atomic::AtomicUsize, +} + +impl DispatchGate { + const fn new() -> Self { + Self { + lock: std::sync::Mutex::new(()), + in_flight: std::sync::atomic::AtomicUsize::new(0), + } + } +} + +/// Decrements [`DispatchGate::in_flight`] when a dispatched closure finishes — including by +/// unwinding, so a panicking closure can never wedge every later round trip. +struct InFlightGuard(&'static DispatchGate); + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.0 + .in_flight + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +/// The Tauri main (UI) thread, recorded ON it by [`adopt_main_thread`] (from plugin `setup`). +static MAIN_THREAD: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Record the calling thread as the Tauri main thread. Called on the main thread by +/// [`adopt_main_thread`]. +fn record_main_thread() { + let _ = MAIN_THREAD.set(std::thread::current().id()); +} + +thread_local! { + /// An app handle owned by the main thread and only ever cloned or dropped there (installed + /// by [`install_main_app`] via [`adopt_main_thread`]). `on_main` closures take their handle from + /// here instead of from a clone made on the calling thread — see [`on_main`] for why a + /// Tauri handle must never be cloned or dropped off the main thread. + static MAIN_APP: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Keep a main-thread-owned clone of `app` for `on_main` closures. Must be called ON the main +/// thread (see [`adopt_main_thread`]); a call from any other thread stores into that thread's +/// slot, which no closure ever reads. +fn install_main_app(app: &tauri::AppHandle) { + MAIN_APP.with(|slot| *slot.borrow_mut() = Some(Box::new(app.clone()))); +} + +/// How plugin setup adopted the Tauri main thread (see [`adopt_main_thread`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MainThreadAdoption { + /// Setup ran on the main thread: recorded and installed in place. + Inline, + /// Setup ran on another thread; recording and installation were posted to the main thread. + Posted, +} + +/// Record the Tauri main thread and give it its own app handle ([`record_main_thread`] + +/// [`install_main_app`]) — ON the main thread, wherever plugin `setup` happens to run. +/// +/// Tauri normally runs plugin `setup` on the main thread, but an app may also register a plugin +/// at runtime from a background thread (`AppHandle::plugin`), and `setup` then runs THERE. +/// Recording that thread as "main" and installing the handle in its thread-local slot would +/// leave every later [`on_main`] round trip failing with "the main-thread app handle is not +/// installed" (the closures read the real main thread's slot, which stays empty). +/// +/// Which thread is the main one is learned from Tauri itself: `run_on_main_thread` runs a +/// closure inline when called ON the main thread and queues it otherwise, so a tiny probe tells +/// the two apart. On the main thread the adoption happens in place (no handle is cloned +/// anywhere else). Off it, the adoption is posted to the main thread with a handle cloned here — +/// one unavoidable off-main clone, of the same kind `setup` itself already makes in that case +/// (it clones the handle for the MCP server) — and round trips made before it runs fail cleanly +/// with "not installed" instead of misbehaving. +pub(crate) fn adopt_main_thread( + app: &tauri::AppHandle, +) -> Result { + adopt_on_main( + app, + |job| app.run_on_main_thread(job).map_err(|e| e.to_string()), + |main: &tauri::AppHandle| { + record_main_thread(); + install_main_app(main); + }, + ) +} + +/// Generic core of [`adopt_main_thread`], testable with a fake main thread. `post` must run a +/// job inline when called on the main thread and queue it there otherwise (Tauri's +/// `run_on_main_thread` contract); `adopt` is always called on the main thread. +fn adopt_on_main( + handle: &H, + post: impl Fn(Box) -> Result<(), String>, + adopt: impl Fn(&H) + Send + 'static, +) -> Result { + use std::sync::atomic::{AtomicBool, Ordering}; + + let here = std::thread::current().id(); + let ran_here = std::sync::Arc::new(AtomicBool::new(false)); + let flag = std::sync::Arc::clone(&ran_here); + // Queued (off the main thread), this runs later on the main thread and finds a different + // thread id there, so it does nothing. + post(Box::new(move || { + if std::thread::current().id() == here { + flag.store(true, Ordering::Release); + } + }))?; + if ran_here.load(Ordering::Acquire) { + adopt(handle); + return Ok(MainThreadAdoption::Inline); + } + let owned = handle.clone(); + post(Box::new(move || adopt(&owned)))?; + Ok(MainThreadAdoption::Posted) +} + +/// The main thread's own app handle (a clone made HERE, on the main thread), if installed. +fn main_app() -> Option> { + MAIN_APP.with(|slot| { + slot.borrow() + .as_ref() + .and_then(|any| any.downcast_ref::>()) + .cloned() + }) +} + +/// Whether the current thread is the recorded Tauri main thread (false if not yet recorded). +fn is_main_thread() -> bool { + MAIN_THREAD + .get() + .is_some_and(|id| *id == std::thread::current().id()) +} /// Run `f` on the Tauri **main (UI) thread** and return its result. /// @@ -193,6 +402,20 @@ static MAIN_DISPATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// `STATUS_*_BUFFER_OVERRUN` once Rust's debug `assert_unchecked` on `Rc::inc_strong` /// (1.78+) starts checking it. See Tauri issue #10001 for the identical crash class. /// +/// **The same applies to the `AppHandle` itself — never clone or drop one off the main thread.** +/// On Linux every Tauri handle (`AppHandle`, `Webview`, `WebviewWindow`) carries +/// tauri-runtime-wry's `Context`, which holds the main-thread `DispatcherMainThreadContext` BY +/// VALUE, which holds tao's `EventLoopWindowTarget`, which holds `windows: +/// Rc>>`. So cloning or dropping any handle bumps a NON-ATOMIC count +/// that the main thread bumps all the time (IPC, window access). This function used to clone +/// the `AppHandle` on the calling thread for every round trip; under concurrent introspection +/// that corrupted the heap (glibc `corrupted double-linked list` / `unaligned fastbin chunk`) on +/// Linux/WebKitGTK. Proven without Victauri: a bare Tauri app whose background threads only +/// clone+drop its `AppHandle` while the page makes IPC calls aborted 3/3; 12 threads doing +/// `run_on_main_thread` with an off-thread clone aborted 5/5, and 5/5 with an EMPTY closure. +/// So the closure takes a handle cloned ON the main thread ([`install_main_app`]), and the +/// caller only borrows `app` to post it (`AppHandle::run_on_main_thread` does not clone). +/// /// `run_on_main_thread` marshals the closure onto the UI thread (and runs it inline if we are /// already on it), so all `Rc` access stays single-threaded. The closure's value comes back /// over a oneshot `std::sync::mpsc` channel; the bounded `recv` is a safety net against a @@ -208,81 +431,223 @@ static MAIN_DISPATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// /// Round trips are additionally serialized through [`MAIN_DISPATCH_LOCK`] — see there for the /// heap corruption that requires it and the bisect that established it. +/// +/// **Blocking.** Off the main thread this call BLOCKS its thread until the closure's outcome is +/// known — up to its 10 s deadline, or about 20 s when the closure started just before it (see +/// [`serialized_round_trip`]). On a multi-threaded tokio runtime it runs under `block_in_place`, +/// so the runtime keeps serving other tasks. On a **current-thread** runtime that is impossible +/// (`block_in_place` panics there), so the whole runtime — every task on it — stalls for that +/// long while the UI is wedged. Victauri's own server runs on a multi-threaded runtime; do not +/// call bridge methods from a current-thread runtime you care about. fn on_main(app: &tauri::AppHandle, what: &str, f: F) -> Result where R: Runtime, T: Send + 'static, F: FnOnce(&tauri::AppHandle) -> T + Send + 'static, { - use std::sync::atomic::{AtomicBool, Ordering}; let timeout = std::time::Duration::from_secs(10); + // Already ON the main thread (a sync Tauri command, a menu handler, or an `on_main` closure + // that calls back into the bridge): run inline, WITHOUT the dispatch lock. Taking the lock + // here could deadlock — a background holder waits for its closure, which is queued behind + // us on this very thread — freezing the UI for the full timeout. Skipping it is safe: no + // other closure can execute on this thread concurrently, and the heap corruption the lock + // prevents needs several cross-thread round trips in flight, which an inline call is not. + if is_main_thread() { + return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(app))) + .map_err(|_| format!("{what} panicked on the main thread")); + } + let round_trip = move || -> Result { - // One round trip at a time (see MAIN_DISPATCH_LOCK). The deadline covers the WAIT FOR - // THE LOCK as well as the round trip itself, so serializing cannot stack N callers into - // N * timeout when the UI wedges: each caller still gives up after `timeout` total, - // exactly as it did before this lock existed. - let deadline = std::time::Instant::now() + timeout; - let _serialize = loop { - match MAIN_DISPATCH_LOCK.try_lock() { - Ok(guard) => break guard, - Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(), - Err(std::sync::TryLockError::WouldBlock) => { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return Err(format!( - "{what} did not complete on the main thread: timed out waiting for the \ - main-thread dispatch lock" - )); - } - std::thread::sleep(remaining.min(std::time::Duration::from_millis(2))); + // No handle is cloned here, on the calling thread (see above): the closure takes the + // main thread's own handle, and `app` is only borrowed to post it. + let owned_what = what.to_string(); + serialized_round_trip( + &MAIN_DISPATCH_LOCK, + what, + timeout, + |job| { + app.run_on_main_thread(job) + .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}")) + }, + move || { + main_app::().map(|main| f(&main)).ok_or_else(|| { + format!( + "{owned_what}: the main-thread app handle is not installed (the plugin \ + setup has not run)" + ) + }) + }, + )? + }; + + // Blocking here must not park a tokio runtime worker — under a wedged UI that could + // otherwise starve the embedded axum/MCP server. On a multi-threaded runtime, `block_in_place` + // tells the scheduler to run other tasks elsewhere while this thread blocks. (It panics on a + // current-thread runtime, so guard on the flavor; with no runtime at all, just block.) + match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) { + Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(round_trip), + _ => round_trip(), + } +} + +/// One serialized round trip: take `gate`'s lock (see [`MAIN_DISPATCH_LOCK`]), then hand `f` to `post` +/// and wait for it. +/// +/// `timeout` is one deadline shared by everything BEFORE the closure starts — the wait for the +/// lock, for an earlier abandoned closure still running, and for this closure to be picked up — +/// so serializing cannot stack N callers into N * timeout when the UI wedges: a closure that has +/// not started by the deadline is abandoned (it never runs) and the call returns at `timeout`. +/// A closure that DID start before the deadline is waited for up to a further `timeout` (the +/// `grace` passed to [`dispatch_and_wait`]) so its real outcome is reported rather than +/// "timed out" for work that happened. So a call returns within `timeout` if its closure never +/// started, and within about 2 × `timeout` in the worst case (it started just before the +/// deadline and then ran long). +fn serialized_round_trip( + gate: &'static DispatchGate, + what: &str, + timeout: std::time::Duration, + post: impl FnOnce(Box) -> Result<(), String>, + f: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + let deadline = std::time::Instant::now() + timeout; + let lock_timeout = || { + format!( + "{what} did not complete on the main thread: timed out waiting for the main-thread \ + dispatch lock" + ) + }; + let _serialize = loop { + match gate.lock.try_lock() { + Ok(guard) => break guard, + Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err(lock_timeout()); } + std::thread::sleep(remaining.min(std::time::Duration::from_millis(2))); } - }; + } + }; + // An earlier caller may have given up on a closure that is STILL RUNNING on the main thread + // (it started, then outlived that caller's timeout + grace). Wait for it — bounded by this + // caller's own deadline, so callers never stack into N * timeout — before putting a second + // round trip in flight beside it. + // A plain sleep. (Round 4 briefly tried a yield spin here and removed it while chasing a + // Linux heap abort; the abort's real cause was the off-main-thread `AppHandle` clone + // documented on `on_main`, not the wait.) + while gate.in_flight.load(std::sync::atomic::Ordering::Acquire) != 0 { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); if remaining.is_zero() { return Err(format!( - "{what} did not complete on the main thread: timed out waiting for the \ - main-thread dispatch lock" + "{what} did not complete on the main thread: timed out waiting for an earlier \ + main-thread call that is still running (it will not run)" )); } + std::thread::sleep(remaining.min(std::time::Duration::from_millis(2))); + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err(lock_timeout()); + } + // Count the closure from BEFORE it can start (the count is raised ahead of the + // queued->running transition inside `dispatch_and_wait`'s job), so once a caller has seen + // its job start, the next lock holder is guaranteed to see it in flight. + let counted_post = move |job: Box| { + post(Box::new(move || { + gate.in_flight + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let _done = InFlightGuard(gate); + job(); + })) + }; + // Returns with the lock still held until the closure has either run or been abandoned, + // so no second round trip is ever in flight beside a closure that may still start. + dispatch_and_wait(what, remaining, timeout, counted_post, f) +} - let (tx, rx) = std::sync::mpsc::channel(); - let app_for_closure = app.clone(); - // If the caller times out and gives up, this flag tells the still-queued closure to skip - // its work — so a state-mutating op (resize/move/close/set_title) cannot apply long after - // the caller already saw a timeout error and moved on (a "spontaneous" window change). - let abandoned = std::sync::Arc::new(AtomicBool::new(false)); - let abandoned_closure = abandoned.clone(); - app.run_on_main_thread(move || { - if abandoned_closure.load(Ordering::Acquire) { - return; - } - let result = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&app_for_closure))); - // Send only fails if the caller already timed out and dropped the receiver — ignore. - let _ = tx.send(result); - }) - .map_err(|e| format!("failed to dispatch {what} to the main thread: {e}"))?; - - match rx.recv_timeout(remaining) { - Ok(Ok(value)) => Ok(value), - Ok(Err(_panic)) => Err(format!("{what} panicked on the main thread")), - Err(e) => { - abandoned.store(true, Ordering::Release); - Err(format!("{what} did not complete on the main thread: {e}")) +/// A dispatched closure that has not started yet. +const JOB_QUEUED: u8 = 0; +/// A dispatched closure that has started (its effects will happen). +const JOB_RUNNING: u8 = 1; +/// A dispatched closure its caller gave up on before it started: it must never run. +const JOB_ABANDONED: u8 = 2; + +/// Hand `f` to `post` (which queues it on the main thread) and wait up to `remaining` for it. +/// +/// The closure and a caller that times out race through ONE compare-and-swap: the closure only +/// runs if it moves Queued -> Running first, and the caller only reports a timeout if it moves +/// Queued -> Abandoned first. So a timeout error always means the work never happened and never +/// will — a state-mutating op (a resize, move, close, title change or eval) cannot apply after +/// the caller already saw the error and moved on. A closure that started just before the +/// deadline is waited for (up to `grace`) and its real outcome returned, instead of "timed out" +/// for work that ran. +fn dispatch_and_wait( + what: &str, + remaining: std::time::Duration, + grace: std::time::Duration, + post: impl FnOnce(Box) -> Result<(), String>, + f: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + use std::sync::atomic::{AtomicU8, Ordering}; + use std::sync::mpsc::RecvTimeoutError; + + let (tx, rx) = std::sync::mpsc::channel(); + let state = std::sync::Arc::new(AtomicU8::new(JOB_QUEUED)); + let job_state = std::sync::Arc::clone(&state); + post(Box::new(move || { + if job_state + .compare_exchange(JOB_QUEUED, JOB_RUNNING, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + // Send only fails if the caller already gave up and dropped the receiver — ignore. + let _ = tx.send(result); + }))?; + + let outcome = match rx.recv_timeout(remaining) { + Ok(outcome) => outcome, + Err(RecvTimeoutError::Disconnected) => { + return Err(format!( + "{what} did not complete on the main thread: the event loop dropped it" + )); + } + Err(RecvTimeoutError::Timeout) => { + if state + .compare_exchange( + JOB_QUEUED, + JOB_ABANDONED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + return Err(format!( + "{what} did not complete on the main thread: timed out before it started \ + (it will not run)" + )); } + // It started before we gave up: its effects will happen, so report its outcome. + rx.recv_timeout(grace).map_err(|_| { + format!( + "{what} started on the main thread but did not finish in time; its outcome \ + is unknown" + ) + })? } }; - - // Blocking here must not park a tokio runtime worker — under a wedged UI that could - // otherwise starve the embedded axum/MCP server. On a multi-threaded runtime, `block_in_place` - // tells the scheduler to run other tasks elsewhere while this thread blocks. (It panics on a - // current-thread runtime, so guard on the flavor; with no runtime at all, just block.) - match tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()) { - Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(round_trip), - _ => round_trip(), - } + outcome.map_err(|_panic| format!("{what} panicked on the main thread")) } impl WebviewBridge for tauri::AppHandle { @@ -297,6 +662,15 @@ impl WebviewBridge for tauri::AppHandle { } fn get_window_states(&self, label: Option<&str>) -> Vec { + // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not + // "no windows" — log it so that case is diagnosable rather than silently indistinguishable. + self.try_get_window_states(label).unwrap_or_else(|e| { + tracing::warn!("get_window_states: {e}"); + Vec::new() + }) + } + + fn try_get_window_states(&self, label: Option<&str>) -> Result, String> { let label = label.map(str::to_string); on_main(self, "get_window_states", move |app| { let windows = app.webview_windows(); @@ -312,38 +686,46 @@ impl WebviewBridge for tauri::AppHandle { let pos = window.outer_position().unwrap_or_default(); let size = window.inner_size().unwrap_or_default(); - states.push(WindowState { - label: win_label.clone(), - title: window.title().unwrap_or_default(), - url: window.url().map(|u| u.to_string()).unwrap_or_default(), - visible: window.is_visible().unwrap_or(false), - focused: window.is_focused().unwrap_or(false), - maximized: window.is_maximized().unwrap_or(false), - minimized: window.is_minimized().unwrap_or(false), - fullscreen: window.is_fullscreen().unwrap_or(false), - position: (pos.x, pos.y), - size: (size.width, size.height), - }); + states.push( + WindowState::new(win_label.clone()) + .with_title(window.title().unwrap_or_default()) + .with_url(window.url().map(|u| u.to_string()).unwrap_or_default()) + .with_visible(window.is_visible().unwrap_or(false)) + .with_focused(window.is_focused().unwrap_or(false)) + .with_maximized(window.is_maximized().unwrap_or(false)) + .with_minimized(window.is_minimized().unwrap_or(false)) + .with_fullscreen(window.is_fullscreen().unwrap_or(false)) + .with_position(pos.x, pos.y) + .with_size(size.width, size.height), + ); } states }) - // An empty Vec here means the main-thread dispatch failed/timed out (a wedged UI), not - // "no windows" — log it so that case is diagnosable rather than silently indistinguishable. - .unwrap_or_else(|e| { - tracing::warn!("get_window_states: {e}"); + } + + fn list_window_labels(&self) -> Vec { + self.try_list_window_labels().unwrap_or_else(|e| { + tracing::warn!("list_window_labels: {e}"); Vec::new() }) } - fn list_window_labels(&self) -> Vec { + fn try_list_window_labels(&self) -> Result, String> { on_main(self, "list_window_labels", |app| { app.webview_windows().keys().cloned().collect() }) - .unwrap_or_else(|e| { - tracing::warn!("list_window_labels: {e}"); - Vec::new() - }) + } + + fn eval_webview_resolved(&self, label: Option<&str>, script: &str) -> Result { + let label = label.map(str::to_string); + let script = script.to_string(); + on_main(self, "eval_webview", move |app| { + let windows = app.webview_windows(); + let webview = find_window(&windows, label.as_deref())?; + webview.eval(&script).map_err(|e| e.to_string())?; + Ok(webview.label().to_string()) + })? } fn get_native_handle(&self, label: Option<&str>) -> Result { @@ -375,21 +757,21 @@ impl WebviewBridge for tauri::AppHandle { #[cfg(windows)] fn native_type_text(&self, label: Option<&str>, text: &str) -> Result<(), String> { let hwnd = self.get_native_handle(label)?; - win_focus(hwnd); + win_focus(hwnd)?; win_send_text(text) } #[cfg(windows)] fn native_key(&self, label: Option<&str>, key: &str) -> Result<(), String> { let hwnd = self.get_native_handle(label)?; - win_focus(hwnd); + win_focus(hwnd)?; win_send_key(key) } #[cfg(windows)] fn native_click(&self, label: Option<&str>, x: f64, y: f64) -> Result<(), String> { let hwnd = self.get_native_handle(label)?; - win_focus(hwnd); + win_focus(hwnd)?; win_click(hwnd, x, y) } @@ -573,18 +955,99 @@ fn win_hwnd(hwnd: isize) -> windows::Win32::Foundation::HWND { windows::Win32::Foundation::HWND(hwnd as *mut core::ffi::c_void) } -/// Bring the target window to the foreground so input is routed to it, then -/// give the OS a brief moment to apply focus. +/// `SendInput` goes to whatever window has the foreground, not to a window we name: trusted +/// input may only be sent once the target's top-level window IS the foreground window. +#[cfg(any(windows, test))] +fn foreground_verdict(target_root: isize, foreground_root: isize) -> Result<(), String> { + if target_root != 0 && target_root == foreground_root { + Ok(()) + } else { + Err( + "refusing trusted input: the app window could not be brought to the foreground \ + (Windows' foreground lock keeps focus with the app the user is working in, e.g. a \ + terminal), so the keystrokes or click would go to that app instead. Bring the app \ + window to the front and retry, or omit `trusted` to use synthetic input." + .to_string(), + ) + } +} + +/// A trusted click lands on whatever window is topmost at the point: it must be the target. +#[cfg(any(windows, test))] +fn click_target_verdict(target_root: isize, point_root: isize) -> Result<(), String> { + if target_root != 0 && target_root == point_root { + Ok(()) + } else { + Err( + "refusing trusted click: the point is covered by another window (or lies outside \ + this one), so the click would land there. Make the element visible on screen and \ + retry, or omit `trusted` to use a synthetic click." + .to_string(), + ) + } +} + +/// A trusted click must land inside the target window's CLIENT area (physical pixels, client +/// coordinates). Checking only that the point's top-level window is ours accepted the title +/// bar, the caption buttons (close!) and the resize border, all of which belong to the same +/// top-level window (R4-IN2). Non-finite coordinates are refused before any `as i32` cast, +/// which would turn `NaN` into 0 and saturate huge values. +#[cfg(any(windows, test))] +fn client_point_verdict(x: f64, y: f64, client_w: i32, client_h: i32) -> Result<(), String> { + let inside = x.is_finite() + && y.is_finite() + && x >= 0.0 + && y >= 0.0 + && x < f64::from(client_w) + && y < f64::from(client_h); + if inside { + Ok(()) + } else { + Err(format!( + "refusing trusted click: ({x}, {y}) lies outside the window's content area \ + ({client_w}x{client_h} physical px), so it would land on the title bar, a caption \ + button or the border. Scroll the element into view and retry, or omit `trusted` to \ + use a synthetic click." + )) + } +} + +/// The top-level window `hwnd` belongs to (0 for none). #[allow(unsafe_code)] #[cfg(windows)] -fn win_focus(hwnd: isize) { - use windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow; +fn win_root(hwnd: windows::Win32::Foundation::HWND) -> isize { + use windows::Win32::UI::WindowsAndMessaging::{GA_ROOT, GetAncestor}; + // SAFETY: GetAncestor accepts any HWND (including null) and returns null when there is none. + unsafe { GetAncestor(hwnd, GA_ROOT) }.0 as isize +} + +/// Bring the target window to the foreground so input is routed to it, and verify it got +/// there. `SetForegroundWindow` is refused while the user is active in another app (Windows' +/// foreground lock); ignoring that sent the agent's keystrokes — Enter included — into the +/// developer's terminal. +#[allow(unsafe_code)] +#[cfg(windows)] +fn win_focus(hwnd: isize) -> Result<(), String> { + use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, SetForegroundWindow}; + let target = win_hwnd(hwnd); + let target_root = win_root(target); // SAFETY: hwnd comes from Tauri's window handle; SetForegroundWindow is safe // to call with any HWND (returns false if it fails). unsafe { - let _ = SetForegroundWindow(win_hwnd(hwnd)); + let _ = SetForegroundWindow(target); } + // Give the OS a brief moment to apply focus, then poll briefly for it to take. std::thread::sleep(std::time::Duration::from_millis(40)); + let mut verdict = Ok(()); + for _ in 0..8 { + // SAFETY: GetForegroundWindow takes no arguments and may return null. + verdict = foreground_verdict(target_root, win_root(unsafe { GetForegroundWindow() })); + if verdict.is_ok() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + verdict } #[cfg(windows)] @@ -726,6 +1189,7 @@ fn win_send_key(key: &str) -> Result<(), String> { #[cfg(windows)] fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { use windows::Win32::Foundation::POINT; + use windows::Win32::Foundation::RECT; use windows::Win32::Graphics::Gdi::ClientToScreen; use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::Input::KeyboardAndMouse::{ @@ -733,20 +1197,28 @@ fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput, }; use windows::Win32::UI::WindowsAndMessaging::{ - GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, - SM_YVIRTUALSCREEN, + GetClientRect, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, WindowFromPoint, }; let h = win_hwnd(hwnd); - // SAFETY: GetDpiForWindow/GetSystemMetrics/ClientToScreen are safe to call - // with a valid HWND; ClientToScreen writes into our stack POINT. + // SAFETY: GetDpiForWindow/GetClientRect/GetSystemMetrics/ClientToScreen are safe to call + // with a valid HWND; GetClientRect and ClientToScreen write into our stack RECT/POINT. let (nx, ny) = unsafe { let dpi = GetDpiForWindow(h); let scale = if dpi == 0 { 1.0 } else { f64::from(dpi) / 96.0 }; + let (px, py) = (x * scale, y * scale); + // Client coordinates: the client rect's origin is always (0, 0). + let mut client = RECT::default(); + GetClientRect(h, &mut client) + .map_err(|e| format!("refusing trusted click: cannot read the client area: {e}"))?; + client_point_verdict(px, py, client.right, client.bottom)?; let mut pt = POINT { - x: (x * scale) as i32, - y: (y * scale) as i32, + x: px as i32, + y: py as i32, }; let _ = ClientToScreen(h, &mut pt); + // The click goes to the topmost window at the point, whichever app that is. + click_target_verdict(win_root(h), win_root(WindowFromPoint(pt)))?; let vx = GetSystemMetrics(SM_XVIRTUALSCREEN); let vy = GetSystemMetrics(SM_YVIRTUALSCREEN); let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); @@ -789,3 +1261,341 @@ fn win_click(hwnd: isize, x: f64, y: f64) -> Result<(), String> { )) } } + +#[cfg(test)] +mod tests { + use super::{dispatch_and_wait, is_main_thread, record_main_thread}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + /// A stand-in main thread that starts each posted job after `delay`. + fn post_after(delay: Duration) -> impl FnOnce(Box) -> Result<(), String> { + move |job| { + std::thread::spawn(move || { + std::thread::sleep(delay); + job(); + }); + Ok(()) + } + } + + /// A stand-in main thread that is guaranteed to have STARTED the posted job before `post` + /// returns (and so before the caller's deadline starts counting): it waits for the job to + /// signal `started`. A plain spawned thread can be scheduled late on a loaded machine, and + /// the caller then (correctly) abandons a job that never started — which turned tests + /// about a job that outlives its caller into flakes. + fn post_started( + started: std::sync::mpsc::Receiver<()>, + ) -> impl FnOnce(Box) -> Result<(), String> { + move |job| { + std::thread::spawn(job); + started + .recv_timeout(Duration::from_secs(60)) + .map_err(|_| "the stand-in main thread never started the job".to_string()) + } + } + + #[test] + fn a_job_still_queued_at_the_deadline_never_runs() { + let ran = Arc::new(AtomicUsize::new(0)); + let r = Arc::clone(&ran); + let out = dispatch_and_wait( + "resize_window", + Duration::from_millis(50), + Duration::from_secs(5), + post_after(Duration::from_millis(300)), + move || r.fetch_add(1, Ordering::SeqCst), + ); + assert!(out.unwrap_err().contains("will not run")); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(ran.load(Ordering::SeqCst), 0, "an abandoned job ran anyway"); + } + + #[test] + fn a_job_running_at_the_deadline_reports_its_real_outcome() { + // It started before the caller gave up, so its effects happen: reporting "timed out" + // would tell the caller a window change / eval did not happen when it did. + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let out = dispatch_and_wait( + "eval_webview", + Duration::from_millis(50), + Duration::from_secs(5), + post_started(started_rx), + move || { + let _ = started_tx.send(()); + std::thread::sleep(Duration::from_millis(300)); + 7 + }, + ); + assert_eq!(out, Ok(7)); + } + + #[test] + fn trusted_input_is_refused_unless_the_target_is_in_front() { + use super::{click_target_verdict, foreground_verdict}; + assert!(foreground_verdict(0x42, 0x42).is_ok()); + let other = foreground_verdict(0x42, 0x99).unwrap_err(); + assert!(other.contains("foreground"), "{other}"); + assert!(foreground_verdict(0x42, 0).is_err(), "no foreground window"); + assert!(foreground_verdict(0, 0).is_err(), "unknown target window"); + assert!(click_target_verdict(0x42, 0x42).is_ok()); + assert!( + click_target_verdict(0x42, 0x99) + .unwrap_err() + .contains("covered") + ); + assert!(click_target_verdict(0, 0).is_err()); + } + + fn leaked_gate() -> &'static super::DispatchGate { + Box::leak(Box::new(super::DispatchGate::new())) + } + + /// R4-LOCK1: a round trip whose closure STARTED but outlived timeout + grace returned + /// "outcome unknown" and released the dispatch lock while the closure was still running on + /// the main thread — so the next caller put a second round trip in flight beside it, the + /// exact condition `MAIN_DISPATCH_LOCK` exists to prevent (`WebKitGTK` heap corruption). + #[test] + fn a_job_outliving_its_caller_blocks_the_next_round_trip_until_it_finishes() { + use super::serialized_round_trip; + use std::time::Instant; + let gate = leaked_gate(); + let first_end = Arc::new(std::sync::Mutex::new(None::)); + let fe = Arc::clone(&first_end); + // The first job is known to have started before its caller's deadline, and it runs + // until released — no sleep decides either (both used to race a loaded scheduler). + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let first = serialized_round_trip( + gate, + "first", + Duration::from_millis(50), + post_started(started_rx), + move || { + let _ = started_tx.send(()); + let _ = release_rx.recv(); + *fe.lock().unwrap() = Some(Instant::now()); + }, + ); + assert!( + first.unwrap_err().contains("outcome is unknown"), + "precondition: the first caller gave up while its job was running" + ); + // Release the first job only once the second caller holds the dispatch lock — it is + // then provably waiting on the still-running job, not merely about to start. + let releaser = std::thread::spawn(move || { + while gate.lock.try_lock().is_ok() { + std::thread::sleep(Duration::from_millis(1)); + } + std::thread::sleep(Duration::from_millis(20)); + release_tx.send(()).unwrap(); + }); + let second_start = serialized_round_trip( + gate, + "second", + Duration::from_secs(5), + post_after(Duration::ZERO), + Instant::now, + ) + .expect("the second round trip runs once the first job is done"); + releaser.join().unwrap(); + let first_end = first_end + .lock() + .unwrap() + .expect("the first job finished before the second started"); + assert!( + second_start >= first_end, + "second round trip started {:?} before the abandoned first job finished", + first_end - second_start + ); + } + + /// The wait for an earlier caller's still-running job is bounded by the caller's OWN + /// deadline (no N * timeout stacking), and a caller that gives up never runs its job. + #[test] + fn waiting_for_an_abandoned_job_is_bounded_by_the_callers_deadline() { + use super::serialized_round_trip; + let gate = leaked_gate(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let first = serialized_round_trip( + gate, + "first", + Duration::from_millis(30), + post_started(started_rx), + move || { + let _ = started_tx.send(()); + std::thread::sleep(Duration::from_millis(1500)); + }, + ); + assert!(first.unwrap_err().contains("outcome is unknown")); + let ran = Arc::new(AtomicUsize::new(0)); + let r = Arc::clone(&ran); + let started = std::time::Instant::now(); + let second = serialized_round_trip( + gate, + "second", + Duration::from_millis(200), + post_after(Duration::ZERO), + move || r.fetch_add(1, Ordering::SeqCst), + ); + let waited = started.elapsed(); + let err = second.unwrap_err(); + assert!(err.contains("still running"), "{err}"); + assert!(waited < Duration::from_millis(1000), "waited {waited:?}"); + std::thread::sleep(Duration::from_millis(1600)); + assert_eq!( + ran.load(Ordering::SeqCst), + 0, + "a caller that gave up ran its job" + ); + } + + /// R4-IN2: a trusted click was only checked for landing on OUR top-level window — which + /// includes its title bar and close button. It must land inside the client area. + #[test] + fn trusted_click_must_land_inside_the_client_area() { + use super::client_point_verdict; + assert!(client_point_verdict(0.0, 0.0, 800, 600).is_ok()); + assert!(client_point_verdict(799.9, 599.9, 800, 600).is_ok()); + for (x, y) in [ + (-1.0, 10.0), // left of the client area (window border) + (10.0, -30.0), // above it: the title bar / caption buttons + (800.0, 10.0), // right edge is exclusive + (10.0, 600.0), + (f64::NAN, 10.0), // `NaN as i32` is 0: would silently click the corner + (10.0, f64::INFINITY), + (1e12, 10.0), // `as i32` saturates to i32::MAX + ] { + let err = client_point_verdict(x, y, 800, 600).unwrap_err(); + assert!(err.contains("outside"), "({x}, {y}): {err}"); + } + assert!( + client_point_verdict(0.0, 0.0, 0, 0).is_err(), + "empty client area" + ); + } + + #[test] + fn a_panicking_job_is_an_error_not_a_hang() { + let out: Result<(), String> = dispatch_and_wait( + "get_native_handle", + Duration::from_secs(5), + Duration::from_secs(5), + post_after(Duration::ZERO), + || panic!("boom"), + ); + assert!(out.unwrap_err().contains("panicked")); + } + + /// A stand-in Tauri main thread: runs posted jobs in order, inline when posted from itself + /// (exactly what tauri-runtime-wry's `send_user_message` does). + struct FakeMainThread { + jobs: std::sync::mpsc::Sender>, + id: std::thread::ThreadId, + } + + impl FakeMainThread { + fn start() -> Self { + let (jobs, rx) = std::sync::mpsc::channel::>(); + let (id_tx, id_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + id_tx.send(std::thread::current().id()).unwrap(); + while let Ok(job) = rx.recv() { + job(); + } + }); + Self { + jobs, + id: id_rx.recv().unwrap(), + } + } + + fn post(&self) -> impl Fn(Box) -> Result<(), String> + '_ { + move |job| { + if std::thread::current().id() == self.id { + job(); + Ok(()) + } else { + self.jobs.send(job).map_err(|e| e.to_string()) + } + } + } + + /// Run `f` on the fake main thread and wait for it. + fn run(&self, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + self.jobs + .send(Box::new(move || tx.send(f()).unwrap())) + .unwrap(); + rx.recv().unwrap() + } + } + + type Adopted = Arc>>; + + fn recording_adopt(log: &Adopted) -> impl Fn(&String) + Send + 'static { + let log = Arc::clone(log); + move |h: &String| { + log.lock() + .unwrap() + .push((std::thread::current().id(), h.clone())); + } + } + + /// R5B-MAIN1: Tauri lets an app register a plugin later from a background thread + /// (`AppHandle::plugin`), and then the plugin's `setup` runs THERE. Recording that thread as + /// "main" and installing the handle in its thread-local left every later `on_main` round + /// trip failing with "the main-thread app handle is not installed". Adoption must happen on + /// the real main thread. + #[test] + fn setup_off_the_main_thread_adopts_the_real_main_thread() { + let main = FakeMainThread::start(); + let log: Adopted = Arc::default(); + let handle = "app".to_string(); + let outcome = super::adopt_on_main(&handle, main.post(), recording_adopt(&log)); + assert_eq!(outcome, Ok(super::MainThreadAdoption::Posted)); + // The posted adoption runs before anything posted after it. + main.run(|| ()); + let adopted = log.lock().unwrap().clone(); + assert_eq!(adopted, vec![(main.id, "app".to_string())]); + } + + /// The normal case — setup on the main thread — adopts in place, before returning. + #[test] + fn setup_on_the_main_thread_adopts_inline() { + let main = Arc::new(FakeMainThread::start()); + let log: Adopted = Arc::default(); + let (m, l) = (Arc::clone(&main), Arc::clone(&log)); + let outcome = main.run(move || { + let outcome = super::adopt_on_main(&"app".to_string(), m.post(), recording_adopt(&l)); + // Adopted before `adopt_on_main` returned, not merely queued. + let adopted_now = l.lock().unwrap().len(); + (outcome, adopted_now) + }); + assert_eq!(outcome, (Ok(super::MainThreadAdoption::Inline), 1)); + assert_eq!(log.lock().unwrap()[0].0, main.id); + } + + #[test] + fn main_thread_is_recorded_once_and_only_that_thread_matches() { + // Record from a dedicated thread (standing in for Tauri's setup thread): only that + // thread is "main"; every other thread — including this test's — is not. + std::thread::spawn(|| { + record_main_thread(); + assert!(is_main_thread(), "the recording thread is the main thread"); + }) + .join() + .unwrap(); + assert!( + !is_main_thread(), + "a different thread must not be treated as main" + ); + std::thread::spawn(|| assert!(!is_main_thread())) + .join() + .unwrap(); + // A second record from another thread must not move it (set-once). + std::thread::spawn(record_main_thread).join().unwrap(); + assert!(!is_main_thread()); + } +} diff --git a/crates/victauri-plugin/src/database.rs b/crates/victauri-plugin/src/database.rs index 0a4e656d..f462a226 100644 --- a/crates/victauri-plugin/src/database.rs +++ b/crates/victauri-plugin/src/database.rs @@ -15,6 +15,10 @@ use std::time::{Duration, Instant}; pub(crate) struct InterruptGuard { done: Arc, handle: Option>, + /// How many times the watchdog woke up (test builds only): pins that it parks until the + /// deadline instead of polling in fixed steps. + #[cfg(test)] + wakes: Arc, } #[cfg(feature = "sqlite")] @@ -23,14 +27,27 @@ impl InterruptGuard { let done = Arc::new(AtomicBool::new(false)); let interrupt = conn.get_interrupt_handle(); let done_for_thread = done.clone(); - let poll = Duration::from_millis(25).min(deadline.max(Duration::from_millis(1))); + #[cfg(test)] + let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + #[cfg(test)] + let wakes_for_thread = Arc::clone(&wakes); + // The watchdog PARKS until the deadline and is unparked on drop, so disarming is + // immediate. (It used to sleep in fixed 25ms steps that the drop then joined — ~25ms + // per guard however fast the guarded query was, which capped db_health at ~200 tables + // per 5s budget even on a tiny database.) let handle = std::thread::spawn(move || { let start = Instant::now(); - while start.elapsed() < deadline { + loop { + #[cfg(test)] + wakes_for_thread.fetch_add(1, Ordering::Relaxed); if done_for_thread.load(Ordering::Acquire) { return; } - std::thread::sleep(poll); + let elapsed = start.elapsed(); + if elapsed >= deadline { + break; + } + std::thread::park_timeout(deadline - elapsed); } if !done_for_thread.load(Ordering::Acquire) { interrupt.interrupt(); @@ -39,6 +56,8 @@ impl InterruptGuard { Self { done, handle: Some(handle), + #[cfg(test)] + wakes, } } } @@ -48,6 +67,7 @@ impl Drop for InterruptGuard { fn drop(&mut self) { self.done.store(true, Ordering::Release); if let Some(h) = self.handle.take() { + h.thread().unpark(); let _ = h.join(); } } @@ -61,10 +81,47 @@ pub(crate) enum Bounded { Failed(String), } +/// Victauri accepts a RANGE of rusqlite versions (`>=0.32, <0.41`) so it unifies with the app's +/// own rusqlite instead of forcing a second `libsqlite3-sys` (`links = "sqlite3"` allows only +/// one per build). Across that range several connection setters changed return type — `()` or +/// `i32` on older versions, `rusqlite::Result` on newer ones (`set_limit` from 0.33, +/// `authorizer`/`progress_handler` from 0.38). They install SECURITY bounds (the authorizer, +/// deadlines, size limits), so a failure must fail CLOSED on every version: this normalizes all +/// three shapes into one `Result` for `?`, instead of a version-dependent `let _ =` that would +/// silently drop a failed install. +#[cfg(feature = "sqlite")] +trait SetupOutcome { + fn into_setup(self) -> Result<(), String>; +} + +#[cfg(feature = "sqlite")] +impl SetupOutcome for () { + fn into_setup(self) -> Result<(), String> { + Ok(()) + } +} + +#[cfg(feature = "sqlite")] +impl SetupOutcome for i32 { + fn into_setup(self) -> Result<(), String> { + Ok(()) + } +} + +#[cfg(feature = "sqlite")] +impl SetupOutcome for rusqlite::Result { + fn into_setup(self) -> Result<(), String> { + self.map(|_| ()).map_err(|e| e.to_string()) + } +} + /// Run `f` under its OWN wall-clock budget: a progress handler plus a hard [`InterruptGuard`], /// both scoped to this call and removed afterwards, so one slow phase cannot poison the phases /// that follow it on the same connection. #[cfg(feature = "sqlite")] +// `unit_arg`: on rusqlite 0.32 some setters return `()`; `.into_setup()` on that unit is the +// point (see `SetupOutcome`), so one source compiles fail-closed across the supported range. +#[allow(clippy::unit_arg)] pub(crate) fn run_bounded( conn: &rusqlite::Connection, budget: Duration, @@ -73,21 +130,31 @@ pub(crate) fn run_bounded( let started = Instant::now(); let timed_out = Arc::new(AtomicBool::new(false)); let marker = Arc::clone(&timed_out); - conn.progress_handler( - DB_HEALTH_PROGRESS_OPS, - Some(move || { - let expired = started.elapsed() >= budget; - if expired { - marker.store(true, Ordering::Relaxed); - } - expired - }), - ); + // The per-phase deadline is a security bound: if it cannot be installed, the phase fails + // rather than running unbounded. + if let Err(e) = conn + .progress_handler( + DB_HEALTH_PROGRESS_OPS, + Some(move || { + let expired = started.elapsed() >= budget; + if expired { + marker.store(true, Ordering::Relaxed); + } + expired + }), + ) + .into_setup() + { + return Bounded::Failed(format!("failed to install the phase deadline: {e}")); + } let result = { let _interrupt = InterruptGuard::arm(conn, budget); f(conn) }; - conn.progress_handler(DB_HEALTH_PROGRESS_OPS, None:: bool>); + // Removing it can only fail harmlessly (the next phase installs its own deadline). + let _ = conn + .progress_handler(DB_HEALTH_PROGRESS_OPS, None:: bool>) + .into_setup(); match result { Ok(v) => Bounded::Done(v), Err(e) @@ -112,102 +179,222 @@ const MAX_DB_HEALTH_CELL_BYTES: i32 = 1_048_576; /// Read-only health report for one `SQLite` database, in budgeted phases. /// -/// The cheap metadata PRAGMAs always run. The two phases that scale with database size — -/// per-table `count(*)` and `quick_check` — each get their own budget; a phase that runs out -/// is REPORTED (`row_count: null` / `integrity_check: "not completed …"`) instead of failing -/// the whole call. Before this, `quick_check` on a multi-GB database exhausted a single shared -/// deadline and the tool returned only "timed out", discarding every cheap result with it. +/// Every phase runs under its own budget, and a phase that runs out is REPORTED instead of +/// failing the whole call: the metadata PRAGMAs + table listing, the per-table `count(*)`s, +/// and `SQLite`'s `quick_check` (a full-file scan that dominates on large databases). Before +/// this, `quick_check` on a multi-GB database exhausted one shared deadline and the tool +/// returned only "timed out", discarding every cheap result with it. +/// +/// The database file is treated as untrusted input: the connection is opened read-only with +/// `trusted_schema=OFF` and `SQLite`'s defensive mode, and virtual tables are listed but never +/// counted (counting one runs its module's code). That does not keep virtual-table module code +/// out of the call by itself: a whole-database `quick_check` connects every virtual table whose +/// module is registered and (`SQLite` >= 3.44) runs its `xIntegrity` check. Victauri registers no +/// module on this connection, so that is `SQLite`'s built-ins (FTS3/4/5, R-Tree — checked: on +/// 3.46 a corrupted FTS5 index reports `malformed inverted index for FTS5 table …`) plus +/// anything the host process installed as an auto-extension. So when the host registered a +/// non-built-in module and the file has virtual tables, the check runs per ordinary table +/// instead (`integrity_check_kind: "quick_check (per table)"`, with the reason in +/// `integrity_check_note`) and no module code runs. #[cfg(feature = "sqlite")] pub(crate) fn db_health_report( path: &str, count_budget: Duration, check_budget: Duration, ) -> Result { - let conn = - rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) - .map_err(|e| format!("cannot open database: {e}"))?; + let conn = open_untrusted_read_only(path)?; conn.set_limit( rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH, MAX_DB_HEALTH_CELL_BYTES, - ); - - let pragma_i64 = |name: &str| -> i64 { - conn.pragma_query_value(None, name, |r| r.get(0)) - .unwrap_or(0) - }; - let journal_mode: String = conn - .pragma_query_value(None, "journal_mode", |r| r.get(0)) - .unwrap_or_else(|_| "unknown".to_string()); - let page_count = pragma_i64("page_count"); - let page_size = pragma_i64("page_size"); - let freelist_count = pragma_i64("freelist_count"); - let wal_checkpoint = if journal_mode == "wal" { - "not run (read-only diagnostics)" - } else { - "n/a (not WAL mode)" - }; - let db_size_mb = (page_count * page_size) as f64 / (1024.0 * 1024.0); - - // Phase 1: table names (bounded listing) + row counts under a shared count budget. - let mut names = Vec::new(); - let mut tables_truncated = false; - { + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; + // The metadata phase runs `LIKE` against schema SQL the database file supplies. + conn.set_limit( + rusqlite::limits::Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH, + MAX_LIKE_PATTERN_BYTES, + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; + + // Phase 0: metadata + the (bounded) table listing. + let meta = run_bounded(&conn, DB_HEALTH_META_BUDGET, |c| { + let text = |name: &str| c.pragma_query_value(None, name, |r| r.get::<_, String>(0)); + let int = |name: &str| c.pragma_query_value(None, name, |r| r.get::<_, i64>(0)); + let journal_mode = text("journal_mode")?; + let page_count = int("page_count")?; + let page_size = int("page_size")?; + let freelist_count = int("freelist_count")?; + // Only a table whose definition is exactly SQLite's own `CREATE TABLE ` form is counted. + // SQLite writes every definition in canonical form (`CREATE TABLE …` / + // `CREATE VIRTUAL TABLE …`, one space each), so a legitimate file never differs; + // a crafted one can (via `writable_schema`) spell a virtual table `CREATE VIRTUAL`, + // `CREATE/**/VIRTUAL`, … which a `LIKE 'CREATE VIRTUAL%'` test missed — and then + // `count(*)` ran the module's code. Anything not in canonical ordinary-table form is + // listed, never counted. + let mut stmt = c.prepare( + "SELECT name, substr(sql, 1, 13) IS 'CREATE TABLE ', \ + instr(upper(substr(sql, 1, 256)), 'VIRTUAL') > 0 \ + FROM sqlite_master WHERE type='table' ORDER BY name", + )?; + let mut names: Vec<(String, TableKind)> = Vec::new(); let mut table_bytes = 0usize; - let mut stmt = conn - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .map_err(|e| format!("cannot list tables: {e}"))?; - let rows = stmt - .query_map([], |r| r.get::<_, String>(0)) - .map_err(|e| format!("cannot list tables: {e}"))?; - for name in rows.flatten() { + let mut truncated = false; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let name: String = row.get(0)?; + let kind = if row.get::<_, Option>(1)?.unwrap_or(false) { + TableKind::Ordinary + } else if row.get::<_, Option>(2)?.unwrap_or(false) { + TableKind::Virtual + } else { + TableKind::Unrecognized + }; if names.len() >= MAX_DB_HEALTH_TABLES || table_bytes.saturating_add(name.len()) > MAX_DB_HEALTH_TABLE_BYTES { - tables_truncated = true; + truncated = true; break; } table_bytes = table_bytes.saturating_add(name.len()); - names.push(name); + names.push((name, kind)); } - } + Ok(( + journal_mode, + page_count, + page_size, + freelist_count, + names, + truncated, + )) + }); + let (journal_mode, page_count, page_size, freelist_count, names, tables_truncated) = match meta + { + Bounded::Done(m) => m, + Bounded::TimedOut => { + return Err(format!( + "database metadata did not load within {} ms", + DB_HEALTH_META_BUDGET.as_millis() + )); + } + Bounded::Failed(e) => return Err(format!("cannot read database metadata: {e}")), + }; + let wal_checkpoint = if journal_mode == "wal" { + "not run (read-only diagnostics)" + } else { + "n/a (not WAL mode)" + }; + #[allow(clippy::cast_precision_loss)] + let db_size_mb = page_count.saturating_mul(page_size) as f64 / (1024.0 * 1024.0); + + // Needed by phase 2 (the loop below consumes `names`). + let ordinary_tables: Vec = names + .iter() + .filter(|(_, kind)| *kind == TableKind::Ordinary) + .map(|(name, _)| name.clone()) + .collect(); + let may_hold_virtual_tables = + tables_truncated || names.iter().any(|(_, kind)| *kind != TableKind::Ordinary); + + // Phase 1: per-table row counts under a shared count budget. let counts_started = Instant::now(); - let mut row_counts_complete = true; + let mut budget_exhausted = false; + let mut all_counted = true; let mut tables = Vec::with_capacity(names.len()); - for name in names { - let remaining = count_budget.saturating_sub(counts_started.elapsed()); - let count = if !row_counts_complete || remaining.is_zero() { - row_counts_complete = false; - None + for (name, kind) in names { + let mut entry = serde_json::json!({ "name": name, "row_count": null }); + if kind == TableKind::Virtual { + entry["virtual"] = serde_json::json!(true); + all_counted = false; + } else if kind == TableKind::Unrecognized { + entry["count_skipped"] = + serde_json::json!("definition is not in SQLite's canonical CREATE TABLE form"); + all_counted = false; } else { - let sql = format!("SELECT count(*) FROM {}", quote_sqlite_identifier(&name)); - match run_bounded(&conn, remaining, |c| { - c.query_row(&sql, [], |r| r.get::<_, i64>(0)) - }) { - Bounded::Done(n) => Some(n), - Bounded::TimedOut => { - row_counts_complete = false; - None + let remaining = count_budget.saturating_sub(counts_started.elapsed()); + if budget_exhausted || remaining.is_zero() { + budget_exhausted = true; + all_counted = false; + } else { + let sql = format!("SELECT count(*) FROM {}", quote_sqlite_identifier(&name)); + match run_bounded(&conn, remaining, |c| { + c.query_row(&sql, [], |r| r.get::<_, i64>(0)) + }) { + Bounded::Done(n) => entry["row_count"] = serde_json::json!(n), + Bounded::TimedOut => { + budget_exhausted = true; + all_counted = false; + } + Bounded::Failed(e) => { + entry["count_error"] = serde_json::json!(e); + all_counted = false; + } } - // A per-table failure (e.g. a virtual table whose module is not loaded) is - // not a budget problem — report no count for that table and keep going. - Bounded::Failed(_) => None, } - }; - tables.push(serde_json::json!({ "name": name, "row_count": count })); - } - - // Phase 2: integrity, on its own budget — a full-file scan that dominates on large DBs. - let integrity = match run_bounded(&conn, check_budget, |c| { - c.pragma_query_value(None, "quick_check", |r| r.get::<_, String>(0)) - }) { - Bounded::Done(s) => s, - Bounded::TimedOut => format!( - "not completed: quick_check exceeded its {} ms budget on a {:.0} MB database \ - (a full-file scan; the result is unknown, not failed)", - check_budget.as_millis(), - db_size_mb - ), - Bounded::Failed(e) => format!("failed: {e}"), + } + tables.push(entry); + } + + // Phase 2: integrity, on its own budget. A whole-database `quick_check` connects every + // virtual table whose module is registered and (SQLite >= 3.44) runs the module's integrity + // routine. SQLite's own modules are fine; a module the HOST registered process-wide (an + // auto-extension) is arbitrary code. With such a module registered and virtual tables in + // the file, check the ordinary tables one by one (`PRAGMA quick_check()`), which + // connects no virtual table. + let per_table_reason = if may_hold_virtual_tables { + match foreign_vtab_modules(&conn) { + Ok(modules) if modules.is_empty() => None, + Ok(modules) => Some(format!( + "the database has virtual tables and the host registered non-built-in \ + virtual-table module(s) {}", + modules.join(", ") + )), + Err(e) => Some(format!( + "the database has virtual tables and the registered virtual-table modules could \ + not be listed ({e})" + )), + } + } else { + None + }; + let (integrity, integrity_kind, integrity_note) = match per_table_reason { + None => { + let integrity = match run_bounded(&conn, check_budget, |c| { + c.pragma_query_value(None, "quick_check", |r| r.get::<_, String>(0)) + }) { + Bounded::Done(s) => s, + Bounded::TimedOut => format!( + "not completed: quick_check exceeded its {} ms budget on a {:.0} MB database \ + (a full-file scan; the result is unknown, not failed)", + check_budget.as_millis(), + db_size_mb + ), + Bounded::Failed(e) => format!("failed: {e}"), + }; + (integrity, "quick_check", None) + } + Some(reason) => { + let integrity = match run_bounded(&conn, check_budget, |c| { + quick_check_tables(c, &ordinary_tables) + }) { + Bounded::Done(problems) if problems.is_empty() => "ok".to_string(), + Bounded::Done(problems) => problems.join("\n"), + Bounded::TimedOut => format!( + "not completed: the per-table quick_check exceeded its {} ms budget on a \ + {:.0} MB database (the result is unknown, not failed)", + check_budget.as_millis(), + db_size_mb + ), + Bounded::Failed(e) => format!("failed: {e}"), + }; + let note = format!( + "{reason}: checked the {} ordinary table(s) one by one instead of the whole \ + database, so no virtual-table module code ran. Virtual tables and \ + database-wide structures (the free list) were not checked.", + ordinary_tables.len() + ); + (integrity, "quick_check (per table)", Some(note)) + } }; Ok(serde_json::json!({ @@ -219,12 +406,144 @@ pub(crate) fn db_health_report( "freelist_count": freelist_count, "wal_checkpoint": wal_checkpoint, "integrity_check": integrity, + "integrity_check_kind": integrity_kind, + "integrity_check_note": integrity_note, "tables": tables, "tables_truncated": tables_truncated, - "row_counts_complete": row_counts_complete, + // Every listed table has a row count (false if the budget ran out, a count failed, or a + // table is virtual and deliberately not counted). + "row_counts_complete": all_counted, + "row_count_budget_exhausted": budget_exhausted, })) } +/// Virtual-table modules that are part of `SQLite` itself (compiled-in extensions and the +/// eponymous introspection tables). Anything else registered on a connection came from the +/// host process. +#[cfg(feature = "sqlite")] +static SQLITE_BUILTIN_VTAB_MODULES: &[&str] = &[ + "fts3", + "fts3tokenize", + "fts4", + "fts4aux", + "fts5", + "fts5vocab", + "rtree", + "rtree_i32", + "geopoly", + "dbstat", + "sqlite_dbpage", + "sqlite_dbdata", + "sqlite_dbptr", + "sqlite_stmt", + "json_each", + "json_tree", + "jsonb_each", + "jsonb_tree", + "carray", + "generate_series", + "bytecode", + "tables_used", + "completion", +]; + +/// Most problems reported by a per-table `quick_check`. +#[cfg(feature = "sqlite")] +const MAX_INTEGRITY_PROBLEMS: usize = 100; + +/// The virtual-table modules registered on `conn` that are not part of `SQLite`. +#[cfg(feature = "sqlite")] +fn foreign_vtab_modules(conn: &rusqlite::Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT name FROM pragma_module_list ORDER BY name")?; + let names = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::>>()?; + Ok(names + .into_iter() + .filter(|name| { + let lower = name.to_ascii_lowercase(); + !lower.starts_with("pragma_") && !SQLITE_BUILTIN_VTAB_MODULES.contains(&lower.as_str()) + }) + .collect()) +} + +/// `PRAGMA quick_check(
    )` for each of `tables` (ordinary tables only: checking one +/// connects no virtual table). Returns the problems found (empty = ok), at most +/// [`MAX_INTEGRITY_PROBLEMS`]. +#[cfg(feature = "sqlite")] +fn quick_check_tables( + conn: &rusqlite::Connection, + tables: &[String], +) -> rusqlite::Result> { + let mut problems = Vec::new(); + for table in tables { + let sql = format!("PRAGMA quick_check({})", quote_sqlite_identifier(table)); + let mut stmt = conn.prepare(&sql)?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let line: String = row.get(0)?; + if line != "ok" && problems.len() < MAX_INTEGRITY_PROBLEMS { + problems.push(line); + } + } + } + Ok(problems) +} + +/// How [`db_health_report`] treats a `sqlite_master` table entry. +#[cfg(feature = "sqlite")] +#[derive(Clone, Copy, PartialEq, Eq)] +enum TableKind { + /// Canonical `CREATE TABLE ` definition: counted. + Ordinary, + /// A virtual table (by any spelling): listed, not counted. + Virtual, + /// Neither: listed, not counted. + Unrecognized, +} + +/// Budget for the metadata PRAGMAs + table listing in [`db_health_report`]. +#[cfg(feature = "sqlite")] +pub(crate) const DB_HEALTH_META_BUDGET: Duration = Duration::from_secs(3); + +/// A hook run on each connection Victauri opens (tests only). +#[cfg(all(test, feature = "sqlite"))] +type OpenHook = Box; + +#[cfg(all(test, feature = "sqlite"))] +thread_local! { + /// Test-only stand-in for what a host process can do to every connection `SQLite` opens + /// (`sqlite3_auto_extension`): register functions or virtual-table modules on it. Run right + /// after each of Victauri's connections is opened. + static ON_OPEN: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(feature = "sqlite")] +fn run_open_hook(_conn: &rusqlite::Connection) { + #[cfg(test)] + ON_OPEN.with(|hook| { + if let Some(hook) = hook.borrow().as_ref() { + hook(_conn); + } + }); +} + +/// Open a database file Victauri did not create as read-only UNTRUSTED input: with +/// `trusted_schema=OFF` (schema-embedded SQL functions / virtual tables cannot run with side +/// effects) and `SQLite`'s defensive mode (no writes to shadow tables / schema corruption). +#[cfg(feature = "sqlite")] +pub(crate) fn open_untrusted_read_only(path: &str) -> Result { + let conn = + rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| format!("cannot open database: {e}"))?; + run_open_hook(&conn); + conn.pragma_update(None, "trusted_schema", false) + .map_err(|e| format!("cannot harden database connection: {e}"))?; + conn.set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) + .map_err(|e| format!("cannot harden database connection: {e}"))?; + Ok(conn) +} /// Quote an arbitrary table name as a `SQLite` identifier (`"…"`, embedded quotes doubled). #[cfg(feature = "sqlite")] pub(crate) fn quote_sqlite_identifier(name: &str) -> String { @@ -242,39 +561,151 @@ const MAX_QUERY_RESULT_BYTES: usize = 5_000_000; #[cfg(feature = "sqlite")] const MAX_QUERY_SQL_BYTES: usize = 1_000_000; #[cfg(feature = "sqlite")] -const QUERY_TIMEOUT: Duration = Duration::from_secs(5); +pub(crate) const QUERY_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(feature = "sqlite")] const QUERY_PROGRESS_OPS: i32 = 10_000; +/// Result-set width cap (`SQLite`'s default is 2000). With 1 MB cells, 2000 columns let a +/// single row reach gigabytes before any Rust-side budget could see it. +#[cfg(feature = "sqlite")] +const MAX_QUERY_COLUMNS: i32 = 256; +/// `LIKE`/`GLOB` pattern length cap (`SQLite`'s default is 50 000). Pattern matching is one +/// C call that never checks for an interrupt, so a long pattern against a long value ran for +/// tens of seconds past the query deadline. +#[cfg(feature = "sqlite")] +const MAX_LIKE_PATTERN_BYTES: i32 = 1_000; +/// How long a query waits on a lock held by the app. Kept short: a query holds its read +/// transaction for the lock wait PLUS its CPU deadline, and an open read transaction stalls +/// WAL checkpointing in the app. +#[cfg(feature = "sqlite")] +const QUERY_BUSY_TIMEOUT: Duration = Duration::from_secs(1); #[cfg(feature = "sqlite")] static READ_ONLY_PREFIXES: &[&str] = &["select", "pragma", "explain", "with"]; +/// One quote- and comment-aware pass over agent-supplied SQL (R4-DB1), following `SQLite`'s +/// tokenizer: `'…'` strings (`''` escapes), `"…"` and `` `…` `` identifiers (doubled-quote +/// escapes), `[…]` identifiers (no escape), `--` to end of line, and `/* … */` (not nested; +/// an unterminated comment or quote runs to the end of input, as in `SQLite`). #[cfg(feature = "sqlite")] -fn strip_sql_comments(sql: &str) -> String { - let mut result = String::with_capacity(sql.len()); - let bytes = sql.as_bytes(); - let len = bytes.len(); - let mut i = 0; - while i < len { - if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' { - while i < len && bytes[i] != b'\n' { - i += 1; +struct SqlScan { + /// The SQL with every comment replaced by one space; quoted spans kept verbatim. + cleaned: String, + /// `cleaned` with the CONTENT of every quoted span replaced by `_` (the delimiters are + /// kept), so a search for `;` or `=` only ever sees code. + masked: String, +} + +#[cfg(feature = "sqlite")] +fn scan_sql(sql: &str) -> SqlScan { + let mut cleaned = String::with_capacity(sql.len()); + let mut masked = String::with_capacity(sql.len()); + let mut chars = sql.chars().peekable(); + while let Some(c) = chars.next() { + match c { + // A named parameter (`$a`, `:a`, `@a`, `#a`). SQLite's tokenizer also accepts a + // Tcl-style `$a(…)` suffix and reads it as part of the SAME token up to `)` or + // whitespace, so a quote inside it does not open a string. Treating that quote as + // one let `SELECT $a(') ; DELETE …'` pass as a single statement (round-4 review). + '$' | ':' | '@' | '#' + if chars + .peek() + .is_some_and(|n| n.is_alphanumeric() || *n == '_' || *n == '$') => + { + cleaned.push(c); + masked.push(c); + while let Some(&n) = chars.peek() { + if n.is_alphanumeric() || n == '_' || n == '$' || (n == ':' && c == '$') { + chars.next(); + cleaned.push(n); + masked.push(n); + } else { + break; + } + } + if chars.peek() == Some(&'(') { + while let Some(&n) = chars.peek() { + if n.is_whitespace() { + break; + } + chars.next(); + cleaned.push(n); + masked.push(if n == '(' || n == ')' { n } else { '_' }); + if n == ')' { + break; + } + } + } } - } else if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' { - i += 2; - while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; + '-' if chars.peek() == Some(&'-') => { + for n in chars.by_ref() { + if n == '\n' { + break; + } + } + cleaned.push(' '); + masked.push(' '); + // Keep the line break so a line comment still separates tokens. + cleaned.push('\n'); + masked.push('\n'); } - if i + 1 < len { - i += 2; + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut prev = '\0'; + for n in chars.by_ref() { + if prev == '*' && n == '/' { + break; + } + prev = n; + } + cleaned.push(' '); + masked.push(' '); + } + '\'' | '"' | '`' | '[' => { + let close = if c == '[' { ']' } else { c }; + // `[…]` has no escape; the other three double their closing quote. + let doubled_escape = c != '['; + cleaned.push(c); + masked.push(c); + while let Some(n) = chars.next() { + if n == close { + if doubled_escape && chars.peek() == Some(&close) { + chars.next(); + cleaned.push(n); + cleaned.push(n); + masked.push_str("__"); + continue; + } + cleaned.push(n); + masked.push(n); + break; + } + cleaned.push(n); + masked.push('_'); + } + } + _ => { + cleaned.push(c); + masked.push(c); } - result.push(' '); - } else { - result.push(bytes[i] as char); - i += 1; } } - result + SqlScan { cleaned, masked } +} + +#[cfg(feature = "sqlite")] +fn strip_sql_comments(sql: &str) -> String { + scan_sql(sql).cleaned +} + +/// Number of non-empty statements: the code between top-level `;` separators (a `;` inside a +/// string, quoted identifier or comment is not a separator). +#[cfg(feature = "sqlite")] +fn statement_count(sql: &str) -> usize { + scan_sql(sql) + .masked + .split(';') + .filter(|s| !s.trim().is_empty()) + .count() } #[cfg(feature = "sqlite")] @@ -295,23 +726,9 @@ fn is_read_only(sql: &str) -> bool { /// is only significant when it appears outside of any quoted string. #[cfg(feature = "sqlite")] fn is_pragma_write(sql: &str) -> bool { - let cleaned = strip_sql_comments(sql); - let trimmed = cleaned.trim_start(); - if !trimmed.to_lowercase().starts_with("pragma") { - return false; - } - let bytes = trimmed.as_bytes(); - let mut in_single = false; - let mut in_double = false; - for &b in bytes { - match b { - b'\'' if !in_double => in_single = !in_single, - b'"' if !in_single => in_double = !in_double, - b'=' if !in_single && !in_double => return true, - _ => {} - } - } - false + let masked = scan_sql(sql).masked; + let trimmed = masked.trim_start(); + trimmed.to_lowercase().starts_with("pragma") && trimmed.contains('=') } /// Read-only / introspection PRAGMAs permitted on the user-facing `query` path. @@ -365,6 +782,385 @@ static SAFE_PRAGMAS: &[&str] = &[ "stats", ]; +/// PRAGMAs on [`SAFE_PRAGMAS`] whose argument selects WHAT to read (a table, an index, a row +/// limit) rather than setting a value. Every other PRAGMA given an argument is a write +/// (`PRAGMA user_version(5)` is the same as `PRAGMA user_version = 5`). +#[cfg(feature = "sqlite")] +static READ_PRAGMAS_WITH_ARG: &[&str] = &[ + "table_info", + "table_xinfo", + "table_list", + "index_list", + "index_info", + "index_xinfo", + "foreign_key_list", + "foreign_key_check", + "integrity_check", + "quick_check", +]; + +/// `SQLITE_DETERMINISTIC`, as reported in `pragma_function_list.flags`. +#[cfg(feature = "sqlite")] +const SQLITE_DETERMINISTIC_FLAG: i64 = 0x800; + +/// Functions `SQLite`'s own compiled-in extensions register as NON-built-in (FTS3/4 and FTS5 +/// register their auxiliary functions as overload placeholders, and `MATCH` on any table is +/// authorized as the function `match`; R-Tree and Geopoly register helpers). They are part of +/// `SQLite`, so `query_db` allows them. FTS3's `optimize` (it merges index segments — a write) +/// is deliberately absent. +#[cfg(feature = "sqlite")] +static SQLITE_EXTENSION_FUNCTIONS: &[&str] = &[ + "match", + "snippet", + "offsets", + "matchinfo", + "bm25", + "highlight", + "fts5", + "fts5_source_id", + "fts5_get_locale", + "fts5_locale", + "fts5_insttoken", + "rtreenode", + "rtreedepth", + "rtreecheck", + "geopoly_area", + "geopoly_bbox", + "geopoly_blob", + "geopoly_ccw", + "geopoly_contains_point", + "geopoly_group_bbox", + "geopoly_json", + "geopoly_overlap", + "geopoly_regular", + "geopoly_svg", + "geopoly_within", + "geopoly_xform", +]; + +/// `SQLite`'s built-in SQL functions (core scalar / aggregate / window, date and time, JSON, +/// math, percentile), used only when the connection cannot report its functions +/// (`pragma_function_list` unavailable, e.g. `SQLITE_OMIT_INTROSPECTION_PRAGMAS`): then +/// `query_db` allows exactly these plus [`SQLITE_EXTENSION_FUNCTIONS`] and nothing an app +/// registered — fail closed. +#[cfg(feature = "sqlite")] +static SQLITE_BUILTIN_FUNCTIONS: &[&str] = &[ + "->", + "->>", + "abs", + "acos", + "acosh", + "asin", + "asinh", + "atan", + "atan2", + "atanh", + "avg", + "ceil", + "ceiling", + "changes", + "char", + "coalesce", + "concat", + "concat_ws", + "cos", + "cosh", + "count", + "cume_dist", + "current_date", + "current_time", + "current_timestamp", + "date", + "datetime", + "degrees", + "dense_rank", + "exp", + "first_value", + "floor", + "format", + "glob", + "group_concat", + "hex", + "if", + "ifnull", + "iif", + "instr", + "json", + "json_array", + "json_array_insert", + "json_array_length", + "json_error_position", + "json_extract", + "json_group_array", + "json_group_object", + "json_insert", + "json_object", + "json_patch", + "json_pretty", + "json_quote", + "json_remove", + "json_replace", + "json_set", + "json_type", + "json_valid", + "jsonb", + "jsonb_array", + "jsonb_array_insert", + "jsonb_extract", + "jsonb_group_array", + "jsonb_group_object", + "jsonb_insert", + "jsonb_object", + "jsonb_patch", + "jsonb_remove", + "jsonb_replace", + "jsonb_set", + "julianday", + "lag", + "last_insert_rowid", + "last_value", + "lead", + "length", + "like", + "likelihood", + "likely", + "ln", + "log", + "log10", + "log2", + "lower", + "ltrim", + "max", + "median", + "min", + "mod", + "nth_value", + "ntile", + "nullif", + "octet_length", + "percent_rank", + "percentile", + "percentile_cont", + "percentile_disc", + "pi", + "pow", + "power", + "printf", + "quote", + "radians", + "random", + "randomblob", + "rank", + "replace", + "round", + "row_number", + "rtrim", + "sign", + "sin", + "sinh", + "soundex", + "sqlite_compileoption_get", + "sqlite_compileoption_used", + "sqlite_log", + "sqlite_offset", + "sqlite_source_id", + "sqlite_version", + "sqrt", + "strftime", + "string_agg", + "substr", + "substring", + "subtype", + "sum", + "tan", + "tanh", + "time", + "timediff", + "total", + "total_changes", + "trim", + "trunc", + "typeof", + "unhex", + "unicode", + "unistr", + "unistr_quote", + "unixepoch", + "unlikely", + "upper", + "zeroblob", +]; + +/// Never allowed, even though built in: `load_extension` (disabled by default, but it would +/// load arbitrary code). +#[cfg(feature = "sqlite")] +static DENIED_FUNCTIONS: &[&str] = &["load_extension"]; + +/// What agent-supplied `query_db` SQL may do on one connection, computed from that connection +/// before its authorizer is installed. +#[cfg(feature = "sqlite")] +#[derive(Default)] +struct QueryPolicy { + /// SQL functions that may run (lowercase names). + functions: std::collections::HashSet, + /// Shadow tables of the database's R-Tree / Geopoly virtual tables (lowercase). The R-Tree + /// module prepares its INSERT/UPDATE/DELETE statements when it connects, so preparing a + /// write on them must be allowed for an R-Tree table to be readable at all; the `READ_ONLY` + /// open still stops any write from executing. + rtree_shadow_tables: std::collections::HashSet, + /// The first function refused, for a clear error. + refused_function: std::sync::Mutex>, +} + +#[cfg(feature = "sqlite")] +impl QueryPolicy { + /// The policy for `conn`: `SQLite`'s built-in functions, its own extension functions, and + /// functions registered DETERMINISTIC may run; any other function (an app can register a + /// side-effecting one on every connection with `sqlite3_auto_extension`) is refused. + fn for_connection(conn: &rusqlite::Connection) -> Self { + let mut functions = Self::listed_functions(conn).unwrap_or_else(|e| { + tracing::debug!( + "query_db: pragma_function_list unavailable ({e}); allowing only SQLite's built-in functions" + ); + SQLITE_BUILTIN_FUNCTIONS + .iter() + .chain(SQLITE_EXTENSION_FUNCTIONS) + .map(|f| (*f).to_string()) + .collect() + }); + for denied in DENIED_FUNCTIONS { + functions.remove(*denied); + } + Self { + functions, + rtree_shadow_tables: Self::rtree_shadow_tables(conn), + refused_function: std::sync::Mutex::new(None), + } + } + + /// Functions allowed per the connection's own `pragma_function_list`. A name is allowed only + /// if EVERY registration under it is built in, deterministic, or one of `SQLite`'s own + /// extension functions — a non-deterministic app function that overrides a built-in name is + /// what would actually run, so it taints the name. + fn listed_functions( + conn: &rusqlite::Connection, + ) -> rusqlite::Result> { + let mut verdicts: std::collections::HashMap = + std::collections::HashMap::new(); + let mut stmt = conn.prepare("SELECT name, builtin, flags FROM pragma_function_list")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let name = row.get::<_, String>(0)?.to_ascii_lowercase(); + let builtin = row.get::<_, Option>(1)?.unwrap_or(0) == 1; + let flags = row.get::<_, Option>(2)?.unwrap_or(0); + let ok = builtin + || flags & SQLITE_DETERMINISTIC_FLAG != 0 + || SQLITE_EXTENSION_FUNCTIONS.contains(&name.as_str()); + let verdict = verdicts.entry(name).or_insert(true); + *verdict = *verdict && ok; + } + Ok(verdicts + .into_iter() + .filter_map(|(name, ok)| ok.then_some(name)) + .collect()) + } + + /// Shadow tables of the R-Tree / Geopoly virtual tables in `main`. Best effort: on any + /// error none are allowed (an R-Tree table is then unreadable, never writable). + fn rtree_shadow_tables(conn: &rusqlite::Connection) -> std::collections::HashSet { + let names = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' \ + AND (substr(sql, 1, 512) LIKE 'CREATE VIRTUAL TABLE % USING rtree%' \ + OR substr(sql, 1, 512) LIKE 'CREATE VIRTUAL TABLE % USING geopoly%') \ + LIMIT 1000", + ) + .and_then(|mut stmt| { + stmt.query_map([], |r| r.get::<_, String>(0))? + .collect::>>() + }) + .unwrap_or_default(); + names + .iter() + .flat_map(|name| { + let name = name.to_ascii_lowercase(); + ["_node", "_rowid", "_parent"].map(|suffix| format!("{name}{suffix}")) + }) + .collect() + } + + fn allows_function(&self, name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if self.functions.contains(&lower) { + return true; + } + let mut refused = self + .refused_function + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if refused.is_none() { + *refused = Some(name.to_string()); + } + false + } + + fn is_rtree_shadow(&self, database: Option<&str>, table: &str) -> bool { + database.is_some_and(|db| db.eq_ignore_ascii_case("main")) + && self + .rtree_shadow_tables + .contains(&table.to_ascii_lowercase()) + } + + /// The function this policy refused, if any (for the error message). + fn refused_function(&self) -> Option { + self.refused_function + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +/// `SQLite` authorizer for agent-supplied `query_db` SQL: default-deny. Reads, recursive CTEs, +/// the functions `policy` allows, and allowlisted read-only PRAGMAs are permitted; everything +/// else (writes, ATTACH, schema changes, transactions, setter PRAGMAs, app-registered +/// non-deterministic functions) is refused before it runs. +#[cfg(feature = "sqlite")] +fn query_authorizer( + policy: &QueryPolicy, + ctx: rusqlite::hooks::AuthContext<'_>, +) -> rusqlite::hooks::Authorization { + use rusqlite::hooks::{AuthAction, Authorization}; + let allow = |ok: bool| { + if ok { + Authorization::Allow + } else { + Authorization::Deny + } + }; + match ctx.action { + AuthAction::Select | AuthAction::Read { .. } | AuthAction::Recursive => { + Authorization::Allow + } + AuthAction::Function { function_name } => allow(policy.allows_function(function_name)), + AuthAction::Insert { table_name } + | AuthAction::Update { table_name, .. } + | AuthAction::Delete { table_name } => { + allow(policy.is_rtree_shadow(ctx.database_name, table_name)) + } + AuthAction::Pragma { + pragma_name, + pragma_value, + } => { + let name = pragma_name.to_ascii_lowercase(); + allow( + SAFE_PRAGMAS.contains(&name.as_str()) + && (pragma_value.is_none() || READ_PRAGMAS_WITH_ARG.contains(&name.as_str())), + ) + } + _ => Authorization::Deny, + } +} + /// Extract the lowercased PRAGMA name from a `PRAGMA [schema.]name ...` statement, /// tolerating an optional `schema.` qualifier. Returns `None` for a non-PRAGMA or /// a malformed one. @@ -530,6 +1326,7 @@ pub fn is_webview_internal(path: &Path) -> bool { /// application actually uses. #[cfg(feature = "sqlite")] #[derive(Debug, Clone)] +#[non_exhaustive] pub struct DbCandidate { /// Absolute path to the discovered database file. pub path: PathBuf, @@ -628,15 +1425,16 @@ pub fn query( ) } +/// The checks a query must pass before any database is even opened: length, read-only +/// statement kind, no PRAGMA write or side-effecting PRAGMA, no stacked statements. The +/// `query_db` tool runs them BEFORE resolving which database to open, so a refused query is +/// refused for what it is — whether or not the app has a database — and touches no file. +/// +/// # Errors +/// +/// Returns the refusal message for the first check the query fails. #[cfg(feature = "sqlite")] -fn query_with_limits( - db_path: &Path, - sql: &str, - params: &[serde_json::Value], - max_rows: Option, - query_timeout: Duration, - max_result_bytes: usize, -) -> Result { +pub fn validate_query(sql: &str) -> Result<(), String> { if sql.len() > MAX_QUERY_SQL_BYTES { return Err(format!( "query exceeds maximum length ({MAX_QUERY_SQL_BYTES} bytes)" @@ -671,18 +1469,30 @@ fn query_with_limits( ); } - let cleaned = strip_sql_comments(sql); - if cleaned.contains(';') { - let parts: Vec<&str> = cleaned - .split(';') - .filter(|s| !s.trim().is_empty()) - .collect(); - if parts.len() > 1 { - return Err( - "stacked queries (multiple statements separated by ;) are not allowed".to_string(), - ); - } + // A UX guard, not the security boundary (that is the authorizer + READ_ONLY open): rusqlite's + // `prepare` silently ignores a trailing statement, so a stacked query would otherwise run + // only its first statement with no error. Quote-aware, so `SELECT 'a;b'` is one statement. + if statement_count(sql) > 1 { + return Err( + "stacked queries (multiple statements separated by ;) are not allowed".to_string(), + ); } + Ok(()) +} + +#[cfg(feature = "sqlite")] +// `unit_arg`: on rusqlite 0.32 some setters return `()`; `.into_setup()` on that unit is the +// point (see `SetupOutcome`), so one source compiles fail-closed across the supported range. +#[allow(clippy::unit_arg)] +fn query_with_limits( + db_path: &Path, + sql: &str, + params: &[serde_json::Value], + max_rows: Option, + query_timeout: Duration, + max_result_bytes: usize, +) -> Result { + validate_query(sql)?; let max_rows = max_rows.unwrap_or(MAX_ROWS_DEFAULT).min(MAX_ROWS_LIMIT); @@ -691,9 +1501,17 @@ fn query_with_limits( rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .map_err(|e| format!("failed to open database: {e}"))?; - + run_open_hook(&conn); + // The database is untrusted input (see `open_untrusted_read_only`), and the query is + // agent-supplied: SQLite's own AUTHORIZER is the enforcement point, not string parsing. + // It sees every PRAGMA SQLite is about to run — statement form, `PRAGMA name(arg)`, and + // table-valued `pragma_*()` functions alike (the string checks above missed the last two). + conn.pragma_update(None, "trusted_schema", false) + .map_err(|e| format!("failed to harden database connection: {e}"))?; + conn.set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true) + .map_err(|e| format!("failed to harden database connection: {e}"))?; // Limit lock waits separately from the CPU deadline enforced below. - conn.busy_timeout(std::time::Duration::from_secs(5)) + conn.busy_timeout(QUERY_BUSY_TIMEOUT) .map_err(|e| format!("failed to set timeout: {e}"))?; // Bound both SQLite's per-value/row allocation and CPU time. `busy_timeout` @@ -701,31 +1519,74 @@ fn query_with_limits( conn.set_limit( rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH, MAX_QUERY_CELL_BYTES, - ); + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; conn.set_limit( rusqlite::limits::Limit::SQLITE_LIMIT_SQL_LENGTH, MAX_QUERY_SQL_BYTES as i32, - ); + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; + conn.set_limit( + rusqlite::limits::Limit::SQLITE_LIMIT_LIKE_PATTERN_LENGTH, + MAX_LIKE_PATTERN_BYTES, + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; let started = Instant::now(); conn.progress_handler( QUERY_PROGRESS_OPS, Some(move || started.elapsed() >= query_timeout), - ); + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; // Hard wall-clock backstop: the progress handler under-samples a single long op, so also // arm an interrupt-handle watchdog. Lives until the query completes (drops/joins on every // return path, including `?` errors below). let _interrupt = InterruptGuard::arm(&conn, query_timeout); + // `SQLITE_LIMIT_COLUMN` also bounds table DEFINITIONS, so a schema holding one wide table + // would fail to parse under it and make the whole database unqueryable. Load the schema + // first (preparing any statement does), then cap the width of the query's result set. + drop( + conn.prepare("SELECT 1 FROM sqlite_master LIMIT 0") + .map_err(|e| sqlite_query_error("failed to load database schema", e, query_timeout))?, + ); + conn.set_limit( + rusqlite::limits::Limit::SQLITE_LIMIT_COLUMN, + MAX_QUERY_COLUMNS, + ) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; + + // The authorizer is installed only now: with the schema loaded (a locked database fails + // once, at the load above, not again in each policy read) and before the agent's SQL is + // prepared. + let policy = Arc::new(QueryPolicy::for_connection(&conn)); + let authorizer_policy = Arc::clone(&policy); + conn.authorizer(Some(move |ctx: rusqlite::hooks::AuthContext<'_>| { + query_authorizer(&authorizer_policy, ctx) + })) + .into_setup() + .map_err(|e| format!("failed to harden database connection: {e}"))?; + let mut stmt = conn .prepare(sql) - .map_err(|e| sqlite_query_error("failed to prepare query", e, query_timeout))?; - - let column_names: Vec = stmt - .column_names() - .iter() - .map(|s| (*s).to_string()) - .collect(); - let column_count = column_names.len(); + .map_err(|e| match policy.refused_function() { + Some(name) => format!( + "failed to prepare query: the SQL function `{name}` is not allowed — query_db runs \ + only SQLite's built-in functions and functions registered as deterministic (an \ + app-registered function could have side effects)" + ), + None => sqlite_query_error("failed to prepare query", e, query_timeout), + })?; + + let column_names = unique_column_keys(&stmt.column_names()); + // `"key":` per column, sized once. + let key_bytes: Vec = column_names.iter().map(|k| json_len(k) + 1).collect(); + // A row object's fixed cost: its braces and the commas between its fields. + let row_overhead = 2 + column_names.len().saturating_sub(1); let sqlite_params: Vec> = params.iter().map(json_to_sql).collect(); @@ -735,12 +1596,13 @@ fn query_with_limits( let mut rows = stmt .query(param_refs.as_slice()) .map_err(|e| sqlite_query_error("query execution failed", e, query_timeout))?; - let mut result_bytes = serde_json::to_vec(&column_names) - .map_err(|e| format!("failed to size query result columns: {e}"))? - .len(); + let mut result_bytes = json_len(&column_names); let mut truncated = false; - while let Some(row) = rows + // The byte budget is charged CELL BY CELL, from each raw value's encoded size, before the + // cell is converted: a row can never be materialized past the cap (it used to be built, + // base64'd and re-serialized in full before the first size check). + 'rows: while let Some(row) = rows .next() .map_err(|e| sqlite_query_error("row read failed", e, query_timeout))? { @@ -748,21 +1610,28 @@ fn query_with_limits( truncated = true; break; } + let mut row_bytes = row_overhead; let mut obj = serde_json::Map::new(); - for (i, col_name) in column_names.iter().enumerate().take(column_count) { - let value = row_value_to_json(row, i); + for (i, col_name) in column_names.iter().enumerate() { + let spent = result_bytes + .saturating_add(row_bytes) + .saturating_add(key_bytes[i]); + let budget = max_result_bytes.saturating_sub(spent); + let Some((value, value_bytes)) = cell_to_json(row, i, budget) else { + truncated = true; + break 'rows; + }; + row_bytes = row_bytes + .saturating_add(key_bytes[i]) + .saturating_add(value_bytes); obj.insert(col_name.clone(), value); } - let row_value = serde_json::Value::Object(obj); - let row_bytes = serde_json::to_vec(&row_value) - .map_err(|e| format!("failed to size query result row: {e}"))? - .len(); if result_bytes.saturating_add(row_bytes) > max_result_bytes { truncated = true; break; } result_bytes = result_bytes.saturating_add(row_bytes); - rows_out.push(row_value); + rows_out.push(serde_json::Value::Object(obj)); } Ok(serde_json::json!({ @@ -807,32 +1676,108 @@ fn json_to_sql(val: &serde_json::Value) -> Box { } } +/// Result-object keys for a statement's columns. `SQLite` allows duplicate result names +/// (`SELECT 1 AS a, 2 AS a`), which as JSON object keys silently dropped all but one value +/// while `columns` still listed both. A repeated name gets a `:N` suffix (`a`, `a:1`, …), +/// skipping any suffix that is itself a real column name, so every key is unique and +/// `columns` lists exactly the keys each row carries. +#[cfg(feature = "sqlite")] +fn unique_column_keys(names: &[&str]) -> Vec { + let originals: std::collections::HashSet<&str> = names.iter().copied().collect(); + let mut used: std::collections::HashSet = std::collections::HashSet::new(); + names + .iter() + .map(|&name| { + let mut key = name.to_string(); + let mut n = 0u32; + while used.contains(&key) || (n > 0 && originals.contains(key.as_str())) { + n += 1; + key = format!("{name}:{n}"); + } + used.insert(key.clone()); + key + }) + .collect() +} + +/// A `std::io::Write` sink that only counts bytes, so an encoded size can be measured +/// without building the encoding. +#[cfg(feature = "sqlite")] +struct ByteCounter(usize); + +#[cfg(feature = "sqlite")] +impl std::io::Write for ByteCounter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(buf.len()); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Exact compact-JSON length of `value`, measured without allocating the encoding. #[cfg(feature = "sqlite")] -fn row_value_to_json(row: &rusqlite::Row, idx: usize) -> serde_json::Value { +fn json_len(value: &T) -> usize { + let mut counter = ByteCounter(0); + // Serializing into a counter cannot fail for these value types; a failure would only + // under-count, and the caller's cap check is then conservative on the next cell. + let _ = serde_json::to_writer(&mut counter, value); + counter.0 +} + +/// Convert one result cell to JSON if its encoded size fits `budget` bytes, returning the value +/// and that size. The size is taken from the RAW cell before converting (a blob's base64 length +/// is computed, not encoded), so a cell that does not fit is never materialized. +#[cfg(feature = "sqlite")] +fn cell_to_json( + row: &rusqlite::Row, + idx: usize, + budget: usize, +) -> Option<(serde_json::Value, usize)> { use rusqlite::types::ValueRef; - match row.get_ref(idx) { - Ok(ValueRef::Null) => serde_json::Value::Null, - Ok(ValueRef::Integer(i)) => serde_json::json!(i), - Ok(ValueRef::Real(f)) => serde_json::json!(f), + let (value, bytes) = match row.get_ref(idx) { + Ok(ValueRef::Null) | Err(_) => (serde_json::Value::Null, 4), + Ok(ValueRef::Integer(i)) => (serde_json::json!(i), json_len(&i)), + Ok(ValueRef::Real(f)) => { + let v = serde_json::json!(f); + let n = json_len(&v); + (v, n) + } Ok(ValueRef::Text(t)) => { + // Borrowed (no copy) for valid UTF-8; a cell is at most MAX_QUERY_CELL_BYTES. let s = String::from_utf8_lossy(t); if let Ok(parsed) = serde_json::from_str::(&s) && (parsed.is_object() || parsed.is_array()) { - return parsed; + let n = json_len(&parsed); + (parsed, n) + } else { + let n = json_len(&*s); + if n > budget { + return None; + } + (serde_json::Value::String(s.into_owned()), n) } - serde_json::Value::String(s.into_owned()) } Ok(ValueRef::Blob(b)) => { use base64::Engine; - serde_json::json!({ + // `{"__blob":true,"size":N,"base64":"…"}` + let bytes = r#"{"__blob":true,"size":,"base64":""}"#.len() + + json_len(&b.len()) + + b.len().div_ceil(3) * 4; + if bytes > budget { + return None; + } + let v = serde_json::json!({ "__blob": true, "size": b.len(), "base64": base64::engine::general_purpose::STANDARD.encode(b), - }) + }); + (v, bytes) } - Err(_) => serde_json::Value::Null, - } + }; + (bytes <= budget).then_some((value, bytes)) } #[cfg(all(test, feature = "sqlite"))] @@ -907,6 +1852,494 @@ mod tests { println!("{}", serde_json::to_string_pretty(&r).unwrap()); } + #[test] + fn authorizer_blocks_pragma_function_and_parenthesized_writes() { + let (_f, path) = create_test_db(); + // Table-valued pragma functions went through the SELECT prefix check unchecked. + let err = query(&path, "SELECT * FROM pragma_optimize", &[], None).unwrap_err(); + assert!( + err.contains("not authorized") || err.contains("prohibited"), + "{err}" + ); + // `PRAGMA name(arg)` is the write form for setter pragmas, with no `=` to catch. + for sql in ["PRAGMA user_version(5)", "PRAGMA journal_mode(DELETE)"] { + assert!(query(&path, sql, &[], None).is_err(), "must block: {sql}"); + } + // Legitimate read forms still work — statement, argument, and function forms. + assert!(query(&path, "PRAGMA table_info(users)", &[], None).is_ok()); + assert!(query(&path, "PRAGMA user_version", &[], None).is_ok()); + let r = query( + &path, + "SELECT name FROM pragma_table_info('users')", + &[], + None, + ) + .unwrap(); + assert_eq!(r["row_count"], 3); + assert!(query(&path, "SELECT count(*) FROM users", &[], None).is_ok()); + } + + /// Register a variadic SQL function answering 42 on `conn` (as an app's + /// `sqlite3_auto_extension` would on every connection), through the C API so the test needs + /// no rusqlite feature beyond the plugin's own. + #[allow(unsafe_code)] + fn register_function(conn: &rusqlite::Connection, name: &str, deterministic: bool) { + use rusqlite::ffi; + unsafe extern "C" fn answer( + ctx: *mut ffi::sqlite3_context, + _argc: std::os::raw::c_int, + _argv: *mut *mut ffi::sqlite3_value, + ) { + // SAFETY: `ctx` is the live context SQLite passes to a scalar function. + unsafe { ffi::sqlite3_result_int(ctx, 42) }; + } + let flags = ffi::SQLITE_UTF8 + | if deterministic { + ffi::SQLITE_DETERMINISTIC + } else { + 0 + }; + let name = std::ffi::CString::new(name).unwrap(); + // SAFETY: a valid open connection handle, a NUL-terminated name, and a callback with + // the signature SQLite expects; no user data or destructor. + let rc = unsafe { + ffi::sqlite3_create_function_v2( + conn.handle(), + name.as_ptr(), + -1, + flags, + std::ptr::null_mut(), + Some(answer), + None, + None, + None, + ) + }; + assert_eq!(rc, ffi::SQLITE_OK); + } + + /// Run `f` with `hook` applied to every connection this thread opens. + fn with_open_hook( + hook: impl Fn(&rusqlite::Connection) + 'static, + f: impl FnOnce() -> T, + ) -> T { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + ON_OPEN.with(|h| *h.borrow_mut() = None); + } + } + ON_OPEN.with(|h| *h.borrow_mut() = Some(Box::new(hook))); + let _reset = Reset; + f() + } + + /// R5B-SQLFN1: `query_db` allowed every SQL function, so a side-effecting function the host + /// registered process-wide (`sqlite3_auto_extension`) ran despite the read-only open. Only + /// built-in functions and ones registered DETERMINISTIC may run. + #[test] + fn app_registered_non_deterministic_functions_are_refused() { + let (_f, path) = create_test_db(); + with_open_hook( + |c| { + register_function(c, "app_side_effect", false); + register_function(c, "app_pure", true); + // Overriding a built-in with a non-deterministic function runs the override. + register_function(c, "lower", false); + }, + || { + let err = query(&path, "SELECT app_side_effect()", &[], None).unwrap_err(); + assert!(err.contains("app_side_effect"), "{err}"); + let err = query(&path, "SELECT lower('A')", &[], None).unwrap_err(); + assert!(err.contains("lower"), "{err}"); + // Hidden in a CTE it is still refused. + let err = query( + &path, + "WITH x AS (SELECT app_side_effect() AS v) SELECT v FROM x", + &[], + None, + ) + .unwrap_err(); + assert!(err.contains("app_side_effect"), "{err}"); + let r = query(&path, "SELECT app_pure() AS v", &[], None).unwrap(); + assert_eq!(r["rows"][0]["v"], 42); + assert!(query(&path, "SELECT upper('a'), abs(-1)", &[], None).is_ok()); + }, + ); + } + + /// The function policy must not break `SQLite`'s own functions: core scalar / aggregate / + /// window, date/time, JSON, and the FTS3/4, FTS5 and R-Tree extension functions (which + /// `SQLite` registers as non-built-in overloads). + #[test] + fn builtin_and_extension_functions_still_work() { + let (_f, path) = create_test_db(); + let conn = rusqlite::Connection::open(&path).unwrap(); + let fts = conn + .execute_batch( + "CREATE VIRTUAL TABLE f5 USING fts5(body); INSERT INTO f5 VALUES ('hello world'); + CREATE VIRTUAL TABLE f4 USING fts4(body); INSERT INTO f4 VALUES ('hello there');", + ) + .is_ok(); + let rtree = conn + .execute_batch( + "CREATE VIRTUAL TABLE r USING rtree(id, x0, x1); INSERT INTO r VALUES (1, 0, 5);", + ) + .is_ok(); + drop(conn); + let mut sqls = vec![ + "SELECT abs(-1), lower('A'), upper('a'), length('x'), substr('abc', 2), \ + coalesce(NULL, 1), printf('%d', 5), hex('a'), typeof(1), round(1.5), \ + replace('a', 'a', 'b'), instr('ab', 'b'), trim(' a '), random() IS NOT NULL", + "SELECT count(*), sum(score), avg(score), max(score), min(score), \ + group_concat(name) FROM users", + "SELECT name, row_number() OVER (ORDER BY score), rank() OVER (ORDER BY score), \ + lag(name) OVER (ORDER BY id) FROM users", + "SELECT date('now'), datetime('now'), julianday('now'), strftime('%Y', 'now'), \ + unixepoch('now')", + "SELECT json_extract('{\"a\":1}', '$.a'), json_object('a', 1), json_array(1, 2), \ + '{\"a\":1}' -> '$.a', '{\"a\":1}' ->> '$.a'", + "SELECT * FROM json_each('[1,2]')", + ]; + if fts { + sqls.push( + "SELECT bm25(f5), highlight(f5, 0, '[', ']'), snippet(f5, 0, '[', ']', '..', 5) \ + FROM f5 WHERE f5 MATCH 'hello'", + ); + sqls.push( + "SELECT snippet(f4), offsets(f4), matchinfo(f4) FROM f4 WHERE f4 MATCH 'hello'", + ); + } + if rtree { + sqls.push("SELECT id FROM r WHERE x0 >= 0 AND x1 <= 10"); + } + for sql in sqls { + let r = query(&path, sql, &[], None); + assert!(r.is_ok(), "{sql}: {r:?}"); + } + // Still refused: loading an extension. + assert!(query(&path, "SELECT load_extension('x')", &[], None).is_err()); + } + + /// The fail-closed fallback list (used when a connection cannot report its functions) must + /// cover every function this `SQLite` reports as built in, and every non-built-in function + /// `SQLite`'s own extensions register must be known — so a newer `SQLite` that adds one fails + /// this test instead of silently refusing it in `query_db`. + #[test] + fn fallback_function_lists_cover_this_sqlite() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let mut stmt = conn + .prepare("SELECT DISTINCT name, builtin FROM pragma_function_list") + .unwrap(); + let rows: Vec<(String, i64)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert!(rows.len() > 50, "{rows:?}"); + let known_non_builtin = ["fts3_tokenizer", "optimize"]; + for (name, builtin) in rows { + let known = if DENIED_FUNCTIONS.contains(&name.as_str()) { + true + } else if builtin == 1 { + SQLITE_BUILTIN_FUNCTIONS.contains(&name.as_str()) + } else { + SQLITE_EXTENSION_FUNCTIONS.contains(&name.as_str()) + || known_non_builtin.contains(&name.as_str()) + }; + assert!(known, "function `{name}` (builtin={builtin}) is not listed"); + } + } + + /// Register a virtual-table module named `name` on `conn` (as a host's + /// `sqlite3_auto_extension` would) whose constructor only counts how often it ran and fails. + #[allow(unsafe_code)] + fn register_counting_module( + conn: &rusqlite::Connection, + name: &str, + connects: &'static std::sync::atomic::AtomicUsize, + ) { + use rusqlite::ffi; + use std::os::raw::{c_char, c_int, c_void}; + unsafe extern "C" fn construct( + _db: *mut ffi::sqlite3, + aux: *mut c_void, + _argc: c_int, + _argv: *const *const c_char, + _vtab: *mut *mut ffi::sqlite3_vtab, + _err: *mut *mut c_char, + ) -> c_int { + // SAFETY: `aux` is the `&'static AtomicUsize` passed as client data below. + let counter = unsafe { &*aux.cast::() }; + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ffi::SQLITE_ERROR + } + // SAFETY: an all-zero `sqlite3_module` is valid (version 0, every method absent). + let mut module: ffi::sqlite3_module = unsafe { std::mem::zeroed() }; + module.xCreate = Some(construct); + module.xConnect = Some(construct); + let module: &'static ffi::sqlite3_module = Box::leak(Box::new(module)); + let name = std::ffi::CString::new(name).unwrap(); + // SAFETY: a valid connection handle, a NUL-terminated name, a module that outlives the + // connection (leaked) and client data that is `'static`; no destructor. + let rc = unsafe { + ffi::sqlite3_create_module_v2( + conn.handle(), + name.as_ptr(), + module, + std::ptr::from_ref(connects).cast_mut().cast(), + None, + ) + }; + assert_eq!(rc, ffi::SQLITE_OK); + } + + /// Plant `CREATE VIRTUAL TABLE
    USING (x)` in the schema without the module + /// being present (`writable_schema`), as a crafted database file can. + fn plant_virtual_table(path: &Path, table: &str, module: &str) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch("PRAGMA writable_schema=ON;").unwrap(); + conn.execute( + "INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) \ + VALUES ('table', ?1, ?1, 0, ?2)", + [ + table.to_string(), + format!("CREATE VIRTUAL TABLE {table} USING {module}(x)"), + ], + ) + .unwrap(); + } + + /// R5B-QC1: on `SQLite` >= 3.44 a whole-database `quick_check` connects every virtual + /// table whose module is registered and runs its integrity routine — module code, including + /// a module the HOST registered process-wide. With such a module registered and a virtual + /// table using it in the file, `db_health` must check the ordinary tables one by one instead, + /// so no module code runs. + #[test] + fn db_health_never_runs_a_host_registered_module() { + static CONNECTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let (_f, path) = create_test_db(); + plant_virtual_table(&path, "planted", "victauri_marker"); + let long = Duration::from_secs(10); + let r = with_open_hook( + |c| register_counting_module(c, "victauri_marker", &CONNECTS), + || db_health_report(path.to_str().unwrap(), long, long).unwrap(), + ); + assert_eq!( + CONNECTS.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the module's constructor ran: {r}" + ); + assert_eq!(r["integrity_check"], "ok", "{r}"); + assert_eq!(r["integrity_check_kind"], "quick_check (per table)", "{r}"); + let note = r["integrity_check_note"].as_str().unwrap_or_default(); + assert!(note.contains("victauri_marker"), "{r}"); + let planted = r["tables"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "planted") + .expect("the planted table is listed"); + assert!(planted["row_count"].is_null(), "{planted}"); + } + + /// Per-table checking still finds corruption in an ordinary table. + #[test] + fn db_health_per_table_check_still_reports_problems() { + static CONNECTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let path = file.path().to_path_buf(); + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT); + INSERT INTO t VALUES (1, 'a'), (2, NULL), (3, 'c');", + ) + .unwrap(); + // Declare a NOT NULL the stored rows violate (quick_check verifies NOT NULL). + conn.execute_batch( + "PRAGMA writable_schema=ON; + UPDATE sqlite_master SET sql = 'CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL)' WHERE name = 't';", + ) + .unwrap(); + } + plant_virtual_table(&path, "planted", "victauri_marker2"); + let long = Duration::from_secs(10); + let r = with_open_hook( + |c| register_counting_module(c, "victauri_marker2", &CONNECTS), + || db_health_report(path.to_str().unwrap(), long, long).unwrap(), + ); + assert_eq!(CONNECTS.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(r["integrity_check_kind"], "quick_check (per table)", "{r}"); + let integrity = r["integrity_check"].as_str().unwrap(); + assert!(integrity.contains("NULL value in t.v"), "{r}"); + } + + /// Without a host-registered module the whole database is still checked, `SQLite`'s own + /// virtual tables included (an FTS5 table), and a virtual table whose module is not loaded + /// is simply not connected. + #[test] + fn db_health_checks_the_whole_database_when_only_builtin_modules_exist() { + let (_f, path) = create_test_db(); + let conn = rusqlite::Connection::open(&path).unwrap(); + if conn + .execute_batch( + "CREATE VIRTUAL TABLE docs USING fts5(body); INSERT INTO docs VALUES ('x');", + ) + .is_err() + { + return; // this SQLite build has no FTS5 + } + drop(conn); + plant_virtual_table(&path, "unknown_vtab", "not_a_loaded_module"); + let long = Duration::from_secs(10); + let r = db_health_report(path.to_str().unwrap(), long, long).unwrap(); + assert_eq!(r["integrity_check"], "ok", "{r}"); + assert_eq!(r["integrity_check_kind"], "quick_check", "{r}"); + } + + #[test] + fn db_health_counts_hundreds_of_tables_within_budget() { + // Each count used to cost a fixed ~25ms (the interrupt watchdog's sleep step, joined + // on drop), capping a 5s budget at ~200 tables even on a tiny database. + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + // One transaction: 400 autocommitted inserts cost a sync each (~75 s on a busy box). + let mut ddl = String::from("BEGIN;"); + for i in 0..400 { + ddl.push_str(&format!( + "CREATE TABLE t{i} (x INTEGER); INSERT INTO t{i} VALUES (1);" + )); + } + ddl.push_str("COMMIT;"); + conn.execute_batch(&ddl).unwrap(); + drop(conn); + // Budgets far above need, so a loaded machine cannot fail this: it checks that hundreds + // of tables are all listed and counted. The per-count cost that capped this at ~200 + // tables is pinned deterministically by `interrupt_guard_parks_instead_of_polling`. + let r = db_health_report( + file.path().to_str().unwrap(), + Duration::from_secs(60), + Duration::from_secs(60), + ) + .unwrap(); + assert_eq!(r["tables"].as_array().unwrap().len(), 400); + assert_eq!(r["row_counts_complete"], true, "{r}"); + } + + /// The watchdog used to sleep in fixed 25 ms steps that every guard's drop then joined — + /// ~25 ms per guarded statement however fast it was, which capped `db_health` at ~200 table + /// counts per 5 s budget. It must park until the deadline (woken early only by the drop). + /// Counted, not timed, so machine load cannot make this flaky. + #[test] + fn interrupt_guard_parks_instead_of_polling() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + let guard = InterruptGuard::arm(&conn, Duration::from_secs(60)); + let wakes = Arc::clone(&guard.wakes); + std::thread::sleep(Duration::from_millis(300)); + drop(guard); + let n = wakes.load(Ordering::Relaxed); + // One wake on start + one on the drop's unpark; allow one spurious park return. A 25 ms + // stepping watchdog would have woken ~12 times in 300 ms. + assert!( + n <= 3, + "the watchdog woke {n} times in 300 ms: it is polling" + ); + } + + #[test] + fn db_health_lists_but_never_counts_virtual_tables() { + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + conn.execute_batch("CREATE TABLE plain (x); INSERT INTO plain VALUES (1);") + .unwrap(); + if conn + .execute_batch("CREATE VIRTUAL TABLE docs USING fts5(body);") + .is_err() + { + return; // this SQLite build has no FTS5; nothing to test + } + drop(conn); + let long = Duration::from_secs(10); + let r = db_health_report(file.path().to_str().unwrap(), long, long).unwrap(); + let docs = r["tables"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "docs") + .expect("virtual table is listed"); + assert_eq!(docs["virtual"], true); + assert!( + docs["row_count"].is_null(), + "virtual tables are never counted" + ); + assert_eq!(r["row_counts_complete"], false); + assert_eq!(r["integrity_check_kind"], "quick_check"); + } + + /// R5-DB1: virtual-table detection was `sql LIKE 'CREATE VIRTUAL%'`, which a crafted file + /// defeats with any other spelling `SQLite` still parses as a virtual table (planted through + /// `writable_schema`) — and then `count(*)` ran the module's code. + #[test] + fn db_health_does_not_count_a_disguised_virtual_table() { + for (i, disguise) in [ + "CREATE VIRTUAL TABLE", + "CREATE\tVIRTUAL TABLE", + "CREATE\nVIRTUAL TABLE", + "CREATE/**/VIRTUAL TABLE", + "CREATE -- c\nVIRTUAL TABLE", + "create virtual table", + ] + .into_iter() + .enumerate() + { + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + conn.execute_batch("CREATE TABLE plain (x); INSERT INTO plain VALUES (1);") + .unwrap(); + if conn + .execute_batch("CREATE VIRTUAL TABLE docs USING fts5(body);") + .is_err() + { + return; // this SQLite build has no FTS5; nothing to test + } + conn.execute_batch("PRAGMA writable_schema=ON;").unwrap(); + let changed = conn + .execute( + "UPDATE sqlite_master SET sql = replace(sql, 'CREATE VIRTUAL TABLE', ?1) \ + WHERE name = 'docs'", + [disguise], + ) + .unwrap(); + assert_eq!(changed, 1); + drop(conn); + // SQLite itself still reads it as the virtual table. + let check = rusqlite::Connection::open(file.path()).unwrap(); + let sql: String = check + .query_row("SELECT sql FROM sqlite_master WHERE name='docs'", [], |r| { + r.get(0) + }) + .unwrap(); + assert!(sql.starts_with(disguise), "case {i}: {sql}"); + check + .execute_batch("INSERT INTO docs(body) VALUES ('still fts5')") + .unwrap_or_else(|e| panic!("case {i}: not a working virtual table: {e}")); + drop(check); + + let long = Duration::from_secs(10); + let r = db_health_report(file.path().to_str().unwrap(), long, long).unwrap(); + let tables = r["tables"].as_array().unwrap(); + let docs = tables.iter().find(|t| t["name"] == "docs").unwrap(); + assert!( + docs["row_count"].is_null(), + "case {i} ({disguise:?}): a virtual table was counted: {docs}" + ); + assert_eq!(docs["virtual"], true, "case {i}: {docs}"); + let plain = tables.iter().find(|t| t["name"] == "plain").unwrap(); + assert_eq!(plain["row_count"], 1, "case {i}: {plain}"); + } + } + #[test] fn quote_sqlite_identifier_doubles_embedded_quotes() { assert_eq!(quote_sqlite_identifier("a\"b"), "\"a\"\"b\""); @@ -981,6 +2414,161 @@ mod tests { assert!(result["result_bytes"].as_u64().unwrap() <= 250_000); } + /// Audit F1: `SQLITE_LIMIT_COLUMN` was the 2000 default, so one row of 1 MB cells could + /// reach gigabytes inside `SQLite` + the host before any budget was consulted. + #[test] + fn result_set_width_is_capped() { + let (_f, path) = create_test_db(); + let cols = (1..=300) + .map(|i| format!("x AS c{i}")) + .collect::>() + .join(", "); + let sql = format!("WITH b(x) AS (SELECT 1) SELECT {cols} FROM b"); + let err = query(&path, &sql, &[], None).unwrap_err(); + assert!(err.contains("too many columns"), "{err}"); + } + + /// The column cap limits result sets, never the ability to read an app's wide table. + #[test] + fn wide_table_is_still_readable_under_the_column_cap() { + let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap(); + let conn = rusqlite::Connection::open(file.path()).unwrap(); + let cols = (1..=300) + .map(|i| format!("c{i} INTEGER")) + .collect::>() + .join(", "); + conn.execute_batch(&format!( + "CREATE TABLE wide ({cols}); INSERT INTO wide (c1, c300) VALUES (7, 9);" + )) + .unwrap(); + drop(conn); + let r = query(file.path(), "SELECT c1, c300 FROM wide", &[], None).unwrap(); + assert_eq!(r["rows"][0]["c1"], 7); + assert_eq!(r["rows"][0]["c300"], 9); + // Only a result set that wide is refused, with a clear reason. + let err = query(file.path(), "SELECT * FROM wide", &[], None).unwrap_err(); + assert!(err.contains("too many columns"), "{err}"); + } + + /// Audit F1: a row of many large blobs stops at the byte cap, and what IS returned is + /// within the cap (the budget is charged per cell, before conversion). + #[test] + fn wide_row_of_large_blobs_stops_at_the_byte_cap() { + let (_f, path) = create_test_db(); + let cols = (1..=40) + .map(|i| format!("x AS c{i}")) + .collect::>() + .join(", "); + let sql = format!("WITH b(x) AS (SELECT zeroblob(1000000)) SELECT {cols} FROM b"); + let r = query(&path, &sql, &[], None).unwrap(); + assert_eq!(r["truncated"], true); + assert_eq!(r["row_count"], 0); + assert!(r["result_bytes"].as_u64().unwrap() <= MAX_QUERY_RESULT_BYTES as u64); + } + + /// `result_bytes` is the exact encoded size of `columns` + every returned row, measured + /// from raw cells without re-serializing the row. + #[test] + fn result_bytes_matches_the_encoded_rows_exactly() { + let (_f, path) = create_test_db(); + let r = query( + &path, + "SELECT id, name, score, NULL AS n, X'00FF10' AS b, '{\"k\": [1, 2]}' AS j, \ + 'q\"\\\n\u{1}é' AS s, 1.5e300 AS big FROM users", + &[], + None, + ) + .unwrap(); + let mut expected = serde_json::to_vec(&r["columns"]).unwrap().len(); + for row in r["rows"].as_array().unwrap() { + expected += serde_json::to_vec(row).unwrap().len(); + } + assert_eq!(r["result_bytes"].as_u64().unwrap() as usize, expected); + assert!(r["rows"][0]["j"].is_object()); + assert_eq!(r["rows"][0]["b"]["size"], 3); + } + + /// Audit F1: `SELECT 1 AS a, 2 AS a` returned `columns: [a, a]` but a row object with one + /// `a` — a value silently vanished. + #[test] + fn duplicate_column_names_keep_every_value() { + let (_f, path) = create_test_db(); + let r = query( + &path, + "SELECT 1 AS a, 2 AS a, 3 AS \"a:1\", 4 AS a", + &[], + None, + ) + .unwrap(); + assert_eq!(r["columns"], serde_json::json!(["a", "a:2", "a:1", "a:3"])); + let row = &r["rows"][0]; + assert_eq!(row["a"], 1); + assert_eq!(row["a:2"], 2); + assert_eq!(row["a:1"], 3); + assert_eq!(row["a:3"], 4); + assert_eq!(row.as_object().unwrap().len(), 4); + } + + /// Audit F2: `LIKE`/`GLOB` never check for an interrupt, so a long pattern ran tens of + /// seconds past the deadline. Long patterns are now refused up front. + #[test] + fn long_like_and_glob_patterns_are_refused() { + let (_f, path) = create_test_db(); + let pattern = format!("%{}%", "a".repeat(5_000)); + for sql in [ + "SELECT name FROM users WHERE name LIKE ?", + "SELECT name FROM users WHERE name GLOB ?", + ] { + // Refused per row BEFORE matching — the refusal itself is the proof. (A wall-clock + // bound here never discriminated: with three short rows the match is instant either + // way, and it only flaked on loaded machines.) + let err = query(&path, sql, &[serde_json::json!(pattern)], None).unwrap_err(); + assert!(err.contains("pattern too complex"), "{sql}: {err}"); + } + // An ordinary pattern still works. + let r = query( + &path, + "SELECT name FROM users WHERE name LIKE ?", + &[serde_json::json!("A%")], + None, + ) + .unwrap(); + assert_eq!(r["row_count"], 1); + } + + /// Audit F7: a query waited up to 5s on the app's lock before its 5s CPU deadline even + /// started, holding a read transaction (and stalling WAL checkpoints) for ~10s. + #[test] + fn lock_wait_is_short() { + let (_f, path) = create_test_db(); + // The query connection's own busy timeout — deterministic, unlike wall time. + let pragma = query(&path, "PRAGMA busy_timeout", &[], None).unwrap(); + let configured = pragma["rows"][0] + .as_object() + .and_then(|row| row.values().next()) + .and_then(serde_json::Value::as_u64) + .unwrap_or_else(|| panic!("busy_timeout not reported: {pragma}")); + assert_eq!( + u128::from(configured), + QUERY_BUSY_TIMEOUT.as_millis(), + "{pragma}" + ); + assert!(QUERY_BUSY_TIMEOUT <= Duration::from_secs(1)); + let locker = rusqlite::Connection::open(&path).unwrap(); + locker.execute_batch("BEGIN EXCLUSIVE").unwrap(); + let started = Instant::now(); + let err = query(&path, "SELECT * FROM users", &[], None).unwrap_err(); + assert!(err.contains("locked"), "{err}"); + // Loose wall bound (the pre-fix wait was 5 s + the 5 s CPU deadline); the exact + // configured timeout is asserted above. + assert!( + started.elapsed() < Duration::from_secs(4), + "lock wait took {:?}", + started.elapsed() + ); + locker.execute_batch("ROLLBACK").unwrap(); + } + #[test] fn cpu_heavy_query_times_out() { let (_f, path) = create_test_db(); @@ -1231,6 +2819,144 @@ mod tests { assert!(err.contains("stacked queries")); } + /// R4-DEP1: the setters that install the authorizer, deadlines and size limits return `()`, + /// `i32` or `rusqlite::Result` depending on the rusqlite version in the supported range. A + /// failed install must surface as an error on every version, never be dropped. + #[test] + fn a_failed_security_setup_is_an_error_on_every_rusqlite_shape() { + assert_eq!(().into_setup(), Ok(())); + assert_eq!(0_i32.into_setup(), Ok(())); + assert_eq!(Ok::(7).into_setup(), Ok(())); + assert!( + Err::<(), _>(rusqlite::Error::InvalidQuery) + .into_setup() + .is_err() + ); + } + + /// R4-DB1: the pre-checks were quote-unaware — a `;`, `--` or `/*` inside a string literal + /// or quoted identifier was treated as a statement separator or comment. Reproduced live: + /// `SELECT 'a;b'` and a `LIKE '%;%'` filter were refused as "stacked queries". + #[test] + fn quoted_separators_and_comment_markers_are_not_code() { + for sql in [ + "SELECT 'a;b'", + "SELECT name FROM t WHERE msg LIKE '%;%'", + "SELECT 'x--y'", + "SELECT '/* not a comment */;'", + "SELECT \"a;b\" FROM t", + "SELECT [a;b] FROM t", + "SELECT `a;b` FROM t", + "SELECT ''';'", + "SELECT \"x\"\";\" FROM t", + "SELECT 'héllo;wörld — ✓'", + "SELECT 'a;b';", + "SELECT 1 -- trailing ; comment", + "SELECT 1 /* ; */", + "PRAGMA table_info('a;b')", + "PRAGMA table_info([a=b])", + ] { + assert_eq!(validate_query(sql), Ok(()), "must allow: {sql}"); + } + } + + /// Round-4 review: `SQLite` reads a Tcl-style parameter `$a(...)` (also `:a(`, `@a(`, `#a(`) + /// as ONE token up to `)` or whitespace, so a quote inside it is not a string — the quote- + /// aware scanner treated it as one and let a real second statement through. Verified + /// against `SQLite`: `SELECT $a(') ; DELETE FROM users --'` is two statements. + #[test] + fn tcl_style_parameters_do_not_hide_a_stacked_statement() { + for sql in [ + "SELECT $a(') ; DELETE FROM users --'", + "SELECT :a(') ; DELETE FROM users --'", + "SELECT @a(') ; DELETE FROM users --'", + "SELECT #a(') ; DELETE FROM users --'", + ] { + let err = validate_query(sql).expect_err(sql); + assert!(err.contains("stacked queries"), "{sql}: {err}"); + } + for sql in [ + "SELECT * FROM t WHERE id = :id", + "SELECT * FROM t WHERE a = $a AND b = @b", + "SELECT $a(x) FROM t", + "SELECT '$a(' || x FROM t", + "SELECT '#1;2' AS s", + ] { + assert_eq!(validate_query(sql), Ok(()), "must allow: {sql}"); + } + } + + /// R4-DB1: the other direction — a comment marker inside a string made the old stripper + /// swallow a REAL second statement, so it passed the stacked-query check. + #[test] + fn real_stacked_statements_are_refused_even_after_quoted_markers() { + for sql in [ + "SELECT 1; SELECT 2", + "SELECT 1; ATTACH 'x.db' AS y", + "SELECT 1 /* ; */ ; DELETE FROM users", + "SELECT 1; -- hidden\nDELETE FROM users", + "SELECT '--'; DELETE FROM users", + "SELECT '/*'; SELECT '*/'", + "SELECT ''';'; DROP TABLE users", + "SELECT \"--\"; DELETE FROM users", + "SELECT [/*]; DELETE FROM users", + "SELECT 'é'; DELETE FROM users", + ] { + let err = validate_query(sql).expect_err(sql); + assert!(err.contains("stacked queries"), "{sql}: {err}"); + } + // Comment-hidden writes are still refused as writes. + for sql in [ + "/* SELECT */ DELETE FROM users", + "-- SELECT\nDELETE FROM users", + "/*/ SELECT */ DELETE FROM users", + ] { + let err = validate_query(sql).expect_err(sql); + assert!(err.contains("read-only"), "{sql}: {err}"); + } + // A write whose `=` hides after a quoted `--` is still a PRAGMA write. + let err = validate_query("PRAGMA user_version = '--'").unwrap_err(); + assert!(err.contains("PRAGMA writes"), "{err}"); + } + + /// R4-DB1: the old stripper pushed `bytes[i] as char`, turning every non-ASCII byte into a + /// separate Latin-1 char in the validation copy. + #[test] + fn sql_scan_keeps_non_ascii_and_masks_only_quoted_content() { + let s = scan_sql("SELECT 'é;✓' /* ; */, \"ü\"\"x\" -- ü;\nFROM [t;1]"); + assert_eq!(s.cleaned, "SELECT 'é;✓' , \"ü\"\"x\" \nFROM [t;1]"); + assert_eq!(s.masked, "SELECT '___' , \"____\" \nFROM [___]"); + assert_eq!(strip_sql_comments("SELECT 'naïve'"), "SELECT 'naïve'"); + assert_eq!(statement_count("SELECT 1;"), 1); + assert_eq!(statement_count("SELECT 1;; ;"), 1); + assert_eq!(statement_count("SELECT 1; SELECT 2"), 2); + assert_eq!(statement_count("SELECT 'unterminated; DROP"), 1); + } + + /// R4-DB1 end to end: the live-failing queries run and return the literal intact. + #[test] + fn quoted_semicolons_run_against_a_real_database() { + let (_f, path) = create_test_db(); + let r = query( + &path, + "SELECT 'a;b' AS v, 'x--y' AS w, 'é;✓' AS u", + &[], + None, + ) + .unwrap(); + assert_eq!(r["rows"][0]["v"], "a;b"); + assert_eq!(r["rows"][0]["w"], "x--y"); + assert_eq!(r["rows"][0]["u"], "é;✓"); + let r = query( + &path, + "SELECT name FROM users WHERE name LIKE '%;%'", + &[], + None, + ) + .unwrap(); + assert_eq!(r["row_count"], 0); + } + #[test] fn allows_trailing_semicolon() { let (_f, path) = create_test_db(); diff --git a/crates/victauri-plugin/src/filmstrip.rs b/crates/victauri-plugin/src/filmstrip.rs index bbb82734..75381a18 100644 --- a/crates/victauri-plugin/src/filmstrip.rs +++ b/crates/victauri-plugin/src/filmstrip.rs @@ -31,41 +31,96 @@ impl Frame { } } -/// Compose `frames` into a grid `cols` wide (rows derived), separated and -/// bordered by `gap` pixels of `bg`. Cells are sized to the largest frame; -/// smaller frames are placed top-left within their cell. Returns -/// `(rgba, width, height)` for the composed sheet, or `None` if `frames` is -/// empty or the resulting buffer would overflow `usize`. +/// Largest composed sheet [`compose`] builds, in bytes of RGBA (512 MiB). +pub const MAX_SHEET_BYTES: usize = 512 * 1024 * 1024; + +/// Width, height and RGBA byte size of a sheet of `n` cells of `cell_w` x `cell_h`, `cols` +/// wide (clamped to `1..=n`), with `gap` pixels around every cell. `None` on overflow (of +/// `usize`, or of `u32` for a side). #[must_use] -pub fn compose( - frames: &[Frame], +pub fn sheet_size( + cell_w: u32, + cell_h: u32, + n: usize, cols: usize, gap: u32, - bg: [u8; 4], -) -> Option<(Vec, u32, u32)> { - if frames.is_empty() { +) -> Option<(u32, u32, usize)> { + if n == 0 { return None; } - let n = frames.len(); let cols = cols.max(1).min(n); let rows = n.div_ceil(cols); let gap = gap as usize; - - let cell_w = frames.iter().map(|f| f.w as usize).max()?; - let cell_h = frames.iter().map(|f| f.h as usize).max()?; - // out_w = cols*cell_w + (cols+1)*gap ; out_h analogous. All checked. let out_w = cols - .checked_mul(cell_w)? + .checked_mul(cell_w as usize)? .checked_add(cols.checked_add(1)?.checked_mul(gap)?)?; let out_h = rows - .checked_mul(cell_h)? + .checked_mul(cell_h as usize)? .checked_add(rows.checked_add(1)?.checked_mul(gap)?)?; let total = out_w.checked_mul(out_h)?.checked_mul(4)?; - // Guard against absurd allocations (e.g. > ~512 MB sheet). - if total > 512 * 1024 * 1024 { - return None; + Some(( + u32::try_from(out_w).ok()?, + u32::try_from(out_h).ok()?, + total, + )) +} + +/// Refuse a sheet larger than [`MAX_SHEET_BYTES`] (or one that overflows), with a reason. +/// +/// # Errors +/// +/// A human-readable reason when the sheet cannot be built. +pub fn check_sheet( + cell_w: u32, + cell_h: u32, + n: usize, + cols: usize, + gap: u32, +) -> Result<(u32, u32, usize), String> { + match sheet_size(cell_w, cell_h, n, cols, gap) { + Some((w, h, total)) if total <= MAX_SHEET_BYTES => Ok((w, h, total)), + Some((w, h, total)) => Err(format!( + "a filmstrip of {n} frames of {cell_w}x{cell_h} would be a {w}x{h} sheet ({} MiB), over the {} MiB limit", + total / (1024 * 1024), + MAX_SHEET_BYTES / (1024 * 1024) + )), + None => Err(format!( + "a filmstrip of {n} frames of {cell_w}x{cell_h} is too large to build" + )), } +} + +/// Compose `frames` into a grid `cols` wide (rows derived), separated and +/// bordered by `gap` pixels of `bg`. Cells are sized to the largest frame; +/// smaller frames are placed top-left within their cell. Returns +/// `(rgba, width, height)` for the composed sheet. +/// +/// # Errors +/// +/// `frames` is empty, or the sheet would exceed [`MAX_SHEET_BYTES`] / overflow. +pub fn compose( + frames: &[Frame], + cols: usize, + gap: u32, + bg: [u8; 4], +) -> Result<(Vec, u32, u32), String> { + let n = frames.len(); + let cell_w = frames + .iter() + .map(|f| f.w) + .max() + .ok_or("no frames to compose")?; + let cell_h = frames + .iter() + .map(|f| f.h) + .max() + .ok_or("no frames to compose")?; + let (sheet_w, sheet_h, total) = check_sheet(cell_w, cell_h, n, cols, gap)?; + let cols = cols.max(1).min(n); + let gap = gap as usize; + let (cell_w, cell_h) = (cell_w as usize, cell_h as usize); + let out_w = sheet_w as usize; // Fill background. let mut out = vec![0u8; total]; @@ -95,7 +150,7 @@ pub fn compose( } } - Some((out, out_w as u32, out_h as u32)) + Ok((out, sheet_w, sheet_h)) } /// Default column count for `n` frames: roughly square, capped so wide strips @@ -134,8 +189,8 @@ mod tests { } #[test] - fn empty_returns_none() { - assert!(compose(&[], 4, 2, [0, 0, 0, 0]).is_none()); + fn empty_is_refused() { + assert!(compose(&[], 4, 2, [0, 0, 0, 0]).is_err()); } #[test] @@ -178,6 +233,22 @@ mod tests { assert_eq!(&rgba[off..off + 4], &[255, 255, 255, 255]); } + /// R5-ANIM1: an oversized sheet is refused with a reason (it used to be a bare `None`, + /// which the scrub handler dropped while still reporting `captured: true`). + #[test] + fn oversized_sheet_is_refused_with_a_reason() { + // 120 frames of 4K: ~3.8 GB of RGBA. + let err = check_sheet(3840, 2160, 120, 8, 4).unwrap_err(); + assert!(err.contains("over the 512 MiB limit"), "{err}"); + assert!(check_sheet(3840, 2160, 4, 2, 4).is_ok()); + assert!(check_sheet(u32::MAX, u32::MAX, 120, 8, 4).is_err()); + // compose refuses by the same rule. Cells take the largest width and height, so two + // thin frames make two 20000x20000 cells (3.2 GB) from 160 KB of pixels. + let frames = [solid(1, 20_000, [0; 4]), solid(20_000, 1, [0; 4])]; + let err = compose(&frames, 2, 0, [0, 0, 0, 0]).unwrap_err(); + assert!(err.contains("over the 512 MiB limit"), "{err}"); + } + #[test] fn default_cols_is_roughly_square() { assert_eq!(default_cols(0), 1); diff --git a/crates/victauri-plugin/src/introspection.rs b/crates/victauri-plugin/src/introspection.rs index 5af67365..b3c3e6e1 100644 --- a/crates/victauri-plugin/src/introspection.rs +++ b/crates/victauri-plugin/src/introspection.rs @@ -13,6 +13,7 @@ use serde::Serialize; /// Per-command timing statistics aggregated from IPC invocations. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct CommandTimingStats { /// Command name. pub command: String, @@ -117,6 +118,11 @@ impl TimingSamples { } } +/// Maximum distinct commands tracked. The map is keyed by the caller-supplied command name, so +/// without a cap an agent (or a loop) invoking ever-new names grows it forever. Once full, new +/// names are not tracked; commands already tracked keep accumulating. +const MAX_TIMED_COMMANDS: usize = 1024; + /// Thread-safe store for per-command timing data. pub struct CommandTimings { inner: RwLock>, @@ -137,7 +143,11 @@ impl CommandTimings { .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.entry(command.to_string()).or_default().record(duration); + if let Some(samples) = map.get_mut(command) { + samples.record(duration); + } else if map.len() < MAX_TIMED_COMMANDS { + map.entry(command.to_string()).or_default().record(duration); + } } /// Get stats for all commands, sorted by total time descending. @@ -187,6 +197,7 @@ impl Default for CommandTimings { /// The type of fault to inject into a command. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub enum FaultType { /// Add artificial latency before command execution. Delay { @@ -206,6 +217,7 @@ pub enum FaultType { /// Configuration for a single fault injection rule. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct FaultConfig { /// Target command name. pub command: String, @@ -225,6 +237,19 @@ pub struct FaultConfig { pub const FAULT_TTL: Duration = Duration::from_secs(900); // 15 minutes impl FaultConfig { + /// Creates a fault rule for `command`, created now with zero triggers so far. + /// `max_triggers == 0` means unlimited (until [`FAULT_TTL`] expires it). + #[must_use] + pub fn new(command: impl Into, fault_type: FaultType, max_triggers: u64) -> Self { + Self { + command: command.into(), + fault_type, + trigger_count: 0, + max_triggers, + created_at: Instant::now(), + } + } + /// Whether this fault should still trigger, evaluated at `now`. A fault is /// inert once it is older than [`FAULT_TTL`] or has hit `max_triggers`. #[must_use] @@ -242,9 +267,29 @@ impl FaultConfig { } } +/// Most fault rules held at once (G-10). Rules are keyed by the caller-chosen command name; at +/// the cap, injecting a rule for a NEW command evicts the oldest-injected rule. +pub(crate) const MAX_FAULT_RULES: usize = 256; + /// Thread-safe registry of active fault injection rules. pub struct FaultRegistry { - inner: RwLock>, + inner: RwLock, +} + +#[derive(Default)] +struct FaultRules { + /// Injection order, so eviction at the cap is deterministic (oldest first). + next_seq: u64, + map: HashMap, +} + +impl FaultRules { + /// Drop rules past [`FAULT_TTL`]: they can never trigger again, and were kept (and listed) + /// forever before (G-10). + fn evict_expired(&mut self, now: Instant) { + self.map + .retain(|_, (_, c)| now.saturating_duration_since(c.created_at) < FAULT_TTL); + } } impl FaultRegistry { @@ -252,27 +297,42 @@ impl FaultRegistry { #[must_use] pub fn new() -> Self { Self { - inner: RwLock::new(HashMap::new()), + inner: RwLock::new(FaultRules::default()), } } - /// Register a fault for a command. + /// Register a fault for a command (replacing any rule for the same command). Expired rules + /// are evicted first; if the registry still holds its maximum (256) rules, the + /// oldest-injected rule is evicted to make room. pub fn inject(&self, config: FaultConfig) { - let mut map = self + let mut rules = self .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.insert(config.command.clone(), config); + rules.evict_expired(Instant::now()); + if !rules.map.contains_key(&config.command) && rules.map.len() >= MAX_FAULT_RULES { + let oldest = rules + .map + .iter() + .min_by_key(|(_, (seq, _))| *seq) + .map(|(k, _)| k.clone()); + if let Some(oldest) = oldest { + rules.map.remove(&oldest); + } + } + let seq = rules.next_seq; + rules.next_seq += 1; + rules.map.insert(config.command.clone(), (seq, config)); } /// Look up and optionally trigger a fault for a command. /// Returns the fault type if one is active and should trigger. pub fn check_and_trigger(&self, command: &str) -> Option { - let mut map = self + let mut rules = self .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(config) = map.get_mut(command) + if let Some((_, config)) = rules.map.get_mut(command) && config.should_trigger() { config.trigger_count += 1; @@ -281,33 +341,34 @@ impl FaultRegistry { None } - /// List all active fault rules. + /// List the fault rules that have not expired (expired ones are evicted here too). #[must_use] pub fn list(&self) -> Vec { - let map = self + let mut rules = self .inner - .read() + .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.values().cloned().collect() + rules.evict_expired(Instant::now()); + rules.map.values().map(|(_, c)| c.clone()).collect() } /// Remove a fault rule for a command. pub fn clear(&self, command: &str) -> bool { - let mut map = self + let mut rules = self .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.remove(command).is_some() + rules.map.remove(command).is_some() } /// Remove all fault rules. pub fn clear_all(&self) -> usize { - let mut map = self + let mut rules = self .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - let count = map.len(); - map.clear(); + let count = rules.map.len(); + rules.map.clear(); count } } @@ -322,6 +383,7 @@ impl Default for FaultRegistry { /// Describes the shape of a JSON value for contract comparison. #[derive(Debug, Clone, Serialize, PartialEq)] +#[non_exhaustive] pub enum JsonShape { /// null Null, @@ -376,6 +438,7 @@ impl JsonShape { /// A recorded contract baseline for a command's response. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct ContractBaseline { /// Command name. pub command: String, @@ -389,8 +452,29 @@ pub struct ContractBaseline { pub recorded_at: String, } +impl ContractBaseline { + /// Creates a contract baseline for `command`. + #[must_use] + pub fn new( + command: impl Into, + args: serde_json::Value, + shape: JsonShape, + sample: impl Into, + recorded_at: impl Into, + ) -> Self { + Self { + command: command.into(), + args, + shape, + sample: sample.into(), + recorded_at: recorded_at.into(), + } + } +} + /// Differences found when checking a contract against baseline. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct ContractDrift { /// Command name. pub command: String, @@ -406,6 +490,7 @@ pub struct ContractDrift { /// A single field type change between baseline and current. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct TypeChange { /// Dot-separated field path. pub path: String, @@ -504,9 +589,20 @@ fn diff_shapes_inner( } } +/// Most contract baselines held at once (G-10). At the cap, recording a baseline for a NEW +/// command evicts the least recently recorded one (the store never refuses a recording). +pub(crate) const MAX_CONTRACT_BASELINES: usize = 1024; + /// Thread-safe store for IPC contract baselines. pub struct ContractStore { - inner: RwLock>, + inner: RwLock, +} + +#[derive(Default)] +struct ContractBaselines { + /// Recording order, so eviction at the cap is deterministic (least recently recorded). + next_seq: u64, + map: HashMap, } impl ContractStore { @@ -514,47 +610,63 @@ impl ContractStore { #[must_use] pub fn new() -> Self { Self { - inner: RwLock::new(HashMap::new()), + inner: RwLock::new(ContractBaselines::default()), } } - /// Record a baseline for a command. - pub fn record(&self, baseline: ContractBaseline) { - let mut map = self + /// Record a baseline for a command, replacing (and refreshing) any earlier one for it. The + /// store holds at most 1024 baselines: at that cap, recording one for a new command evicts + /// the least recently recorded baseline, whose command is returned. + pub fn record(&self, baseline: ContractBaseline) -> Option { + let mut store = self .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.insert(baseline.command.clone(), baseline); + let mut evicted = None; + if !store.map.contains_key(&baseline.command) && store.map.len() >= MAX_CONTRACT_BASELINES { + evicted = store + .map + .iter() + .min_by_key(|(_, (seq, _))| *seq) + .map(|(k, _)| k.clone()); + if let Some(oldest) = &evicted { + store.map.remove(oldest); + } + } + let seq = store.next_seq; + store.next_seq += 1; + store.map.insert(baseline.command.clone(), (seq, baseline)); + evicted } /// Get the baseline for a command. #[must_use] pub fn get(&self, command: &str) -> Option { - let map = self + let store = self .inner .read() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.get(command).cloned() + store.map.get(command).map(|(_, b)| b.clone()) } /// Get all baselines. #[must_use] pub fn all(&self) -> Vec { - let map = self + let store = self .inner .read() .unwrap_or_else(std::sync::PoisonError::into_inner); - map.values().cloned().collect() + store.map.values().map(|(_, b)| b.clone()).collect() } /// Clear all baselines. pub fn clear(&self) -> usize { - let mut map = self + let mut store = self .inner .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - let count = map.len(); - map.clear(); + let count = store.map.len(); + store.map.clear(); count } } @@ -569,6 +681,7 @@ impl Default for ContractStore { /// A single phase in the startup timeline. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct StartupPhase { /// Phase name. pub name: String, @@ -651,6 +764,7 @@ impl Default for StartupTimeline { /// A Tauri event captured from the application's native event bus. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct CapturedTauriEvent { /// Event name (e.g. "notification-added", `tauri://focus`). pub name: String, @@ -660,6 +774,22 @@ pub struct CapturedTauriEvent { pub timestamp: String, } +impl CapturedTauriEvent { + /// Creates a captured event record (`timestamp` is an ISO 8601 string). + #[must_use] + pub fn new( + name: impl Into, + payload: impl Into, + timestamp: impl Into, + ) -> Self { + Self { + name: name.into(), + payload: payload.into(), + timestamp: timestamp.into(), + } + } +} + const DEFAULT_EVENT_BUS_CAPACITY: usize = 1000; /// Thread-safe ring buffer for captured Tauri events. @@ -779,13 +909,18 @@ impl AppStateProbes { /// registered under that name. #[must_use] pub fn run(&self, name: &str) -> Option { - let probe = self - .inner + self.get(name).map(|p| p()) + } + + /// The probe registered under `name`, to run somewhere other than the caller's thread + /// (the `app_state` tool runs it on the blocking pool with a timeout). + #[must_use] + pub fn get(&self, name: &str) -> Option> { + self.inner .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(name) - .cloned(); - probe.map(|p| p()) + .cloned() } /// Number of registered probes. @@ -808,6 +943,7 @@ impl AppStateProbes { /// Info about a tracked async task spawned by Victauri. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct TrackedTaskInfo { /// Human-readable task name. pub name: String, @@ -856,6 +992,14 @@ impl TaskTracker { finished } + /// Register a new task and return a guard that marks it finished when DROPPED — at the end + /// of the task body, when the task panics (the drop runs during unwinding), or when its + /// future is dropped unfinished. Setting the flag by hand after the body left a panicked + /// task reported active forever (G-9). + pub(crate) fn track_guarded(&self, name: &str) -> TaskFinishedGuard { + TaskFinishedGuard(self.track(name)) + } + /// List all tracked tasks with their current status. #[must_use] pub fn list(&self) -> Vec { @@ -894,10 +1038,22 @@ impl Default for TaskTracker { } } +/// Marks a tracked task finished when dropped (see [`TaskTracker::track_guarded`]). Move it +/// into the spawned task and hold it for the task's whole body. +#[must_use = "the task is marked finished as soon as the guard is dropped"] +pub(crate) struct TaskFinishedGuard(std::sync::Arc); + +impl Drop for TaskFinishedGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + // ── Child Process Enumeration ────────────────────────────────────────── /// Information about a child process of the Tauri application. #[derive(Debug, Clone, Serialize)] +#[non_exhaustive] pub struct ChildProcessInfo { /// Process ID. pub pid: u32, @@ -1187,10 +1343,323 @@ fn enumerate_children_macos(parent_pid: u32) -> Vec { children } +// ── Page loads ───────────────────────────────────────────────────────────── + +/// Each window's latest bridge ready signal — sent whenever its JS bridge (re)initializes, i.e. +/// on every page load or reload — with the nonce identifying that page load. An eval running in +/// a page that has since been replaced can never deliver its result, so a ready signal from a +/// DIFFERENT page lets the caller fail fast ("the page reloaded while the call was in flight") +/// instead of waiting out the full timeout. +#[derive(Default)] +pub(crate) struct PageLoads { + last_load: std::sync::Mutex>, + seq: std::sync::atomic::AtomicU64, + changed: tokio::sync::Notify, +} + +/// One recorded ready signal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PageLoad { + /// Increases with every ready signal (from any window), so a caller can tell "a new signal + /// since I looked" from "the same one". + pub seq: u64, + /// The page-load nonce the signal carried (`None` from a bridge that sends none). + pub nonce: Option, +} + +/// Longest nonce kept from a ready signal (the bridge's is a UUID; the signal is page-callable). +const MAX_PAGE_NONCE_LEN: usize = 128; + +/// Most windows whose latest page load is remembered (G-10: an entry per window label ever seen +/// was kept forever). At the cap, the window that loaded least recently is forgotten. +const MAX_PAGE_LOAD_WINDOWS: usize = 256; + +impl PageLoads { + /// Record that window `label` sent a ready signal for the page identified by `nonce`. + pub fn record_load(&self, label: &str, nonce: Option<&str>) { + let nonce = nonce.map(|n| n.chars().take(MAX_PAGE_NONCE_LEN).collect()); + { + let mut loads = self + .last_load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Numbered under the lock, so a window's stored seq only ever grows. + let seq = self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + if !loads.contains_key(label) && loads.len() >= MAX_PAGE_LOAD_WINDOWS { + let oldest = loads + .iter() + .min_by_key(|(_, load)| load.seq) + .map(|(k, _)| k.clone()); + if let Some(oldest) = oldest { + loads.remove(&oldest); + } + } + loads.insert(label.to_string(), PageLoad { seq, nonce }); + } + self.changed.notify_waiters(); + } + + /// Window `label`'s latest ready signal, if any. + #[must_use] + pub fn latest(&self, label: &str) -> Option { + self.last_load + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(label) + .cloned() + } + + /// The sequence number of the most recent ready signal from any window (0 if none yet). + #[must_use] + pub fn current_seq(&self) -> u64 { + self.seq.load(std::sync::atomic::Ordering::Relaxed) + } + + /// Resolves the next time any window records a page load. + pub fn changed(&self) -> tokio::sync::futures::Notified<'_> { + self.changed.notified() + } +} +// ── Recording drain watermarks ───────────────────────────────────────────── + +/// Per-window drain positions for pulling the JS bridge's event stream into a recording, +/// shared by the background drain loop and `recording flush`. +/// +/// Each kept its own watermark before, so a flush re-recorded what the drain had captured +/// (and vice versa). A position is the bridge's per-page event SEQUENCE (plus the page +/// instance it belongs to), not a wall-clock timestamp: a timestamp watermark clamped to the +/// Rust clock re-read an event stamped ahead of it on every drain, and lost events pushed in +/// the same millisecond after a read. Positions are reset whenever a recording starts or is +/// imported — the new epoch is the recorder generation, and a window's first read in it skips +/// the page's pre-recording history by the `floor_ms` timestamp. A per-window async lock +/// serializes the two readers for the same window. +#[derive(Default)] +pub(crate) struct DrainWatermarks { + inner: std::sync::Mutex, +} + +#[derive(Default)] +struct WatermarkState { + floor_ms: f64, + epoch: u64, + per_label: HashMap, + locks: HashMap>>, +} + +/// How far a window's event stream has been drained: the bridge's page `instance` and the +/// last sequence number read from it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DrainMark { + /// Identifies one page load of the window (the sequence restarts on reload). + pub instance: String, + /// The last sequence number read from that page. + pub seq: u64, +} + +/// Where the next drain of a window starts, captured in one step. +#[derive(Debug, Clone)] +pub(crate) struct DrainCursor { + /// The recording epoch (recorder generation) this position belongs to. + pub epoch: u64, + /// Entries completed at or before this time predate the recording (first read only). + pub floor_ms: f64, + /// The window's position, or `None` if it has not been read in this epoch. + pub mark: Option, +} + +impl DrainWatermarks { + fn state(&self) -> std::sync::MutexGuard<'_, WatermarkState> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Where to read `label`'s stream from. + #[must_use] + pub fn cursor(&self, label: &str) -> DrainCursor { + let s = self.state(); + DrainCursor { + epoch: s.epoch, + floor_ms: s.floor_ms, + mark: s.per_label.get(label).cloned(), + } + } + + /// Record that `label` was read up to `mark`, if `epoch` is still the current one (a read + /// that straddled a reset must not plant an old position in the new epoch). Returns + /// whether the position was stored. + pub fn advance(&self, label: &str, epoch: u64, mark: DrainMark) -> bool { + let mut s = self.state(); + if s.epoch != epoch { + return false; + } + s.per_label.insert(label.to_string(), mark); + true + } + + /// Start recording epoch `epoch`: every window is read afresh, skipping entries at or + /// before `floor_ms`. Monotonic: epochs are recorder generations (strictly increasing), so a + /// reset for an epoch at or below the current one is a stale caller — a `start` whose + /// handler ran late — and is ignored (R4-RACE1). + pub fn reset(&self, floor_ms: f64, epoch: u64) { + let mut s = self.state(); + if epoch <= s.epoch { + return; + } + s.floor_ms = floor_ms; + s.epoch = epoch; + s.per_label.clear(); + } + + /// Forget windows that no longer exist (bounds the maps across ephemeral windows). + pub fn retain(&self, labels: &[String]) { + let mut s = self.state(); + s.per_label.retain(|l, _| labels.contains(l)); + s.locks.retain(|l, _| labels.contains(l)); + } + + /// The lock that serializes readers of `label`'s stream. + #[must_use] + pub fn lock_for(&self, label: &str) -> std::sync::Arc> { + std::sync::Arc::clone(self.state().locks.entry(label.to_string()).or_default()) + } +} #[cfg(test)] mod tests { use super::*; + /// G-10: expired fault rules were never evicted, and nothing bounded the number of rules. + #[test] + fn fault_registry_evicts_expired_rules_and_is_bounded() { + let reg = FaultRegistry::new(); + let mut expired = FaultConfig::new("old", FaultType::Drop, 0); + expired.created_at = Instant::now() + .checked_sub(FAULT_TTL + Duration::from_secs(1)) + .expect("clock far enough from boot"); + reg.inject(expired); + reg.inject(FaultConfig::new("fresh", FaultType::Drop, 0)); + let listed: Vec = reg.list().into_iter().map(|f| f.command).collect(); + assert_eq!( + listed, + vec!["fresh".to_string()], + "expired rule still listed" + ); + assert_eq!( + reg.inner.read().unwrap().map.len(), + 1, + "expired rule still stored" + ); + + for i in 0..300 { + reg.inject(FaultConfig::new(format!("cmd-{i}"), FaultType::Drop, 0)); + } + let stored = reg.inner.read().unwrap().map.len(); + assert_eq!(stored, 256, "fault rules must be capped"); + // The newest rule is kept; the oldest ones were evicted first. + assert!(reg.check_and_trigger("cmd-299").is_some()); + assert!(reg.check_and_trigger("fresh").is_none()); + // Re-injecting an existing command at the cap replaces it without evicting another. + reg.inject(FaultConfig::new("cmd-299", FaultType::Drop, 5)); + assert_eq!(reg.inner.read().unwrap().map.len(), 256); + } + + /// G-10: the contract store had no cap. At the cap the OLDEST baseline is evicted (and + /// named), rather than refusing the new one. + #[test] + fn contract_store_is_bounded_evicting_the_oldest() { + let store = ContractStore::new(); + let baseline = + |c: &str| ContractBaseline::new(c, serde_json::Value::Null, JsonShape::Null, "", "t"); + for i in 0..1030 { + store.record(baseline(&format!("cmd-{i}"))); + } + assert_eq!(store.all().len(), 1024); + assert!( + store.get("cmd-0").is_none(), + "the oldest baseline goes first" + ); + assert!(store.get("cmd-5").is_none()); + assert!(store.get("cmd-6").is_some()); + assert!(store.get("cmd-1029").is_some()); + // Re-recording an existing command refreshes it in place: nothing is evicted. + store.record(baseline("cmd-6")); + assert_eq!(store.all().len(), 1024); + assert!(store.get("cmd-7").is_some()); + // The refreshed baseline is now the newest, so the next eviction takes cmd-7. + store.record(baseline("cmd-new")); + assert!(store.get("cmd-6").is_some()); + assert!(store.get("cmd-7").is_none()); + } + + /// G-10: `PageLoads` kept an entry for every window label ever seen. + #[test] + fn page_loads_are_bounded_keeping_the_most_recent_windows() { + let loads = PageLoads::default(); + for i in 0..300 { + loads.record_load(&format!("win-{i}"), Some("n")); + } + assert_eq!(loads.last_load.lock().unwrap().len(), 256); + assert!(loads.latest("win-299").is_some()); + assert!( + loads.latest("win-0").is_none(), + "the least recently loaded window goes first" + ); + // A window that loads again becomes the most recent and survives the next eviction. + loads.record_load("win-44", Some("n2")); + loads.record_load("win-new", None); + assert!(loads.latest("win-44").is_some()); + assert!(loads.latest("win-45").is_none()); + } + + /// R4-RACE1: a delayed reset for an OLDER recording generation moved the epoch backwards, + /// so the drain for the current recording (which captured the newer epoch) had every + /// `advance` refused and the recording silently captured no webview events. + #[test] + fn drain_watermark_reset_is_monotonic() { + let w = DrainWatermarks::default(); + w.reset(200.0, 2); + let mark = DrainMark { + instance: "page-a".to_string(), + seq: 7, + }; + assert!(w.advance("main", 2, mark.clone())); + // The reset for generation 1 (an earlier `start` whose handler was delayed) lands late. + w.reset(100.0, 1); + let c = w.cursor("main"); + assert_eq!( + c.epoch, 2, + "a stale reset must not move the epoch backwards" + ); + assert!((c.floor_ms - 200.0).abs() < f64::EPSILON); + assert_eq!(c.mark, Some(mark)); + // The current recording's drain keeps advancing. + assert!(w.advance( + "main", + 2, + DrainMark { + instance: "page-a".to_string(), + seq: 9, + } + )); + // A newer generation still resets. + w.reset(300.0, 3); + let c = w.cursor("main"); + assert_eq!((c.epoch, c.mark), (3, None)); + } + + // C15e: the map is keyed by the caller-chosen command name, so it must be bounded. + #[test] + fn command_timings_bound_distinct_commands() { + let t = CommandTimings::new(); + for i in 0..(MAX_TIMED_COMMANDS + 100) { + t.record(&format!("cmd-{i}"), Duration::from_millis(1)); + } + assert_eq!(t.all_stats().len(), MAX_TIMED_COMMANDS); + // Commands already tracked keep accumulating once the cap is reached. + t.record("cmd-0", Duration::from_millis(1)); + assert_eq!(t.stats_for("cmd-0").unwrap().count, 2); + } + #[test] fn p95_is_nearest_rank_not_max_for_small_samples() { let t = CommandTimings::new(); @@ -1288,6 +1757,32 @@ mod tests { assert!(bus.is_empty()); } + /// G-9: the finished flag was only set when a task body returned normally, so a panicked + /// task was reported active forever. The guard sets it on unwind (and on a dropped future). + #[test] + fn a_panicking_tracked_task_is_reported_finished() { + let tracker = std::sync::Arc::new(TaskTracker::new()); + let guard = tracker.track_guarded("event_drain_loop"); + let r = std::thread::spawn(move || { + let _finished = guard; + panic!("task blew up"); + }) + .join(); + assert!(r.is_err()); + assert_eq!( + tracker.active_count(), + 0, + "a panicked task is still reported active" + ); + assert!(tracker.list()[0].is_finished); + + // A task that is still running stays active until its guard goes. + let running = tracker.track_guarded("mcp_server"); + assert_eq!(tracker.active_count(), 1); + drop(running); + assert_eq!(tracker.active_count(), 0); + } + #[test] fn task_tracker_lifecycle() { let tracker = TaskTracker::new(); diff --git a/crates/victauri-plugin/src/js_bridge.rs b/crates/victauri-plugin/src/js_bridge.rs index 0fec6059..93fd3152 100644 --- a/crates/victauri-plugin/src/js_bridge.rs +++ b/crates/victauri-plugin/src/js_bridge.rs @@ -54,7 +54,188 @@ pub fn init_script(caps: &BridgeCapacities) -> String { // release — it silently drifted (stuck at 0.7.8 through 0.7.10), so `get_diagnostics` // reported a stale `bridge_version` and the startup self-check logged a false // "Bridge version mismatch" on every launch. Deriving it here makes drift impossible. - + &INIT_SCRIPT_BODY.replace("__VICTAURI_BRIDGE_VERSION__", env!("CARGO_PKG_VERSION")) + + &INIT_SCRIPT_BODY + .replace("__VICTAURI_BRIDGE_VERSION__", env!("CARGO_PKG_VERSION")) + .replace("__VICTAURI_AGENT_KEY__", agent_key()) +} + +/// Per-process secret that unlocks the bridge's agent-only operations (clearing logs and route +/// rules, dialog auto-responses, animation scrub / sweep recording). It is embedded in the init script's closure and in the +/// scripts Victauri itself injects, never in anything page script can read, so a page cannot +/// silently remove the agent's block/mock rules or erase captured evidence. +#[doc(hidden)] +#[must_use] +pub fn agent_key() -> &'static str { + static KEY: std::sync::LazyLock = + std::sync::LazyLock::new(|| uuid::Uuid::new_v4().simple().to_string()); + KEY.as_str() +} + +/// JS expression evaluating to the bridge's agent-only operations object (or `undefined` when +/// the bridge is not loaded). Only valid inside scripts Victauri injects. `pub` only so the +/// jsdom suite can build the real agent snippets. +#[doc(hidden)] +#[must_use] +pub fn agent_ops_js() -> String { + format!("window.__VICTAURI__?._agent(\"{}\")", agent_key()) +} + +/// Eval-wrapper code that calls one agent-only bridge operation, `call` (e.g. +/// `scrubSeek(0.5)`), awaiting a returned promise; an object with `error` when the bridge is +/// not loaded. Used by the `animation` tool. `pub` only so the jsdom suite can run the real +/// snippets. +#[doc(hidden)] +#[must_use] +pub fn agent_op_call_js(call: &str) -> String { + format!( + "return await (function () {{ var o = {}; \ + return o ? o.{call} : {{ error: 'the Victauri bridge is not loaded in this page' }}; }})()", + agent_ops_js() + ) +} + +// ── Eval scripts ───────────────────────────────────────────────────────────── +// +// The scripts an agent eval injects, in order: the liveness probe, then the wrapper around the +// user code, then the parse check. Internal plumbing (`pub` only so the jsdom suite can drive +// the real scripts); ids are Victauri-generated UUIDs and nonces come from the bridge. + +fn js_literal(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "null".to_string()) +} + +/// The pre-eval liveness probe: answers `"probe_ok:"` (just `"probe_ok"` without a +/// bridge). The id follows `id:` with no space, unlike the wrapper's `id: `. The callback args +/// have no prototype, so a page's `Object.prototype.toJSON` cannot break their serialization +/// (R5-JS2). +#[doc(hidden)] +#[must_use] +pub fn eval_probe_script(id: &str) -> String { + format!( + "(async()=>{{var v=window.__VICTAURI__;\ + var n=(v&&typeof v._pageNonce==='string')?':'+v._pageNonce:'';\ + await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',\ + Object.assign(Object.create(null),{{id:{},result:'\"probe_ok'+n+'\"'}}));}})();", + js_literal(id) + ) +} + +/// The page nonce reported by a liveness-probe answer (`None` for a page without a nonce). +#[doc(hidden)] +#[must_use] +pub fn probe_answer_nonce(raw: &str) -> Option { + let answer: String = serde_json::from_str(raw).ok()?; + answer + .strip_prefix("probe_ok:") + .filter(|n| !n.is_empty()) + .map(str::to_string) +} + +/// The wrapper that runs `code` (already `return`-prefixed as needed) and settles its outcome +/// exactly once through the bridge. +#[doc(hidden)] +#[must_use] +pub fn eval_wrapper_script(id: &str, code: &str) -> String { + let id_js = js_literal(id); + // Code carrying the agent key (only Victauri's own agent-op snippets — the key is a + // per-process secret) runs in a STRICT wrapper. In a sloppy one a page hook on anything the + // snippet touches (a `then` getter consulted when the result settles, a built-in an op calls) + // reaches the wrapper through `hook.caller` or V8 stack-frame `getFunction()`, and its source + // holds the key (R4-JS2). Strict functions are censored from both. User code keeps sloppy + // semantics (implicit globals etc.), so strictness is applied only where the key is. + let strict = if code.contains(agent_key()) { + "'use strict';" + } else { + "" + }; + // `{code}` is followed by a NEWLINE so a trailing `// comment` in the user code cannot + // comment out the rest of the wrapper (it used to turn every such eval into a parse error). + // `_evalBegin` runs synchronously when the script is evaluated, before the parse check. + format!( + r" + (async () => {{ {strict} + const __vic = {{ id: {id_js}, bridge: window.__VICTAURI__ }}; + const __settle = (p) => (__vic.bridge && __vic.bridge._evalSettle) + ? __vic.bridge._evalSettle(__vic.id, p) + : window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{ + id: __vic.id, result: JSON.stringify(p) + }}); + if (__vic.bridge && __vic.bridge._evalBegin && !__vic.bridge._evalBegin(__vic.id)) return; + try {{ + const __result = await (async () => {{ {code} + }})(); + const __type = __result === undefined ? 'undefined' + : __result === null ? 'null' : 'value'; + const __val = __type === 'value' ? __result : null; + await __settle({{ __victauri_ok: __val, __victauri_type: __type }}); + }} catch (e) {{ + await __settle({{ __victauri_err: (function (x) {{ + // A Tauri command's `Err(serde struct)` rejects with a plain object: + // ``String(obj)`` would collapse it to '[object Object]'. + if (x && typeof x.message === 'string') return x.message; + if (typeof x === 'string') return x; + try {{ var s = JSON.stringify(x); if (s !== undefined) return s; }} catch (_) {{}} + try {{ return String(x); }} catch (_) {{ return Object.prototype.toString.call(x); }} + }})(e) }}); + }} + }})(); + " + ) +} + +/// The parse check, delivered right after the wrapper to the same window. It reports "did not +/// begin executing" only if the wrapper never started in the page the eval was armed in +/// (`nonce`, from the liveness probe; `None` disables the check). +#[doc(hidden)] +#[must_use] +pub fn eval_check_script(id: &str, nonce: Option<&str>) -> String { + format!( + "(function () {{ var v = window.__VICTAURI__; \ + if (v && v._evalCheck) v._evalCheck({}, {}); }})();", + js_literal(id), + nonce.map_or_else(|| "null".to_string(), js_literal) + ) +} + +/// The script the in-app `victauri_eval_js` command (a page-originated eval) injects: runs +/// `code` and delivers its result to `victauri_eval_callback` under `id_json` (a JSON string +/// literal). `pub` only so the jsdom suite can drive the real script. +#[doc(hidden)] +#[must_use] +pub fn page_eval_script(id_json: &str, code: &str) -> String { + // The result body is built by the bridge's `_pageEvalBody` (the `JSON.stringify` captured at + // init, blind to a `toJSON` planted on the universal prototypes, and never throwing), and the + // callback args have no prototype, so Tauri's own serialization of them cannot hit a planted + // `toJSON` either (R5B-PRISTINE1, mirroring R5-JS2 for agent evals). Without the bridge it + // falls back to the page's JSON, as before. Result format is unchanged: the value's JSON + // (`null` for `undefined`), or `{"__error": message}`. + format!( + r" + (async () => {{ + const __vb = window.__VICTAURI__; + const __body = (__vb && __vb._pageEvalBody) + ? __vb._pageEvalBody + : (v, isErr) => isErr + ? JSON.stringify({{ __error: v && v.message }}) + : (JSON.stringify(v) ?? 'null'); + const __send = (body) => {{ + const a = Object.create(null); + a.id = {id_json}; + a.result = body; + return window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', a); + }}; + let __out; + try {{ + const __result = await (async () => {{ {code} + }})(); + __out = __body(__result, false); + }} catch (e) {{ + __out = __body(e, true); + }} + await __send(__out); + }})(); + " + ) } /// The body of the init script (after capacity variable declarations). @@ -140,12 +321,34 @@ const INIT_SCRIPT_BODY: &str = r#" // the URL is a Tauri IPC URL, else null. var IPC_PREFIXES = ['http://ipc.localhost/', 'ipc://localhost/']; function ipcCommandPath(url) { + if (typeof url !== 'string') { + try { url = String(url); } catch (e) { return null; } + } for (var pi = 0; pi < IPC_PREFIXES.length; pi++) { if (url.indexOf(IPC_PREFIXES[pi]) === 0) return url.substring(IPC_PREFIXES[pi].length); } return null; } function isIpcUrl(url) { return ipcCommandPath(url) !== null; } + // The URL as stored in the network / route-match logs (R5B-DATAURL1). A log holds up to + // CAP_NETWORK entries, so keeping every URL verbatim let an app that fetches large `data:` / + // `blob:`-sized URLs pin CAP_NETWORK × their size (1000 × 1 MB = 1 GB). A longer URL is cut + // with a length marker; matching and IPC parsing always see the full URL. + var MAX_LOGGED_URL = 2048; + function loggedUrl(url) { + if (typeof url !== 'string' || url.length <= MAX_LOGGED_URL || isIpcUrl(url)) return url; + var head = truncText(url, MAX_LOGGED_URL); + return head + '…[+' + (url.length - head.length) + ' chars]'; + } + // Victauri's own IPC (plugin:victauri|*). Decided from the parsed IPC command path — NOT a + // substring anywhere in the URL, which let any page request containing + // "plugin%3Avictauri%7C" in a query string escape route rules and network logging. + function isVictauriInternalUrl(url) { + var p = ipcCommandPath(url); + if (p === null) return false; + try { p = decodeURIComponent(p); } catch (e) {} + return p.indexOf('plugin:victauri|') === 0; + } var navigationLog = []; var dialogLog = []; var interactionLog = []; @@ -160,32 +363,99 @@ const INIT_SCRIPT_BODY: &str = r#" var routeMatchLog = []; var CAP_ROUTE_MATCHES = 200; - // Convert a glob ("*" wildcard) to a RegExp. Other chars are escaped. + // Convert a glob to a RegExp. `*` (any run of characters) is the only wildcard; every other + // character matches itself — including `?`, which used to slip through unescaped as a regex + // quantifier, so `*/api/search?q=*` matched nothing (R5-JS4). function globToRegExp(glob) { - var re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + var re = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); return new RegExp('^' + re + '$'); } + // The element actually hit at (x, y), descending into open shadow roots (which + // `elementFromPoint` retargets to their host). Closed shadow roots stay at the host. + function deepElementFromPoint(doc, x, y) { + var hit = doc.elementFromPoint(x, y); + for (var depth = 0; hit && hit.shadowRoot && depth < 32; depth++) { + var inner = hit.shadowRoot.elementFromPoint(x, y); + if (!inner || inner === hit) break; + hit = inner; + } + return hit; + } + // `a` contains `b` in the flat tree (what is rendered and hit-tested), or is `b`: across + // shadow boundaries (host), and from slotted light-DOM content to the it renders in + // (assignedSlot). Without the slot step, a click on ` +

    Attacker comment text

    + Docs + + +

    Terms

    + ls + + About + "# + .to_string(), + // The page replaces JSON.stringify after the bridge loaded; the tree must not use it. + setup_js: Some( + r#"window.__origStringify = JSON.stringify; + JSON.stringify = function() { return '"FORGED"\n[e1] button "x"'; };"# + .into(), + ), + tests: vec![case( + "one well-formed line per element", + r#" + var tree = window.__VICTAURI__.snapshot('compact').tree; + JSON.stringify = window.__origStringify; + var STR = /"(?:[^"\\\n\u2028\u2029\u0085]|\\.)*"/.source; + var TOK = /[A-Za-z0-9_\-.:\/#?&=%+~,]+/.source; + var VAL = '(?:' + TOK + '|' + STR + ')'; + var WORD = '(?:[a-z][a-z0-9-]*|' + STR + ')'; + var LINE = new RegExp('^(?: )*\\[e\\d+\\] ' + WORD + '(?: role=' + STR + ')?(?: ' + STR + + ')?(?: \\[disabled\\])?(?: value=' + STR + ')?(?: @' + VAL + ')?(?: type=' + VAL + + ')?(?: href=' + VAL + ')?$'); + var lines = tree.split('\n').filter(function(l) { return l.length; }); + var bad = lines.filter(function(l) { return !LINE.test(l); }); + var refs = lines.map(function(l) { return (l.match(/\[(e\d+)\]/) || [])[1]; }); + var stripped = lines.map(function(l) { return l.replace(new RegExp(STR, 'g'), '""'); }).join('\n'); + function count(s, sub) { return s.split(sub).length - 1; } + var docsLine = lines.filter(function(l) { return l.indexOf('"Docs"') !== -1; })[0] || ''; + return { + tree: tree, + bad: bad, + lines: lines.length, + elements: document.body.querySelectorAll('*').length + 1, + unique_refs: new Set(refs).size === lines.length, + href_fields: count(stripped, ' href='), + type_fields: count(stripped, ' type='), + testid_fields: count(stripped, ' @'), + value_fields: count(stripped, ' value='), + docs_href_is_real: /href=https:\/\/evil\.example\/steal$/.test(docsLine), + forged: tree.indexOf('FORGED') !== -1, + raw_line_separator: /[\u2028\u2029\u0085]/.test(tree), + }; + "#, + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + let tree = r["tree"].as_str().unwrap_or_default(); + assert_eq!( + r["bad"], + serde_json::json!([]), + "malformed lines in:\n{tree}" + ); + assert_eq!(r["lines"], r["elements"], "one line per element:\n{tree}"); + assert_eq!(r["unique_refs"], true, "{tree}"); + assert_eq!(r["href_fields"], 2, "{tree}"); + assert_eq!(r["type_fields"], 1, "{tree}"); + assert_eq!(r["testid_fields"], 3, "{tree}"); + assert_eq!(r["value_fields"], 3, "{tree}"); + assert_eq!(r["docs_href_is_real"], true, "{tree}"); + assert_eq!(r["forged"], false, "{tree}"); + assert_eq!(r["raw_line_separator"], false, "{tree}"); + // Plain tokens stay bare (the documented, common form). + assert!(tree.contains("@plain-id href=/about?x=1"), "{tree}"); +} + +/// Audit V-4: truncation counted UTF-16 code units and could split a surrogate pair; the lone +/// half serialized as an escape `serde_json` rejects, failing the whole tool call. +#[test] +fn truncation_never_splits_surrogate_pairs() { + let html = format!( + r#"Emoji + +

    {c}🎉 and more text here to exceed sixty

    + + e +
    {f}🎉x
    + g + + {i}🎉x + "#, + b = "b".repeat(99), + c = "c".repeat(56), + d = "d".repeat(79), + e = "e".repeat(59), + f = "f".repeat(199), + g = "g".repeat(58), + h = "h".repeat(39), + i = "i".repeat(49), + ); + let def = TestDef { + bridge_script: bridge_script(), + setup_html: html, + setup_js: None, + tests: vec![case( + "every truncation site keeps pairs whole", + r" + console.log('a'.repeat(4095) + '\u{1F600}tail'); + window.__vtestTrustedClick(document.getElementById('i')); + // JSON.stringify escapes a LONE surrogate as \udXXX; a whole pair is emitted raw. + var lone = /\\ud[89a-f][0-9a-f]{2}/i; + function hasLone(v) { return lone.test(JSON.stringify(v)); } + var stream = window.__VICTAURI__.getEventStream(0); + var clicks = stream.filter(function(e) { return e.type === 'dom_interaction'; }); + return { + console: hasLone(window.__VICTAURI__.getConsoleLogs()), + find: hasLone(window.__VICTAURI__.findElements({ css: '#b' })), + compact: hasLone(window.__VICTAURI__.snapshot('compact')), + json: hasLone(window.__VICTAURI__.snapshot('json')), + stream: hasLone(stream), + recorded_click: clicks.length, + }; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + for site in ["console", "find", "compact", "json", "stream"] { + assert_eq!(r[site], false, "lone surrogate from {site}: {r}"); + } + assert_eq!(r["recorded_click"], 1, "trusted click not recorded: {r}"); +} + +/// Audit V-7: page script sharing the window must not be able to forge eval results by planting +/// `toJSON` on Object/Array.prototype, nor learn pending eval ids through hooked built-ins +/// (`Map.prototype.set`, `window.String`, `setTimeout`) and settle them first. +#[test] +fn eval_results_cannot_be_forged_through_prototypes() { + let stub = r" + window.__calls = []; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, args) { + window.__calls.push({ id: args.id, result: args.result }); + return Promise.resolve(null); + } }; + "; + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: Some(stub.into()), + tests: vec![ + case( + "an inherited toJSON cannot rewrite the envelope or the value", + r" + Object.defineProperty(Object.prototype, 'toJSON', { configurable: true, value: function() { + return { __victauri_ok: 'FORGED', __victauri_type: 'value' }; + } }); + Object.defineProperty(Array.prototype, 'toJSON', { configurable: true, value: function() { return 'FORGED'; } }); + function Money() {} + Object.defineProperty(Money.prototype, 'toJSON', { value: function() { return 'USD 5'; } }); + var b = window.__VICTAURI__; + b._evalSettle('id-1', { __victauri_ok: 'real', __victauri_type: 'value' }); + b._evalSettle('id-2', { __victauri_ok: { list: [1, 2], nested: { k: 'v' } }, __victauri_type: 'value' }); + b._evalSettle('id-3', { __victauri_ok: { m: new Money(), d: new Date(0) }, __victauri_type: 'value' }); + delete Object.prototype.toJSON; + delete Array.prototype.toJSON; + return window.__calls.map(function(c) { return c.result; }); + ", + ), + case( + "hooked Map/Set/String/setTimeout never see a pending eval id", + r" + var stolen = []; + var origSet = Map.prototype.set, origAdd = Set.prototype.add, origHas = Set.prototype.has, + origString = window.String, origTimeout = window.setTimeout; + Map.prototype.set = function(k, v) { stolen.push(k); return origSet.call(this, k, v); }; + Set.prototype.add = function(k) { stolen.push(k); return origAdd.call(this, k); }; + Set.prototype.has = function(k) { stolen.push(k); return origHas.call(this, k); }; + window.String = function(x) { stolen.push(x); return origString(x); }; + window.setTimeout = function(fn, ms) { stolen.push('timer'); return origTimeout(fn, ms); }; + var id = '9b2e7c1e-aaaa-4bbb-8ccc-123456789abc'; + window.__VICTAURI__._evalBegin(id); + window.__VICTAURI__._evalCheck(id, window.__VICTAURI__._pageNonce); + stolen.forEach(function(s) { + window.__VICTAURI__._evalSettle(s, { __victauri_ok: 'forged', __victauri_type: 'value' }); + }); + window.__VICTAURI__._evalSettle(id, { __victauri_ok: 'real', __victauri_type: 'value' }); + Map.prototype.set = origSet; Set.prototype.add = origAdd; Set.prototype.has = origHas; + window.String = origString; window.setTimeout = origTimeout; + var mine = window.__calls.filter(function(c) { return c.id === id; }); + return { stolen: stolen.filter(function(s) { return s === id; }).length, + first: mine[0] && mine[0].result, count: mine.length }; + ", + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let settled = results[0].result.clone().unwrap(); + assert_eq!( + settled, + serde_json::json!([ + r#"{"__victauri_ok":"real","__victauri_type":"value"}"#, + r#"{"__victauri_ok":{"list":[1,2],"nested":{"k":"v"}},"__victauri_type":"value"}"#, + r#"{"__victauri_ok":{"m":"USD 5","d":"1970-01-01T00:00:00.000Z"},"__victauri_type":"value"}"#, + ]) + ); + let leak = results[1].result.clone().unwrap(); + assert_eq!(leak["stolen"], 0, "pending eval id leaked: {leak}"); + assert_eq!( + leak["first"], + r#"{"__victauri_ok":"real","__victauri_type":"value"}"# + ); + assert_eq!(leak["count"], 1); +} + +/// Audit V-8: log reads returned entries whose nested request args / response bodies were the +/// LIVE captured objects, so page script rewrote a logged call (amount 10 -> 10000). +#[test] +fn ipc_and_network_log_copies_are_deep() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "mutating a returned entry does not change the log", + r#" + await fetch('http://ipc.localhost/transfer_funds', { method: 'POST', + body: JSON.stringify({ amount: 10, to: 'alice' }), + headers: { 'x-vtest-body': '{"ok":true}' } }); + await new Promise(function(r) { setTimeout(r, 20); }); + var e = window.__VICTAURI__.getIpcLog()[0]; + e.args.amount = 10000; e.args.to = 'mallory'; e.result.ok = false; + var n = window.__VICTAURI__.getNetworkLog()[0]; + n.request_args.amount = 5; n.response_body.ok = 'x'; + var again = window.__VICTAURI__.getIpcLog()[0]; + var net = window.__VICTAURI__.getNetworkLog()[0]; + return { args: again.args, result: again.result, + net_args: net.request_args, net_body: net.response_body }; + "#, + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + assert_eq!(r["args"], serde_json::json!({"amount": 10, "to": "alice"})); + assert_eq!(r["result"], serde_json::json!({"ok": true})); + assert_eq!( + r["net_args"], + serde_json::json!({"amount": 10, "to": "alice"}) + ); + assert_eq!(r["net_body"], serde_json::json!({"ok": true})); +} + +/// Audit V-9: agent-only operations (clearing logs and route rules, dialog auto-responses) and +/// the animation scrub/sweep state must not be reachable from page script. +#[test] +fn agent_only_controls_are_not_page_callable() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "page sees no agent controls; the agent key unlocks them", + r" + var b = window.__VICTAURI__; + var names = ['clearIpcLog','clearConsoleLogs','clearMutationLog','clearNetworkLog', + 'clearDialogLog','setDialogAutoResponse','clearRoute','clearRoutes', + 'scrubPrepare','scrubSeek','scrubRestore','installSweepRecorder','readSweep']; + var exposed = names.filter(function(n) { return n in b; }); + b.addRoute({ pattern: '/blocked', action: 'block' }); + var wrongKey = b._agent('guess'); + var noKey = b._agent(); + var confirmBefore = window.confirm('sure?'); + var ops = b._agent('__AGENT_KEY__'); + var frozen = Object.isFrozen(ops); + var rulesBefore = b.getRouteRules().length; + var cleared = ops.clearRoutes(); + ops.setDialogAutoResponse('confirm', 'accept'); + var confirmAfter = window.confirm('sure?'); + // Sweep/scrub state is closure-held: a page-planted global is ignored. + ops.installSweepRecorder(null); + window.__VICTAURI_SWEEP__ = { sessions: [{ samples: [], timing: {}, keyframes: [] }], stopped: false }; + var sweep = ops.readSweep(false); + return { exposed: exposed, wrong_key: wrongKey, no_key: noKey, frozen: frozen, + rules_before: rulesBefore, removed: cleared.removed, + rules_after: b.getRouteRules().length, + confirm_before: confirmBefore, confirm_after: confirmAfter, + sweep_sessions: sweep.session_count }; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + assert_eq!(r["exposed"], serde_json::json!([]), "{r}"); + assert_eq!(r["wrong_key"], serde_json::Value::Null); + assert_eq!(r["no_key"], serde_json::Value::Null); + assert_eq!(r["frozen"], true); + assert_eq!(r["rules_before"], 1); + assert_eq!(r["removed"], 1); + assert_eq!(r["rules_after"], 0); + assert_eq!(r["confirm_before"], false); + assert_eq!(r["confirm_after"], true); + assert_eq!( + r["sweep_sessions"], 0, + "page-planted sweep state was read: {r}" + ); +} + +/// Audit V-11: a page restored from the back/forward cache is not re-initialized, so a +/// teardown on `pagehide` left console and mutation capture permanently off. +#[test] +fn bfcache_restore_keeps_console_and_mutation_capture() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "persisted pagehide/pageshow keeps capture; pageshow re-installs after a teardown", + r" + // The browser's own (trusted) events: the bridge ignores synthetic ones (R4-JS1). + function fire(type, persisted) { + window.__vtestTrustedPageTransition(type, persisted); + } + function logged(msg) { + return window.__VICTAURI__.getConsoleLogs().some(function(l) { return l.message === msg; }); + } + fire('pagehide', true); + fire('pageshow', true); + console.log('after-bfcache'); + document.body.appendChild(document.createElement('div')); + await new Promise(function(r) { setTimeout(r, 150); }); + var kept = { console: logged('after-bfcache'), + mutations: window.__VICTAURI__.getMutationLog().length > 0 }; + fire('pagehide', false); + // The non-persisted pagehide really tore capture down (the path under test). + var tornDown = window.__VICTAURI__.getConsoleLogs().length === 0 + && window.__VICTAURI__.getMutationLog().length === 0; + fire('pageshow', true); + console.log('after-reinstall'); + document.body.appendChild(document.createElement('div')); + await new Promise(function(r) { setTimeout(r, 150); }); + return { kept: kept, torn_down: tornDown, reinstalled: { console: logged('after-reinstall'), + mutations: window.__VICTAURI__.getMutationLog().length > 0 } }; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + assert_eq!( + r["kept"], + serde_json::json!({"console": true, "mutations": true}), + "{r}" + ); + assert_eq!(r["torn_down"], true, "{r}"); + assert_eq!( + r["reinstalled"], + serde_json::json!({"console": true, "mutations": true}), + "{r}" + ); +} + +/// Audit V-14: record-mode selectors must be valid CSS whatever the id / test id / text holds +/// (React `useId` ids like `:r0:`, quotes, backslashes, newlines, emoji). +/// +/// Grammar: `#` + `CSS.escape(id)`; every quoted value (`[data-testid="…"]`, `[role="…"]`, +/// `[name="…"]`, `:has-text("…")`) is a CSS string body — `\` -> `\\`, `"` -> `\"`, chars +/// < U+0020, U+007F, U+2028, U+2029 -> `\` + lowercase hex + one space; classes are +/// `CSS.escape`d. +#[test] +fn record_mode_selectors_are_escaped_css() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: r#"Rec"# + .to_string(), + setup_js: Some( + r#" + function add(tag, attrs, text) { + var el = document.createElement(tag); + for (var k in attrs) el.setAttribute(k, attrs[k]); + if (text) el.textContent = text; + document.body.appendChild(el); + return el; + } + add('button', { id: ':r0:' }, 'useId'); + add('div', { 'data-testid': 'a"b\\c\nd \u{1F600}' }, 'tid'); + add('div', { role: 'button' }, 'Say "hi" \\ now\nnext \u{1F600}'); + add('span', {}, 'He said "go"'); + add('input', { name: 'q"x' }); + add('div', { class: 'md:flex w-1/2' }, 'cls'); + add('button', { id: '1st' }, 'digit'); + "# + .into(), + ), + tests: vec![case( + "recorded selectors", + r" + var els = document.body.children; + var out = []; + for (var i = 0; i < els.length; i++) { + window.__vtestTrustedClick(els[i]); + var ev = window.__VICTAURI__.getEventStream(0) + .filter(function(e) { return e.type === 'dom_interaction'; }); + var sel = ev[ev.length - 1].selector; + // The CSS part (before any :has-text) must select exactly this element. + var css = sel.split(':has-text(')[0]; + var matched = null; + try { matched = document.querySelector(css) === els[i]; } catch (e) { matched = 'throws: ' + e.message; } + out.push({ selector: sel, css_matches: matched }); + } + return out; + ", + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = results[0].result.clone().unwrap(); + let want = [ + r"#\:r0\:", + "[data-testid=\"a\\\"b\\\\c\\a d \u{1F600}\"]", + "[role=\"button\"]:has-text(\"Say \\\"hi\\\" \\\\ now\\a next \u{1F600}\")", + "span:has-text(\"He said \\\"go\\\"\")", + "input[name=\"q\\\"x\"]", + r"div.md\:flex.w-1\/2", + r"#\31 st", + ]; + for (i, w) in want.iter().enumerate() { + assert_eq!(r[i]["selector"], *w, "selector {i}: {r}"); + assert_eq!( + r[i]["css_matches"], true, + "selector {i} is not valid CSS for its element: {r}" + ); + } +} + +// ── Eval lifecycle: the real probe / wrapper / parse-check scripts ──────────── + +/// JS helpers for driving the eval scripts: a Tauri `invoke` stub that records callbacks, +/// `run(script)` (global eval; returns the thrown error's name), and the scripts themselves +/// as `S.` (check scripts are built for the placeholder nonce `__NONCE__`). +fn eval_lifecycle_prelude(scripts: &[(&str, String)]) -> String { + let mut js = String::from( + r#" + var calls = []; + window.__TAURI_INTERNALS__ = { invoke: function(cmd, a) { calls.push(a); return Promise.resolve(null); } }; + function cb(id) { return calls.filter(function(c) { return c.id === id; }).map(function(c) { return c.result; }); } + function run(s) { try { (0, eval)(s); return null; } catch (e) { return e.name; } } + function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } + var N = window.__VICTAURI__._pageNonce; + function inPage(s, nonce) { return s.replace('"__NONCE__"', JSON.stringify(nonce === undefined ? N : nonce)); } + var S = {}; + "#, + ); + for (name, script) in scripts { + js.push_str(&format!( + "S[{}] = {};\n", + serde_json::to_string(name).unwrap(), + serde_json::to_string(script).unwrap() + )); + } + js +} + +#[test] +#[allow(clippy::too_many_lines)] +fn eval_lifecycle_has_no_timer_race_and_settles_once() { + use victauri_plugin::js_bridge::{ + eval_check_script, eval_probe_script, eval_wrapper_script, probe_answer_nonce, + }; + let order = "window.__orders = (window.__orders || 0) + 1; return 'order placed'"; + let prelude = eval_lifecycle_prelude(&[ + ("wrap_a", eval_wrapper_script("id-a", order)), + ("check_a", eval_check_script("id-a", Some("__NONCE__"))), + ("wrap_b", eval_wrapper_script("id-b", "return 1 +")), + ("check_b", eval_check_script("id-b", Some("__NONCE__"))), + ("wrap_c", eval_wrapper_script("id-c", order)), + ("check_c", eval_check_script("id-c", Some("__NONCE__"))), + ("wrap_d", eval_wrapper_script("id-d", "return 1 +")), + ("check_d", eval_check_script("id-d", Some("__NONCE__"))), + ( + "wrap_e", + eval_wrapper_script( + "id-e", + "await new Promise(r => setTimeout(r, 50)); return 7", + ), + ), + ("check_e", eval_check_script("id-e", Some("__NONCE__"))), + ("probe", eval_probe_script("id-p")), + ]); + let with = |code: &str| format!("{prelude}\n{code}"); + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![ + case( + "a parse check delivered late never reports code that ran", + &with( + r" + run(S.wrap_a); + await sleep(900); // the check's main-thread round trip was delayed + run(inPage(S.check_a)); + await sleep(20); + return { cbs: cb('id-a'), orders: window.__orders }; + ", + ), + ), + case( + "a syntax error is reported once and a late settle is dropped", + &with( + r" + var parse = run(S.wrap_b); + run(inPage(S.check_b)); + window.__VICTAURI__._evalSettle('id-b', { __victauri_ok: 1, __victauri_type: 'value' }); + await sleep(20); + return { parse: parse, cbs: cb('id-b') }; + ", + ), + ), + case( + "code delivered after its check was reported never runs", + &with( + r" + run(inPage(S.check_c)); + run(S.wrap_c); + await sleep(20); + return { cbs: cb('id-c'), orders: window.__orders || 0 }; + ", + ), + ), + case( + "a check landing in another page stays silent", + &with( + r" + run(S.wrap_d); + run(inPage(S.check_d, 'another-page')); + await sleep(20); + return { cbs: cb('id-d') }; + ", + ), + ), + case( + "a slow eval is not reported by its check and settles once", + &with( + r" + run(S.wrap_e); + run(inPage(S.check_e)); + var early = cb('id-e').length; + await sleep(120); + return { early: early, cbs: cb('id-e') }; + ", + ), + ), + case( + "the probe reports this page's immutable nonce", + &with( + r" + try { window.__VICTAURI__._pageNonce = 'forged'; } catch (e) {} + run(S.probe); + await sleep(20); + return { answer: cb('id-p')[0], nonce: N, still: window.__VICTAURI__._pageNonce }; + ", + ), + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let r = |i: usize| results[i].result.clone().unwrap(); + + let a = r(0); + assert_eq!( + a["cbs"], + serde_json::json!([r#"{"__victauri_ok":"order placed","__victauri_type":"value"}"#]), + "exactly the real result, no parse error: {a}" + ); + assert_eq!(a["orders"], 1); + + let b = r(1); + assert_eq!(b["parse"], "SyntaxError"); + let cbs = b["cbs"].as_array().unwrap(); + assert_eq!(cbs.len(), 1, "one outcome only: {b}"); + assert!( + cbs[0].as_str().unwrap().contains("__victauri_not_run"), + "{b}" + ); + + let c = r(2); + assert_eq!( + c["orders"], 0, + "code reported as never begun must not run: {c}" + ); + assert_eq!(c["cbs"].as_array().unwrap().len(), 1, "{c}"); + + assert_eq!(r(3)["cbs"], serde_json::json!([])); + + let e = r(4); + assert_eq!(e["early"], 0); + assert_eq!( + e["cbs"], + serde_json::json!([r#"{"__victauri_ok":7,"__victauri_type":"value"}"#]) + ); + + let p = r(5); + let nonce = p["nonce"].as_str().unwrap(); + assert!(!nonce.is_empty()); + assert_eq!(p["still"], nonce, "page script cannot replace the nonce"); + assert_eq!( + probe_answer_nonce(p["answer"].as_str().unwrap()).as_deref(), + Some(nonce) + ); + assert_eq!(probe_answer_nonce(r#""probe_ok""#), None); +} + +#[test] +fn eval_results_json_cannot_carry_are_reported_as_code_that_ran() { + use victauri_plugin::js_bridge::eval_wrapper_script; + let prelude = eval_lifecycle_prelude(&[ + ( + "circular", + eval_wrapper_script("id-1", "window.__ran = 1; var o = {}; o.self = o; return o"), + ), + ("bigint", eval_wrapper_script("id-2", "return 10n")), + ( + "func", + eval_wrapper_script("id-3", "return function f() {}"), + ), + ( + "plain", + eval_wrapper_script("id-4", "return { a: [1, 'x'] }"), + ), + ]); + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![case( + "unserializable results", + &format!( + "{prelude} + run(S.circular); run(S.bigint); run(S.func); run(S.plain); + await sleep(20); + return {{ c: cb('id-1'), b: cb('id-2'), f: cb('id-3'), p: cb('id-4'), ran: window.__ran }}; + " + ), + )], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let v = results[0].result.clone().unwrap(); + let body = |k: &str| -> serde_json::Value { + let arr = v[k].as_array().unwrap(); + assert_eq!(arr.len(), 1, "{k}: {v}"); + serde_json::from_str(arr[0].as_str().unwrap()).unwrap() + }; + assert_eq!(v["ran"], 1); + let c = body("c"); + assert!( + c["__victauri_unserializable"] + .as_str() + .unwrap() + .contains("circular"), + "{c}" + ); + assert!(c.get("__victauri_err").is_none(), "{c}"); + assert!( + body("b")["__victauri_unserializable"] + .as_str() + .unwrap() + .contains("BigInt") + ); + assert!( + body("f")["__victauri_unserializable"] + .as_str() + .unwrap() + .contains("function") + ); + assert_eq!( + body("p"), + serde_json::json!({"__victauri_ok": {"a": [1, "x"]}, "__victauri_type": "value"}) + ); +} + // ── Hardening regressions (bounded capture, never-throw hooks, ref reuse) ──── fn case(name: &str, code: &str) -> TestCase { @@ -5087,11 +6067,29 @@ fn bridge_hardening_regressions() { await fetch('http://ipc.localhost/bad_cmd', { method: 'POST', body: '{}', headers: { 'x-vtest-tauri-response': 'error' } }); var all = window.__VICTAURI__.getEventStream(); - var last = all[all.length - 1].timestamp; + // The watermark a consumer keeps is the newest seq_ts (IPC completion time) + // or timestamp — exactly what the Rust drain computes. + var last = all.reduce(function(m, e) { + var k = (e.seq_ts !== undefined) ? e.seq_ts : e.timestamp; + return k > m ? k : m; + }, 0); var incl = window.__VICTAURI__.getEventStream(last).length; var excl = window.__VICTAURI__.getEventStream(last, true).length; var ipc = all.filter(function(e) { return e.type === 'ipc'; })[0]; - return { incl_nonzero: incl > 0, excl: excl, ipc_status: ipc && ipc.status }; + // A slow call pending at one watermark read is emitted exactly once, when + // it completes (it used to be keyed by start time and never re-read). + var slow = fetch('http://ipc.localhost/slow_cmd', { method: 'POST', body: '{}', + headers: { 'x-vtest-delay-ms': '120' } }); + var during = window.__VICTAURI__.getEventStream(0, true) + .filter(function(e) { return e.command === 'slow_cmd'; }).length; + var mark = Date.now(); + await slow; + await new Promise(function(r) { setTimeout(r, 20); }); + var after = window.__VICTAURI__.getEventStream(mark, true) + .filter(function(e) { return e.command === 'slow_cmd'; }); + return { incl_nonzero: incl > 0, excl: excl, ipc_status: ipc && ipc.status, + slow_during: during, slow_after: after.length, + slow_status: after[0] && after[0].status }; ", ), TestCase { @@ -5183,6 +6181,15 @@ fn bridge_hardening_regressions() { assert_eq!(e["incl_nonzero"], true); assert_eq!(e["excl"], 0); assert_eq!(e["ipc_status"], "error"); + assert_eq!( + e["slow_during"], 0, + "a pending call is not emitted in watermark mode" + ); + assert_eq!( + e["slow_after"], 1, + "a slow call is emitted once when it completes" + ); + assert_eq!(e["slow_status"], "ok"); let rt = r(6); assert_eq!(rt["bad_ok"], false); @@ -5194,3 +6201,42 @@ fn bridge_hardening_regressions() { assert_eq!(x["url_is_string"], "string"); assert_ne!(x["status"], "pending"); } + +// Live-4DA soak finding: during a page reload `document.body` is null, and find_elements +// reported a VALID selector as "invalid CSS selector" (the null TypeError was misattributed). +#[test] +fn find_elements_distinguishes_page_not_ready_from_bad_selector() { + let def = TestDef { + bridge_script: bridge_script(), + setup_html: default_html(), + setup_js: None, + tests: vec![ + case( + "valid selector with no body reports page-not-ready", + r" + document.documentElement.removeChild(document.body); + return window.__VICTAURI__.findElements({ css: 'button' }); + ", + ), + case( + "invalid selector is still reported as invalid", + r"return window.__VICTAURI__.findElements({ css: 'button[' });", + ), + ], + }; + let Some(results) = run_tests(&def) else { + return; + }; + assert_all_pass(&results); + let no_body = results[0].result.as_ref().unwrap()["error"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!(no_body.contains("page not ready"), "{no_body}"); + assert!(!no_body.contains("invalid CSS selector"), "{no_body}"); + let bad = results[1].result.as_ref().unwrap()["error"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!(bad.contains("invalid CSS selector"), "{bad}"); +} diff --git a/crates/victauri-plugin/tests/bridge_tests/run_tests.js b/crates/victauri-plugin/tests/bridge_tests/run_tests.js index 74f75830..5a6b7033 100644 --- a/crates/victauri-plugin/tests/bridge_tests/run_tests.js +++ b/crates/victauri-plugin/tests/bridge_tests/run_tests.js @@ -18,6 +18,10 @@ const fs = require("fs"); const { JSDOM } = require("jsdom"); +const { fireAnEvent } = require("jsdom/lib/jsdom/living/helpers/events.js"); +const { implForWrapper } = require("jsdom/lib/jsdom/living/generated/utils.js"); +const MouseEventIface = require("jsdom/lib/jsdom/living/generated/MouseEvent.js"); +const { fireAPageTransitionEvent } = require("jsdom/lib/jsdom/living/helpers/page-transition-event.js"); const testDefPath = process.argv[2]; if (!testDefPath) { @@ -216,6 +220,12 @@ async function run() { // can update its network log entries. if (!window.fetch) { window.fetch = function (input, init) { + // Native fetch() requires its first argument. + if (arguments.length === 0) { + return Promise.reject( + new TypeError("Failed to execute 'fetch': 1 argument required, but only 0 present."), + ); + } const url = typeof input === "string" ? input @@ -255,6 +265,14 @@ async function run() { }, }; } + // Optional `x-vtest-delay-ms` keeps the call in flight (pending) for a while, to test + // how the bridge reports a slow command across event-stream reads. + var delayMs = Number(reqHeaders["x-vtest-delay-ms"] || 0); + if (delayMs > 0) { + return new Promise(function (res) { + setTimeout(function () { res(makeResponse()); }, delayMs); + }); + } return Promise.resolve(makeResponse()); }; } @@ -296,6 +314,29 @@ async function run() { window.scrollTo = function () {}; } + // Dispatch a TRUSTED (isTrusted === true) click, as a real user click would be — page + // script cannot forge one, so the bridge's record-mode observer ignores synthetic events. + window.__vtestTrustedClick = function (el) { + fireAnEvent("click", implForWrapper(el), MouseEventIface, { + bubbles: true, + cancelable: true, + }); + }; + + // Dispatch a TRUSTED pagehide / pageshow on the window, as the browser does on a real + // unload or back/forward-cache restore (page script can only dispatch untrusted ones). + window.__vtestTrustedPageTransition = function (type, persisted) { + fireAPageTransitionEvent(type, window, !!persisted); + }; + + // Opt-in: expose Node's fetch-API classes (jsdom has none) BEFORE the bridge loads, so a + // test can exercise the bridge's handling of genuine `Request` objects. + if (test.node_web_api || testDef.node_web_api) { + window.Request = Request; + window.Response = Response; + window.Headers = Headers; + } + // Inject the bridge script try { window.eval(testDef.bridge_script); diff --git a/crates/victauri-plugin/tests/common/mod.rs b/crates/victauri-plugin/tests/common/mod.rs index 5ff9e9bb..5c7c8c36 100644 --- a/crates/victauri-plugin/tests/common/mod.rs +++ b/crates/victauri-plugin/tests/common/mod.rs @@ -1,10 +1,8 @@ #![allow(dead_code)] -use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::Mutex; -use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState}; +use victauri_core::WindowState; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; @@ -54,17 +52,17 @@ fn extract_probe_id(script: &str) -> Option { pub fn make_windows(labels: &[&str]) -> Vec { labels .iter() - .map(|label| WindowState { - label: label.to_string(), - title: format!("{label} title"), - url: format!("http://localhost/{label}"), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (0, 0), - size: (800, 600), + .map(|label| { + WindowState::new(label.to_string()) + .with_title(format!("{label} title")) + .with_url(format!("http://localhost/{label}")) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(0, 0) + .with_size(800, 600) }) .collect() } @@ -72,30 +70,7 @@ pub fn make_windows(labels: &[&str]) -> Vec { // ── Test State Constructor ───────────────────────────────────────────────── pub fn test_state() -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: victauri_plugin::privacy::PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), - }) + Arc::new(VictauriState::for_tests()) } // ── SimpleMockBridge ─────────────────────────────────────────────────────── @@ -182,17 +157,17 @@ impl WebviewBridge for RejectingMockBridge { self.labels .iter() .filter(|l| label.is_none() || label == Some(l.as_str())) - .map(|l| WindowState { - label: l.clone(), - title: format!("{l} title"), - url: format!("http://localhost/{l}"), - visible: true, - focused: l == "main", - maximized: false, - minimized: false, - fullscreen: false, - position: (0, 0), - size: (800, 600), + .map(|l| { + WindowState::new(l.clone()) + .with_title(format!("{l} title")) + .with_url(format!("http://localhost/{l}")) + .with_visible(true) + .with_focused(l == "main") + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(0, 0) + .with_size(800, 600) }) .collect() } diff --git a/crates/victauri-plugin/tests/deep_adversarial_tests.rs b/crates/victauri-plugin/tests/deep_adversarial_tests.rs index 20374d9d..61fb828f 100644 --- a/crates/victauri-plugin/tests/deep_adversarial_tests.rs +++ b/crates/victauri-plugin/tests/deep_adversarial_tests.rs @@ -1,11 +1,9 @@ mod common; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use serde_json::json; -use tokio::sync::Mutex; -use victauri_core::{CommandRegistry, EventLog, EventRecorder}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::build_app_stateful; @@ -34,17 +32,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -131,29 +129,10 @@ impl WebviewBridge for CallbackMockBridge { // ═══════════════════════════════════════════════════════════════════════════ fn make_state_with_privacy(privacy: PrivacyConfig) -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = privacy; + s }) } @@ -977,9 +956,10 @@ async fn privacy_command_blocklist_blocks_invoke() { async fn privacy_command_allowlist_restricts_invoke() { let mut allow = HashSet::new(); allow.insert("greet".to_string()); - let config = PrivacyConfig { - command_allowlist: Some(allow), - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.command_allowlist = Some(allow); + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| "\"ok\"".to_string()).await; @@ -1006,10 +986,11 @@ async fn privacy_blocklist_wins_over_allowlist() { allow.insert("save_key".to_string()); let mut block = HashSet::new(); block.insert("save_key".to_string()); - let config = PrivacyConfig { - command_allowlist: Some(allow), - command_blocklist: block, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.command_allowlist = Some(allow); + c.command_blocklist = block; + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| "\"ok\"".to_string()).await; @@ -1031,9 +1012,10 @@ async fn privacy_blocklist_wins_over_allowlist() { #[tokio::test] async fn privacy_redaction_scrubs_eval_output() { - let config = PrivacyConfig { - redaction_enabled: true, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.redaction_enabled = true; + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| { @@ -1248,9 +1230,10 @@ async fn privacy_disable_all_tools() { for tool in &all_tools { disabled.insert(tool.to_string()); } - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = make_state_with_privacy(config); let base = start_server(state, &["main"]).await; @@ -1261,7 +1244,7 @@ async fn privacy_disable_all_tools() { "window" => json!({"action": "list"}), "recording" => json!({"action": "start"}), "logs" => json!({"action": "console"}), - "inspect" => json!({"action": "performance"}), + "inspect" => json!({"action": "get_performance"}), "css" => json!({"action": "inject", "css": "body{}"}), "interact" => json!({"action": "click", "ref_id": "e1"}), "input" => json!({"action": "fill", "ref_id": "e1", "value": "x"}), @@ -1284,9 +1267,10 @@ async fn privacy_disabled_tool_also_disabled_in_rest_listing() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); disabled.insert("screenshot".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = make_state_with_privacy(config); let base = start_server(state, &["main"]).await; @@ -1319,9 +1303,10 @@ async fn privacy_disabled_tool_also_disabled_in_rest_listing() { #[tokio::test] async fn privacy_redaction_on_rest_api() { - let config = PrivacyConfig { - redaction_enabled: true, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.redaction_enabled = true; + c }; let state = make_state_with_privacy(config); let base = start_callback_server(state, &["main"], |_| { @@ -2943,3 +2928,61 @@ async fn delete_to_health_rejected() { resp.status() ); } + +/// Audit N1, end to end over the real HTTP server: in the Test profile `navigate.go_to` is +/// FullControl-only, but `{"action": {"go_to": null}}` (a shape serde accepts for the enum) +/// and a positional REST array body were gated as the bare `navigate` — which Test allows — +/// and the handler then navigated the webview. Neither may reach the bridge now, via REST or +/// MCP, while the string form is still refused by the profile. +#[tokio::test] +async fn tag_shaped_or_positional_action_cannot_bypass_the_profile() { + let navigated = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = Arc::clone(&navigated); + let state = make_state_with_privacy(victauri_plugin::privacy::test_privacy_config()); + let base = start_callback_server(state, &["main"], move |script| { + if script.contains("evil.example") { + seen.store(true, std::sync::atomic::Ordering::SeqCst); + } + "null".to_string() + }) + .await; + let client = reqwest::Client::new(); + for body in [ + r#"{"action":{"go_to":null},"url":"https://evil.example"}"#, + r#"["go_to","https://evil.example",null,null,null,null]"#, + ] { + let resp = rest_call(&client, &base, "navigate", body).await; + let status = resp.status(); + let text = resp.text().await.unwrap(); + assert_eq!(status, 400, "REST {body} must be refused: {status} {text}"); + } + let (mcp, sid) = mcp_session(&base).await; + let body = call_tool( + &mcp, + &base, + &sid, + "navigate", + json!({"action": {"go_to": null}, "url": "https://evil.example"}), + ) + .await; + assert!( + body.contains("must be a string"), + "MCP tag-shaped action must be refused: {body}" + ); + let body = call_tool( + &mcp, + &base, + &sid, + "navigate", + json!({"action": "go_to", "url": "https://evil.example"}), + ) + .await; + assert!( + body.contains("disabled"), + "string go_to stays FullControl-only: {body}" + ); + assert!( + !navigated.load(std::sync::atomic::Ordering::SeqCst), + "the webview was navigated" + ); +} diff --git a/crates/victauri-plugin/tests/integration_tests.rs b/crates/victauri-plugin/tests/integration_tests.rs index 467566c7..f1bd13a9 100644 --- a/crates/victauri-plugin/tests/integration_tests.rs +++ b/crates/victauri-plugin/tests/integration_tests.rs @@ -1,14 +1,11 @@ mod common; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use chrono::Utc; -use tokio::sync::Mutex; -use victauri_core::{ - AppEvent, CommandInfo, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, -}; +use victauri_core::{AppEvent, CommandInfo, EventLog, EventRecorder, IpcCall, IpcResult}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::{ @@ -40,17 +37,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -270,15 +267,15 @@ fn sample_command(name: &str) -> CommandInfo { } fn sample_ipc_call(command: &str, result: IpcResult) -> IpcCall { - IpcCall { - id: uuid::Uuid::new_v4().to_string(), - command: command.to_string(), - timestamp: Utc::now(), - duration_ms: Some(5), + IpcCall::new( + uuid::Uuid::new_v4().to_string(), + command.to_string(), + Utc::now(), result, - arg_size_bytes: 42, - webview_label: "main".to_string(), - } + Some(5), + 42, + "main".to_string(), + ) } // ── Handler construction tests ────────────────────────────────────────────── @@ -1496,29 +1493,10 @@ async fn mcp_read_unknown_resource_fails() { #[tokio::test] async fn state_port_reflected_in_info() { - let state = Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(9999), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + let state = Arc::new({ + let mut s = VictauriState::for_tests(); + s.port = std::sync::atomic::AtomicU16::new(9999); + s }); let bridge: Arc = Arc::new(SimpleMockBridge::new(&["main"])); @@ -1570,29 +1548,12 @@ fn builder_default_port() { #[test] fn builder_custom_port_reflected_in_state() { - let state = Arc::new(VictauriState { - event_log: EventLog::new(500), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(8888), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(500), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + let state = Arc::new({ + let mut s = VictauriState::for_tests(); + s.event_log = EventLog::new(500); + s.port = std::sync::atomic::AtomicU16::new(8888); + s.recorder = EventRecorder::new(500); + s }); assert_eq!(state.port.load(std::sync::atomic::Ordering::Relaxed), 8888); @@ -1675,11 +1636,11 @@ async fn ipc_replay_sequence_returns_only_ipc() { state .recorder .record_event(AppEvent::Ipc(sample_ipc_call("cmd1", IpcResult::Pending))); - state.recorder.record_event(AppEvent::WindowEvent { - label: "main".to_string(), - event: "focus".to_string(), - timestamp: Utc::now(), - }); + state.recorder.record_event(AppEvent::window_event( + "main".to_string(), + "focus".to_string(), + Utc::now(), + )); state .recorder .record_event(AppEvent::Ipc(sample_ipc_call("cmd2", IpcResult::Pending))); @@ -1839,29 +1800,10 @@ fn generate_token_produces_valid_uuid() { // ── Privacy integration tests ───────────────────────────────────────────── fn privacy_state(config: PrivacyConfig) -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: config, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = config; + s }) } @@ -1929,9 +1871,10 @@ async fn privacy_disabled_tools_hidden_from_list() { disabled.insert("screenshot".to_string()); disabled.insert("inject_css".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_test_server(config, &["main"]).await; @@ -1980,9 +1923,10 @@ async fn privacy_disabled_tool_call_rejected() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_test_server(config, &["main"]).await; @@ -2057,9 +2001,10 @@ async fn privacy_strict_mode_disables_dangerous_tools() { async fn privacy_info_shows_privacy_mode() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_test_server(config, &["main"]).await; @@ -4538,17 +4483,9 @@ async fn rest_get_plugin_info_returns_version() { #[tokio::test] async fn rest_get_registry_returns_json_result() { let state = test_state(); - state.registry.register(CommandInfo { - name: "test_cmd".to_string(), - plugin: None, - description: Some("A test command".to_string()), - args: vec![], - return_type: None, - is_async: false, - intent: None, - category: None, - examples: vec![], - }); + state + .registry + .register(CommandInfo::new("test_cmd").with_description("A test command")); let base = start_test_server(state, &["main"]).await; let client = reqwest::Client::new(); diff --git a/crates/victauri-plugin/tests/stress_tests.rs b/crates/victauri-plugin/tests/stress_tests.rs index db969912..a00f7ff9 100644 --- a/crates/victauri-plugin/tests/stress_tests.rs +++ b/crates/victauri-plugin/tests/stress_tests.rs @@ -1,15 +1,10 @@ mod common; -use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::Mutex; - -use victauri_core::{CommandRegistry, EventLog, EventRecorder}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::{build_app_full, build_app_stateful}; -use victauri_plugin::privacy::PrivacyConfig; use common::SimpleMockBridge; @@ -32,17 +27,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -127,30 +122,7 @@ impl WebviewBridge for CallbackMockBridge { // ── Helpers ──────────────────────────────────────────────────────────────── fn test_state() -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), - }) + Arc::new(VictauriState::for_tests()) } async fn start_test_server(state: Arc, labels: &[&str]) -> String { @@ -762,29 +734,10 @@ async fn large_css_injection() { #[tokio::test] async fn strict_privacy_blocks_dangerous_tools() { let config = victauri_plugin::privacy::strict_privacy_config(); - let state = Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: config, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + let state = Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = config; + s }); let base = start_test_server(state, &["main"]).await; let (client, sid) = mcp_session(&base).await; diff --git a/crates/victauri-plugin/tests/tool_contract_tests.rs b/crates/victauri-plugin/tests/tool_contract_tests.rs index 4c92477d..959b2438 100644 --- a/crates/victauri-plugin/tests/tool_contract_tests.rs +++ b/crates/victauri-plugin/tests/tool_contract_tests.rs @@ -1,11 +1,8 @@ mod common; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; -use tokio::sync::Mutex; - -use victauri_core::{CommandRegistry, EventLog, EventRecorder}; use victauri_plugin::VictauriState; use victauri_plugin::bridge::WebviewBridge; use victauri_plugin::mcp::build_app_stateful; @@ -32,17 +29,17 @@ impl CallbackMockBridge { Self { windows: labels .iter() - .map(|label| victauri_core::WindowState { - label: label.to_string(), - title: format!("{label} Window"), - url: "http://localhost/".to_string(), - visible: true, - focused: labels.first() == Some(label), - maximized: false, - minimized: false, - fullscreen: false, - position: (100, 100), - size: (800, 600), + .map(|label| { + victauri_core::WindowState::new(label.to_string()) + .with_title(format!("{label} Window")) + .with_url("http://localhost/".to_string()) + .with_visible(true) + .with_focused(labels.first() == Some(label)) + .with_maximized(false) + .with_minimized(false) + .with_fullscreen(false) + .with_position(100, 100) + .with_size(800, 600) }) .collect(), pending_evals, @@ -127,56 +124,14 @@ impl WebviewBridge for CallbackMockBridge { // ── Helpers ──────────────────────────────────────────────────────────────── fn test_state() -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: PrivacyConfig::default(), - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), - }) + Arc::new(VictauriState::for_tests()) } fn privacy_state(config: PrivacyConfig) -> Arc { - Arc::new(VictauriState { - event_log: EventLog::new(1000), - registry: CommandRegistry::new(), - port: std::sync::atomic::AtomicU16::new(0), - pending_evals: Arc::new(Mutex::new(HashMap::new())), - recorder: EventRecorder::new(1000), - privacy: config, - eval_timeout: std::time::Duration::from_secs(30), - shutdown_tx: tokio::sync::watch::channel(false).0, - started_at: std::time::Instant::now(), - tool_invocations: std::sync::atomic::AtomicU64::new(0), - allow_file_navigation: false, - command_timings: victauri_plugin::introspection::CommandTimings::new(), - fault_registry: victauri_plugin::introspection::FaultRegistry::new(), - contract_store: victauri_plugin::introspection::ContractStore::new(), - startup_timeline: victauri_plugin::introspection::StartupTimeline::new(), - event_bus: victauri_plugin::introspection::EventBusMonitor::default(), - task_tracker: victauri_plugin::introspection::TaskTracker::new(), - bridge_ready: std::sync::atomic::AtomicBool::new(true), - bridge_notify: tokio::sync::Notify::new(), - db_search_paths: Vec::new(), - screencast: std::sync::Arc::new(victauri_plugin::screencast::Screencast::default()), - probes: victauri_plugin::introspection::AppStateProbes::default(), + Arc::new({ + let mut s = VictauriState::for_tests(); + s.privacy = config; + s }) } @@ -1979,7 +1934,7 @@ async fn logs_events_happy_path() { async fn logs_slow_ipc_happy_path() { let state = test_state(); let base = start_callback_server(state, &["main"], |script| { - if script.contains("getIpcLog()") { + if script.contains("getIpcLog(") { r#"[{"command":"slow_cmd","duration_ms":500},{"command":"fast_cmd","duration_ms":5}]"# .to_string() } else { @@ -2024,9 +1979,10 @@ async fn logs_slow_ipc_missing_threshold() { async fn privacy_eval_js_disabled() { let mut disabled = HashSet::new(); disabled.insert("eval_js".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_server(config, &["main"]).await; let (client, sid) = mcp_session(&base).await; @@ -2048,9 +2004,10 @@ async fn privacy_eval_js_disabled() { async fn privacy_screenshot_disabled() { let mut disabled = HashSet::new(); disabled.insert("screenshot".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_server(config, &["main"]).await; let (client, sid) = mcp_session(&base).await; @@ -2065,9 +2022,10 @@ async fn privacy_screenshot_disabled() { async fn privacy_navigate_disabled() { let mut disabled = HashSet::new(); disabled.insert("navigate".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let base = start_privacy_server(config, &["main"]).await; let (client, sid) = mcp_session(&base).await; @@ -2089,9 +2047,10 @@ async fn privacy_navigate_disabled() { async fn privacy_inject_css_disabled() { let mut disabled = HashSet::new(); disabled.insert("inject_css".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = privacy_state(config); let base = start_callback_server(state, &["main"], |_| "null".to_string()).await; @@ -2114,9 +2073,10 @@ async fn privacy_inject_css_disabled() { async fn privacy_fill_disabled() { let mut disabled = HashSet::new(); disabled.insert("fill".to_string()); - let config = PrivacyConfig { - disabled_tools: disabled, - ..Default::default() + let config = { + let mut c = PrivacyConfig::default(); + c.disabled_tools = disabled; + c }; let state = privacy_state(config); let base = start_callback_server(state, &["main"], |_| "null".to_string()).await; @@ -2403,11 +2363,11 @@ async fn wait_for_event_matches_recent_event() { // A valid, far-future timestamp is unconditionally within the look-back window. state .event_bus - .push(victauri_plugin::introspection::CapturedTauriEvent { - name: "analysis-complete".to_string(), - payload: r#"{"scored":437}"#.to_string(), - timestamp: "2099-01-01T00:00:00Z".to_string(), - }); + .push(victauri_plugin::introspection::CapturedTauriEvent::new( + "analysis-complete".to_string(), + r#"{"scored":437}"#.to_string(), + "2099-01-01T00:00:00Z".to_string(), + )); let base = start_test_server(state, &["main"]).await; let body = rest_call( &base, @@ -2432,11 +2392,11 @@ async fn wait_for_event_outside_lookback_times_out() { // An old event predates the default 2s look-back, so it must NOT satisfy the wait. state .event_bus - .push(victauri_plugin::introspection::CapturedTauriEvent { - name: "analysis-complete".to_string(), - payload: "{}".to_string(), - timestamp: "2000-01-01T00:00:00Z".to_string(), - }); + .push(victauri_plugin::introspection::CapturedTauriEvent::new( + "analysis-complete".to_string(), + "{}".to_string(), + "2000-01-01T00:00:00Z".to_string(), + )); let base = start_test_server(state, &["main"]).await; let body = rest_call( &base, @@ -2495,3 +2455,46 @@ async fn app_state_lists_empty_when_no_probes() { "app_state with no probes should still return a probes list: {list}" ); } + +// Red-team M2: a browser page on any localhost origin could fire a cross-origin "simple" +// (text/plain, no-preflight) POST at an auth-disabled server. Browser-originated bodies must be +// JSON; non-browser clients (no Origin header) are unaffected. +#[tokio::test] +async fn browser_simple_post_is_refused_but_json_and_curl_work() { + let base = start_test_server(test_state(), &["main"]).await; + let client = reqwest::Client::new(); + let url = format!("{base}/api/tools/get_plugin_info"); + + let simple = client + .post(&url) + .header("Origin", "http://localhost:3000") + .header("Content-Type", "text/plain") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!( + simple.status(), + 415, + "CORS-simple POST from a page must be refused" + ); + + let json = client + .post(&url) + .header("Origin", "http://localhost:3000") + .header("Content-Type", "application/json; charset=utf-8") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(json.status(), 200); + + let curl_like = client + .post(&url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(curl_like.status(), 200, "non-browser clients keep working"); +} diff --git a/crates/victauri-plugin/tests/trusted_input_probes.rs b/crates/victauri-plugin/tests/trusted_input_probes.rs new file mode 100644 index 00000000..f13fd788 --- /dev/null +++ b/crates/victauri-plugin/tests/trusted_input_probes.rs @@ -0,0 +1,319 @@ +//! The trusted-input probes (R4-IN1 / R4-IN2) run in a real JS engine (jsdom, via the bridge +//! test runner in `tests/bridge_tests/`) against the real bridge script: what they report +//! decides whether OS-level input is sent at all, so their page-side logic is what these +//! tests pin. +//! +//! They live in their own test binary, not the library's unit tests: a child process spawned +//! on Windows inherits the sockets open in the spawning process at that moment, and a +//! seconds-long `node` run then holds another test's server connection open past its close. + +mod trusted_probe_js { + use std::io::Write; + use std::path::PathBuf; + use victauri_plugin::mcp::{trusted_click_probe_js, trusted_focus_probe_js}; + + fn runner_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("bridge_tests") + } + + /// One case: (name, page html, setup js, `findElements` query for the target, probe). + type Case<'a> = (&'a str, &'a str, &'a str, &'a str, String); + + /// Each probe is built for the placeholder ref `__VREF__` and run after resolving the + /// real ref of the first element the case's query matches. Returns each case's result, + /// or `None` when jsdom is not installed (and nothing requires it). + fn run(cases: &[Case<'_>]) -> Option> { + if !runner_dir().join("node_modules").join("jsdom").exists() { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "jsdom is not installed: `npm ci` in crates/victauri-plugin/tests/bridge_tests/" + ); + eprintln!("SKIP: jsdom not installed"); + return None; + } + let tests: Vec = cases + .iter() + .map(|(name, html, setup_js, find_query, probe)| { + let code = format!( + "var __found = window.__VICTAURI__.findElements({find_query}); \ + if (!__found.length) throw new Error('fixture element not found'); \ + var __vref = __found[0].ref_id;\n{}", + probe.replace("\"__VREF__\"", "__vref") + ); + serde_json::json!({ + "name": name, "code": code, + "setup_html": html, "setup_js": setup_js, + }) + }) + .collect(); + let def = serde_json::json!({ + "bridge_script": victauri_plugin::js_bridge::init_script( + &victauri_plugin::js_bridge::BridgeCapacities::default() + ), + "setup_html": "", + "tests": tests, + }); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(def.to_string().as_bytes()).unwrap(); + tmp.flush().unwrap(); + let out = match std::process::Command::new("node") + .arg(runner_dir().join("run_tests.js")) + .arg(tmp.path()) + .output() + { + Ok(out) => out, + Err(e) => { + assert!( + std::env::var_os("CI").is_none() + && std::env::var_os("VICTAURI_REQUIRE_JSDOM").is_none(), + "node could not be run: {e}" + ); + eprintln!("SKIP: node could not be run: {e}"); + return None; + } + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout + .lines() + .find_map(|l| l.strip_prefix("VICTAURI_RESULTS:")) + .unwrap_or_else(|| { + panic!( + "no results: {stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + let results: Vec = serde_json::from_str(line).unwrap(); + Some( + results + .into_iter() + .map(|r| { + assert_eq!(r["passed"], true, "{}: {}", r["name"], r["error"]); + r["result"].clone() + }) + .collect(), + ) + } + + const FORM: &str = r#" + + +
    not focusable
    +
    + "#; + + /// R4-IN1: the focus probe must report whether focus LANDED on the element, not + /// whether the element exists — keys go to whatever holds focus. + #[test] + fn focus_probe_reports_where_focus_actually_landed() { + let probe = trusted_focus_probe_js("__VREF__"); + let shadow_setup = "var r = document.getElementById('host').attachShadow({mode:'open'}); \ + r.innerHTML = '';"; + let Some(results) = run(&[ + ( + "plain input", + FORM, + "", + "{placeholder:'target'}", + probe.clone(), + ), + ( + "a focus handler moves focus elsewhere", + FORM, + "document.getElementById('target').addEventListener('focus', function () { \ + document.getElementById('other').focus(); });", + "{placeholder:'target'}", + probe.clone(), + ), + ( + "element that cannot take focus", + FORM, + "", + "{css:'#plain'}", + probe.clone(), + ), + ( + "input inside an open shadow root", + FORM, + shadow_setup, + "{placeholder:'in-shadow'}", + probe.clone(), + ), + ]) else { + return; + }; + let focused = |v: &serde_json::Value| v["focused"] == true && v["found"] == true; + assert!(focused(&results[0]), "plain input: {}", results[0]); + assert!(!focused(&results[1]), "focus moved away: {}", results[1]); + assert_eq!(results[1]["found"], true, "{}", results[1]); + assert!(!focused(&results[2]), "not focusable: {}", results[2]); + assert!(focused(&results[3]), "shadow input: {}", results[3]); + } + + const PAGE: &str = r#" + + +
    cover
    +
    + "#; + + /// A hit-test for jsdom (which has no layout): the element under any point is `#btn`, + /// unless a case replaces `window.__hit`. + const HIT_TEST: &str = "window.__hit = function (doc) { \ + return doc.getElementById('btn') || doc.body; }; \ + document.elementFromPoint = function (x, y) { \ + return window.__hit(document, x, y); };"; + + /// R4-IN2: the trusted-click probe takes coordinates from page-controlled layout, so it + /// must refuse a point that is off-screen, covered or on a disabled element (the OS click + /// would land on something else), and must add a same-origin frame's offset. + #[test] + fn click_probe_refuses_unclickable_points_and_offsets_frames() { + let probe = trusted_click_probe_js("__VREF__"); + let off_screen = format!( + "{HIT_TEST} var b = document.getElementById('btn'); \ + b.getBoundingClientRect = function () {{ return {{left: 5000, top: 10, \ + width: 80, height: 32, right: 5080, bottom: 42, x: 5000, y: 10}}; }};" + ); + let covered = format!( + "{HIT_TEST} window.__hit = function (doc) {{ return doc.getElementById('cover'); }};" + ); + let in_frame = "var f = document.createElement('iframe'); \ + document.getElementById('frame-host').appendChild(f); \ + var fw = f.contentWindow, fd = f.contentDocument; \ + fd.body.innerHTML = ''; \ + var baseRect = window.HTMLElement.prototype.getBoundingClientRect; \ + fw.HTMLElement.prototype.getBoundingClientRect = baseRect; \ + fw.HTMLElement.prototype.scrollIntoView = function () {}; \ + window.HTMLElement.prototype.getBoundingClientRect = function () { \ + if (this.tagName === 'IFRAME') return {left: 100, top: 200, width: 300, \ + height: 150, right: 400, bottom: 350, x: 100, y: 200}; \ + return baseRect.call(this); }; \ + fd.elementFromPoint = function () { return fd.getElementById('inner'); }; \ + document.elementFromPoint = function () { return f; };"; + let Some(results) = run(&[ + ( + "visible button", + PAGE, + HIT_TEST, + "{css:'#btn'}", + probe.clone(), + ), + ( + "button laid out off-screen", + PAGE, + &off_screen, + "{css:'#btn'}", + probe.clone(), + ), + ( + "button covered by another element", + PAGE, + &covered, + "{css:'#btn'}", + probe.clone(), + ), + ( + "disabled button", + PAGE, + HIT_TEST, + "{css:'#off'}", + probe.clone(), + ), + ( + "button in a same-origin iframe", + PAGE, + in_frame, + "{tag:'button', text:'In frame'}", + probe.clone(), + ), + ]) else { + return; + }; + // jsdom's stub lays every BUTTON out at (10,10) 80×32: center (50, 26). + assert_eq!(results[0]["x"], 50.0, "{}", results[0]); + assert_eq!(results[0]["y"], 26.0, "{}", results[0]); + for (i, why) in [(1, "off-screen"), (2, "covered"), (3, "disabled")] { + assert!( + results[i]["error"].is_string() && results[i].get("x").is_none(), + "{why}: {}", + results[i] + ); + } + // Frame content origin (100, 200) + the button's center inside the frame (50, 26). + assert_eq!(results[4]["x"], 150.0, "{}", results[4]); + assert_eq!(results[4]["y"], 226.0, "{}", results[4]); + } + + /// A button inside an OPEN shadow root, hit-tested the way a browser does: + /// `document.elementFromPoint` reports the shadow HOST (retargeting); only the shadow root's + /// own `elementFromPoint` sees the button. `cover_inner` makes something else inside the + /// shadow tree the hit element instead. + fn shadow_setup(cover_inner: bool) -> String { + format!( + "var h = document.createElement('div'); h.id = 'host'; document.body.appendChild(h); \ + var sr = h.attachShadow({{mode: 'open'}}); \ + sr.innerHTML = 'cover'; \ + document.elementFromPoint = function () {{ return h; }}; \ + sr.elementFromPoint = function () {{ return sr.getElementById('{}'); }};", + if cover_inner { "sc" } else { "sb" } + ) + } + + /// Round 4 (regression review): `elementFromPoint` retargets to the shadow host and + /// `Node.contains` never crosses a shadow boundary, so EVERY element inside a web component + /// read as "covered by " — refused for trusted clicks and, since the actionability + /// check was added, for synthetic `interact click` too (reproduced live on `WebView2`). + #[test] + fn clicks_reach_elements_inside_open_shadow_roots() { + let probe = trusted_click_probe_js("__VREF__"); + let synthetic = "return await window.__VICTAURI__.click(\"__VREF__\")".to_string(); + let open = shadow_setup(false); + let covered = shadow_setup(true); + let find = "{text:'Shadow Go'}"; + let Some(results) = run(&[ + ("trusted: shadow button", PAGE, &open, find, probe.clone()), + ( + "trusted: covered inside the shadow tree", + PAGE, + &covered, + find, + probe, + ), + ( + "synthetic: shadow button", + PAGE, + &open, + find, + synthetic.clone(), + ), + ( + "synthetic: covered inside the shadow tree", + PAGE, + &covered, + find, + synthetic, + ), + ]) else { + return; + }; + assert!( + results[0]["x"].is_number(), + "trusted shadow click: {}", + results[0] + ); + assert!( + results[1]["error"].is_string(), + "trusted covered: {}", + results[1] + ); + assert_eq!( + results[2]["ok"], true, + "synthetic shadow click: {}", + results[2] + ); + assert_eq!(results[3]["ok"], false, "synthetic covered: {}", results[3]); + } +} diff --git a/crates/victauri-test/Cargo.toml b/crates/victauri-test/Cargo.toml index dbf68191..47534cd1 100644 --- a/crates/victauri-test/Cargo.toml +++ b/crates/victauri-test/Cargo.toml @@ -25,10 +25,16 @@ thiserror = { workspace = true } tokio = { workspace = true } png = "0.17" base64 = "0.22" +# Unpredictable names for the UID-probe files created in the shared temp dir (R4-DISC2). +uuid = { workspace = true } + +[target.'cfg(windows)'.dependencies] +# Process liveness + owner check for discovery, and the discovery-directory owner check +# (`process.rs`), instead of spawning tasklist. +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } [dev-dependencies] axum = { workspace = true } -uuid = { workspace = true } http-body-util = "0.1" tower = { version = "0.5", features = ["util"] } tokio = { workspace = true } diff --git a/crates/victauri-test/LICENSE b/crates/victauri-test/LICENSE new file mode 100644 index 00000000..bed34b88 --- /dev/null +++ b/crates/victauri-test/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 4DA Systems Pty Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/victauri-test/README.md b/crates/victauri-test/README.md index d5fcfa48..affeb746 100644 --- a/crates/victauri-test/README.md +++ b/crates/victauri-test/README.md @@ -20,7 +20,7 @@ Typed HTTP client for the Victauri MCP server with high-level convenience method ```toml [dev-dependencies] -victauri-test = "0.8" +victauri-test = "0.9" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } ``` @@ -111,8 +111,9 @@ let opts = VisualOptions { ..Default::default() }; +// Errors with `TestError::VisualRegression` when the screenshot differs beyond the threshold. let diff = client.screenshot_visual("dashboard", &opts).await?; -assert!(diff.is_match); +assert!(diff.is_match(opts.threshold_percent)); ``` ## IPC Coverage @@ -131,7 +132,7 @@ let report = client.smoke_test().await?; report.assert_all_passed(); ``` -Runs 11 checks: health, DOM snapshot, eval, IPC integrity, registry, window state, screenshot, memory, console errors, performance, ghost commands. +Runs 11 checks: `eval_js` works, DOM snapshot valid, screenshot captures an image, windows exist, IPC integrity healthy, no uncaught errors, accessibility audit, DOM complete under the load budget, JS heap under the memory budget, recording lifecycle, and health endpoint hardened. ## Documentation diff --git a/crates/victauri-test/src/app.rs b/crates/victauri-test/src/app.rs index 73d1563b..82b1dafb 100644 --- a/crates/victauri-test/src/app.rs +++ b/crates/victauri-test/src/app.rs @@ -168,7 +168,8 @@ impl TestApp { reason: format!("health check failed: {e}"), })?; - if !resp.status().is_success() { + // A 429 is the server answering (rate-limited): alive (R4-NET1). + if !crate::client::health_status_means_alive(resp.status().as_u16()) { return Err(TestError::Connection { host: "127.0.0.1".into(), port, @@ -198,6 +199,10 @@ impl TestApp { async fn wait_for_ready(&mut self, timeout: Duration) -> Result<(), TestError> { let http = reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .timeout(Duration::from_secs(2)) .build() .map_err(|e| TestError::Connection { @@ -240,7 +245,7 @@ impl TestApp { let url = format!("http://127.0.0.1:{port}/health"); if let Ok(resp) = http.get(&url).send().await - && resp.status().is_success() + && crate::client::health_status_means_alive(resp.status().as_u16()) { self.port = port; self.token = token; @@ -278,10 +283,13 @@ impl TestApp { if self.port != 0 { let token = std::env::var("VICTAURI_AUTH_TOKEN") .ok() - .filter(|value| !value.trim().is_empty()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) .or_else(|| crate::discovery::scan_discovery_dirs_for_token_on_port(self.port)); return (self.port, token); } + // Never pairs an explicit token with the default port (R4-TOK1); an ambiguous or + // refused resolution polls the default port without a token until it resolves. crate::discovery::resolve_connection() } } diff --git a/crates/victauri-test/src/assertions.rs b/crates/victauri-test/src/assertions.rs index 6071dad9..0040f6e0 100644 --- a/crates/victauri-test/src/assertions.rs +++ b/crates/victauri-test/src/assertions.rs @@ -54,6 +54,7 @@ enum Check { /// A single check result — pass or fail with context. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct CheckResult { /// Human-readable description of what was checked. pub description: String, @@ -63,14 +64,34 @@ pub struct CheckResult { pub detail: String, } +impl CheckResult { + /// Creates a check result (`detail` is conventionally empty when `passed`). + #[must_use] + pub fn new(description: impl Into, passed: bool, detail: impl Into) -> Self { + Self { + description: description.into(), + passed, + detail: detail.into(), + } + } +} + /// Collection of check results from a `verify()` run. #[derive(Debug)] +#[non_exhaustive] pub struct VerifyReport { /// Individual check results in order. pub results: Vec, } impl VerifyReport { + /// Creates a report from individual check results (e.g. to export custom + /// checks through [`Self::to_junit`]). + #[must_use] + pub fn new(results: Vec) -> Self { + Self { results } + } + /// Returns true if all checks passed. #[must_use] pub fn all_passed(&self) -> bool { @@ -106,7 +127,17 @@ impl VerifyReport { .failures() .iter() .enumerate() - .map(|(i, f)| format!(" {}. {} — {}", i + 1, f.description, f.detail)) + // Descriptions and details can carry page text (console errors, DOM text, state + // values): render each single-line so it cannot drive the terminal or start a + // `::error` workflow command in a CI log (R5-TERM2, as the smoke summary does). + .map(|(i, f)| { + format!( + " {}. {} — {}", + i + 1, + crate::terminal::single_line(&f.description), + crate::terminal::single_line(&f.detail) + ) + }) .collect(); panic!( "verify() failed ({}/{} checks passed):\n{}", @@ -554,12 +585,13 @@ fn console_log_errors(log: &Value) -> Vec { .filter_map(|entry| { let level = entry.get("level").and_then(Value::as_str).unwrap_or(""); if level == "error" { + // Page-controlled: neutralize before it lands in a check detail that is printed, + // joined with "; " and put into panics (R5-TERM2). let msg = entry .get("message") .and_then(Value::as_str) - .unwrap_or("(no message)") - .to_string(); - Some(msg) + .unwrap_or("(no message)"); + Some(crate::terminal::single_line(msg)) } else { None } @@ -744,6 +776,52 @@ mod tests { assert_eq!(errors[1], "another error"); } + /// R5-TERM2: console.error text is page-controlled. It must not carry control + /// characters / ANSI escapes, or a newline that starts a `::error` GitHub workflow + /// command, into the check detail — which is printed and put into panics. + #[test] + fn console_errors_are_neutralized_for_terminals() { + let log = json!([ + {"level": "error", "message": "boom\n::error file=src/main.rs::forged\u{1b}[2J\u{202e}"} + ]); + let errors = console_log_errors(&log); + assert_eq!(errors.len(), 1); + let e = &errors[0]; + assert!(!e.chars().any(char::is_control), "{e:?}"); + assert!(!e.contains('\u{202e}'), "{e:?}"); + assert!( + e.contains("boom") && e.contains("forged"), + "text is kept visibly: {e}" + ); + } + + /// R5-TERM2: `assert_all_passed` panics with check details, which may carry page text + /// from any check — render them single-line, like the smoke summary (R4-TERM1). + #[test] + fn assert_all_passed_panic_message_is_terminal_safe() { + let report = VerifyReport { + results: vec![CheckResult { + description: "desc\u{1b}[31m".into(), + passed: false, + detail: "page said\n::warning::forged\r\u{7}".into(), + }], + }; + let payload = std::panic::catch_unwind(|| report.assert_all_passed()).unwrap_err(); + let msg = payload + .downcast_ref::() + .cloned() + .unwrap_or_default(); + assert!(msg.starts_with("verify() failed"), "{msg}"); + assert!( + !msg.contains('\u{1b}') && !msg.contains('\r') && !msg.contains('\u{7}'), + "{msg:?}" + ); + assert!( + !msg.lines().any(|l| l.trim_start().starts_with("::")), + "no line may start a workflow command: {msg:?}" + ); + } + #[test] fn console_errors_empty_for_no_errors() { let log = json!([{"level": "log", "message": "all good"}]); diff --git a/crates/victauri-test/src/client.rs b/crates/victauri-test/src/client.rs index dc8c77f6..5cd15768 100644 --- a/crates/victauri-test/src/client.rs +++ b/crates/victauri-test/src/client.rs @@ -32,21 +32,43 @@ async fn wait_past_ipc_checkpoint_ms(checkpoint_ms: u64) { const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); /// Headroom added on top of a tool's own `timeout_ms` so the HTTP request never -/// times out before the server-side wait it is waiting on has finished. -const TOOL_TIMEOUT_HEADROOM: Duration = Duration::from_secs(10); +/// times out before the server-side wait it is waiting on has finished. Before that wait +/// starts the server may wait for the webview bridge (up to 5 s), probe it (up to 2 s) and +/// make main-thread round trips — the same allowance `victauri bridge` uses. +const TOOL_TIMEOUT_HEADROOM: Duration = Duration::from_secs(40); + +/// The server's ceiling for any per-call `timeout_ms` (`invoke_command`). +const MAX_TOOL_TIMEOUT_MS: u64 = 300_000; /// Per-request HTTP timeout for a tool call. Tools that block server-side for a /// caller-chosen `timeout_ms` (e.g. `wait_for`, which accepts up to 120 000 ms) /// need the HTTP timeout to outlast that wait; otherwise the client gives up at /// the fixed 60 s default while the server is still legitimately waiting. +/// +/// A tool call WITHOUT `timeout_ms` is bounded server-side by the app's configured eval timeout, +/// which can be up to 300 s; the client must not give up first, or a retry would run a +/// side-effecting call twice (round-4 review — the same class `victauri bridge` fixed). The +/// server still answers as soon as the call finishes, so this never slows a normal call. fn request_timeout_for(arguments: &Value) -> Duration { - arguments + let ms = arguments .get("timeout_ms") .and_then(Value::as_u64) - .map_or(DEFAULT_REQUEST_TIMEOUT, |ms| { - DEFAULT_REQUEST_TIMEOUT - .max(Duration::from_millis(ms).saturating_add(TOOL_TIMEOUT_HEADROOM)) - }) + .unwrap_or(MAX_TOOL_TIMEOUT_MS); + DEFAULT_REQUEST_TIMEOUT.max( + Duration::from_millis(ms.min(MAX_TOOL_TIMEOUT_MS)).saturating_add(TOOL_TIMEOUT_HEADROOM), + ) +} + +/// Whether an HTTP status from `GET /health` proves the Victauri server is alive. +/// +/// A `2xx` does, and so does `429 Too Many Requests`: `/health` shares the public rate-limit +/// bucket, so anyone on the machine can flood it, and a rate-limited reply is still the +/// server answering. Treating 429 as "down" let an unauthenticated flood make every client +/// report a live app as unreachable (round-4 audit R4-NET1). +#[doc(hidden)] +#[must_use] +pub fn health_status_means_alive(status: u16) -> bool { + (200..300).contains(&status) || status == 429 } /// Truncate `text` to at most `max_chars` characters without splitting a UTF-8 @@ -106,6 +128,7 @@ fn find_sse_response(text: &str, expected_id: u64) -> Option { /// println!("v{} — {} tools, up {:.0}s", info.version, info.tools.total, info.uptime_secs); /// ``` #[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] pub struct PluginInfo { /// Plugin version string (e.g. `"0.2.0"`). pub version: String, @@ -124,6 +147,7 @@ pub struct PluginInfo { /// Tool information nested inside [`PluginInfo`]. #[derive(Debug, Clone, Default, Deserialize)] +#[non_exhaustive] pub struct PluginToolInfo { /// Total number of tools registered. #[serde(default)] @@ -143,6 +167,7 @@ pub struct PluginToolInfo { /// assert!(mb < 512.0, "memory usage too high: {mb:.1} MB"); /// ``` #[derive(Debug, Clone, Deserialize)] +#[non_exhaustive] pub struct MemoryStats { /// Current working set size in bytes. pub working_set_bytes: u64, @@ -257,6 +282,11 @@ pub struct VictauriClient { /// re-handshake instead of replaying stale credentials. `false` for explicit /// [`Self::connect_with_token`] clients, whose endpoint is the caller's choice. discovered: bool, + /// Tauri app identifier of the server this client connected to, recorded at + /// [`Self::discover`] time (from `/info`, else discovery metadata). Rediscovery + /// only accepts a server with this same identity, so a restarted client can never + /// silently bind to a different app that took over the port. + app_identifier: Option, } impl VictauriClient { @@ -285,6 +315,10 @@ impl VictauriClient { let host = "127.0.0.1"; let base_url = format!("http://{host}:{port}"); let http = reqwest::Client::builder() + // Below the plugin's 10 s idle keep-alive (its request-head deadline), so a pooled + // connection is never reused just as the server closes it (a request lost that way looks + // "possibly delivered" and a tool call is then not retried). + .pool_idle_timeout(std::time::Duration::from_secs(5)) .timeout(DEFAULT_REQUEST_TIMEOUT) .connect_timeout(std::time::Duration::from_secs(10)) .build() @@ -305,6 +339,7 @@ impl VictauriClient { next_id: 10, auth_token: token.map(String::from), discovered: false, + app_identifier: None, }) } @@ -412,7 +447,7 @@ impl VictauriClient { /// restarted, the client reconnected, or the `notifications/initialized` was missed. async fn reinitialize(&mut self) -> Result<(), TestError> { if self.discovered { - self.rediscover(); + self.rediscover()?; } let token = self.auth_token.clone(); let session_id = Self::perform_handshake( @@ -424,42 +459,175 @@ impl VictauriClient { ) .await?; self.session_id = session_id; + if self.discovered { + self.verify_identity().await?; + } Ok(()) } - /// Re-resolve the endpoint (port + auth token) from discovery, exactly as - /// [`Self::discover`] did. Only meaningful for discovered clients: after an app - /// restart the plugin has a fresh token and possibly a different port. - fn rediscover(&mut self) { - let (port, token) = crate::discovery::resolve_connection(); + /// Re-resolve the endpoint (port + auth token) from discovery for the SAME app + /// [`Self::discover`] connected to. Only meaningful for discovered clients: after + /// an app restart the plugin has a fresh token and possibly a different port. + /// + /// Errors (leaving the endpoint unchanged) when no live server with the recorded + /// app identity is found — it never falls back to the default port. + fn rediscover(&mut self) -> Result<(), TestError> { + let Some((port, token)) = + crate::discovery::resolve_rediscovery(self.app_identifier.as_deref()) + else { + return Err(TestError::Connection { + host: self.host.clone(), + port: self.port, + reason: self.app_identifier.as_ref().map_or_else( + || { + "rediscovery found no unambiguous live Victauri server — the app may \ + still be restarting" + .to_string() + }, + |id| { + format!( + "rediscovery found no live Victauri server for app '{id}' — the \ + app may still be restarting (other running apps are ignored)" + ) + }, + ), + }); + }; if port != self.port { self.port = port; self.base_url = format!("http://{}:{port}", self.host); } self.auth_token = token; + Ok(()) + } + + /// Read the host app's identifier from the server's `/info` endpoint. + async fn fetch_app_identifier(&self) -> Option { + self.fetch_app_identity().await?.0 + } + + /// The host app's `(app_identifier, app_product_name)` from the server's `/info` + /// endpoint; `None` when it does not answer one. + async fn fetch_app_identity(&self) -> Option<(Option, Option)> { + let mut req = self.http.get(format!("{}/info", self.base_url)); + if let Some(ref t) = self.auth_token { + req = req.header("Authorization", format!("Bearer {t}")); + } + let info: Value = req.send().await.ok()?.json().await.ok()?; + let field = |k: &str| { + info.get(k) + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + Some((field("app_identifier"), field("app_product_name"))) + } + + /// After a rediscovery re-handshake, confirm the server is still the app this + /// client was pinned to (defense in depth for explicit `VICTAURI_PORT` configs, + /// where discovery metadata is not consulted). + async fn verify_identity(&self) -> Result<(), TestError> { + let Some(expected) = self.app_identifier.as_deref() else { + return Ok(()); + }; + match self.fetch_app_identifier().await { + Some(actual) if actual != expected => Err(TestError::Connection { + host: self.host.clone(), + port: self.port, + reason: format!( + "rediscovered server on port {} is app '{actual}', not '{expected}' — \ + refusing to switch apps", + self.port + ), + }), + _ => Ok(()), + } + } + + /// The Tauri app identifier this client is pinned to, when known (recorded by + /// [`Self::discover`]; `None` for [`Self::connect_with_token`] clients). + #[must_use] + pub fn app_identifier(&self) -> Option<&str> { + self.app_identifier.as_deref() } /// Auto-discover a running Victauri server via temp files. /// /// Discovery priority: - /// 1. `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN` env vars (explicit override) - /// 2. Per-process discovery directory: `/victauri//port` - /// 3. Default: port 7373, no auth + /// 1. `VICTAURI_PORT` (+ optional `VICTAURI_AUTH_TOKEN`) — the explicit endpoint + /// 2. Per-process discovery directories (`//{port,token,metadata.json}`), + /// narrowed by `VICTAURI_APP` (exact bundle identifier or product name) when set + /// 3. Default: port 7373, no auth — only when no Victauri app is discovered at all + /// + /// A `VICTAURI_AUTH_TOKEN` without `VICTAURI_PORT` is only sent to the running app whose + /// own discovery token equals it — never to whatever holds the default port. /// /// # Errors /// - /// Returns [`TestError::Connection`] if the server is unreachable or - /// returns a non-success status. Returns [`TestError::Request`] on - /// HTTP transport failures. + /// Returns [`TestError::Other`] when discovery cannot pick one app (several are running + /// and nothing selects one, the selected app is not running, or an explicit token + /// matches no running app) — the message names the running apps and how to select one. + /// Returns [`TestError::Connection`] if the server is unreachable or returns a + /// non-success status. Returns [`TestError::Request`] on HTTP transport failures. pub async fn discover() -> Result { - // Classify the discovery directory BEFORE `discover_port` runs — that path - // deletes stale (unreachable) discovery dirs as a side effect, so the - // diagnosis must be captured first to explain a subsequent failure. + Self::discover_app(None).await + } + + /// [`Self::discover`], selecting the app by its exact Tauri bundle identifier (or + /// product name) when several Victauri apps are running. `None` falls back to the + /// `VICTAURI_APP` env var, then to "the single running app". + /// + /// # Errors + /// + /// As [`Self::discover`]. + pub async fn discover_app(app: Option<&str>) -> Result { + // Classify the discovery directory BEFORE resolving — resolution deletes the + // entries of dead apps as a side effect, so the diagnosis must be captured first + // to explain a subsequent failure. let diagnosis = crate::discovery::diagnose_discovery(); - let (port, token) = crate::discovery::resolve_connection(); + let (port, token) = + crate::discovery::try_resolve_connection(app).map_err(TestError::Other)?; match Self::connect_with_token(port, token.as_deref()).await { Ok(mut client) => { client.discovered = true; + // Pin the app identity so a later rediscovery can only re-attach to + // this same app. `/info` is authoritative (it is the server we just + // reached); discovery metadata is the fallback for older plugins. + let (identifier, product_name) = + client.fetch_app_identity().await.unwrap_or_default(); + client.app_identifier = + identifier.or_else(|| crate::discovery::identifier_for_port(port)); + // An app selector must match the app actually reached — essential when + // `VICTAURI_PORT` names the endpoint (R5B-PORTAPP1), and a cheap re-check + // otherwise. An app whose identity is unknown (a pre-0.7.4 plugin) passes. + let selector = app + .map(str::to_string) + .or_else(crate::discovery::configured_app); + if let Some(selector) = selector + && (client.app_identifier.is_some() || product_name.is_some()) + && !crate::discovery::identity_matches( + client.app_identifier.as_deref(), + product_name.as_deref(), + &selector, + ) + { + let found = client + .app_identifier + .clone() + .or(product_name) + .unwrap_or_default(); + let message = if crate::discovery::configured_port().is_some() { + crate::discovery::port_app_mismatch(port, &found, &selector) + } else { + format!( + "the server on port {port} is app '{}', not the selected '{}' — \ + refusing to drive a different app", + crate::terminal::single_line(&found), + crate::terminal::single_line(&selector) + ) + }; + return Err(TestError::Other(message)); + } Ok(client) } Err(TestError::Connection { host, port, reason }) => { @@ -475,14 +643,17 @@ impl VictauriClient { /// Check whether the server is still reachable. /// - /// Sends a GET to `/health` and returns `true` if the response is 200 OK. + /// Sends a GET to `/health` and returns `true` if the server answered with a success + /// status — or with `429 Too Many Requests`: a rate-limited answer still proves the + /// server is alive (an unauthenticated `/health` flood must not make a live app look + /// dead, R4-NET1). #[must_use] pub async fn is_alive(&self) -> bool { self.http .get(format!("{}/health", self.base_url)) .send() .await - .is_ok_and(|r| r.status().is_success()) + .is_ok_and(|r| health_status_means_alive(r.status().as_u16())) } /// Re-establish an MCP session after the app restarts. @@ -504,20 +675,29 @@ impl VictauriClient { pub async fn reconnect(&self, max_wait: std::time::Duration) -> Result { let start = std::time::Instant::now(); loop { - let (port, token) = if self.discovered { - crate::discovery::resolve_connection() + // A discovered client only re-attaches to its own app (same identifier); + // no match yet means "not back yet" — never a default-port guess. + let endpoint = if self.discovered { + crate::discovery::resolve_rediscovery(self.app_identifier.as_deref()) } else { - (self.port, self.auth_token.clone()) + Some((self.port, self.auth_token.clone())) }; - let alive = self - .http - .get(format!("http://{}:{port}/health", self.host)) - .send() - .await - .is_ok_and(|r| r.status().is_success()); - if alive { + let alive = match &endpoint { + Some((port, _)) => self + .http + .get(format!("http://{}:{port}/health", self.host)) + .send() + .await + .is_ok_and(|r| health_status_means_alive(r.status().as_u16())), + None => false, + }; + if alive && let Some((port, token)) = endpoint { let mut client = Self::connect_with_token(port, token.as_deref()).await?; client.discovered = self.discovered; + client.app_identifier.clone_from(&self.app_identifier); + if client.discovered { + client.verify_identity().await?; + } return Ok(client); } if start.elapsed() > max_wait { @@ -561,6 +741,7 @@ impl VictauriClient { let mut resp = None; let mut reinitialized = false; let mut reauthed = false; + let mut reconnected = false; for attempt in 0..4 { let mut req = self .http @@ -578,7 +759,18 @@ impl VictauriClient { if let Some(ref t) = self.auth_token { req = req.header("Authorization", format!("Bearer {t}")); } - let r = req.send().await?; + let r = match req.send().await { + Ok(r) => r, + // Connection refused/reset on a discovered client: the app most likely + // restarted (possibly on another port). Rediscover the SAME app once and + // retry; explicit-endpoint clients surface the error as-is. + Err(e) if e.is_connect() && self.discovered && !reconnected && attempt < 3 => { + reconnected = true; + self.reinitialize().await?; + continue; + } + Err(e) => return Err(e.into()), + }; let status = r.status(); if status == 429 && attempt < 3 { @@ -713,10 +905,13 @@ impl VictauriClient { return Ok(parsed); } let hint = if status == reqwest::StatusCode::UNAUTHORIZED { - " — auth token missing or wrong (auth is on by default; the token is in \ - /victauri//token, or set VICTAURI_AUTH_TOKEN)" + format!( + " — auth token missing or wrong (auth is on by default; the app writes it to \ + {}; or set VICTAURI_AUTH_TOKEN)", + crate::discovery::TOKEN_LOCATIONS + ) } else { - "" + String::new() }; return Err(TestError::Connection { host: host.to_string(), @@ -942,6 +1137,9 @@ impl VictauriClient { /// Invoke a Tauri command by name with optional arguments. /// + /// Uses the server's default command timeout; for a slow command use + /// [`Self::invoke_command_with_timeout`]. + /// /// # Errors /// /// Returns errors from [`VictauriClient::call_tool`]. @@ -950,11 +1148,28 @@ impl VictauriClient { command: &str, args: Option, ) -> Result { - let mut params = json!({"command": command}); - if let Some(a) = args { - params["args"] = a; - } - self.call_tool("invoke_command", params).await + self.call_tool("invoke_command", invoke_command_params(command, args, None)) + .await + } + + /// [`Self::invoke_command`] with an explicit server-side timeout (`timeout_ms`, capped + /// by the server at 300 000 ms) — the option the server's own timeout error tells you to + /// set for a slow command. The HTTP request timeout is extended to outlast it. + /// + /// # Errors + /// + /// Returns errors from [`VictauriClient::call_tool`]. + pub async fn invoke_command_with_timeout( + &mut self, + command: &str, + args: Option, + timeout_ms: u64, + ) -> Result { + self.call_tool( + "invoke_command", + invoke_command_params(command, args, Some(timeout_ms)), + ) + .await } /// Get the IPC call log. @@ -1484,7 +1699,7 @@ impl VictauriClient { /// /// Returns errors from [`VictauriClient::call_tool`]. #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_since")] - pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result, TestError> { + pub async fn ipc_calls_since(&mut self, checkpoint: u64) -> Result, TestError> { // Timestamp filter, NOT positional skip: the server's log tools return a // capped sliding window of the NEWEST entries (default 100), so // `skip(checkpoint_length)` silently yields nothing the moment a busy app @@ -1496,7 +1711,7 @@ impl VictauriClient { .filter(|e| { e.get("timestamp") .and_then(Value::as_u64) - .map_or(checkpoint == 0, |t| t > checkpoint as u64) + .map_or(checkpoint == 0, |t| t > checkpoint) }) .collect()) } @@ -1530,10 +1745,7 @@ impl VictauriClient { /// # Errors /// /// Returns errors from [`VictauriClient::call_tool`]. - pub async fn get_ipc_calls_since( - &mut self, - checkpoint: usize, - ) -> Result, TestError> { + pub async fn get_ipc_calls_since(&mut self, checkpoint: u64) -> Result, TestError> { #[allow(deprecated)] self.ipc_calls_since(checkpoint).await } @@ -1576,14 +1788,14 @@ impl VictauriClient { /// /// Returns errors from [`VictauriClient::call_tool`]. #[deprecated(since = "0.2.0", note = "renamed to create_ipc_checkpoint")] - pub async fn ipc_checkpoint(&mut self) -> Result { + pub async fn ipc_checkpoint(&mut self) -> Result { self.create_ipc_checkpoint().await } /// Snapshot the newest IPC-log timestamp, for use with `ipc_calls_since`. /// - /// Returns the maximum entry `timestamp` (epoch milliseconds) currently - /// visible in the IPC log, or `0` when the log is empty. Pass this value to + /// Returns the maximum entry `timestamp` (epoch milliseconds, a `u64` — epoch ms do not + /// fit a 32-bit `usize`) currently visible in the IPC log, or `0` when the log is empty. Pass this value to /// [`VictauriClient::ipc_calls_since`] to get only the calls that occurred /// after the checkpoint. For non-empty logs this method waits until the /// local clock has advanced past the checkpoint millisecond before it @@ -1596,7 +1808,7 @@ impl VictauriClient { /// # Errors /// /// Returns errors from [`VictauriClient::call_tool`]. - pub async fn create_ipc_checkpoint(&mut self) -> Result { + pub async fn create_ipc_checkpoint(&mut self) -> Result { // The checkpoint is the NEWEST entry timestamp (epoch ms), not the log // length: the log tools serve a capped sliding window, so a length // snapshot breaks (always-empty `calls_since`) once the app has logged @@ -1608,7 +1820,7 @@ impl VictauriClient { .max() .unwrap_or(0); wait_past_ipc_checkpoint_ms(checkpoint_ms).await; - Ok(checkpoint_ms as usize) + Ok(checkpoint_ms) } // ── Typed Response Methods (Phase 4E) ──────────────────────────────────── @@ -1932,6 +2144,17 @@ impl VictauriClient { self.scroll_to(&ref_id).await } + /// Scroll the first element whose accessible text contains the given string into view. + /// + /// # Errors + /// + /// Returns [`TestError::ElementNotFound`] if no matching element is found. + /// Returns other errors from [`VictauriClient::call_tool`]. + pub async fn scroll_to_by_text(&mut self, text: &str) -> Result { + let ref_id = self.find_ref_by_text(text).await?; + self.scroll_to(&ref_id).await + } + /// Get the text content of an element identified by HTML `id`. /// /// # Errors @@ -2086,6 +2309,18 @@ impl VictauriClient { } } +/// `invoke_command` tool arguments. +fn invoke_command_params(command: &str, args: Option, timeout_ms: Option) -> Value { + let mut params = json!({"command": command}); + if let Some(a) = args { + params["args"] = a; + } + if let Some(ms) = timeout_ms { + params["timeout_ms"] = json!(ms); + } + params +} + fn save_screenshot_to_file( base64_data: &str, path: &std::path::Path, @@ -2364,6 +2599,36 @@ pub fn assert_state_matches(verification: &Value) { mod transport_tests { use super::*; + /// R5B-HINT1: the 401 hint named `/victauri//token`, which 0.9 no longer writes + /// on Unix (per-user roots) — pointing a user at an empty directory. + #[tokio::test] + async fn unauthorized_hint_names_the_real_discovery_locations() { + let resp = reqwest::Response::from( + axum::http::Response::builder() + .status(401) + .body("unauthorized") + .unwrap(), + ); + let Err(TestError::Connection { reason, .. }) = + VictauriClient::parse_response(resp, "127.0.0.1", 7373, 1).await + else { + panic!("a 401 must be a connection error"); + }; + assert!( + !reason.contains("/victauri//token"), + "stale location: {reason}" + ); + #[cfg(unix)] + assert!( + reason.contains("$XDG_RUNTIME_DIR/victauri//token") + && reason.contains("victauri-//token"), + "{reason}" + ); + #[cfg(windows)] + assert!(reason.contains(r"%TEMP%\victauri\\token"), "{reason}"); + assert!(reason.contains("VICTAURI_AUTH_TOKEN"), "{reason}"); + } + #[test] fn truncate_chars_never_splits_a_code_point() { // "a" + 2-byte chars: byte 200 falls mid-"é", where `&odd[..200]` panics. @@ -2376,17 +2641,94 @@ mod transport_tests { #[test] fn request_timeout_outlasts_tool_wait() { - assert_eq!(request_timeout_for(&json!({})), DEFAULT_REQUEST_TIMEOUT); + // No `timeout_ms`: the server bounds the call by the app's eval timeout (up to 300 s), + // so the client must outlast that ceiling, not give up at 60 s and invite a retry. + assert_eq!(request_timeout_for(&json!({})), Duration::from_secs(340)); assert_eq!( request_timeout_for(&json!({"timeout_ms": 5_000})), DEFAULT_REQUEST_TIMEOUT ); assert_eq!( request_timeout_for(&json!({"timeout_ms": 120_000})), - Duration::from_secs(130) + Duration::from_secs(160) + ); + // Clamped the way the server clamps it. + assert_eq!( + request_timeout_for(&json!({"timeout_ms": u64::MAX})), + Duration::from_secs(340) + ); + } + + #[test] + fn invoke_command_with_timeout_sends_timeout_ms_and_outlasts_it() { + // R4-TC1: the server tells a slow command's caller to set `timeout_ms`, but the + // client had no way to send it. + let params = invoke_command_params("slow", Some(json!({"n": 1})), Some(200_000)); + assert_eq!(params["command"], "slow"); + assert_eq!(params["args"]["n"], 1); + assert_eq!(params["timeout_ms"], 200_000); + assert!(request_timeout_for(¶ms) > Duration::from_millis(200_000)); + // The plain form is unchanged. + assert_eq!( + invoke_command_params("fast", None, None), + json!({"command": "fast"}) ); } + /// A minimal HTTP server answering every request with `status` and an empty body, which + /// records the JSON bodies it received. + async fn fixed_status_server( + status: u16, + ) -> (u16, std::sync::Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let record = std::sync::Arc::clone(&seen); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + let record = std::sync::Arc::clone(&record); + tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + let n = sock.read(&mut buf).await.unwrap_or(0); + record + .lock() + .unwrap() + .push(String::from_utf8_lossy(&buf[..n]).into_owned()); + let reply = format!( + "HTTP/1.1 {status} X\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = sock.write_all(reply.as_bytes()).await; + }); + } + }); + (port, seen) + } + + #[test] + fn a_rate_limited_health_answer_means_alive() { + // R4-NET1. + assert!(health_status_means_alive(200)); + assert!(health_status_means_alive(204)); + assert!(health_status_means_alive(429)); + assert!(!health_status_means_alive(401)); + assert!(!health_status_means_alive(404)); + assert!(!health_status_means_alive(500)); + assert!(!health_status_means_alive(503)); + } + + #[tokio::test] + async fn is_alive_treats_a_rate_limited_server_as_alive() { + // R4-NET1: an unauthenticated /health flood (→ 429) must not make a live app look dead. + let (port, _) = fixed_status_server(429).await; + assert!(client_at(port, false, None).is_alive().await); + let (port, _) = fixed_status_server(503).await; + assert!(!client_at(port, false, None).is_alive().await); + } + #[test] fn sse_response_is_matched_by_id_not_position() { let body = "id: 0\ndata:\n\n\ @@ -2398,6 +2740,93 @@ mod transport_tests { assert!(find_sse_response(body, 99).is_none()); } + /// A port that refuses connections for as long as the returned socket lives: BOUND but + /// never listening. (Binding then releasing — the old approach — let another process on a + /// busy CI runner grab the port, turning "connection refused" into a non-HTTP reply.) + fn closed_port() -> (u16, tokio::net::TcpSocket) { + let socket = tokio::net::TcpSocket::new_v4().unwrap(); + socket.bind("127.0.0.1:0".parse().unwrap()).unwrap(); + let port = socket.local_addr().unwrap().port(); + (port, socket) + } + + fn client_at(port: u16, discovered: bool, app_identifier: Option<&str>) -> VictauriClient { + VictauriClient { + http: reqwest::Client::new(), + base_url: format!("http://127.0.0.1:{port}"), + host: "127.0.0.1".to_string(), + port, + session_id: None, + next_id: 10, + auth_token: None, + discovered, + app_identifier: app_identifier.map(str::to_string), + } + } + + fn explicit_discovery_env() -> bool { + std::env::var_os("VICTAURI_PORT").is_some() + || std::env::var_os("VICTAURI_AUTH_TOKEN").is_some() + } + + #[tokio::test] + async fn connection_refused_on_discovered_client_rediscovers_same_app_only() { + if explicit_discovery_env() { + return; // explicit endpoint config bypasses discovery by design + } + let (port, _held) = closed_port(); + // An identity no live app can have: rediscovery must find nothing and say so, + // instead of binding to whatever app holds the default port. + let id = format!("com.victauri.test.absent-{}", std::process::id()); + let mut client = client_at(port, true, Some(&id)); + let err = client + .call_tool("get_plugin_info", json!({})) + .await + .expect_err("nothing is listening"); + match err { + TestError::Connection { reason, .. } => { + assert!( + reason.contains(&id), + "a connection error on a discovered client must trigger an identity-pinned \ + rediscovery: {reason}" + ); + } + other => panic!("expected a rediscovery Connection error, got {other:?}"), + } + assert_eq!(client.port(), port, "no default-port (7373) fallback"); + assert_eq!(client.app_identifier(), Some(id.as_str())); + } + + #[tokio::test] + async fn connection_refused_on_explicit_client_is_not_rediscovered() { + let (port, _held) = closed_port(); + let mut client = client_at(port, false, None); + let err = client + .call_tool("get_plugin_info", json!({})) + .await + .expect_err("nothing is listening"); + assert!( + matches!(err, TestError::Request(ref e) if e.is_connect()), + "an explicit-endpoint client surfaces the transport error as-is: {err:?}" + ); + assert_eq!(client.port(), port); + } + + #[test] + fn rediscover_without_a_match_leaves_the_endpoint_untouched() { + if explicit_discovery_env() { + return; + } + let (port, _held) = closed_port(); + let id = format!("com.victauri.test.absent2-{}", std::process::id()); + let mut client = client_at(port, true, Some(&id)); + client.auth_token = Some("keep".to_string()); + assert!(client.rediscover().is_err()); + assert_eq!(client.port, port); + assert_eq!(client.base_url, format!("http://127.0.0.1:{port}")); + assert_eq!(client.auth_token.as_deref(), Some("keep")); + } + #[test] fn sse_multi_line_data_is_joined() { let body = "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\r\ndata: \"id\":3,\"error\":{\"code\":-1}}\r\n\r\n"; diff --git a/crates/victauri-test/src/coverage.rs b/crates/victauri-test/src/coverage.rs index 5857f34f..cd90b7fe 100644 --- a/crates/victauri-test/src/coverage.rs +++ b/crates/victauri-test/src/coverage.rs @@ -11,6 +11,7 @@ use crate::error::TestError; /// IPC coverage report showing which commands were exercised. #[derive(Debug)] +#[non_exhaustive] pub struct CoverageReport { /// Total number of registered commands. pub total_commands: usize, @@ -26,6 +27,7 @@ pub struct CoverageReport { /// A command name with its invocation count. #[derive(Debug)] +#[non_exhaustive] pub struct CommandCalls { /// Name of the Tauri command. pub name: String, diff --git a/crates/victauri-test/src/discovery.rs b/crates/victauri-test/src/discovery.rs index 4460ad28..852be8df 100644 --- a/crates/victauri-test/src/discovery.rs +++ b/crates/victauri-test/src/discovery.rs @@ -1,22 +1,114 @@ //! Per-process server discovery for CI parallelism. //! -//! Victauri servers write discovery files to `/victauri//` with +//! Victauri servers write discovery files to a per-user `//` (see +//! `discovery_roots`) with //! port, token, and metadata. This module scans those directories and returns -//! the live server(s). Stale directories from dead processes are cleaned up -//! by checking TCP connectivity on the advertised port. +//! the live server(s). A directory is deleted only when its owning process is +//! definitely dead — never merely because its port did not answer a probe (a +//! live-but-busy app writes its entry once, so deleting it would make the app +//! undiscoverable for the rest of its life). use std::path::PathBuf; -fn victauri_base_dir() -> PathBuf { - std::env::temp_dir().join("victauri") +/// The discovery roots the plugin may have written to, most specific first (mirrors the +/// plugin's `discovery_root`). On Unix the roots are per-user — `$XDG_RUNTIME_DIR/victauri` +/// when that directory is private to us, then `/victauri-` (a shared +/// `/tmp/victauri` could be pre-created by another user, blocking discovery), then the home +/// fallback `$XDG_STATE_HOME/victauri` / `$HOME/.local/state/victauri` (where the plugin +/// registers when `/victauri-` was taken over) — and the legacy `/victauri` +/// is still read, subject to the same ownership check, for pre-0.9 plugins. Other platforms +/// use `/victauri` (a per-user temp dir; Windows readers verify its ownership). +#[cfg(not(test))] +fn discovery_roots() -> Vec { + real_discovery_roots() +} + +/// Unit tests never read (or prune) the machine's REAL discovery directories — a developer's +/// running apps live there. Every discovery path in this crate's unit tests resolves against +/// one private, empty, process-wide root instead. +#[cfg(test)] +fn discovery_roots() -> Vec { + static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); + vec![ + ROOT.get_or_init(|| tempfile::tempdir().expect("isolated discovery root")) + .path() + .to_path_buf(), + ] +} + +/// Where a running app's auth token lives, for error messages — the same roots +/// [`discovery_roots`] scans, in the same order. +#[cfg(unix)] +pub const TOKEN_LOCATIONS: &str = "$XDG_RUNTIME_DIR/victauri//token (when that \ + directory is private to you), else /victauri-//token, or \ + ~/.local/state/victauri//token when that one was not usable"; +/// Where a running app's auth token lives, for error messages. +#[cfg(not(unix))] +pub const TOKEN_LOCATIONS: &str = r"%TEMP%\victauri\\token"; + +#[cfg_attr(all(test, not(unix)), allow(dead_code))] +fn real_discovery_roots() -> Vec { + let legacy = std::env::temp_dir().join("victauri"); + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let mut roots = Vec::new(); + if let Some(euid) = current_euid() { + if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .filter(|dir| { + std::fs::symlink_metadata(dir).is_ok_and(|m| { + m.file_type().is_dir() + && m.uid() == euid + && m.permissions().mode() & 0o077 == 0 + }) + }) + { + roots.push(runtime.join("victauri")); + } + roots.push(std::env::temp_dir().join(format!("victauri-{euid}"))); + // The plugin's fallback when `/victauri-` was taken over by another + // user (R5B-LINDISC1) — scanned after it, before the legacy root. + roots.extend(home_state_root( + std::env::var_os("XDG_STATE_HOME"), + std::env::var_os("HOME"), + )); + } + roots.push(legacy); + roots + } + #[cfg(not(unix))] + { + vec![legacy] + } +} + +/// The plugin's per-user fallback discovery root under the home directory: +/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` (each only when absolute). +/// The plugin registers there when `/victauri-` is untrusted — e.g. another local +/// user pre-created it, and sticky `/tmp` stops us deleting it (R5B-LINDISC1). +#[cfg(unix)] +fn home_state_root( + xdg_state_home: Option, + home: Option, +) -> Option { + let absolute = + |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute()); + xdg_state_home + .and_then(absolute) + .or_else(|| { + home.and_then(absolute) + .map(|h| h.join(".local").join("state")) + }) + .map(|state| state.join("victauri")) } /// Whether a discovery directory is safe to trust (audit #15). On Unix the temp /// root (e.g. `/tmp`) is world-writable, so an attacker can plant a fake `` /// dir pointing at a server they control to steal the token / forge results. We /// trust a dir only if it is a real directory (not a symlink), owned by the current -/// effective user, and not group/other-writable. On Windows the temp dir is already -/// per-user, and the writer restricts ACLs via `icacls`, so no extra check is needed. +/// effective user, and not group/other-writable. (Windows has its own owner check below.) #[cfg(unix)] fn dir_is_trusted(path: &std::path::Path) -> bool { use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -36,15 +128,12 @@ fn dir_is_trusted(path: &std::path::Path) -> bool { /// `#![forbid(unsafe_code)]`): exclusively create a file and read back its owner uid. #[cfg(unix)] fn current_euid() -> Option { - use std::sync::atomic::{AtomicU64, Ordering}; - - static NEXT_PROBE: AtomicU64 = AtomicU64::new(0); for _ in 0..16 { - let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); + // Unpredictable name (R4-DISC2): a guessable `_` name in the shared temp + // dir let another user pre-create every probe path and deny us our own uid. let probe = std::env::temp_dir().join(format!( - ".victauri_uidprobe_{}_{}", - std::process::id(), - sequence + ".victauri_uidprobe_{}", + uuid::Uuid::new_v4().simple() )); if let Some(uid) = uid_from_exclusive_probe(&probe) { return Some(uid); @@ -70,14 +159,20 @@ fn uid_from_exclusive_probe(probe: &std::path::Path) -> Option { uid } -#[cfg(not(unix))] -fn dir_is_trusted(_path: &std::path::Path) -> bool { - true +/// Windows: a real directory (a symlink or junction is not `is_dir()` under +/// `symlink_metadata`) OWNED by the current user (see +/// [`crate::process::dir_owned_by_current_user`]). `%TEMP%` is normally per-user, but it can +/// be shared (an app launched from MSYS2 uses `C:\msys64\tmp`), where another user could +/// plant `victauri\\` entries pointing at a port they control (R5B-WINDISC1). +#[cfg(windows)] +fn dir_is_trusted(path: &std::path::Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) + && crate::process::dir_owned_by_current_user(path) } -/// Discover one unambiguous live server, keeping its port and token together. -pub fn scan_discovery_dirs_for_connection() -> Option<(u16, Option)> { - unique_connection(&find_live_servers()) +#[cfg(not(any(unix, windows)))] +fn dir_is_trusted(_path: &std::path::Path) -> bool { + true } /// Return a discovery token only when exactly one live entry advertises `port`. @@ -100,33 +195,192 @@ pub fn scan_discovery_dir_for_pid(pid: u32) -> Option<(u16, Option)> { pub fn configured_port() -> Option { std::env::var("VICTAURI_PORT") .ok() - .and_then(|value| value.parse::().ok()) + .and_then(|value| value.trim().parse::().ok()) .filter(|port| *port != 0) } fn configured_token() -> Option { std::env::var("VICTAURI_AUTH_TOKEN") .ok() - .filter(|token| !token.trim().is_empty()) + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) +} + +/// The app selector from `VICTAURI_APP` (a Tauri bundle identifier), if set. +pub fn configured_app() -> Option { + std::env::var("VICTAURI_APP") + .ok() + .map(|app| app.trim().to_string()) + .filter(|app| !app.is_empty()) } -/// Resolve a connection without ever pairing a token with a different port. +/// Resolve a connection without ever pairing a token with a different port, falling back +/// to the default port with NO token when discovery cannot pick one app. Prefer +/// [`try_resolve_connection`], which explains an ambiguous or refused resolution. pub fn resolve_connection() -> (u16, Option) { - let explicit_port = configured_port(); - let explicit_token = configured_token(); + try_resolve_connection(None).unwrap_or((DEFAULT_PORT, None)) +} + +/// The port a Victauri app binds when nothing else is configured. +const DEFAULT_PORT: u16 = 7373; + +/// Resolve the endpoint to connect to from `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN` / +/// `VICTAURI_APP` (or `app`, which overrides `VICTAURI_APP`) and the live discovery entries. +/// +/// # Errors +/// +/// A human-readable explanation when several apps match and nothing selects one, when the +/// selected app is not running, or when an explicit token matches no running app (it is +/// then sent nowhere rather than to whatever process holds the default port). +pub fn try_resolve_connection(app: Option<&str>) -> Result<(u16, Option), String> { + let app = app.map(str::to_string).or_else(configured_app); + let port = configured_port(); + let token = configured_token(); + // Only scan when the answer depends on discovery (an explicit port + token with no app + // selector does not; a selector is checked against that port's entry — R5B-PORTAPP1). + let servers = if port.is_some() && token.is_some() && app.is_none() { + Vec::new() + } else { + find_live_servers() + }; + resolve_from(port, token, app.as_deref(), &servers) +} +/// Pure core of [`try_resolve_connection`]. +fn resolve_from( + explicit_port: Option, + explicit_token: Option, + app: Option<&str>, + servers: &[DiscoveredServer], +) -> Result<(u16, Option), String> { + // An explicit port is the caller naming the endpoint: pair it with the explicit token, + // else with the token of the one live entry on exactly that port. An app selector set as + // well must AGREE with that port's app (R5B-PORTAPP1) — it used to be silently ignored, so + // the client drove whatever app held the port. (The client re-checks `/info` on connect, + // which also covers a port with no discovery entry.) if let Some(port) = explicit_port { - let token = explicit_token.or_else(|| scan_discovery_dirs_for_token_on_port(port)); - return (port, token); + if let (Some(app), Some(entry)) = (app, unique_server_on_port(servers, port)) + && (entry.identifier.is_some() || entry.product_name.is_some()) + && !entry.matches_app(app) + { + return Err(port_app_mismatch(port, &entry.label(), app)); + } + let token = explicit_token.or_else(|| unique_token_for_port(servers, port)); + return Ok((port, token)); } - // A configured token is an explicit credential for the default endpoint. Do not - // send it to an arbitrary auto-discovered server. + let candidates: Vec<&DiscoveredServer> = servers + .iter() + .filter(|server| app.is_none_or(|app| server.matches_app(app))) + .collect(); + + // An explicit token WITHOUT an explicit port (R4-TOK1): it used to be sent to whatever + // listened on 7373 — on a shared machine, a squatter there received it and could replay + // it against the real app. Send it only to the unique live, trusted app whose own + // discovery token IS that token. if let Some(token) = explicit_token { - return (7373, Some(token)); + let owners: Vec<&&DiscoveredServer> = candidates + .iter() + .filter(|server| server.token.as_deref() == Some(token.as_str())) + .collect(); + return match owners.as_slice() { + [owner] => Ok((owner.port, Some(token))), + [] => Err( + "VICTAURI_AUTH_TOKEN is set, but no running Victauri app's discovery entry \ + carries that token, so it was not sent anywhere (without VICTAURI_PORT it \ + would have gone to whatever process holds the default port). Set \ + VICTAURI_PORT to the app's port to use this token explicitly, or unset \ + VICTAURI_AUTH_TOKEN to use the discovered one." + .to_string(), + ), + many => Err(ambiguity_message(many.iter().map(|s| **s))), + }; } - scan_discovery_dirs_for_connection().unwrap_or((7373, None)) + match (candidates.as_slice(), app) { + ([one], _) => Ok((one.port, one.token.clone())), + ([], Some(app)) => { + let running = if servers.is_empty() { + " No Victauri app is running.".to_string() + } else { + let labels: Vec = servers.iter().map(DiscoveredServer::label).collect(); + format!(" Running apps:\n {}", labels.join("\n ")) + }; + Err(format!( + "No running Victauri app matches the app selector '{app}' (VICTAURI_APP / \ + --app: an exact bundle identifier or product name).{running}" + )) + } + // Nothing discovered: the legacy default endpoint, with no token. + ([], None) => Ok((DEFAULT_PORT, None)), + (many, _) => Err(ambiguity_message(many.iter().copied())), + } +} + +/// `VICTAURI_PORT` and an app selector name different apps. +pub fn port_app_mismatch(port: u16, found: &str, app: &str) -> String { + format!( + "VICTAURI_PORT={port} is app {}, but the app selector (--app / VICTAURI_APP) is '{}'. \ + Unset one of them, or point VICTAURI_PORT at that app's port.", + crate::terminal::single_line(found), + crate::terminal::single_line(app) + ) +} + +/// "Several apps match" — names each as `identifier (port N, pid P)` and how to pick one +/// (the same guidance `victauri bridge` gives). +fn ambiguity_message<'a>(servers: impl Iterator) -> String { + let labels: Vec = servers.map(DiscoveredServer::label).collect(); + format!( + "Multiple Victauri apps are running:\n {}\nSelect one with `--app ` \ + (victauri CLI) or the VICTAURI_APP env var, or pin the port with VICTAURI_PORT.", + labels.join("\n ") + ) +} + +/// Re-resolve the endpoint of a previously discovered client, pinned to its app. +/// +/// Explicit configuration (`VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN`) is honored exactly +/// as [`try_resolve_connection`] does — the caller named the endpoint. Otherwise only a +/// live discovery entry whose app `identifier` equals `expected_identifier` is accepted; +/// when the identity is unknown (`None`, e.g. a plugin too old to report one) the single +/// live server is accepted as before. Returns `None` — never the default port — when +/// nothing (or more than one server) matches, so a restarted client cannot silently bind +/// to a different app that happens to hold the shared default port. +pub fn resolve_rediscovery(expected_identifier: Option<&str>) -> Option<(u16, Option)> { + if configured_port().is_some() || configured_token().is_some() { + return try_resolve_connection(expected_identifier).ok(); + } + select_for_identity(&find_live_servers(), expected_identifier) +} + +/// The app identifier advertised in discovery metadata by the single live server on +/// `port`, if exactly one such server exists and it recorded an identifier. +pub fn identifier_for_port(port: u16) -> Option { + let servers = find_live_servers(); + let mut matching = servers.iter().filter(|server| server.port == port); + let server = matching.next()?; + if matching.next().is_some() { + return None; + } + server.identifier.clone() +} + +fn select_for_identity( + servers: &[DiscoveredServer], + expected_identifier: Option<&str>, +) -> Option<(u16, Option)> { + let Some(expected) = expected_identifier else { + return unique_connection(servers); + }; + let mut matching = servers + .iter() + .filter(|server| server.identifier.as_deref() == Some(expected)); + let server = matching.next()?; + if matching.next().is_some() { + return None; + } + Some((server.port, server.token.clone())) } /// Non-destructive classification of the discovery directory, used to turn a @@ -182,17 +436,14 @@ impl DiscoveryStatus { /// when you want to explain *why* a connection failed. #[must_use] pub fn diagnose_discovery() -> DiscoveryStatus { - let base = victauri_base_dir(); - if !dir_is_trusted(&base) { - return DiscoveryStatus::None; - } - let Ok(entries) = std::fs::read_dir(&base) else { - return DiscoveryStatus::None; - }; - let mut stale = Vec::new(); let mut any_dir = false; - for entry in entries.flatten() { + let entries = discovery_roots() + .into_iter() + .filter(|base| dir_is_trusted(base)) + .filter_map(|base| std::fs::read_dir(base).ok()) + .flat_map(Iterator::flatten); + for entry in entries { let path = entry.path(); if !path.is_dir() { continue; @@ -216,7 +467,7 @@ pub fn diagnose_discovery() -> DiscoveryStatus { any_dir = true; // A dead owner process is stale even if the advertised (shared default) port is // reachable — that reachability is a different live app, not this one. - if !is_process_alive(pid) { + if !crate::process::is_own_live_process(pid) { stale.push((pid, port)); continue; } @@ -242,6 +493,33 @@ struct DiscoveredServer { pid: u32, port: u16, token: Option, + /// Tauri app identifier from `metadata.json`, when the plugin recorded one. + identifier: Option, + /// Tauri product name from `metadata.json`, when the plugin recorded one. + product_name: Option, +} + +impl DiscoveredServer { + /// An app selector matches the bundle identifier or the product name EXACTLY (ASCII + /// case-insensitive, like `victauri bridge --app`) — never a substring, so `com.example` + /// can't silently bind `com.example.other`. + fn matches_app(&self, app: &str) -> bool { + identity_matches( + self.identifier.as_deref(), + self.product_name.as_deref(), + app, + ) + } + + /// `identifier (port N, pid P)` — the label `victauri bridge` prints, plus the pid. + fn label(&self) -> String { + let name = self + .identifier + .as_deref() + .or(self.product_name.as_deref()) + .unwrap_or(""); + format!("{name} (port {}, pid {})", self.port, self.pid) + } } fn unique_connection(servers: &[DiscoveredServer]) -> Option<(u16, Option)> { @@ -252,33 +530,61 @@ fn unique_connection(servers: &[DiscoveredServer]) -> Option<(u16, Option Option { + unique_server_on_port(servers, port)?.token.clone() +} + +/// The ONE live entry advertising `port`; `None` when there is none, or several. +fn unique_server_on_port(servers: &[DiscoveredServer], port: u16) -> Option<&DiscoveredServer> { let mut matching = servers.iter().filter(|server| server.port == port); let server = matching.next()?; if matching.next().is_some() { return None; } - server.token.clone() + Some(server) +} + +/// Whether an app selector names this identity: the bundle identifier or the product name, +/// EXACTLY (ASCII case-insensitive) — the rule discovery, `/info` checks and the CLI bridge share. +pub fn identity_matches(identifier: Option<&str>, product_name: Option<&str>, app: &str) -> bool { + identifier.is_some_and(|v| v.eq_ignore_ascii_case(app)) + || product_name.is_some_and(|v| v.eq_ignore_ascii_case(app)) } fn find_live_servers() -> Vec { - find_live_servers_in(&victauri_base_dir(), is_process_alive, port_is_reachable) + let mut servers: Vec = Vec::new(); + for root in discovery_roots() { + for server in find_live_servers_in(&root, crate::process::liveness, port_is_reachable) { + // A pid already found under a more specific root counts once. + if !servers.iter().any(|seen| seen.pid == server.pid) { + servers.push(server); + } + } + } + servers } -/// Testable core of [`find_live_servers`]: scan `base`, keep only entries whose OWNING -/// PROCESS is alive and whose advertised port is reachable, and prune the rest. +/// Testable core of [`find_live_servers`]: scan `base` and keep only entries whose OWNING +/// PROCESS is alive and ours, and whose advertised port is reachable. /// /// Liveness is gated on the owning **pid**, not just port reachability — because every /// Victauri app registers on the SAME default port (7373). A crashed app's stale entry /// still advertises 7373, and a *different* live app now holding 7373 makes that port /// probe succeed, so a reachability-only check keeps the dead entry and pairs its stale /// token with the live server → 401. Checking the pid distinguishes them: a dead owner -/// means the entry is stale even when the shared port answers. (The MCP-bridge discovery -/// path already did this; this brings the test/CLI path in line.) +/// means the entry is stale even when the shared port answers. +/// +/// An entry is DELETED only when its owner is definitely dead (R4-DISC1). A live owner +/// whose port did not answer one short probe (busy, still starting, mid-rebuild) is skipped +/// for now but kept — the plugin writes its entry once, so deleting it made a live app +/// undiscoverable for its whole lifetime. An owner whose liveness could not be established +/// (no working `kill`, an unreadable elevated process) is likewise skipped, never deleted. fn find_live_servers_in( base: &std::path::Path, - is_alive: impl Fn(u32) -> bool, + liveness: impl Fn(u32) -> crate::process::Liveness, is_reachable: impl Fn(u16) -> bool, ) -> Vec { + use crate::process::Liveness; + if !dir_is_trusted(base) { return Vec::new(); } @@ -303,30 +609,51 @@ fn find_live_servers_in( if !dir_is_trusted(&path) { continue; } - // A dead owner process means this entry is stale — even if its advertised port is - // reachable, that is a DIFFERENT live app now holding the shared default port, not - // this one. Reachability can't tell them apart, so gate on the pid. - if !is_alive(pid) { - let _ = std::fs::remove_dir_all(&path); - continue; + match liveness(pid) { + Liveness::Own => {} + // Definitely gone: the entry is stale even if its advertised (shared default) + // port answers — that is a DIFFERENT live app. Clean it up. + Liveness::Dead => { + let _ = std::fs::remove_dir_all(&path); + continue; + } + // Alive but not verifiably ours, or unknowable: never use its token, never + // delete it. + _ => continue, } - let port_path = path.join("port"); - let Ok(port_str) = std::fs::read_to_string(&port_path) else { + let Ok(port_str) = std::fs::read_to_string(path.join("port")) else { continue; }; let Ok(port) = port_str.trim().parse::() else { continue; }; - // Live owner but unreachable port = mid-rebuild / not listening yet — not usable now. + // Live owner but unreachable port = busy / not listening yet — not usable NOW, but + // the entry stays for the next scan. if !is_reachable(port) { - let _ = std::fs::remove_dir_all(&path); continue; } let token = std::fs::read_to_string(path.join("token")) .ok() .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()); - servers.push(DiscoveredServer { pid, port, token }); + let metadata = std::fs::read_to_string(path.join("metadata.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()); + let field = |key: &str| { + metadata + .as_ref() + .and_then(|meta| meta.get(key)) + .and_then(serde_json::Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + servers.push(DiscoveredServer { + pid, + port, + token, + identifier: field("identifier"), + product_name: field("product_name"), + }); } servers } @@ -339,31 +666,43 @@ fn port_is_reachable(port: u16) -> bool { .is_ok() } -/// Cross-platform "is this pid a live process?" — mirrors the audited helper in the -/// MCP-bridge discovery path (`victauri-cli::bridge`). Used to prune discovery entries -/// whose owning app has exited even when a different app holds the same shared port. -#[cfg(windows)] -fn is_process_alive(pid: u32) -> bool { - std::process::Command::new("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) -} - -#[cfg(not(windows))] -fn is_process_alive(pid: u32) -> bool { - // `kill -0` sends no signal but succeeds iff the process exists and is signalable by us - // (discovery entries are our own user's processes). Portable across Linux and macOS. - std::process::Command::new("kill") - .args(["-0", &pid.to_string()]) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) -} - #[cfg(test)] mod tests { use super::*; + use crate::process::Liveness; + + /// Audit N6: the per-user root is scanned first; the legacy shared root is last. + #[cfg(unix)] + #[test] + fn discovery_roots_are_per_user_first() { + let roots = real_discovery_roots(); + let euid = current_euid().unwrap(); + assert!(roots.contains(&std::env::temp_dir().join(format!("victauri-{euid}")))); + assert_eq!(roots.last(), Some(&std::env::temp_dir().join("victauri"))); + assert_ne!(roots[0], std::env::temp_dir().join("victauri")); + // R5B-LINDISC1: the home fallback is scanned after the per-user temp root, before + // the legacy one. + let home = home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) + .expect("HOME is set in the test environment"); + let at = |p: &std::path::Path| roots.iter().position(|r| r == p); + let tmp_root = std::env::temp_dir().join(format!("victauri-{euid}")); + assert!(at(&home) > at(&tmp_root), "{roots:?}"); + assert!(at(&home) < Some(roots.len() - 1), "{roots:?}"); + } + + #[cfg(unix)] + #[test] + fn home_state_root_prefers_an_absolute_xdg_state_home() { + assert_eq!( + home_state_root(Some("/x/state".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/x/state/victauri")) + ); + assert_eq!( + home_state_root(Some("rel".into()), Some("/home/u".into())), + Some(std::path::PathBuf::from("/home/u/.local/state/victauri")) + ); + assert_eq!(home_state_root(None, Some("rel".into())), None); + } #[cfg(unix)] #[test] @@ -413,6 +752,8 @@ mod tests { pid: 10, port: 7374, token: Some("token-b".to_string()), + identifier: None, + product_name: None, }]; assert_eq!( unique_connection(&servers), @@ -427,11 +768,15 @@ mod tests { pid: 10, port: 7373, token: Some("token-a".to_string()), + identifier: None, + product_name: None, }, DiscoveredServer { pid: 11, port: 7374, token: Some("token-b".to_string()), + identifier: None, + product_name: None, }, ]; assert_eq!( @@ -445,11 +790,15 @@ mod tests { pid: 12, port: 7373, token: Some("old-token".to_string()), + identifier: None, + product_name: None, }, DiscoveredServer { pid: 13, port: 7373, token: Some("new-token".to_string()), + identifier: None, + product_name: None, }, ]; assert_eq!(unique_token_for_port(&duplicate, 7373), None); @@ -470,7 +819,17 @@ mod tests { std::fs::write(dir.join("token"), token).unwrap(); } // pid 2 alive, pid 1 dead; the shared port answers for both. - let servers = find_live_servers_in(base.path(), |pid| pid == 2, |_port| true); + let servers = find_live_servers_in( + base.path(), + |pid| { + if pid == 2 { + Liveness::Own + } else { + Liveness::Dead + } + }, + |_port| true, + ); assert_eq!( servers.len(), @@ -495,22 +854,290 @@ mod tests { ); } + fn server(pid: u32, port: u16, token: &str, identifier: Option<&str>) -> DiscoveredServer { + DiscoveredServer { + pid, + port, + token: Some(token.to_string()), + identifier: identifier.map(str::to_string), + product_name: None, + } + } + #[test] - fn live_owner_with_unreachable_port_is_pruned() { - // A live owning process whose port isn't listening yet (mid-rebuild) is not usable now. + fn rediscovery_pins_the_app_identity() { + // The restarted app came back on 7374; a DIFFERENT app now holds 7373. + let servers = vec![ + server(20, 7373, "other-token", Some("com.other.app")), + server(21, 7374, "mine-token", Some("com.mine.app")), + ]; + assert_eq!( + select_for_identity(&servers, Some("com.mine.app")), + Some((7374, Some("mine-token".to_string()))) + ); + } + + #[test] + fn rediscovery_never_falls_back_when_the_app_is_absent() { + // Only a different app is live (on the default port): no match => None, never + // that app and never a default-port guess. + let servers = vec![server(20, 7373, "other-token", Some("com.other.app"))]; + assert_eq!(select_for_identity(&servers, Some("com.mine.app")), None); + assert_eq!(select_for_identity(&[], Some("com.mine.app")), None); + } + + #[test] + fn rediscovery_rejects_an_ambiguous_identity_match() { + let servers = vec![ + server(20, 7373, "a", Some("com.mine.app")), + server(21, 7374, "b", Some("com.mine.app")), + ]; + assert_eq!(select_for_identity(&servers, Some("com.mine.app")), None); + } + + #[test] + fn rediscovery_without_known_identity_keeps_the_unique_server_rule() { + let one = vec![server(20, 7375, "t", None)]; + assert_eq!( + select_for_identity(&one, None), + Some((7375, Some("t".to_string()))) + ); + let two = vec![server(20, 7375, "t", None), server(21, 7376, "u", None)]; + assert_eq!(select_for_identity(&two, None), None); + } + + #[test] + fn scan_reads_the_identifier_from_metadata() { + let base = tempfile::tempdir().unwrap(); + let dir = base.path().join("77"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7380").unwrap(); + std::fs::write(dir.join("token"), "tok").unwrap(); + std::fs::write( + dir.join("metadata.json"), + r#"{"pid":77,"port":7380,"identifier":"com.meta.app","product_name":"Meta"}"#, + ) + .unwrap(); + let servers = find_live_servers_in(base.path(), |_| Liveness::Own, |_| true); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].identifier.as_deref(), Some("com.meta.app")); + assert_eq!( + select_for_identity(&servers, Some("com.meta.app")), + Some((7380, Some("tok".to_string()))) + ); + } + + #[test] + fn live_owner_with_unreachable_port_is_skipped_but_kept() { + // A live owning process whose port did not answer one short probe (busy, starting, + // mid-rebuild) is not usable NOW — but its entry must survive: the plugin writes it + // once, so deleting it made the app undiscoverable for its whole life (R4-DISC1). + // (This test used to assert the deletion — that was the bug.) let base = tempfile::tempdir().unwrap(); let dir = base.path().join("4242"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("port"), "7373").unwrap(); std::fs::write(dir.join("token"), "tok").unwrap(); - let servers = find_live_servers_in(base.path(), |_pid| true, |_port| false); + let servers = find_live_servers_in(base.path(), |_pid| Liveness::Own, |_port| false); assert!( servers.is_empty(), "unreachable port => not a usable server" ); assert!( - !base.path().join("4242").exists(), - "the unreachable dir is pruned" + base.path().join("4242").exists(), + "a live app's discovery entry must never be deleted" + ); + // The next scan, once the app answers, finds it. + let servers = find_live_servers_in(base.path(), |_pid| Liveness::Own, |_port| true); + assert_eq!(servers.len(), 1); + } + + #[test] + fn unverifiable_or_unknown_owners_are_neither_used_nor_deleted() { + // R4-DISC1: an alive-but-unreadable owner (elevated app on Windows) or an owner whose + // liveness could not be checked at all (no `kill` binary) must not lose its entry, + // and must not have its token used. + let base = tempfile::tempdir().unwrap(); + for pid in ["10", "11", "12"] { + let dir = base.path().join(pid); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7373").unwrap(); + std::fs::write(dir.join("token"), format!("tok-{pid}")).unwrap(); + } + let servers = find_live_servers_in( + base.path(), + |pid| match pid { + 10 => Liveness::Unverified, + 11 => Liveness::Unknown, + _ => Liveness::OtherUser, + }, + |_| true, + ); + assert!( + servers.is_empty(), + "no token may be used from these entries" + ); + for pid in ["10", "11", "12"] { + assert!(base.path().join(pid).exists(), "entry {pid} must survive"); + } + } + + fn named( + pid: u32, + port: u16, + token: &str, + identifier: &str, + product: &str, + ) -> DiscoveredServer { + DiscoveredServer { + pid, + port, + token: Some(token.to_string()), + identifier: Some(identifier.to_string()), + product_name: Some(product.to_string()), + } + } + + #[test] + fn an_explicit_token_without_a_port_goes_only_to_the_app_that_owns_it() { + // R4-TOK1: VICTAURI_AUTH_TOKEN with no VICTAURI_PORT used to resolve to + // (7373, token) — handing the token to whoever squats the default port. + let servers = vec![ + named(20, 7373, "squatter-sees-nothing", "com.other.app", "Other"), + named(21, 7374, "my-token", "com.mine.app", "Mine"), + ]; + assert_eq!( + resolve_from(None, Some("my-token".into()), None, &servers), + Ok((7374, Some("my-token".to_string()))) + ); + // No running app carries it: refuse (never the default port), and say how to fix. + let err = resolve_from(None, Some("my-token".into()), None, &servers[..1]).unwrap_err(); + assert!(err.contains("VICTAURI_PORT"), "{err}"); + let err = resolve_from(None, Some("my-token".into()), None, &[]).unwrap_err(); + assert!(err.contains("not sent anywhere"), "{err}"); + // Explicit port + explicit token keeps the old behaviour exactly. + assert_eq!( + resolve_from(Some(7373), Some("t".into()), None, &servers), + Ok((7373, Some("t".to_string()))) + ); + } + + #[test] + fn several_live_apps_are_an_error_naming_each_and_how_to_select() { + // R4-CLI1: with two apps and no selector, the CLI silently fell back to + // (7373, no token) and then blamed a 401 on a stale CLI. + let servers = vec![ + named(20, 7373, "a", "com.a.app", "A"), + named(21, 7374, "b", "com.b.app", "B"), + ]; + let err = resolve_from(None, None, None, &servers).unwrap_err(); + assert!(err.contains("com.a.app (port 7373"), "{err}"); + assert!(err.contains("com.b.app (port 7374"), "{err}"); + assert!( + err.contains("VICTAURI_APP") && err.contains("--app"), + "{err}" ); + assert!(err.contains("VICTAURI_PORT"), "{err}"); + // A selector picks one, by exact identifier or exact product name. + assert_eq!( + resolve_from(None, None, Some("com.b.app"), &servers), + Ok((7374, Some("b".to_string()))) + ); + assert_eq!( + resolve_from(None, None, Some("A"), &servers), + Ok((7373, Some("a".to_string()))) + ); + // Never a substring match. + let err = resolve_from(None, None, Some("com.b"), &servers).unwrap_err(); + assert!(err.contains("No running Victauri app matches"), "{err}"); + // One app, or none, behaves as before. + assert_eq!( + resolve_from(None, None, None, &servers[..1]), + Ok((7373, Some("a".to_string()))) + ); + assert_eq!(resolve_from(None, None, None, &[]), Ok((7373, None))); + } + + /// Hand `path` to `NT AUTHORITY\SYSTEM` — the stand-in for "another user" (needs an + /// elevated test run; `false` when that is not possible, and the caller skips). + #[cfg(windows)] + fn give_to_system(path: &std::path::Path) -> bool { + std::process::Command::new("icacls") + .arg(path) + .args(["/setowner", "*S-1-5-18", "/q"]) + .output() + .is_ok_and(|o| o.status.success()) + } + + /// R5B-WINDISC1: on a shared TEMP another user can plant `victauri\\…`; the + /// readers must refuse a root or entry directory this user does not own. + #[cfg(windows)] + #[test] + fn windows_discovery_refuses_directories_owned_by_another_user() { + let write_entry = |base: &std::path::Path, pid: &str| { + let dir = base.join(pid); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("port"), "7373").unwrap(); + std::fs::write(dir.join("token"), format!("tok-{pid}")).unwrap(); + dir + }; + // A planted entry in a root we own. + let base = tempfile::tempdir().unwrap(); + write_entry(base.path(), "10"); + let planted = write_entry(base.path(), "11"); + if !give_to_system(&planted) { + eprintln!("skipped: cannot change a directory's owner (not elevated)"); + return; + } + let servers = find_live_servers_in(base.path(), |_| Liveness::Own, |_| true); + let pids: Vec = servers.iter().map(|s| s.pid).collect(); + assert_eq!(pids, [10], "the planted entry's token must never be used"); + assert!( + planted.exists(), + "a foreign directory is never deleted either" + ); + + // A planted ROOT: nothing under it is trusted, even entries we own. + let root = tempfile::tempdir().unwrap(); + write_entry(root.path(), "12"); + assert!(give_to_system(root.path())); + assert!(find_live_servers_in(root.path(), |_| Liveness::Own, |_| true).is_empty()); + } + + /// R5B-PORTAPP1: an explicit port and an app selector must agree. + #[test] + fn an_explicit_port_and_a_disagreeing_app_selector_are_refused() { + let servers = vec![ + named(20, 7373, "a", "com.a.app", "A"), + named(21, 7374, "b", "com.b.app", "B"), + ]; + let err = resolve_from(Some(7373), None, Some("com.b.app"), &servers).unwrap_err(); + assert!( + err.contains("VICTAURI_PORT=7373") && err.contains("com.a.app"), + "{err}" + ); + assert!(err.contains("com.b.app"), "{err}"); + // With an explicit token too. + assert!(resolve_from(Some(7373), Some("t".into()), Some("com.b.app"), &servers).is_err()); + // Agreeing (by identifier or product name, any case) is fine. + assert_eq!( + resolve_from(Some(7374), None, Some("b"), &servers), + Ok((7374, Some("b".to_string()))) + ); + // No discovery entry on the port: left to the client's `/info` check. + assert_eq!( + resolve_from(Some(7999), None, Some("com.b.app"), &servers), + Ok((7999, None)) + ); + } + + #[test] + fn duplicate_identifiers_are_ambiguous_not_first_wins() { + let servers = vec![ + named(20, 7373, "a", "com.dup.app", "Dup"), + named(21, 7374, "b", "com.dup.app", "Dup"), + ]; + let err = resolve_from(None, None, Some("com.dup.app"), &servers).unwrap_err(); + assert!(err.contains("pid 20") && err.contains("pid 21"), "{err}"); } } diff --git a/crates/victauri-test/src/error.rs b/crates/victauri-test/src/error.rs index 70f5e9ef..d60efce7 100644 --- a/crates/victauri-test/src/error.rs +++ b/crates/victauri-test/src/error.rs @@ -77,9 +77,19 @@ impl std::fmt::Display for TestError { -32603 => "\n Hint: internal error — check the Tauri app's stderr for details", _ => "", }; - write!(f, "MCP error {code}: {message}{hint}") + // The message can carry page-derived text (R5B-TERM3). + write!( + f, + "MCP error {code}: {}{hint}", + crate::terminal::untrusted_multi_line(message) + ) } - Self::ToolError(msg) => write!(f, "tool call failed: {msg}"), + // A tool error is often a page's own exception text (R5B-TERM3). + Self::ToolError(msg) => write!( + f, + "tool call failed: {}", + crate::terminal::untrusted_multi_line(msg) + ), Self::Assertion(msg) => write!(f, "assertion failed: {msg}"), Self::Timeout(msg) => { write!( @@ -122,3 +132,39 @@ impl std::error::Error for TestError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// R5B-TERM3: a tool error / JSON-RPC error message can carry page-derived text (a JS + /// exception message). Printed raw, it could drive the terminal (ESC sequences) or forge a + /// GitHub Actions annotation on a line of its own (`::error …`). Legitimate line breaks stay. + #[test] + fn untrusted_error_text_is_neutralized_but_keeps_line_breaks() { + let hostile = "boom at line 1\n::error file=src/main.rs::forged\n ::warning::x\n\ + ##[error]legacy\u{1b}[2J\u{9b}31m\u{202e}rtl"; + for rendered in [ + TestError::ToolError(hostile.to_string()).to_string(), + TestError::Mcp { + code: -32000, + message: hostile.to_string(), + } + .to_string(), + ] { + assert!(rendered.contains("boom at line 1\n"), "{rendered}"); + for line in rendered.lines() { + let t = line.trim_start(); + assert!( + !t.starts_with("::") && !t.starts_with("##["), + "a line became a workflow command: {line:?}\n{rendered}" + ); + } + assert!( + !rendered.chars().any(|c| c.is_control() && c != '\n') + && !rendered.contains('\u{202e}'), + "raw control/bidi characters: {rendered:?}" + ); + } + } +} diff --git a/crates/victauri-test/src/lib.rs b/crates/victauri-test/src/lib.rs index 4b5c1b3c..e37b5eab 100644 --- a/crates/victauri-test/src/lib.rs +++ b/crates/victauri-test/src/lib.rs @@ -1,5 +1,6 @@ #![deny(missing_docs)] -#![forbid(unsafe_code)] +// `deny`, not `forbid`: the one audited exception is the Win32 liveness query in `process`. +#![deny(unsafe_code)] //! Integration testing for Tauri apps via the Victauri MCP server. //! //! Provides [`TestApp`] for managed app lifecycle and [`VictauriClient`] with @@ -43,8 +44,12 @@ mod discovery; mod error; pub mod locator; pub mod prelude; +#[doc(hidden)] +pub mod process; pub mod reporting; pub mod smoke; +#[doc(hidden)] +pub mod terminal; pub mod visual; pub use app::TestApp; @@ -52,9 +57,12 @@ pub use assertions::{ CheckResult, VerifyBuilder, VerifyReport, assert_ipc_called, assert_ipc_called_with, assert_ipc_not_called, }; +#[doc(hidden)] +pub use client::health_status_means_alive; pub use client::{ - MemoryStats, PluginInfo, VictauriClient, WaitForBuilder, assert_ipc_healthy, assert_json_eq, - assert_json_truthy, assert_no_a11y_violations, assert_performance_budget, assert_state_matches, + MemoryStats, PluginInfo, PluginToolInfo, VictauriClient, WaitForBuilder, assert_ipc_healthy, + assert_json_eq, assert_json_truthy, assert_no_a11y_violations, assert_performance_budget, + assert_state_matches, }; pub use error::TestError; pub use locator::{Bounds, Locator, LocatorExpect, LocatorMatch}; diff --git a/crates/victauri-test/src/locator.rs b/crates/victauri-test/src/locator.rs index 87c1f0ef..37479ecd 100644 --- a/crates/victauri-test/src/locator.rs +++ b/crates/victauri-test/src/locator.rs @@ -35,6 +35,7 @@ use crate::error::TestError; /// Bounding rectangle of a DOM element in CSS pixels. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] pub struct Bounds { /// X offset from the viewport left edge. pub x: f64, @@ -48,6 +49,7 @@ pub struct Bounds { /// A single element resolved from a [`Locator`] query. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub struct LocatorMatch { /// Ref handle ID used to target this element in subsequent actions. pub ref_id: String, @@ -422,42 +424,39 @@ impl Locator { client.select_option(&el.ref_id, values).await } - /// Check a checkbox or radio button (sets `checked = true`). + /// Check a checkbox or radio button. + /// + /// Toggles it with a real `click()` when it is not already checked, so the page + /// sees the `click`/`input`/`change` sequence a user produces. (Assigning + /// `el.checked` is invisible to React-controlled inputs: React's value tracker + /// sees no change and swallows the synthetic `change` event.) /// /// # Errors /// - /// Returns [`TestError::ElementNotFound`] if no element matches. + /// Returns [`TestError::ElementNotFound`] if no element matches or it went + /// stale, and [`TestError::Assertion`] if it did not end up checked (disabled, + /// or a handler prevented the change). pub async fn check(&self, client: &mut VictauriClient) -> Result { - let el = self.resolve_one(client).await?; - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({}); \ - if (!el) return null; \ - if (!el.checked) {{ el.checked = true; \ - el.dispatchEvent(new Event('change', {{bubbles:true}})); \ - el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \ - return true; }})()", - serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()), - ); - client.eval_js(&code).await + self.set_checked(client, true).await } - /// Uncheck a checkbox (sets `checked = false`). + /// Uncheck a checkbox (toggled the same way as [`check`](Self::check)). /// /// # Errors /// - /// Returns [`TestError::ElementNotFound`] if no element matches. + /// Returns [`TestError::ElementNotFound`] if no element matches or it went + /// stale, and [`TestError::Assertion`] for a checked radio button (a user cannot + /// uncheck one either) or if it did not end up unchecked. pub async fn uncheck(&self, client: &mut VictauriClient) -> Result { - let el = self.resolve_one(client).await?; - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({}); \ - if (!el) return null; \ - if (el.checked) {{ el.checked = false; \ - el.dispatchEvent(new Event('change', {{bubbles:true}})); \ - el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \ - return true; }})()", - serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()), - ); - client.eval_js(&code).await + self.set_checked(client, false).await + } + + async fn set_checked( + &self, + client: &mut VictauriClient, + want: bool, + ) -> Result { + self.eval_on_element(client, &set_checked_js(want)).await } // ── Query methods ─────────────────────────────────────────────────── @@ -524,7 +523,8 @@ impl Locator { /// /// Returns [`TestError::ElementNotFound`] if no element matches. pub async fn is_checked(&self, client: &mut VictauriClient) -> Result { - let val = self.eval_on_element(client, "return !!el.checked;").await?; + let body = format!("{CHECKED_STATE_JS} return isOn();"); + let val = self.eval_on_element(client, &body).await?; Ok(val.as_bool().unwrap_or(false)) } @@ -573,8 +573,9 @@ impl Locator { client: &mut VictauriClient, attr_name: &str, ) -> Result, TestError> { - let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\""); - let js_body = format!("return el.getAttribute(\"{escaped}\");"); + // A JSON string is a valid JS string literal for any input (newlines, U+2028…). + let name = serde_json::to_string(attr_name).unwrap_or_else(|_| "\"\"".to_string()); + let js_body = format!("return el.getAttribute({name});"); let val = self.eval_on_element(client, &js_body).await?; if val.is_null() { Ok(None) @@ -604,13 +605,8 @@ impl Locator { let elements = self.resolve_all(client).await?; let mut texts = Vec::with_capacity(elements.len()); for el in &elements { - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({}); \ - if (!el) return \"\"; \ - return el.textContent || \"\"; }})()", - serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()), - ); - let val = client.eval_js(&code).await?; + let code = element_script(&el.ref_id, "return el.textContent || \"\";"); + let val = element_result(client.eval_js(&code).await?, self)?; texts.push(value_to_string(&val)); } Ok(texts) @@ -823,15 +819,44 @@ impl Locator { js_body: &str, ) -> Result { let el = self.resolve_one(client).await?; - let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()); - let code = format!( - "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \ - if (!el) return null; {js_body} }})()" - ); - client.eval_js(&code).await + let code = element_script(&el.ref_id, js_body); + element_result(client.eval_js(&code).await?, self) } } +/// Marker an [`element_script`] returns when its ref no longer resolves. +const MISSING_KEY: &str = "__victauri_missing_ref"; +/// Marker an [`element_script`] returns, with a message, when its action failed. +const ERROR_KEY: &str = "__victauri_error"; + +/// Wraps `js_body` (which sees the element as `el`) in a script that reports a ref +/// that no longer resolves — instead of returning `null`, which callers would read +/// as an empty text, an absent attribute or an unchecked box. +fn element_script(ref_id: &str, js_body: &str) -> String { + let ref_str = serde_json::to_string(ref_id).unwrap_or_else(|_| "\"\"".to_string()); + format!( + "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \ + if (!el) return {{ {MISSING_KEY}: true }}; {js_body} }})()" + ) +} + +/// Turns an [`element_script`] result's markers into errors. +fn element_result(val: Value, locator: &Locator) -> Result { + if val.get(MISSING_KEY).and_then(Value::as_bool) == Some(true) { + return Err(TestError::ElementNotFound(format!( + "{locator} (the element went stale before it could be read)" + ))); + } + if let Some(msg) = val.get(ERROR_KEY).and_then(Value::as_str) { + // The message comes from the page: never let it drive a terminal or a CI log (R5-TERM2). + return Err(TestError::Assertion(format!( + "{locator}: {}", + crate::terminal::single_line(msg) + ))); + } + Ok(val) +} + impl fmt::Display for Locator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.strategy { @@ -1496,12 +1521,50 @@ async fn check_checked(locator: &Locator, client: &mut VictauriClient) -> Result let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()); let code = format!( "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \ - if (!el) return false; return !!el.checked; }})()" + if (!el) return false; {CHECKED_STATE_JS} return isOn(); }})()" ); let val = client.eval_js(&code).await?; Ok(val.as_bool().unwrap_or(false)) } +/// JS (sees the element as `el`) defining `isOn()`: the checked state of a native +/// checkbox/radio (`el.checked`), else of an ARIA checkbox/radio/switch/menuitemcheckbox +/// (`aria-checked`, as Radix/Mantine/Headless UI render them on a `