diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000000..4481ebc6ba3 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,41 @@ +nextest-version = "0.9.136" +# The PostgreSQL lane uses a run-scoped desired-state template database and a +# per-test wrapper, both of which require nextest's script support. +experimental = ["setup-scripts", "wrapper-scripts"] + +[scripts.setup.postgres-template] +# Bootstrap the desired-state source database once per nextest invocation. +command = { command-line = "scripts/postgres-test-setup.sh", relative-to = "workspace-root" } +slow-timeout = "60s" + +[scripts.wrapper.postgres-isolation] +# Clone or create a unique database for each test process, then drop it on exit. +command = { command-line = "scripts/postgres-test-wrapper.sh", relative-to = "workspace-root" } + +[profile.postgres-ci] +# This structural convention keeps new PostgreSQL-backed tests discoverable +# without maintaining an exact list of test names. +default-filter = """ +(test(/postgres_tests::/) or binary(/^postgres_/)) +and not test(/(^|::)external_infra[^:]*::/) +""" +fail-fast = false +# Eight workers was the fastest stable setting in the Blox benchmark while the +# wrapper retained one database per concurrently running test process. +test-threads = 8 + +[test-groups.postgres-cluster-global] +# These tests inspect cluster-wide activity or create least-privilege sessions, +# so database-per-test isolation alone cannot make them independent. +max-threads = 1 + +[[profile.postgres-ci.overrides]] +filter = "test(/cluster_global_/)" +test-group = "postgres-cluster-global" + +[[profile.postgres-ci.scripts]] +# Script filters are separate from default-filter: they attach the setup and +# isolation wrapper to the same automatically discovered test set. +filter = "(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)" +setup = "postgres-template" +run-wrapper = "postgres-isolation" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c509848b66c..8fbc6f39015 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,14 @@ jobs: - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' + - '.config/nextest.toml' + - 'scripts/postgres-test-*.sh' + - 'scripts/reconcile-schema-after-pgschema.sql' + - 'bin/pgschema' + - 'bin/.pgschema-*.pkg' + - 'scripts/check-postgres-test-discovery.py' + - 'scripts/test-postgres-test-discovery.sh' + - 'scripts/test-postgres-test-wrapper.sh' - 'deny.toml' - '.github/workflows/ci.yml' - 'scripts/run-tests.sh' @@ -73,6 +81,11 @@ jobs: - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' + - name: Validate PostgreSQL test discovery + if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' + run: | + scripts/test-postgres-test-discovery.sh + scripts/test-postgres-test-wrapper.sh - name: Release workflow source contract run: scripts/test-release-ref-contract.sh - name: Relay image eligibility contract @@ -87,6 +100,8 @@ jobs: run: | scripts/test-mobile-release-contract.sh scripts/test-mobile-release-candidate-publisher.sh + - name: Desktop instance environment contract + run: scripts/test-desktop-instance-detection.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh - name: Codex security review contract @@ -354,7 +369,8 @@ jobs: target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 @@ -388,9 +404,22 @@ jobs: -p buzz-relay \ -p buzz-test-client \ --lib \ - --bin buzz-relay \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst + postgres_package_args=() + while IFS= read -r package; do + postgres_package_args+=(-p "$package") + done < <(scripts/postgres-test-packages.sh) + if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then + echo "no PostgreSQL test packages were discovered" >&2 + exit 1 + fi + cargo nextest archive \ + --cargo-profile ci \ + "${postgres_package_args[@]}" \ + --lib \ + --tests \ + --archive-file target/ci/postgres-tests.tar.zst - name: Save relay artifacts cache # PR-scoped exact-source entries cannot warm main or other PRs and churn # the shared cache pool. sccache provides read-only PR reuse instead. @@ -401,7 +430,8 @@ jobs: target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - name: Upload relay artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -410,9 +440,86 @@ jobs: target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst if-no-files-found: error retention-days: 1 + postgres-tests: + name: PostgreSQL Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [changes, desktop-e2e-relay] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U buzz -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PG_BIN_DIR: /usr/bin + REDIS_URL: redis://localhost:6379 + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Tune disposable PostgreSQL + env: + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + # Database-per-test cloning forces checkpoints. Durability is redundant + # for this disposable service and makes runtime depend on runner disk I/O. + psql --dbname postgres --set ON_ERROR_STOP=1 <<'SQL' + ALTER SYSTEM SET fsync = off; + ALTER SYSTEM SET full_page_writes = off; + ALTER SYSTEM SET synchronous_commit = off; + SELECT pg_reload_conf(); + SQL + psql --dbname postgres --tuples-only --no-align --command \ + "SELECT bool_and(setting = 'off') FROM pg_settings WHERE name IN ('fsync', 'full_page_writes', 'synchronous_commit')" \ + | grep --fixed-strings --line-regexp t + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Download backend test archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: PostgreSQL-backed tests + env: + BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + scripts/postgres-test-run.sh \ + --archive-file target/ci/postgres-tests.tar.zst + desktop-e2e-integration-shard: name: Desktop E2E Integration (${{ matrix.shard }}/2) runs-on: ubuntu-latest @@ -693,68 +800,14 @@ jobs: VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" - - name: Workflow message provenance tests + - name: Workflow message provenance unit tests # The relay's workflow_sink suite is not selected by the infra-free - # unit job. Run both its pure tests and ignored PostgreSQL tests here so - # authored-template provenance cannot regress behind a green CI build. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(/workflow_sink/)' \ - --run-ignored all - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Replaceable persistence PostgreSQL tests - # Transaction, concurrency, and mention-index coverage for the - # replaceable-event store seam. These tests require real Postgres and - # are ignored by the infrastructure-free unit-test job. - run: | - filter='package(buzz-db) and test(/tests::(parameterized_|concurrent_parameterized_)/)' - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E "${filter}" \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Database pressure observability PostgreSQL tests - # Explicit pool acquisition and advisory-lock metrics require real - # Postgres and are ignored by the infrastructure-free unit-test job. - run: | - filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E "${filter}" \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Writer session timeout guardrails - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-db) and test(session_timeouts_install_through_db_new_and_bound_lock_waits)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Audit writer session timeout guardrails - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Audit worker lock-timeout recovery + # unit job. Its ignored database cases run in the isolated PostgreSQL + # lane; keep the pure provenance cases covered here without duplication. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(audit_worker_retries_lock_timeout_until_original_entry_is_appended_once)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + -E 'package(buzz-relay) and test(/workflow_sink/)' - name: Start relay run: | chmod +x ./target/ci/buzz-relay @@ -783,26 +836,6 @@ jobs: done cat /tmp/buzz-relay.log exit 1 - - name: Invite security tests - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Workspace profile (kind:9033) gate tests - # Call-site integration for the 9033 authorization gate: open relay - # rosterless/steward transitions and the closed-relay admin/owner rule, - # against real Postgres. #[ignore]d in the default suite, selected - # explicitly here — see handlers::relay_admin::tests. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against @@ -815,88 +848,6 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 - - name: NIP-MP coordinate deletion guard - # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: - # a stale tombstone (created_at earlier than the live head) spares that - # head, and an equal-timestamp tombstone deletes it. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API nip98 read-write attribution test - # The only real HTTP → nip98 operator principal → mutation → cross-table - # attribution coverage: an authenticated operator's dismiss attributes - # to the operator's own key with relay_operator authority. Staffing - # PUT/DELETE attribution is covered by - # nip98_staffing_put_and_delete_write_attributed_audit_rows in the - # roster-audit lane below. #[ignore]d in the default suite — see - # api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(=api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API unrostered-signer replay invariant - # The only causal proof that a validly-signing but unrostered key cannot - # consume NIP-98 replay slots: it asserts principal resolution fails - # BEFORE the replay ID is claimed (tracking.claim_count() == 0). This - # test is non-ignored, so it runs neither in Backend Integration's - # ignored-only selectors nor in the infra-free unit job — the unit job's - # api::admin selector excludes it because DB-free it only passes by - # waiting out the ~30s sqlx acquire timeout on a read-route fallthrough. - # It lives here so a reachable Postgres resolves (and fails) the lookup - # fast instead of timing out. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API roster-audit / timeout / canonicalization security tests - # Security-review fixes for the roster admin API, all #[ignore]d in the - # default suite (they need Postgres) and selected by no other job: - # - buzz-db relay_operators::tests: audit pre-image trail, per-target - # lock serialization, insertion-time audit ordering, and - # audit-failure rollback coupling. - # - buzz-db relay_operators::tests last-operator invariant: sole DB - # operator cannot self-demote or self-delete to zero, config presence - # lifts the guard, and concurrent cross-target deletes racing to zero - # leave exactly one operator (roster-wide advisory lock). - # - buzz-relay api::admin: NIP-98 staffing writes attributed audit rows, - # adversarial expirationSecs rejected at the resolve route, mixed-case - # staffing normalizes to one canonical row. - # - # --test-threads=1: the last-operator invariant counts the roster - # globally, and the sole-operator tests clear the roster then assert - # their operator is the only one. They must not race each other on the - # shared test roster, so this lane runs serially. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - --test-threads=1 \ - -E '(package(buzz-db) and test(=relay_operators::tests::roster_mutations_write_pre_image_audit_rows)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_upserts_serialize_and_record_true_pre_image)) or (package(buzz-db) and test(=relay_operators::tests::audit_order_follows_seq_under_backward_clock)) or (package(buzz-db) and test(=relay_operators::tests::audit_insert_failure_rolls_back_roster_mutation)) or (package(buzz-db) and test(=relay_operators::tests::demoting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::deleting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::config_present_allows_deleting_last_db_operator)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_deletes_racing_to_zero_leave_one_operator)) or (package(buzz-relay) and test(=api::admin::tests::nip98_staffing_put_and_delete_write_attributed_audit_rows)) or (package(buzz-relay) and test(=api::admin::tests::resolve_route_rejects_adversarial_expiration_and_leaves_report_open)) or (package(buzz-relay) and test(=api::admin::tests::mixed_case_non_config_staffing_normalizes_to_one_row))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API escalation-scoping tests - # Escalation scoping for the moderation queue, all #[ignore]d (they need - # Postgres) and selected by no other job: - # - GET /reports defaults to the escalated-only backstop, scope=all - # restores full visibility, explicit status= overrides the default. - # - member reports with category 'illegal' auto-escalate at ingestion - # while every other category still lands 'open'. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-relay) and test(=api::admin::tests::reports_default_lists_escalated_only)) or (package(buzz-relay) and test(=api::admin::tests::reports_scope_all_lists_every_status)) or (package(buzz-relay) and test(=api::admin::tests::reports_explicit_status_filter_overrides_default)) or (package(buzz-db) and test(=moderation::tests::illegal_report_auto_escalates_at_ingest)) or (package(buzz-db) and test(=moderation::tests::non_illegal_report_lands_open_at_ingest)) or (package(buzz-db) and test(=relay_admin_actions::tests::auto_escalated_report_reopens_like_an_admin_escalated_one))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 138d192fa12..9905e97b767 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -361,7 +361,7 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) |----------|---------| | `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. | | `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. | -| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. | +| `is_not_global_unicast(ip)` | SSRF protection: enumerated-deny policy — blocks a specific set of non-public address classes and accepts everything else (including addresses not covered by an explicit deny rule, e.g. `fe00::1`). Blocked IPv4 classes: loopback, private (RFC 1918), link-local, CGNAT (RFC 6598), benchmarking (RFC 2544), IETF Protocol Assignments (192.0.0.0/24, exceptions: 192.0.0.9 PCP anycast, 192.0.0.10 TURN anycast), documentation (RFC 5737: 192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526), multicast (RFC 5771, 224/4), reserved/class-E (240/4). Blocked IPv6 classes: loopback, unspecified, ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 anycast, 2001:3::/32 AMT, 2001:4:112::/48 AS112-v6, 2001:20::/28 ORCHIDv2, 2001:30::/28 DETs), documentation (2001:db8::/32, 3fff::/20), 6to4 (2002::/16), Discard-Only (100::/64), Dummy prefix (100:0:0:1::/64), SRv6 SIDs (5f00::/16), NAT64 local-use (64:ff9b:1::/48). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms checked recursively. Compat alias: `is_private_ip`. | **Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime. @@ -746,12 +746,10 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic ### SSRF Protection -`is_private_ip()` in `buzz-core` covers: -- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255) -- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32) -- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address +`is_not_global_unicast(ip)` (compat alias `is_private_ip`) in `buzz-core` is an enumerated-deny policy: it blocks a specific set of non-public address classes and accepts everything else, including addresses not covered by an explicit deny rule (e.g. `fe00::1`). Blocked IPv4 classes: loopback (127.0.0.0/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None/blank → conservative deny), multicast/RFC 5771 (224/4), and reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, global exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), and NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known (64:ff9b::/96, RFC 6052) forms is checked recursively against the IPv4 table; SIIT IPv4-translated (::ffff:0:0:0/96) follows the same path. -Applied in: `buzz-workflow` (CallWebhook action), `buzz-core` (shared utility). +Applied in: `buzz-auth` (JWKS boundary), `buzz-workflow` (CallWebhook action), +desktop `link_preview` (SSRF check). ### Audit Integrity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0247b570a23..dbe4ba5dd2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,6 +215,64 @@ connections, NIP-42 auth, event ingestion, search indexing, and workflow execution. `just test` starts Docker services automatically if they're not already running. +### PostgreSQL-backed tests + +PostgreSQL-backed tests run in a dedicated nextest lane. Mark them ignored with +a PostgreSQL reason and place them in a module whose name ends in +`postgres_tests`. Standalone integration-test targets use a `postgres_` +filename prefix instead. Tests that also require infrastructure beyond +PostgreSQL and Redis live under an `external_infra*_tests` module and are +excluded without changing their descriptive function names. + +See the [buzz-db testing guide](crates/buzz-db/TESTING.md) for the crate-level +checklist. + +`scripts/test-postgres-test-discovery.sh` enforces the convention across every +Rust source file. It fails CI when an ignored PostgreSQL test would be omitted, +or when a Redis-only or hybrid test is accidentally included, so module or file +renames cannot silently change lane membership. The archive and runner derive +their Cargo package set from the same markers, so a database test in a new crate +does not require a separate package-list update. + +The `postgres-ci` nextest profile creates one database per test process, so +destructive and concurrent tests must use the database URL supplied through +`BUZZ_TEST_DATABASE_URL`, `TEST_DATABASE_URL`, or `DATABASE_URL`; do not +hard-code the shared development database. Ordinary tests receive the committed +desired-state schema from `schema/schema.sql`. Tests under +`migration::postgres_tests` receive an empty database and own the embedded +migration lifecycle. A test outside that module whose behavior intentionally +depends on migration-created triggers or seed rows uses a +`migration_schema_` function-name prefix and also receives an empty database +with `BUZZ_TEST_SCHEMA_MODE=migration`. Test helpers that normally call the +migrator honor `BUZZ_TEST_SCHEMA_MODE=desired` so the desired-state contract is +not re-migrated. + +Tests that inspect cluster-wide PostgreSQL state or open least-privilege +sessions use a `cluster_global_` function-name segment; migration-backed cases +use `migration_schema_cluster_global_`. Nextest serializes this small group +while the database-isolated remainder stays parallel. + +The setup process requires a PostgreSQL role that can create and drop databases +and owns the databases it creates; the harness itself does not require +superuser access. The complete inventory includes privilege-boundary tests that +create temporary roles and inspect all sessions, so grant that role +`CREATEROLE` and membership in `pg_read_all_stats` (or use an ephemeral +superuser, as CI does). +Set `BUZZ_POSTGRES_ADMIN_URL` to that role's maintenance database, and set +`PGHOST`, `PGPORT`, `PGUSER`, and `PGPASSWORD` for the desired-state +schema bootstrap. PostgreSQL client tools are resolved from `PATH` unless +`PG_BIN_DIR` is set. Tests that use Redis read `REDIS_URL`. + +With native PostgreSQL and Redis running, the complete lane is below. The +runner bounds compilation to the packages discovered from the current source +tree and removes the run-scoped desired-state source database on exit. +Per-test and source-database cleanup retries transient PostgreSQL disconnect +races and emits a warning if all five attempts fail. + +```bash +./scripts/postgres-test-run.sh +``` + ### End-to-End Tests End-to-end tests live in `crates/buzz-test-client/tests/`: diff --git a/Cargo.lock b/Cargo.lock index b3e5dcf74ec..2ef284bd428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,10 +939,12 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "futures-util", "hex", "jsonwebtoken", "nostr 0.44.7", "rand 0.10.1", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index dee13966732..0e2c0897bc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,7 @@ chrono = { version = "0.4", features = ["serde"] } jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } # HTTP client (webhook delivery) -reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["json", "rustls", "stream"], default-features = false } # Cryptography sha2 = "0.11" diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 9ae1d168590..6819fe23ca3 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -269,7 +269,7 @@ fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result, + /// The authenticated key-source contract: validated JWKS URI, refresh + /// interval, and hard deadline. Included in `derive_assertion_policy_id` + /// so that a change to the endpoint, refresh schedule, or hard-deadline + /// rule changes the policy ID and invalidates all prepared evidence. + jwks_source_contract: JwksSourceContract, id: AssertionPolicyId, } @@ -382,6 +389,10 @@ pub enum IssuerPolicyError { /// so subject classification could not be total and mutually exclusive. #[error("subject class contract is not exclusive")] NonExclusiveSubjectClass, + /// The [`JwksSourceContract`] was not valid — invalid URI, zero or + /// out-of-range timing, or `refresh_interval >= hard_deadline`. + #[error("invalid JWKS source contract")] + InvalidJwksSourceContract, } impl IssuerPolicy { @@ -397,6 +408,7 @@ impl IssuerPolicy { skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: JwksSourceContract, ) -> Result { // Identity-bearing strings are validated for bounds but never mutated: // exact `iss`/`aud`/`sub` bytes select policies and form the identity @@ -459,6 +471,7 @@ impl IssuerPolicy { skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + &jwks_source_contract, ); Ok(Self { @@ -471,6 +484,7 @@ impl IssuerPolicy { skew_seconds, maximum_assertion_age_seconds, maximum_status_age_seconds, + jwks_source_contract, id, }) } @@ -524,6 +538,11 @@ impl IssuerPolicy { pub const fn id(&self) -> AssertionPolicyId { self.id } + + /// The authenticated key-source contract for this policy's JWKS endpoint. + pub fn jwks_source_contract(&self) -> &JwksSourceContract { + &self.jwks_source_contract + } } /// A closed set of issuer policies keyed by exact `iss`. Selection preserves @@ -560,6 +579,12 @@ impl IssuerRegistry { pub fn is_empty(&self) -> bool { self.policies.is_empty() } + + /// Iteration order is deliberately unspecified; callers must not depend on + /// registration order. + pub fn all_policies(&self) -> impl Iterator { + self.policies.values() + } } /// Sort and deduplicate a set-valued list of strings into its canonical form. @@ -625,6 +650,7 @@ fn derive_assertion_policy_id( skew_seconds: u64, maximum_assertion_age_seconds: u64, maximum_status_age_seconds: Option, + jwks_source_contract: &JwksSourceContract, ) -> AssertionPolicyId { let mut hasher = Sha256::new(); hasher.update(b"buzz:nip-fi:assertion-policy:v1\0"); @@ -680,6 +706,23 @@ fn derive_assertion_policy_id( hasher.update(skew_seconds.to_be_bytes()); hasher.update(maximum_assertion_age_seconds.to_be_bytes()); hasher.update(maximum_status_age_seconds.unwrap_or(0).to_be_bytes()); + // Authenticated key-source contract (NIP-FI.md, "Policy identity and + // snapshots"): URI selects the authenticated source; interval defines + // bounded refresh; hard deadline defines the accepted time rule. These are + // contract, not mutable state — key rotation (JWKS content change) leaves + // all three unchanged and must not move the ID. + hasher.update(b"jwks-source-contract\0"); + hash_field(&mut hasher, jwks_source_contract.jwks_uri().as_bytes()); + hasher.update( + jwks_source_contract + .refresh_interval_seconds() + .to_be_bytes(), + ); + hasher.update( + jwks_source_contract + .key_snapshot_hard_deadline_seconds() + .to_be_bytes(), + ); AssertionPolicyId(hasher.finalize().into()) } diff --git a/crates/buzz-auth/src/nip_fi/discovery.rs b/crates/buzz-auth/src/nip_fi/discovery.rs new file mode 100644 index 00000000000..8d1b1500b12 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/discovery.rs @@ -0,0 +1,66 @@ +//! NIP-11 federated-identity discovery output. +//! +//! [`FederatedIdentityDiscovery`] serializes to the `federated_identity` +//! object required by the NIP-FI.md "Discovery" section of the NIP-11 relay +//! information document. +//! +//! ## Privacy invariants +//! +//! The discovery object MUST NOT contain: enrollment mode, TOFU posture, +//! issuer URLs, audiences, claim names, tenant IDs, or deployment-local +//! identifiers. For a fixed set of claimed profiles the complete output is +//! byte-identical across every enrollment policy and lifecycle state. +//! [FI-TRACE-DISCOVERY-PRIVATE] +//! +//! ## Offline-jwt residual bound +//! +//! `maximum_residual_upstream_revocation_seconds` is `null` for `offline-jwt` +//! deployments. An offline-jwt deployment MUST NOT advertise a finite value +//! here (NIP-FI.md:259-266). + +use serde::{Deserialize, Serialize}; + +/// The `assertion_freshness` sub-object in the `federated_identity` discovery +/// document. Describes the claimed freshness posture without exposing any +/// issuer or deployment-private state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssertionFreshnessDiscovery { + /// The wire string identifying the freshness class. + pub class: FreshnessClassDiscovery, + /// `null` for `offline-jwt`; advertising a finite bound here requires a + /// live status witness that is not yet implemented. + pub maximum_residual_upstream_revocation_seconds: Option, +} + +/// The freshness class as a stable NIP-FI wire string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FreshnessClassDiscovery { + /// No revocation bound is claimed; JWKS snapshot validation only. + OfflineJwt, +} + +/// The `federated_identity` NIP-11 discovery object. Fields never expose +/// enrollment mode, issuer, audience, or private state. +/// [FI-TRACE-DISCOVERY-PRIVATE] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FederatedIdentityDiscovery { + /// Fixed value `"client-attached"` for the core NIP-FI transport mode. + pub core: String, + /// The freshness contract claimed by this deployment. + pub assertion_freshness: AssertionFreshnessDiscovery, +} + +impl FederatedIdentityDiscovery { + /// The only supported posture: claims no residual revocation bound, which + /// is the honest description of JWKS-only assertion verification. + pub fn offline_jwt() -> Self { + Self { + core: "client-attached".to_owned(), + assertion_freshness: AssertionFreshnessDiscovery { + class: FreshnessClassDiscovery::OfflineJwt, + maximum_residual_upstream_revocation_seconds: None, + }, + } + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs new file mode 100644 index 00000000000..618ee6b0696 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -0,0 +1,738 @@ +//! JWKS discovery, snapshot caching, and the production [`IssuerKeySource`] +//! implementation for federated-assertion verification. +//! +//! ## Design invariants +//! +//! - **Issuer binding is sealed.** [`ProductionJwksSource`] builds each +//! [`AssertionKeySet`] using the crate-private constructor and stores it +//! keyed by the exact `iss` it authenticates. A caller cannot relabel one +//! issuer's JWKS as another's — the cross-issuer bypass is closed at both +//! the request seam (the verifier re-checks `iss`) and here. +//! +//! - **No stale-key fallback.** On fetch error the source returns the current +//! snapshot if it is within its hard deadline, or `None`. It never serves +//! an expired snapshot. [FI-TRACE-JWKS-REMOVE] +//! +//! - **Bounded resource acquisition.** HTTP response streaming stops at +//! [`MAX_JWKS_RESPONSE_BYTES`] + 1 byte before any allocation for parsing. +//! Key count is bounded by [`super::config::MAX_JWKS_KEYS`] inside +//! [`AssertionKeySet::new`]. +//! +//! - **Coalesced refresh.** A single in-flight refresh per issuer prevents +//! thundering-herd. Concurrent callers observe the snapshot just after the +//! racing refresh commits. +//! +//! - **No secrets or key material in errors or logs.** [`JwksFetchError`] +//! carries only non-sensitive diagnostic codes. + +use super::config::MAX_JWKS_KEYS; +use super::verifier::{AssertionKeySet, IssuerKeySource}; +use buzz_core::network::is_not_global_unicast; +use chrono::{DateTime, Duration, Utc}; +use futures_util::StreamExt as _; +use jsonwebtoken::jwk::JwkSet; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; +use url::Url; + +/// Maximum HTTP response body for a JWKS endpoint. Streaming stops at this +/// limit before any deserialization, preventing OOM from a malicious server. +pub const MAX_JWKS_RESPONSE_BYTES: usize = 512 * 1024; // 512 KiB + +/// Hard upper bound on JWKS timing fields. Values above this are rejected at +/// config construction to prevent `u64`→`i64` conversion overflow and Chrono +/// range panics when computing snapshot deadlines. +pub const MAX_JWKS_TIMING_SECONDS: u64 = 365 * 24 * 3600; // 1 year + +/// Hard deadline for the complete JWKS fetch: hostname resolution, connect, +/// headers, and body streaming combined. Applied via `tokio::time::timeout` +/// so a stalled resolver cannot keep `fetch_jwks` pending indefinitely. +pub const JWKS_REQUEST_TIMEOUT_SECS: u64 = 10; + +/// Validate that a JWKS URI is safe to fetch: HTTPS scheme, no credentials, +/// no fragment, and the host (if a bare IP) is not private/reserved. +/// Hostname targets are resolved and checked at every fetch in `fetch_jwks` +/// to prevent DNS rebinding — this check catches the most common +/// misconfiguration at construction time. +pub fn validate_jwks_uri(uri: &str) -> Result<(), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + if parsed.scheme() != "https" { + return Err(JwksFetchError::InvalidUri); + } + // Credentials in the URI are never legitimate for a public JWKS endpoint + // and would be forwarded to the server, leaking material in logs. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Fragments are client-side only; their presence indicates a misconfigured URI. + if parsed.fragment().is_some() { + return Err(JwksFetchError::InvalidUri); + } + // Reject bare private/reserved IP targets at construction time. + if let Some(url::Host::Ipv4(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V4(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + if let Some(url::Host::Ipv6(addr)) = parsed.host() { + if is_not_global_unicast(&std::net::IpAddr::V6(addr)) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(()) +} + +/// The authenticated key-source contract owned by one [`IssuerPolicy`]. +/// +/// Encodes the three deployment-configured fields whose change alters which +/// keys the runtime trusts and how long it trusts them: +/// +/// - `jwks_uri` — selects the authenticated key source; a different endpoint +/// may serve different keys even for the same issuer. +/// - `refresh_interval_seconds` — defines bounded refresh behavior; a longer +/// interval allows stale keys to persist longer. +/// - `key_snapshot_hard_deadline_seconds` — defines the source's accepted +/// time rule; the per-snapshot absolute deadline that flows into every +/// sealed [`VerifiedAssertion`][crate::nip_fi::VerifiedAssertion]'s +/// revalidation dependencies derives from this. +/// +/// This type is the single source of truth for these fields. `IssuerJwksConfig` +/// is built from it (pairing it with the bare issuer string) rather than +/// independently restating the same values. Having both types carry independent +/// copies of these fields would let them drift silently; startup validation +/// detects any mismatch that a compatibility path temporarily introduces. +/// +/// All three fields are validated at construction — an invalid value is caught +/// at configuration time, not at first token verification. +/// +/// ## Why these fields are contract, not mutable state +/// +/// Per the settled NIP-FI spec ("Policy identity and snapshots"): +/// `assertion_policy_id` covers "authenticated key/status-source contracts" +/// and "time rules". Key additions/removals (JWKS rotation) and per-snapshot +/// deadlines remain *revalidation dependencies* — they change per-token state +/// without changing the contract. These three fields define what the contract +/// *is*; JWKS content is what the contract currently *says*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JwksSourceContract { + /// Validated JWKS endpoint URI normalized to its canonical `Url` serialization. + /// `Url::to_string()` lowercases the scheme and host, removes the default + /// HTTPS port, and resolves dot-segments — so equivalent URI spellings hash + /// identically. Validated at construction; only stored after parse succeeds. + jwks_uri: String, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly less than + /// `key_snapshot_hard_deadline_seconds`. + refresh_interval_seconds: u64, + /// Positive, ≤ [`MAX_JWKS_TIMING_SECONDS`], strictly greater than + /// `refresh_interval_seconds`. + key_snapshot_hard_deadline_seconds: u64, +} + +impl JwksSourceContract { + /// Validate and seal the three JWKS source fields. + /// + /// Rejects: + /// - `jwks_uri` that fails [`validate_jwks_uri`] + /// - zero `refresh_interval_seconds` or `key_snapshot_hard_deadline_seconds` + /// - `refresh_interval_seconds >= key_snapshot_hard_deadline_seconds` (the + /// hard deadline must be strictly greater so a snapshot is fresh for at + /// least one refresh cycle) + /// - either timing field exceeding [`MAX_JWKS_TIMING_SECONDS`] + pub fn new( + jwks_uri: String, + refresh_interval_seconds: u64, + key_snapshot_hard_deadline_seconds: u64, + ) -> Option { + if refresh_interval_seconds == 0 + || key_snapshot_hard_deadline_seconds == 0 + || key_snapshot_hard_deadline_seconds <= refresh_interval_seconds + || refresh_interval_seconds > MAX_JWKS_TIMING_SECONDS + || key_snapshot_hard_deadline_seconds > MAX_JWKS_TIMING_SECONDS + { + return None; + } + // Parse once, reject via validate_jwks_uri's rule-set, then store the + // canonical serialization produced by `Url::to_string()`. The `url` + // crate lowercases scheme and host, removes the default HTTPS port, + // and resolves dot-segments — guaranteeing that equivalent URI spellings + // (e.g. uppercase host, explicit `:443`, `.///../`) produce an identical + // stored string and therefore an identical `AssertionPolicyId` hash. + let canonical_uri = match Url::parse(&jwks_uri) { + Ok(parsed) => parsed.to_string(), + Err(_) => return None, + }; + // Re-validate on the canonical form so that any normalisation that + // would introduce a forbidden form (e.g. port stripping that leaves + // a bare-IP host) is caught here rather than silently stored. + if validate_jwks_uri(&canonical_uri).is_err() { + return None; + } + Some(Self { + jwks_uri: canonical_uri, + refresh_interval_seconds, + key_snapshot_hard_deadline_seconds, + }) + } + + /// The validated JWKS endpoint URI. + pub fn jwks_uri(&self) -> &str { + &self.jwks_uri + } + + /// Seconds between successive JWKS refreshes. + pub const fn refresh_interval_seconds(&self) -> u64 { + self.refresh_interval_seconds + } + + /// Hard upper bound (from fetch time) on how long a snapshot may be served. + pub const fn key_snapshot_hard_deadline_seconds(&self) -> u64 { + self.key_snapshot_hard_deadline_seconds + } +} + +/// Resolve `host:port` to IP addresses and reject if any are private/reserved. +/// +/// Returns the first safe address for DNS pinning. Blocks on the OS resolver +/// via `spawn_blocking` to avoid blocking the async runtime. +/// +/// Uses the `(host, port)` tuple form of `ToSocketAddrs` — not +/// `format!("{host}:{port}")` — so IPv6 literal hosts (returned without +/// brackets by `Url::host_str()`) are handled correctly without socket-address +/// ambiguity. +/// +/// Rejecting *any* resolved address (not just the first) closes split-horizon +/// DNS attacks: if an attacker can cause one DNS record to resolve to a private +/// address, the entire request is blocked even when other records are public. +pub(crate) async fn resolve_and_check_ssrf( + host: &str, + port: u16, +) -> Result { + // Fast path: if the host is already a parsed IP literal, skip the resolver. + if let Ok(ip) = host.parse::() { + if is_not_global_unicast(&ip) { + return Err(JwksFetchError::InvalidUri); + } + return Ok(ip); + } + + // Hostname path: use the tuple form to avoid IPv6-bracket ambiguity. + let host_owned = host.to_owned(); + let addrs: Vec = tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + (host_owned.as_str(), port) + .to_socket_addrs() + .map(|iter| iter.map(|sa| sa.ip()).collect::>()) + }) + .await + .map_err(|_| JwksFetchError::NetworkError)? + .map_err(|_| JwksFetchError::NetworkError)?; + + if addrs.is_empty() { + return Err(JwksFetchError::NetworkError); + } + for ip in &addrs { + if is_not_global_unicast(ip) { + return Err(JwksFetchError::InvalidUri); + } + } + Ok(addrs[0]) +} + +#[derive(Clone)] +struct CachedSnapshot { + key_set: AssertionKeySet, + fetched_at: DateTime, + hard_deadline: DateTime, + /// SHA-256 of the raw JWKS bytes. Suppresses generation advances when the + /// document is unchanged between refreshes. [FI-TRACE-JWKS-ADD/REMOVE] + content_digest: [u8; 32], +} + +struct IssuerState { + snapshot: Option, + /// Advances only when `content_digest` changes; never wraps (saturating). + generation_counter: u64, + /// Owned permit for in-flight refresh. Held across the complete fetch + + /// state commit; dropped automatically if the caller future is cancelled. + /// `try_lock_owned()` succeeds iff no refresh is in progress. + refresh_permit: Arc>, +} + +impl IssuerState { + fn new() -> Self { + Self { + snapshot: None, + generation_counter: 0, + refresh_permit: Arc::new(tokio::sync::Mutex::new(())), + } + } +} + +/// Per-issuer JWKS endpoint configuration. Pairs the exact `iss` value with +/// the policy-owned [`JwksSourceContract`] that was already validated at +/// [`IssuerPolicy`][super::config::IssuerPolicy] construction. +/// +/// `IssuerJwksConfig` is the single combination of issuer string and contract +/// that `ProductionJwksSource` operates on. Because the contract fields are +/// sealed inside [`JwksSourceContract`] and validated there, this type carries +/// no independent copies of those values — startup validation enforces that the +/// contract embedded here matches the one carried by the corresponding policy. +#[derive(Debug, Clone)] +pub struct IssuerJwksConfig { + /// The exact `iss` value this config authenticates. Must match the + /// corresponding [`IssuerPolicy`][super::config::IssuerPolicy] exactly. + pub issuer: String, + /// The validated key-source contract owned by the matching policy. Carries + /// the JWKS URI, refresh interval, and hard deadline — validated at + /// [`JwksSourceContract::new`], not re-validated here. + pub contract: JwksSourceContract, +} + +/// Reason a JWKS fetch or parse operation failed. No key material, issuer +/// URLs, or raw response content appear in these variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum JwksFetchError { + /// Non-HTTPS scheme, embedded credentials, fragment, bare + /// private/reserved IP host, or DNS resolved to a private/reserved address. + #[error("JWKS URI failed safety validation")] + InvalidUri, + /// Response body exceeded [`MAX_JWKS_RESPONSE_BYTES`]. + #[error("JWKS response exceeded size limit")] + ResponseTooLarge, + /// Network failure, TLS error, request timeout, or non-2xx status. + #[error("JWKS HTTP request failed")] + NetworkError, + /// Response body was not parseable as a JWK Set. + #[error("JWKS response was not parseable")] + ParseError, + /// Parsed key set was empty or exceeded [`super::config::MAX_JWKS_KEYS`]. + #[error("JWKS key set bounds violation")] + KeyCountBoundsViolation, +} + +/// Sealed injection seam for JWKS HTTP fetching. Only types inside `buzz_auth` +/// may implement it — external types cannot name the private supertrait. +/// +/// Implementations MUST: +/// - validate the URI (scheme, credentials, fragment, bare private-IP host) +/// before any I/O; +/// - resolve hostname targets and reject any private/reserved resolved address; +/// - deny redirects (3xx responses rejected as `NetworkError`); +/// - enforce a finite per-fetch deadline covering resolution, connect, headers, +/// and body streaming — the entire operation must be bounded; +/// - enforce [`MAX_JWKS_RESPONSE_BYTES`] via incremental streaming; +/// - reject non-2xx responses. +pub trait JwksFetcher: super::verifier::sealed::Sealed + Send + Sync + 'static { + /// Fetch and return the raw JSON body from the given JWKS URI. + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a; +} + +/// Production [`JwksFetcher`] backed by `reqwest`. Each call to `fetch_jwks` +/// builds a dedicated pinned client — no shared connection state between fetches. +/// +/// Per-fetch boundary enforcement: +/// - hostname DNS is resolved and every address checked against +/// `buzz_core::network::is_not_global_unicast` before the request is sent; +/// - the request is pinned to the validated address to prevent DNS rebinding +/// TOCTOU (the OS resolver is called once per fetch, not once per URL); +/// - the complete operation (resolution, connect, headers, body streaming) is +/// bounded by [`JWKS_REQUEST_TIMEOUT_SECS`] via `tokio::time::timeout`; +/// - 3xx responses are rejected as `NetworkError` — redirects are never followed; +/// - the body is streamed incrementally and stopped at +/// [`MAX_JWKS_RESPONSE_BYTES`] + 1. +#[derive(Clone, Debug)] +pub struct HttpJwksFetcher; + +impl HttpJwksFetcher { + /// Builds a new fetcher. Security invariants are enforced per-request in + /// `fetch_jwks` — each call constructs a dedicated pinned client. + pub fn new() -> Self { + Self + } +} + +impl Default for HttpJwksFetcher { + fn default() -> Self { + Self::new() + } +} + +impl super::verifier::sealed::Sealed for HttpJwksFetcher {} + +impl JwksFetcher for HttpJwksFetcher { + async fn fetch_jwks<'a>(&'a self, uri: &'a str) -> Result { + with_deadline( + fetch_jwks_inner(uri), + std::time::Duration::from_secs(JWKS_REQUEST_TIMEOUT_SECS), + ) + .await + } +} + +/// Bound `fut` with a hard `tokio::time::timeout`. Elapsed maps to +/// `NetworkError`. Production passes `fetch_jwks_inner(uri)`; tests pass +/// `std::future::pending()` to verify the seam deterministically. +async fn with_deadline(fut: F, timeout: std::time::Duration) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(timeout, fut) + .await + .map_err(|_| JwksFetchError::NetworkError)? +} + +/// Extract the bare host string and port from a validated JWKS URI. +/// +/// The host is extracted via the typed `Url::host()` accessor, **not** +/// `host_str()`. `host_str()` returns IPv6 literals with brackets (e.g. +/// `[2606:4700::1]`), which breaks `IpAddr::parse`: brackets are not valid, +/// so the fast path in `resolve_and_check_ssrf` would fail and fall through +/// to the DNS path, which may attempt to resolve `[2606:4700::1]` as a +/// hostname instead of an IP literal. +/// +/// The extracted bare host string is also the correct input form for +/// `reqwest::ClientBuilder::resolve(host, addr)`, whose key must match the +/// URL authority form (bare, without brackets for IPv6). Whether the +/// connector-level pin behaves as expected under mutation is a runtime +/// boundary concern; this function's contract is that it produces the bare +/// form required as input. +/// +/// This function is `pub(crate)` so tests can assert the extracted host string +/// directly and confirm the mutation (restoring `host_str()`) turns the +/// equivalence oracle red without making a live network request. +/// +/// ## Mutation oracle +/// Restoring `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` (the +/// `host_str()` form) causes the IPv6 host extraction test to fail: the +/// returned string carries brackets, `IpAddr::parse` rejects it, and the +/// extracted host no longer matches the bare URL authority form. +pub(crate) fn extract_url_host_and_port(uri: &str) -> Result<(String, u16), JwksFetchError> { + let parsed = Url::parse(uri).map_err(|_| JwksFetchError::InvalidUri)?; + let host = match parsed.host() { + Some(url::Host::Ipv4(addr)) => addr.to_string(), + // MUST use the typed accessor — `host_str()` returns `[2606:4700::1]` + // (with brackets) for IPv6 literals, which breaks IpAddr::parse. + Some(url::Host::Ipv6(addr)) => addr.to_string(), + Some(url::Host::Domain(d)) => d.to_owned(), + None => return Err(JwksFetchError::InvalidUri), + }; + let port = parsed.port_or_known_default().unwrap_or(443); + Ok((host, port)) +} + +/// Inner fetch logic. Called only by `HttpJwksFetcher::fetch_jwks` via `with_deadline`. +async fn fetch_jwks_inner(uri: &str) -> Result { + // Full URI validation first — scheme, credentials, fragment, bare + // private-IP host. This enforces the JwksFetcher contract for direct + // callers of HttpJwksFetcher regardless of whether ProductionJwksSource + // pre-validated the URI. + validate_jwks_uri(uri)?; + + let (host, port) = extract_url_host_and_port(uri)?; + + // Resolve and check every IP before sending. Pins DNS to the validated + // address to prevent rebinding TOCTOU between check and connect. + let safe_ip = resolve_and_check_ssrf(&host, port).await?; + + // Build a per-request client that: + // - denies redirects (a 3xx to an internal host bypasses the URI check); + // - has no system proxy (proxy would resolve the original hostname itself); + // - pins this request to the validated IP. + let pinned_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve(&host, std::net::SocketAddr::new(safe_ip, port)) + .build() + .map_err(|_| JwksFetchError::NetworkError)?; + + let response = pinned_client + .get(uri) + .send() + .await + .map_err(|_| JwksFetchError::NetworkError)?; + + // Reject non-2xx. A 3xx here means our no-redirect policy was somehow + // bypassed — treat as a network error. + if !response.status().is_success() { + return Err(JwksFetchError::NetworkError); + } + + // Early-exit on Content-Length before streaming. A lying or absent + // Content-Length is caught by the incremental counter below. + if let Some(content_length) = response.content_length() { + if content_length as usize > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + } + + // Stream incrementally; stop at MAX_JWKS_RESPONSE_BYTES + 1 so we + // never buffer more than the limit before rejecting. + let mut body = Vec::with_capacity(MAX_JWKS_RESPONSE_BYTES.min(64 * 1024)); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| JwksFetchError::NetworkError)?; + if body.len().saturating_add(chunk.len()) > MAX_JWKS_RESPONSE_BYTES { + return Err(JwksFetchError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + String::from_utf8(body).map_err(|_| JwksFetchError::ParseError) +} + +fn parse_and_bound_jwks(body: &str) -> Result { + let key_set: JwkSet = serde_json::from_str(body).map_err(|_| JwksFetchError::ParseError)?; + if key_set.keys.is_empty() || key_set.keys.len() > MAX_JWKS_KEYS { + return Err(JwksFetchError::KeyCountBoundsViolation); + } + Ok(key_set) +} + +/// Multi-issuer JWKS cache that performs bounded periodic refresh and never +/// serves snapshots past their hard deadline. +/// +/// Must be constructed at startup after +/// [`super::startup::validate_nip_fi_config`] passes. Shared across async +/// tasks via the inner `Arc>`. +/// +/// ## Security +/// +/// - Each issuer's JWKS is stored under its exact `iss` — no relabelling. +/// - Expired snapshots are purged on access; no stale-key fallback. +/// - Errors are logged with a stable code; no key material appears in logs. +pub struct ProductionJwksSource { + configs: HashMap, + states: Arc>>>, + fetcher: Arc, + /// Clock used for `hard_deadline` computation and expiry checks. Always + /// `Arc::new(Utc::now)` in production; tests supply a controlled clock. + now_fn: Arc DateTime + Send + Sync>, +} + +impl ProductionJwksSource { + /// Returns `None` when `configs` is empty or any two configs share the + /// same `issuer` (duplicate issuers make trust configuration ambiguous). + /// + /// Contract fields (`jwks_uri`, `refresh_interval_seconds`, + /// `key_snapshot_hard_deadline_seconds`) are pre-validated inside the + /// embedded [`JwksSourceContract`] — no re-validation is performed here. + pub fn new(configs: Vec, fetcher: F) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + now_fn: Arc::new(Utc::now), + }) + } + + /// **Test-only.** Construct with an injectable clock so tests can advance + /// `now` past snapshot hard deadlines without wall-clock sleep. + #[cfg(test)] + pub(crate) fn new_with_clock( + configs: Vec, + fetcher: F, + now_fn: Arc DateTime + Send + Sync>, + ) -> Option { + if configs.is_empty() { + return None; + } + let mut config_map = HashMap::with_capacity(configs.len()); + let mut state_map = HashMap::with_capacity(configs.len()); + for c in configs { + if config_map.contains_key(&c.issuer) { + return None; + } + let issuer = c.issuer.clone(); + state_map.insert(issuer.clone(), Mutex::new(IssuerState::new())); + config_map.insert(issuer, c); + } + Some(Self { + configs: config_map, + states: Arc::new(RwLock::new(state_map)), + fetcher: Arc::new(fetcher), + now_fn, + }) + } + + async fn fetch_fresh( + &self, + issuer: &str, + prev_digest: Option<[u8; 32]>, + prev_generation: u64, + ) -> Option<(CachedSnapshot, u64)> { + let config = self.configs.get(issuer)?; + let body = match self.fetcher.fetch_jwks(config.contract.jwks_uri()).await { + Ok(b) => b, + Err(err) => { + warn!(error = %err, "nip-fi jwks fetch failed; will use cached snapshot if live"); + return None; + } + }; + + let jwks = match parse_and_bound_jwks(&body) { + Ok(k) => k, + Err(err) => { + warn!(error = %err, "nip-fi jwks parse failed; will use cached snapshot if live"); + return None; + } + }; + + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); + + // Advance only when the document changed so key-rotation events are + // visible [FI-TRACE-JWKS-ADD/REMOVE] while identical refetches are + // stable. Saturating add prevents wrap on the (unreachable) u64 ceiling. + let generation = if Some(content_digest) == prev_digest { + prev_generation + } else { + prev_generation.saturating_add(1).max(1) + }; + + let now = (self.now_fn)(); + // MAX_JWKS_TIMING_SECONDS ≤ ~31.5M < i64::MAX, so this conversion is + // always safe for values that passed the bounds check in JwksSourceContract::new(). + let deadline_secs = i64::try_from(config.contract.key_snapshot_hard_deadline_seconds()) + .unwrap_or(i64::MAX / 2); + let hard_deadline = now + + Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| Duration::seconds(i64::MAX / 2)); + + let key_set = AssertionKeySet::new(issuer.to_owned(), generation, jwks, hard_deadline)?; + + Some(( + CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }, + generation, + )) + } + + /// Returns the cached snapshot for `issuer`, refreshing inline if stale. + /// Returns `None` when no live snapshot is available and the fetch fails. + /// + /// Coalesces concurrent callers: a second call while a refresh is in + /// flight returns the current snapshot immediately rather than starting a + /// second fetch. The refresh permit is an RAII guard — if this future is + /// cancelled while DNS, HTTP, or streaming is pending, the guard drops and + /// the permit is released, so the next caller can start a new fetch. + pub async fn get_snapshot(&self, issuer: &str) -> Option { + let states = self.states.read().await; + let state_mutex = states.get(issuer)?; + let mut state = state_mutex.lock().await; + + let now = (self.now_fn)(); + let config = self.configs.get(issuer)?; + + if let Some(ref cached) = state.snapshot { + if now >= cached.hard_deadline { + state.snapshot = None; + } + } + + let needs_refresh = match state.snapshot { + None => true, + Some(ref cached) => { + let age_secs = (now - cached.fetched_at).num_seconds().max(0) as u64; + age_secs >= config.contract.refresh_interval_seconds() + } + }; + + if !needs_refresh { + return state.snapshot.as_ref().map(|c| c.key_set.clone()); + } + + // Try to acquire the per-issuer refresh permit. Failure means another + // caller is already fetching; return the current snapshot rather than + // starting a second fetch. + let permit = match Arc::clone(&state.refresh_permit).try_lock_owned() { + Ok(g) => g, + Err(_) => return state.snapshot.as_ref().map(|c| c.key_set.clone()), + }; + + let prev_digest = state.snapshot.as_ref().map(|c| c.content_digest); + let prev_generation = state.generation_counter; + drop(state); + drop(states); + + let fresh = self.fetch_fresh(issuer, prev_digest, prev_generation).await; + + // Re-acquire state to commit and release the permit atomically. + let states = self.states.read().await; + if let Some(state_mutex) = states.get(issuer) { + let mut st = state_mutex.lock().await; + if let Some((ref cached, new_generation)) = fresh { + st.generation_counter = new_generation; + st.snapshot = Some(cached.clone()); + } + // Drop the permit only after the state commit is visible. + drop(permit); + let now2 = (self.now_fn)(); + return st + .snapshot + .as_ref() + .filter(|c| now2 < c.hard_deadline) + .map(|c| c.key_set.clone()); + } + + drop(permit); + None + } +} + +impl super::verifier::sealed::Sealed for ProductionJwksSource {} + +impl IssuerKeySource for ProductionJwksSource { + /// Called per-request by the verifier after the cache has been warmed via + /// [`get_snapshot`][Self::get_snapshot]. + /// + /// Uses `try_read`/`try_lock` — safe to call from any async context. + /// Fails closed (returns `None`) when the lock is momentarily held by an + /// in-flight refresh, rather than blocking or panicking. [FI-INV-14] + fn key_set(&self, issuer: &str) -> Option { + let states = self.states.try_read().ok()?; + let state_mutex = states.get(issuer)?; + let state = state_mutex.try_lock().ok()?; + let now = (self.now_fn)(); + state + .snapshot + .as_ref() + .filter(|c| now < c.hard_deadline) + .map(|c| c.key_set.clone()) + } +} + +impl std::fmt::Debug for ProductionJwksSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // No issuer URIs or key material in debug output. + write!( + f, + "ProductionJwksSource([REDACTED; {} issuers])", + self.configs.len() + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/jwks/tests.rs b/crates/buzz-auth/src/nip_fi/jwks/tests.rs new file mode 100644 index 00000000000..6d9f21a1502 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/jwks/tests.rs @@ -0,0 +1,1619 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +struct FakeJwksFetcher { + body: Result, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for FakeJwksFetcher {} + +impl JwksFetcher for FakeJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self.body.clone(); + self.call_count.fetch_add(1, Ordering::SeqCst); + async move { result } + } +} + +fn minimal_jwks_json(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) +} + +fn make_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid test contract"), + } +} + +fn make_config_with_uri(issuer: &str, jwks_uri: &str) -> Option { + JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600).map(|contract| IssuerJwksConfig { + issuer: issuer.to_owned(), + contract, + }) +} + +#[tokio::test] +async fn get_snapshot_returns_sealed_key_set_on_success() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + let ks = source.get_snapshot(issuer).await.unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +#[tokio::test] +async fn get_snapshot_returns_none_for_unknown_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let source = + ProductionJwksSource::new(vec![make_config("https://id.example")], fetcher).unwrap(); + + assert!(source.get_snapshot("https://other.example").await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_network_error_with_no_cache() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::NetworkError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_oversized_response() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ResponseTooLarge), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn get_snapshot_returns_none_on_parse_error() { + let fetcher = FakeJwksFetcher { + body: Err(JwksFetchError::ParseError), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + assert!(source.get_snapshot(issuer).await.is_none()); +} + +#[tokio::test] +async fn parse_and_bound_rejects_empty_key_set() { + let err = parse_and_bound_jwks(r#"{"keys":[]}"#).unwrap_err(); + assert_eq!(err, JwksFetchError::KeyCountBoundsViolation); +} + +#[tokio::test] +async fn parse_and_bound_rejects_oversized_key_set() { + let keys: Vec = (0..=MAX_JWKS_KEYS) + .map(|i| format!( + r#"{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","kid":"k{i}"}}"# + )) + .collect(); + let body = format!(r#"{{"keys":[{}]}}"#, keys.join(",")); + assert_eq!( + parse_and_bound_jwks(&body).unwrap_err(), + JwksFetchError::KeyCountBoundsViolation + ); +} + +#[tokio::test] +async fn new_rejects_empty_configs() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + assert!(ProductionJwksSource::new(vec![], fetcher).is_none()); +} + +/// Timing validation is now performed by `JwksSourceContract::new`. These +/// tests verify the contract constructor rejects bad timing, since an invalid +/// contract prevents building an `IssuerJwksConfig` entirely. +#[test] +fn contract_rejects_refresh_ge_hard_deadline() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 3600, + 3600, + ) + .is_none()); +} + +#[test] +fn contract_rejects_zero_refresh_interval() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 0, + 3600, + ) + .is_none()); +} + +#[test] +fn contract_rejects_timing_above_maximum() { + assert!(JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + MAX_JWKS_TIMING_SECONDS + 1, + MAX_JWKS_TIMING_SECONDS + 2, + ) + .is_none()); +} + +#[tokio::test] +async fn new_rejects_duplicate_issuer() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let config_a = make_config(issuer); + let config_b = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks-alt.json".to_owned(), + 600, + 7200, + ) + .unwrap(), + }; + assert!(ProductionJwksSource::new(vec![config_a, config_b], fetcher).is_none()); +} + +/// URI validation is now performed by `JwksSourceContract::new`; an invalid +/// URI makes the contract `None` and prevents an `IssuerJwksConfig` from being +/// built at all. The tests below verify that `JwksSourceContract::new` rejects +/// the same invalid URIs that `ProductionJwksSource::new` previously checked. +#[test] +fn contract_rejects_non_https_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "http://id.example/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_loopback_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://127.0.0.1/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_private_ip_jwks_uri() { + assert!(make_config_with_uri( + "https://id.example", + "https://10.0.0.1/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_jwks_uri_with_credentials() { + assert!(make_config_with_uri( + "https://id.example", + "https://user:pass@id.example/.well-known/jwks.json" + ) + .is_none()); +} + +#[test] +fn contract_rejects_jwks_uri_with_fragment() { + assert!(make_config_with_uri( + "https://id.example", + "https://id.example/.well-known/jwks.json#keys" + ) + .is_none()); +} + +/// `key_set()` fails closed (returns `None`) before any snapshot is warmed via +/// `get_snapshot` — the synchronous path never fetches. +#[tokio::test] +async fn sync_key_set_returns_none_before_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!(source.key_set(issuer).is_none()); +} + +#[tokio::test] +async fn sync_key_set_returns_snapshot_after_warmup() { + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let issuer = "https://id.example"; + let source = ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap(); + + source.get_snapshot(issuer).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let ks = source.key_set(issuer).unwrap(); + assert_eq!(ks.issuer(), issuer); +} + +/// Identical document fetched twice must not advance the generation counter +/// — stable generation for unchanged JWKS prevents spurious revalidation. +#[tokio::test] +async fn generation_stable_for_identical_document() { + let issuer = "https://id.example"; + let fetcher = FakeJwksFetcher { + body: Ok(minimal_jwks_json("k1")), + call_count: Arc::new(AtomicUsize::new(0)), + }; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), + }; + let source = ProductionJwksSource::new(vec![config], fetcher).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert_eq!(gen1, gen2); +} + +/// Changed document must advance the generation so key-rotation events are +/// visible [FI-TRACE-JWKS-ADD/REMOVE]. +#[tokio::test] +async fn generation_advances_for_changed_document() { + let issuer = "https://id.example"; + + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(minimal_jwks_json("k2")), + Ok(minimal_jwks_json("k1")), + ])); + + struct MultiBodyFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for MultiBodyFetcher {} + impl JwksFetcher for MultiBodyFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + 3600, + ) + .unwrap(), + }; + let source = ProductionJwksSource::new(vec![config], MultiBodyFetcher { bodies }).unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + source.get_snapshot(issuer).await.unwrap(); + let gen1 = source.key_set(issuer).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + let gen2 = source.key_set(issuer).unwrap().generation(); + + assert!(gen2 > gen1, "gen1={gen1}, gen2={gen2}"); +} + +#[test] +fn validate_uri_accepts_valid_https() { + assert!(validate_jwks_uri("https://id.example/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_accepts_public_ipv6() { + assert!(validate_jwks_uri("https://[2606:4700::1]/.well-known/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_http() { + assert_eq!( + validate_jwks_uri("http://id.example/.well-known/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_loopback_ip() { + assert_eq!( + validate_jwks_uri("https://127.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_private_ip() { + assert_eq!( + validate_jwks_uri("https://192.168.1.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_link_local_ip() { + assert_eq!( + validate_jwks_uri("https://169.254.169.254/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_1() { + // 192.0.2.0/24 — RFC 5737 TEST-NET-1, never globally routed. + assert_eq!( + validate_jwks_uri("https://192.0.2.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_2() { + // 198.51.100.0/24 — RFC 5737 TEST-NET-2. + assert_eq!( + validate_jwks_uri("https://198.51.100.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_ip_test_net_3() { + // 203.0.113.0/24 — RFC 5737 TEST-NET-3. + assert_eq!( + validate_jwks_uri("https://203.0.113.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_multicast_ip() { + // 224.0.0.1 — all-hosts multicast group (224.0.0.0/4). + assert_eq!( + validate_jwks_uri("https://224.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_reserved_class_e_ip() { + // 240.0.0.1 — reserved class E (240.0.0.0/4). + assert_eq!( + validate_jwks_uri("https://240.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_ietf_protocol_assignments_ipv4() { + // 192.0.0.0/24 — IETF Protocol Assignments (non-global by default). + // 192.0.0.1 is a representative interior address. + assert_eq!( + validate_jwks_uri("https://192.0.0.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_accepts_ietf_protocol_assignments_pcp_turn_anycast() { + // 192.0.0.9 (PCP anycast, RFC 7723) and 192.0.0.10 (TURN anycast, RFC 8155) + // are the only globally-reachable exceptions inside 192.0.0.0/24. + assert!(validate_jwks_uri("https://192.0.0.9/jwks.json").is_ok()); + assert!(validate_jwks_uri("https://192.0.0.10/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_deprecated_6to4_anycast_ipv4() { + // 192.88.99.0/24 — deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank; conservative posture: block. + assert_eq!( + validate_jwks_uri("https://192.88.99.1/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_ietf_protocol_assignments_v6_interior() { + // 2001:2::1 — interior of 2001::/23 IETF Protocol Assignments (non-global). + assert_eq!( + validate_jwks_uri("https://[2001:2::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_accepts_ietf_protocol_assignments_v6_global_exception() { + // 2001:1::1 (PCP anycast, RFC 7723) — globally reachable exception inside 2001::/23. + assert!(validate_jwks_uri("https://[2001:1::1]/jwks.json").is_ok()); +} + +#[test] +fn validate_uri_rejects_discard_only_v6() { + // 100::1 — 100::/64 Discard-Only address space (RFC 6666). + assert_eq!( + validate_jwks_uri("https://[100::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_documentation_v6_3fff() { + // 3fff::1 — 3fff::/20 Documentation space (RFC 9637). + assert_eq!( + validate_jwks_uri("https://[3fff::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_srv6_sids_v6() { + // 5f00::1 — 5f00::/16 SRv6 SID space (RFC 9252). + assert_eq!( + validate_jwks_uri("https://[5f00::1]/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_credentials() { + assert_eq!( + validate_jwks_uri("https://user:pass@id.example/jwks.json").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_fragment() { + assert_eq!( + validate_jwks_uri("https://id.example/jwks.json#section").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[test] +fn validate_uri_rejects_unparseable() { + assert_eq!( + validate_jwks_uri("not a url").unwrap_err(), + JwksFetchError::InvalidUri + ); +} + +#[tokio::test] +async fn http_fetcher_rejects_http_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("http://id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_credentials_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://user:pass@id.example/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_fragment_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://id.example/.well-known/jwks.json#section") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn http_fetcher_rejects_private_ip_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://10.0.0.1/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_rejects_ipv6_loopback_fast_path() { + let err = super::resolve_and_check_ssrf("::1", 443).await.unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +#[tokio::test] +async fn resolve_ssrf_accepts_public_ipv6_fast_path() { + let ip = super::resolve_and_check_ssrf("2606:4700::1", 443) + .await + .unwrap(); + assert_eq!(ip, "2606:4700::1".parse::().unwrap()); +} + +/// The public fetcher rejects an IPv6 loopback JWKS URI before any network +/// connection is attempted. `fetch_jwks_inner` calls `validate_jwks_uri` as +/// its first step; `validate_jwks_uri` parses the URI, extracts the host via +/// `Url::host()`, and rejects any address matched by the shared enumerated +/// deny policy as +/// `InvalidUri`. `::1` (loopback) never reaches the extraction or +/// resolved-target enforcement stages. Bracket-free extraction and +/// resolved-target value-flow evidence is covered by the dedicated +/// `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` test; +/// connector-boundary behavior is a separate runtime concern. +#[tokio::test] +async fn http_fetcher_rejects_ipv6_loopback_uri_as_invalid() { + // https://[::1]/... is rejected by validate_jwks_uri (SSRF: loopback) + // before extraction or resolved-target enforcement runs. + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://[::1]/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!( + err, + JwksFetchError::InvalidUri, + "IPv6 loopback URI must be rejected as InvalidUri, not NetworkError" + ); +} + +/// Rejected private IPv6 site-local URI at the pre-connection SSRF boundary. +/// fec0::/10 (deprecated site-local, RFC 3879) must deny as InvalidUri. +#[tokio::test] +async fn http_fetcher_rejects_ipv6_site_local_uri_before_connection() { + let fetcher = HttpJwksFetcher::new(); + let err = fetcher + .fetch_jwks("https://[fec0::1]/.well-known/jwks.json") + .await + .unwrap_err(); + assert_eq!(err, JwksFetchError::InvalidUri); +} + +/// `with_deadline` fires before the outer guard: removing `tokio::time::timeout` +/// inside `with_deadline` leaves the pending future unresolved and the outer guard fires. +#[tokio::test(start_paused = true)] +async fn with_deadline_fires_before_outer_guard() { + let inner = super::with_deadline( + std::future::pending::>(), + std::time::Duration::ZERO, + ); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), inner).await; + assert_eq!( + result.expect("outer guard fired — with_deadline timeout seam missing"), + Err(JwksFetchError::NetworkError), + ); +} + +// A fetcher whose per-call behaviour is scripted by an explicit sequence of steps. +// Each call pops the next step: signals `entered` on entry, then blocks until +// its release channel resolves. +struct FetchStep { + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver, +} + +struct ScriptedFetcher { + steps: std::sync::Mutex>, + call_count: Arc, +} + +impl super::super::verifier::sealed::Sealed for ScriptedFetcher {} + +impl JwksFetcher for ScriptedFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count.fetch_add(1, Ordering::SeqCst); + let step = self.steps.lock().unwrap().pop_front(); + async move { + match step { + Some(FetchStep { entered, release }) => { + let _ = entered.send(()); + release.await.map_err(|_| JwksFetchError::NetworkError) + } + None => Err(JwksFetchError::NetworkError), + } + } + } +} + +fn script(steps: impl IntoIterator) -> ScriptedFetcher { + ScriptedFetcher { + steps: std::sync::Mutex::new(steps.into_iter().collect()), + call_count: Arc::new(AtomicUsize::new(0)), + } +} + +fn pending_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + // release_tx is returned to the caller; the fetch future is genuinely + // pending until the caller drops or sends it — not resolved immediately. + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +fn ready_step(body: String) -> (FetchStep, tokio::sync::oneshot::Receiver<()>) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + let _ = release_tx.send(body); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + ) +} + +fn blocking_step() -> ( + FetchStep, + tokio::sync::oneshot::Receiver<()>, + tokio::sync::oneshot::Sender, +) { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::(); + ( + FetchStep { + entered: entered_tx, + release: release_rx, + }, + entered_rx, + release_tx, + ) +} + +/// A second concurrent `get_snapshot` while the first fetch is in progress must +/// not start a second fetch — the RAII permit coalesces callers. +#[tokio::test] +async fn concurrent_refresh_coalesces_without_second_fetch() { + let (step, entered_rx, release_tx) = blocking_step(); + let fetcher = script([step]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + + entered_rx.await.unwrap(); // first fetch holds the permit + + let second_result = source.get_snapshot(issuer).await; + let count_after_second = call_count.load(Ordering::SeqCst); + + let _ = release_tx.send(minimal_jwks_json("k1")); + let first_result = first.await.unwrap(); + + assert!(first_result.is_some()); + assert!(second_result.is_none()); + assert_eq!(count_after_second, 1); +} + +/// Aborting the first caller releases the RAII permit; the next call on the same +/// source fetches and succeeds. A manual boolean cleared only on success would +/// leave the permit poisoned. +#[tokio::test] +async fn aborted_first_caller_releases_permit_for_next_caller() { + let (step1, entered_rx_1, _release_tx_1) = pending_step(); + let (step2, _entered_rx_2) = ready_step(minimal_jwks_json("k2")); + + let fetcher = script([step1, step2]); + let call_count = Arc::clone(&fetcher.call_count); + + let issuer = "https://id.example"; + let source = Arc::new(ProductionJwksSource::new(vec![make_config(issuer)], fetcher).unwrap()); + + { + let source2 = Arc::clone(&source); + let issuer_owned = issuer.to_owned(); + let first = tokio::spawn(async move { source2.get_snapshot(&issuer_owned).await }); + entered_rx_1.await.unwrap(); + first.abort(); + let _ = first.await; + // _release_tx_1 drops here: the fetch future was blocked on an open + // receiver when abort fired — not resolved via an error path. + } + + let result = source.get_snapshot(issuer).await; + assert!(result.is_some()); + assert_eq!(call_count.load(Ordering::SeqCst), 2); +} + +/// An expired snapshot must never be served — both `get_snapshot` and the +/// synchronous `key_set` path return `None` after the hard deadline passes. +#[tokio::test] +async fn expired_snapshot_never_served_after_hard_deadline() { + let issuer = "https://id.example"; + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + "https://id.example/.well-known/jwks.json".to_owned(), + 1, + 2, + ) + .unwrap(), + }; + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Err::(JwksFetchError::NetworkError), + Ok(minimal_jwks_json("k1")), + ])); + struct FailAfterFirstFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for FailAfterFirstFetcher {} + impl JwksFetcher for FailAfterFirstFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + let source = ProductionJwksSource::new(vec![config], FailAfterFirstFetcher { bodies }).unwrap(); + + assert!( + source.get_snapshot(issuer).await.is_some(), + "initial fetch must succeed" + ); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + assert!( + source.get_snapshot(issuer).await.is_none(), + "expired snapshot must not be served after hard deadline" + ); + + use crate::nip_fi::verifier::IssuerKeySource; + assert!( + source.key_set(issuer).is_none(), + "key_set must not serve an expired snapshot" + ); +} + +/// Two issuers are fully isolated: distinct key material, independent generation +/// counters, no cross-issuer forgery. Three distinct P-256 keypairs (A1, A2, +/// B1) driven through `ProductionJwksSource` into `FederatedAssertionVerifier`. +#[tokio::test] +async fn two_issuer_keys_and_generations_are_isolated() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + + // Three genuinely distinct P-256 keypairs (PKCS#8 PEM + public JWK coords). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const PKCS8_B1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgKcmDf3+zDWyC96/X\n\ + Gv8aYK552uF5aE6nXKzxAfl4fSWhRANCAATf0ccbp1c4mMd6WvSuliv5ZAS8iIWL\n\ + Ne2tqOfFa0hRpa41DANab1/EuDGi7PtIo8xSYwkaoib1MAJlfLvRMjQA\n\ + -----END PRIVATE KEY-----\n"; + const X_B1: &str = "39HHG6dXOJjHelr0rpYr-WQEvIiFizXtrajnxWtIUaU"; + const Y_B1: &str = "rjUMA1pvX8S4MaLs-0ijzFJjCRqiJvUwAmV8u9EyNAA"; + + const KID_A1: &str = "a-key-1"; + const KID_A2: &str = "a-key-2"; + const KID_B1: &str = "b-key-1"; + + let issuer_a = "https://a.example"; + let issuer_b = "https://b.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + fn policy(issuer: &str, aud: &str) -> IssuerPolicy { + let contract = JwksSourceContract::new( + format!( + "https://{}/jwks.json", + issuer.trim_start_matches("https://") + ), + 1, + 3600, + ) + .expect("valid contract"); + IssuerPolicy::new( + issuer.to_owned(), + vec![aud.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + contract, + ) + .expect("valid policy") + } + + fn configs(issuer_a: &str, issuer_b: &str) -> (IssuerJwksConfig, IssuerJwksConfig) { + ( + IssuerJwksConfig { + issuer: issuer_a.to_owned(), + contract: JwksSourceContract::new( + "https://a.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), + }, + IssuerJwksConfig { + issuer: issuer_b.to_owned(), + contract: JwksSourceContract::new( + "https://b.example/.well-known/jwks.json".to_owned(), + 1, + 3600, + ) + .unwrap(), + }, + ) + } + + struct TwoFetcher { + a: std::sync::Mutex>, + b: String, + } + impl super::super::verifier::sealed::Sealed for TwoFetcher {} + impl JwksFetcher for TwoFetcher { + fn fetch_jwks<'a>( + &'a self, + uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = if uri.contains("a.example") { + self.a + .lock() + .unwrap() + .pop_front() + .map(Ok) + .unwrap_or(Err(JwksFetchError::NetworkError)) + } else { + Ok(self.b.clone()) + }; + async move { result } + } + } + + let mut registry = IssuerRegistry::new(); + registry.insert(policy(issuer_a, audience)); + registry.insert(policy(issuer_b, audience)); + + // Pre-rotation: source serves A1 and B1. + let (cfg_a, cfg_b) = configs(issuer_a, issuer_b); + let pre = ProductionJwksSource::new( + vec![cfg_a, cfg_b], + TwoFetcher { + a: std::sync::Mutex::new([jwks_str(KID_A1, X_A1, Y_A1)].into()), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + pre.get_snapshot(issuer_a).await.unwrap(); + pre.get_snapshot(issuer_b).await.unwrap(); + + let v_pre = FederatedAssertionVerifier::new(registry.clone(), pre); + v_pre + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect("A1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must verify pre-rotation"); + v_pre + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A"); + + // Post-rotation: fresh source, A rotates A1→A2, B unchanged. + let (cfg_a2, cfg_b2) = configs(issuer_a, issuer_b); + let post = ProductionJwksSource::new( + vec![cfg_a2, cfg_b2], + TwoFetcher { + a: std::sync::Mutex::new( + [jwks_str(KID_A1, X_A1, Y_A1), jwks_str(KID_A2, X_A2, Y_A2)].into(), + ), + b: jwks_str(KID_B1, X_B1, Y_B1), + }, + ) + .unwrap(); + post.get_snapshot(issuer_a).await.unwrap(); + post.get_snapshot(issuer_b).await.unwrap(); + + use crate::nip_fi::verifier::IssuerKeySource; + let gen_a_pre = post.key_set(issuer_a).unwrap().generation(); + let gen_b_stable = post.key_set(issuer_b).unwrap().generation(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + post.get_snapshot(issuer_a).await.unwrap(); + + let gen_a_post = post.key_set(issuer_a).unwrap().generation(); + let gen_b_post = post.key_set(issuer_b).unwrap().generation(); + assert!( + gen_a_post > gen_a_pre, + "A generation must advance after rotation" + ); + assert_eq!( + gen_b_post, gen_b_stable, + "B generation must not advance when only A rotates" + ); + + let v_post = FederatedAssertionVerifier::new(registry, post); + v_post + .verify(&sign(PKCS8_A2, KID_A2, issuer_a, audience)) + .expect("A2 token must verify post-rotation"); + v_post + .verify(&sign(PKCS8_A1, KID_A1, issuer_a, audience)) + .expect_err("old A1 token must fail after A2 rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_A1, issuer_a, audience)) + .expect_err("B1 key must not forge issuer A post-rotation"); + v_post + .verify(&sign(PKCS8_B1, KID_B1, issuer_b, audience)) + .expect("B1 token must still verify post-rotation"); +} + +/// Public-API regression: one long-lived [`FederatedAssertionVerifier`] backed +/// by a shared `Arc` observes key rotation through the +/// same cache it was constructed with — it does NOT need to be rebuilt when +/// keys rotate. +/// +/// Scenario: +/// A1 → initial key set (generation 1) +/// A2 → rotated key set (generation 2, committed after a refresh interval) +/// +/// The verifier is constructed once before A2 is known, then the source is +/// refreshed in-place (simulating a normal JWKS rotation). The same verifier +/// must then reject A1-signed tokens and accept A2-signed tokens, because it +/// reads from the shared cache. +/// +/// Mutation (correctness): change `Arc` to a plain +/// `ProductionJwksSource` (no sharing). The verifier would hold its own +/// copy of the pre-rotation cache and could not observe the refresh. A2 tokens +/// would fail and A1 tokens would pass — the test turns red on both assertions. +#[tokio::test] +async fn shared_arc_source_verifier_observes_rotation() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + use std::sync::Arc; + + // Two genuinely distinct P-256 keypairs (re-use the constants from the + // two-issuer test). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\n\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\n\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\n\ + -----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const KID_A1: &str = "arc-key-1"; + const KID_A2: &str = "arc-key-2"; + + let issuer = "https://arc-issuer.example"; + let audience = "https://relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": now, "exp": now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + // Scripted fetcher: first call returns A1 JWKS, second call returns A2 JWKS. + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(jwks_str(KID_A2, X_A2, Y_A2)), // popped second + Ok(jwks_str(KID_A1, X_A1, Y_A1)), // popped first + ])); + + struct RotatingFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for RotatingFetcher {} + impl JwksFetcher for RotatingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let jwks_contract = + JwksSourceContract::new(format!("https://{issuer}/.well-known/jwks.json"), 1, 3600) + .unwrap(); + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract.clone(), + }; + + // Wrap the source in Arc — this is the sharing path under test. + let source = + Arc::new(ProductionJwksSource::new(vec![config], RotatingFetcher { bodies }).unwrap()); + + // Warm the cache with A1 JWKS. + source.get_snapshot(issuer).await.unwrap(); + + // Build the verifier from an Arc clone. This is the one long-lived + // verifier we never rebuild. + let mut registry = IssuerRegistry::new(); + registry.insert( + IssuerPolicy::new( + issuer.to_owned(), + vec![audience.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + jwks_contract, + ) + .unwrap(), + ); + let verifier = FederatedAssertionVerifier::new(registry, Arc::clone(&source)); + + // Pre-rotation: A1 token verifies. + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect("A1 token must verify before rotation"); + + // Advance past the refresh interval so the next get_snapshot triggers a + // re-fetch (which will return A2 JWKS from the scripted fetcher). + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + source.get_snapshot(issuer).await.unwrap(); + + // Post-rotation: the SAME verifier (never rebuilt) must now see A2 keys. + // This proves the verifier reads from the shared Arc cache, not a + // snapshot captured at construction time. + // + // Mutation: if the verifier held a plain `ProductionJwksSource` (cloned + // at construction), it would serve the pre-rotation A1 snapshot forever — + // A2 would fail and A1 would still pass, turning both assertions red. + verifier + .verify(&sign_token(PKCS8_A2, KID_A2, issuer, audience)) + .expect("A2 token must verify through the shared Arc after rotation"); + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect_err("old A1 token must be rejected after rotation (kid no longer in JWKS)"); +} + +/// **Fix 1 — URI canonicalization convergence/divergence oracle.** +/// +/// `JwksSourceContract::new` must store the `Url`-normalized form of the URI, +/// not the caller's raw input bytes. This means: +/// - An uppercase host (`EXAMPLE.COM`) normalizes to lowercase (`example.com`) +/// and produces the same `AssertionPolicyId` as the lowercase form. +/// - An explicit default HTTPS port (`:443`) is removed by `Url` normalization +/// and produces the same ID as the form without the port. +/// - A genuinely different host always produces a distinct ID. +/// +/// Mutation (correctness): changing `JwksSourceContract::new` to store the raw +/// input `jwks_uri` instead of `parsed.to_string()` causes the uppercase-host +/// and explicit-port variant tests to fail — the raw bytes differ, the SHA-256 +/// hash diverges, and `assert_eq!` on the policy IDs turns red. +#[test] +fn jwks_contract_uri_canonicalization_convergence_and_divergence() { + use crate::nip_fi::{config::IssuerPolicy, FreshnessClass, TokenClass}; + use jsonwebtoken::Algorithm; + + fn make_policy(jwks_uri: &str) -> Option { + let contract = JwksSourceContract::new(jwks_uri.to_owned(), 300, 3600)?; + IssuerPolicy::new( + "https://issuer.example".to_owned(), + vec!["https://aud.example".to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 30, + 600, + None, + contract, + ) + .ok() + .map(|p| p.id()) + } + + let canonical = + make_policy("https://issuer.example/.well-known/jwks.json").expect("canonical form"); + + // Equivalent spellings must converge after `Url` normalization. + let uppercase_host = + make_policy("https://ISSUER.EXAMPLE/.well-known/jwks.json").expect("uppercase host"); + assert_eq!( + canonical, uppercase_host, + "uppercase host must normalize to lowercase and produce identical policy ID; \ + mutation: store raw input bytes → this diverges" + ); + + let explicit_port = + make_policy("https://issuer.example:443/.well-known/jwks.json").expect("explicit port"); + assert_eq!( + canonical, explicit_port, + "explicit default HTTPS port :443 must be stripped by Url normalization; \ + mutation: store raw input bytes → this diverges" + ); + + // A genuinely different host MUST diverge (not accidentally collapse). + let different_host = + make_policy("https://other.example/.well-known/jwks.json").expect("different host"); + assert_ne!( + canonical, different_host, + "different JWKS host must produce distinct policy ID" + ); + + // A different path MUST diverge. + let different_path = + make_policy("https://issuer.example/.well-known/other-jwks.json").expect("different path"); + assert_ne!( + canonical, different_path, + "different JWKS path must produce distinct policy ID" + ); + + // Dot-segment path that resolves to the same resource MUST converge. + // `Url::parse` resolves `./jwks.json` relative paths during parsing, so + // `/.well-known/./jwks.json` normalises to `/.well-known/jwks.json`. + // Mutation: store raw input bytes -> the dot-segment form remains in the + // stored URI, the SHA-256 hash diverges, and `assert_eq!` turns red. + let dot_segment = + make_policy("https://issuer.example/.well-known/./jwks.json").expect("dot-segment path"); + assert_eq!( + canonical, dot_segment, + "dot-segment-equivalent path must normalize and produce identical policy ID; \ + mutation: store raw input bytes -> this diverges" + ); +} + +/// **Fix 2 — Public bracketed-IPv6 JWKS URI through the resolved-target and pin-input seam.** +/// +/// This seam test is network-free: both public `2606:4700::1` and site-local +/// `fec0::1` are IP literals, so `resolve_and_check_ssrf` takes the fast path +/// (`host.parse::()` then `is_not_global_unicast`) without any DNS +/// lookup. +/// +/// The seam covers the three stages `fetch_jwks_inner` traverses in order: +/// 1. `extract_url_host_and_port` — typed `Url::host()` yields bare +/// `"2606:4700::1"`, not the bracketed `"[2606:4700::1]"` that +/// `host_str()` returns. +/// 2. `resolve_and_check_ssrf(host, port)` — fast path: `host.parse::()` +/// succeeds only for the bare form, passes `is_not_global_unicast`, and +/// returns the `IpAddr`. +/// 3. Reqwest `.resolve(host, SocketAddr::new(ip, port))` uses the raw `host` +/// string as its pin key. The key must equal the URL authority form — +/// bare for IPv6, brackets forbidden. +/// +/// This test proves that the extracted host string is bare (the correct input +/// form for `reqwest::ClientBuilder::resolve`). It does not exercise the +/// reqwest connector; connector-boundary behavior is a runtime concern. +/// +/// For `fec0::1`: `extract_url_host_and_port` still extracts the bare address; +/// `resolve_and_check_ssrf` rejects it via `is_not_global_unicast`. +/// +/// ## Mutation oracle +/// Replace `Some(url::Host::Ipv6(addr)) => addr.to_string()` with +/// `Some(url::Host::Ipv6(addr)) => format!("[{}]", addr)` in +/// `extract_url_host_and_port`. The bracketed string is returned. +/// - `"[2606:4700::1]".parse::()` fails → SSRF fast path unreachable +/// → public acceptance assertion flips red. +/// - `is_not_global_unicast` is never called on `fec0::1` (the parse also +/// fails) → `resolve_and_check_ssrf` returns `NetworkError` not `InvalidUri` +/// → fec0 rejection-kind assertion flips red. +/// - The pin-input equality assertion also flips red (bracket mismatch). +#[tokio::test] +async fn resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection() { + use buzz_core::network::is_not_global_unicast; + + // ── Stage 1: extraction ─────────────────────────────────────────────────── + let uri = "https://[2606:4700::1]/.well-known/jwks.json"; + let (host, port) = + super::extract_url_host_and_port(uri).expect("public IPv6 URI must be parseable"); + assert_eq!( + host, "2606:4700::1", + "host must be bare (mutation: bracket → IpAddr::parse fails)" + ); + assert_eq!(port, 443u16, "default HTTPS port"); + + // ── Stage 2: IpAddr resolution (SSRF fast path) ─────────────────────────── + // `host.parse::()` succeeds only for the bare form. This is exactly + // the fast path in `resolve_and_check_ssrf` that bypasses DNS. + let ip: std::net::IpAddr = host + .parse() + .expect("bracket-free host must parse as IpAddr; mutation: bracketed form fails here"); + assert!(ip.is_ipv6(), "must be an IPv6 address"); + + // `is_not_global_unicast` must return false for a public address. + assert!( + !is_not_global_unicast(&ip), + "2606:4700::1 must pass as globally reachable; mutation: SSRF check would reject it" + ); + + // Confirm resolve_and_check_ssrf accepts the public address (network-free fast path). + let resolved = super::resolve_and_check_ssrf(&host, port) + .await + .expect("public IPv6 must be accepted by SSRF check"); + assert_eq!( + resolved, ip, + "resolved address must equal the IpAddr parsed from the bare host" + ); + + // ── Stage 3: pin-key string form ──────────────────────────────────────── + // The host string extracted by `extract_url_host_and_port` is the value + // passed to reqwest's `.resolve(host, ...)`. For a reqwest pin to apply, + // the key passed to `.resolve()` must equal the URL authority form. For + // IPv6 literals the URL authority form is bare (no brackets), so the + // extracted host must also be bare. This assertion verifies that the + // extracted host string is bare — it does not directly exercise the + // reqwest connector, but proves the input to the pin call is correct. + let socket_addr = std::net::SocketAddr::new(resolved, port); + let expected_pin_key = "2606:4700::1"; + assert_eq!( + host, expected_pin_key, + "extracted host must equal the bare URL authority for use as reqwest pin key; \ + mutation: bracketed extraction returns \"[2606:4700::1]\" (differs from authority form)" + ); + // Sanity: confirm the SocketAddr is valid (no panic = key formation succeeded). + let _ = socket_addr; + + // ── fec0::/10 rejection through the same seam ──────────────────────────── + // Stage 1: extraction succeeds (SSRF decision is downstream). + let fec0_uri = "https://[fec0::1]/.well-known/jwks.json"; + let (fec0_host, fec0_port) = + super::extract_url_host_and_port(fec0_uri).expect("extraction succeeds for fec0 URI"); + assert_eq!(fec0_host, "fec0::1", "fec0 host must be bare"); + assert_eq!(fec0_port, 443u16); + + // Stage 2: IpAddr parse succeeds for the bare form. + let fec0_ip: std::net::IpAddr = fec0_host + .parse() + .expect("bracket-free fec0 host parses as IpAddr; mutation: bracketed form fails here"); + + // is_not_global_unicast must block fec0::/10 (deprecated site-local, RFC 3879). + assert!( + is_not_global_unicast(&fec0_ip), + "fec0::1 must be rejected by is_not_global_unicast; mutation: wrong bracket form \ + bypasses this check (parse fails, NetworkError not InvalidUri)" + ); + + // resolve_and_check_ssrf must return InvalidUri for fec0::1. + let fec0_err = super::resolve_and_check_ssrf(&fec0_host, fec0_port) + .await + .unwrap_err(); + assert_eq!( + fec0_err, + JwksFetchError::InvalidUri, + "fec0::1 must be rejected as InvalidUri, not NetworkError; \ + mutation: bracketed form -> parse fails -> DNS path -> NetworkError (red)" + ); +} + +/// **Fix 3 — Unchanged verifier observes A1→A2 rotation beyond A1's original absolute deadline.** +/// +/// Uses an injectable clock (`new_with_clock`) to advance controlled `now` past +/// A1's immutable hard deadline without wall-clock sleep. A1's deadline is +/// computed at first-fetch time (T0) and never mutated. The clock then advances +/// to T0 + HARD_DEADLINE_SECS + 1, beyond A1's original absolute deadline. +/// `get_snapshot` fires because the snapshot is expired, fetches A2, and the +/// one unchanged verifier (never rebuilt) must reflect the new keys. +/// +/// ## Mutation oracles +/// 1. **Sharing:** Replace `Arc::clone(&source)` passed to the verifier with a +/// fresh `Arc::new(second_source)` built from the same configs but independent, +/// sharing the same controlled clock. Warm the independent source with a +/// separate A1 fetch before advancing the clock. After advancement, +/// `key_set()` on the verifier's independent source filters the expired A1 +/// snapshot (`filter(|c| now < c.hard_deadline)`) and returns no keys — +/// the verifier never re-fetches and never observes A2. The A2-accept +/// assertion flips red reliably, because the verifier never observes A2. +/// The A1-reject assertion stays green: the independent cache is also +/// expired (same advanced clock), so that source also returns no A1 keys — +/// A1 tokens are still rejected, but through expiry of the independent +/// cache rather than through shared-arc rotation. **A2 acceptance is the +/// reliable shared-source oracle here.** +/// +/// Note: the expiry-purge (`state.snapshot = None` in `get_snapshot`) is +/// correctness-critical for concurrent callers: it clears the expired snapshot +/// before permit acquisition, so a caller that loses the permit race and falls +/// back to `state.snapshot` receives `None` rather than an expired snapshot. +/// A1 rejection after the deadline is also enforced independently by the `key_set` +/// read path (`filter(|c| now < c.hard_deadline)`), but the purge is what +/// prevents the fallback path from serving a stale snapshot to concurrent +/// refresh losers, so no separate purge mutation oracle is claimed here. +#[tokio::test] +async fn shared_arc_source_verifier_rejects_expired_a1_accepts_a2() { + use crate::nip_fi::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass, + }; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use serde_json::json; + use std::sync::atomic::{AtomicI64, Ordering}; + use std::sync::Arc; + + // Two distinct P-256 keypairs (reuse constants from shared_arc test). + const PKCS8_A1: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\ + \n-----END PRIVATE KEY-----\n"; + const X_A1: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const Y_A1: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + const PKCS8_A2: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMKMRn6EQMn67Z6tu\ + DbUTZWzrQpbRRTL3SJSMSd+EDG2hRANCAATGgMYxftLlZ11AIANHcr0b13pWkaLy\ + lkOeBZRG0bBMoUesLN7EdVYhtzcrCeNJh031QuO+UDWcwOmShbeR43x6\ + \n-----END PRIVATE KEY-----\n"; + const X_A2: &str = "xoDGMX7S5WddQCADR3K9G9d6VpGi8pZDngWURtGwTKE"; + const Y_A2: &str = "R6ws3sR1ViG3NysJ40mHTfVC475QNZzA6ZKFt5HjfHo"; + + const KID_A1: &str = "exp-key-1"; + const KID_A2: &str = "exp-key-2"; + const HARD_DEADLINE_SECS: u64 = 3600; + + let issuer = "https://exp-issuer.example"; + let audience = "https://exp-relay.example"; + + fn jwks_str(kid: &str, x: &str, y: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","use":"sig","alg":"ES256","kid":"{kid}","x":"{x}","y":"{y}"}}]}}"# + ) + } + + fn sign_token(pkcs8_pem: &str, kid: &str, iss: &str, aud: &str) -> String { + let wall_now = chrono::Utc::now().timestamp(); + let claims = json!({"iss": iss, "aud": aud, "sub": "u", + "iat": wall_now, "exp": wall_now + 600}); + let mut hdr = Header::new(Algorithm::ES256); + hdr.kid = Some(kid.to_owned()); + hdr.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()).expect("valid EC PEM"); + jsonwebtoken::encode(&hdr, &claims, &key).expect("sign") + } + + // Scripted fetcher: first call -> A1, second call -> A2. + let bodies = Arc::new(std::sync::Mutex::new(vec![ + Ok::(jwks_str(KID_A2, X_A2, Y_A2)), // popped second + Ok(jwks_str(KID_A1, X_A1, Y_A1)), // popped first + ])); + + struct RotatingFetcher { + bodies: Arc>>>, + } + impl super::super::verifier::sealed::Sealed for RotatingFetcher {} + impl JwksFetcher for RotatingFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + let result = self + .bodies + .lock() + .unwrap() + .pop() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } + } + + let jwks_contract = JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + 1, + HARD_DEADLINE_SECS, + ) + .unwrap(); + + // Controlled clock: atomic epoch-seconds, starts at real T0. + let t0 = chrono::Utc::now().timestamp(); + let clock = Arc::new(AtomicI64::new(t0)); + let clock2 = Arc::clone(&clock); + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + chrono::DateTime::from_timestamp(clock2.load(Ordering::SeqCst), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH) + }); + + let config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: jwks_contract.clone(), + }; + // Mutation oracle 1 (sharing): pass a second independent Arc to the verifier, + // separately warmed with A1 before advancing the clock. After advancement, + // A2-accept flips red (verifier never observes A2 keys); A1-reject stays + // green (independent cache also expired, so A1 keys are absent there too). + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![config], + RotatingFetcher { bodies }, + Arc::clone(&now_fn), + ) + .unwrap(), + ); + + // Step 1: warm cache with A1 JWKS (first scripted fetch at T0). + let snap_a1 = source.get_snapshot(issuer).await.unwrap(); + let gen_a1 = snap_a1.generation(); + // A1's hard deadline is T0 + HARD_DEADLINE_SECS; never mutated by this test. + let deadline_a1 = snap_a1.hard_deadline(); + + // Step 2: build the ONE long-lived verifier. + let mut registry = IssuerRegistry::new(); + registry.insert( + IssuerPolicy::new( + issuer.to_owned(), + vec![audience.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + HARD_DEADLINE_SECS, + None, + jwks_contract, + ) + .unwrap(), + ); + let verifier = FederatedAssertionVerifier::new(registry, Arc::clone(&source)); + + // Pre-advancement: A1 verifies. + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect("A1 token must verify before clock advances past its deadline"); + + // Step 3: advance clock past A1's original hard deadline (no sleep). + clock.store(t0 + HARD_DEADLINE_SECS as i64 + 1, Ordering::SeqCst); + + // Step 4: re-fetch through the SAME shared source. + // Expiry purge fires (now > A1 deadline), second scripted response is A2. + let snap_a2 = source.get_snapshot(issuer).await.unwrap(); + let gen_a2 = snap_a2.generation(); + let deadline_a2 = snap_a2.hard_deadline(); + + assert!( + gen_a2 > gen_a1, + "generation must advance: A1={gen_a1} A2={gen_a2}" + ); + // A2's deadline is computed at advanced clock time, so it is later than A1's. + assert!( + deadline_a2 > deadline_a1, + "A2 deadline must be later than A1's original" + ); + + // Step 5: the SAME unchanged verifier reflects A2 keys. + verifier + .verify(&sign_token(PKCS8_A2, KID_A2, issuer, audience)) + .expect( + "A2 token must verify through the unchanged verifier after A1 deadline expired; \ + mutation oracle: use independent Arc -> A2-accept flips red (reliable oracle)", + ); + verifier + .verify(&sign_token(PKCS8_A1, KID_A1, issuer, audience)) + .expect_err("A1 must be rejected after expiry + rotation"); +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index f7d1243a058..ce977090645 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -1,32 +1,19 @@ -//! NIP-FI federated-identity authorization — canonical assertion verifier and -//! contracts (Phase A, PR 1). -//! -//! This module is the closed, provider-neutral contract layer at the root of -//! the NIP-FI dependency graph. It defines: -//! -//! - the multi-issuer assertion-policy [`config`] and the two deterministic -//! semantic contract identities ([`AssertionPolicyId`], -//! [`TransportContractId`]); -//! - the origin-sealed normalized [`VerifiedAssertion`] result (`FI-INV-16`); -//! - the single [`FederatedAssertionVerifier`] (`FI-INV-16` canonical verifier); -//! - the privacy-preserving four-class [`DenialClass`] wire contract -//! (`FI-INV-13`). -//! -//! It has no dependencies on other NIP-FI PRs. It defines no database schema, -//! migration, runtime JWKS fetching, binding resolution, enrollment, or -//! request/proof binding — those belong to later PRs. Identity is issuer- -//! qualified `(iss, sub)` throughout: the `sub` claim is the fixed subject -//! coordinate and `nostr_pubkey` is the fixed key claim, never configurable, -//! so no deployment can seal a mutable attribute as identity. Issuer URL and -//! audience remain deployment configuration. +//! NIP-FI federated-identity authorization — assertion verifier, JWKS runtime, +//! startup validation, and discovery. -/// The exact client-attached header field ([NIP-FI.md](../../../docs/nips/NIP-FI.md), -/// "Client-attached transport"). `Authorization` remains reserved for NIP-98. +/// The client-attached transport header for federated-identity assertions. +/// +/// `Authorization` remains reserved for NIP-98; this separate header avoids +/// conflating authentication schemes at the relay ingress. +/// ([NIP-FI.md](../../../docs/nips/NIP-FI.md), "Client-attached transport") pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; pub mod config; pub mod denial; +pub mod discovery; +pub mod jwks; +pub mod startup; pub mod verifier; pub use assertion::{ @@ -39,4 +26,12 @@ pub use config::{ NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use discovery::{ + AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, +}; +pub use jwks::{ + HttpJwksFetcher, IssuerJwksConfig, JwksFetchError, JwksFetcher, JwksSourceContract, + ProductionJwksSource, +}; +pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; diff --git a/crates/buzz-auth/src/nip_fi/startup/mod.rs b/crates/buzz-auth/src/nip_fi/startup/mod.rs new file mode 100644 index 00000000000..410c862ec70 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/mod.rs @@ -0,0 +1,134 @@ +//! Startup validation for the NIP-FI assertion runtime. +//! +//! [`validate_nip_fi_config`] is the production entry point. It rejects any +//! configuration that would make the runtime unsafe, incomplete, or ambiguous +//! before the relay accepts any protected traffic. The relay MUST call this and +//! refuse to start on error in [`Enforce`][NipFiMode::Enforce] mode +//! (`FI-INV-14`, `FI-INV-15`). + +use super::config::{FreshnessClass, IssuerRegistry}; +use super::jwks::IssuerJwksConfig; + +/// Variant names are stable contract values; do not rename without a +/// `VERIFIER_CONTRACT_VERSION` bump. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NipFiMode { + /// NIP-FI is disabled. Protected ingresses are unreachable or absent. + Off, + /// Production enforcement: every protected ingress requires valid + /// federated assertion evidence. The relay MUST call + /// [`validate_nip_fi_config`] before accepting traffic in this mode. + Enforce, + /// All protected routes deny unconditionally. Used when a prior + /// enforce-mode deployment was misconfigured and must fail closed while + /// the operator repairs configuration. [FI-INV-14] + DenyProtected, +} + +/// Every variant corresponds to a concrete, operator-actionable defect. +/// No key material, token bytes, or raw claim values appear. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NipFiStartupError { + /// Registry has no entries; enforce mode requires at least one issuer. + #[error("NIP-FI enforce mode requires at least one issuer policy")] + EmptyRegistry, + + /// The duplicate `iss` is omitted to avoid leaking configuration into + /// operational logs. + #[error("NIP-FI issuer registry contains a duplicate issuer")] + DuplicateIssuer, + + /// Every registered issuer requires a JWKS endpoint in enforce mode. + #[error("NIP-FI issuer has no JWKS configuration")] + MissingJwksConfig, + + /// Mismatched configs are rejected to prevent silent key-source confusion. + #[error("NIP-FI JWKS config issuer does not match any registered policy")] + UnmatchedJwksConfig, + + /// The `JwksSourceContract` embedded in the `IssuerJwksConfig` does not + /// match the contract in the corresponding `IssuerPolicy`. Both must carry + /// exactly the same contract to keep a single source of truth per issuer. + #[error("NIP-FI JWKS config contract does not match the registered policy contract")] + JwksContractMismatch, + + /// `current-status` requires an authenticated status witness that is not + /// yet implemented. Use `FreshnessClass::OfflineJwt` instead. + #[error( + "NIP-FI current-status freshness is not yet supported; \ + use offline-jwt posture" + )] + UnsupportedPosture, +} + +/// Validates the complete NIP-FI runtime configuration. On error the relay +/// MUST refuse to start or fall back to [`NipFiMode::DenyProtected`]. +pub fn validate_nip_fi_config( + mode: NipFiMode, + registry: &IssuerRegistry, + jwks_configs: &[IssuerJwksConfig], +) -> Result<(), NipFiStartupError> { + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(()); + } + + if registry.is_empty() { + return Err(NipFiStartupError::EmptyRegistry); + } + + // IssuerRegistry overwrites duplicates silently; assert uniqueness here so + // a misconfigured multi-issuer call-site is caught before traffic is served. + { + let mut seen = std::collections::HashSet::new(); + for policy in registry.all_policies() { + if !seen.insert(policy.issuer()) { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + } + + // Reject current-status policies: the status witness is not yet + // implemented. Fail closed rather than advertise a freshness guarantee the + // verifier cannot satisfy. + for policy in registry.all_policies() { + if policy.freshness() == FreshnessClass::CurrentStatus { + return Err(NipFiStartupError::UnsupportedPosture); + } + } + + // Build JWKS map, rejecting duplicates. Two configs for the same issuer + // would make the effective endpoint selection order-dependent. + let mut jwks_map: std::collections::HashMap<&str, &IssuerJwksConfig> = + std::collections::HashMap::with_capacity(jwks_configs.len()); + for config in jwks_configs { + if jwks_map.insert(config.issuer.as_str(), config).is_some() { + return Err(NipFiStartupError::DuplicateIssuer); + } + } + + for config in jwks_configs { + if registry.policy_for_issuer(&config.issuer).is_none() { + return Err(NipFiStartupError::UnmatchedJwksConfig); + } + // Contract fields are pre-validated inside `JwksSourceContract::new` + // at `IssuerPolicy` construction. Enforce that the config carries the + // same contract as the policy — a mismatch would mean two independent + // copies of the URI/timing drifted apart, violating the single-source- + // of-truth invariant. + let policy = registry.policy_for_issuer(&config.issuer).unwrap(); + if &config.contract != policy.jwks_source_contract() { + return Err(NipFiStartupError::JwksContractMismatch); + } + } + + for policy in registry.all_policies() { + if !jwks_map.contains_key(policy.issuer()) { + return Err(NipFiStartupError::MissingJwksConfig); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/nip_fi/startup/tests.rs b/crates/buzz-auth/src/nip_fi/startup/tests.rs new file mode 100644 index 00000000000..04b28a7b964 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/startup/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::nip_fi::config::{FreshnessClass, IssuerPolicy, IssuerRegistry, TokenClass}; +use crate::nip_fi::jwks::{IssuerJwksConfig, JwksSourceContract}; +use jsonwebtoken::Algorithm as JwtAlgorithm; + +fn test_contract(issuer: &str) -> JwksSourceContract { + // Build a canonical JWKS URI from the issuer URL. The issuer may already + // be a full HTTPS URL (e.g. "https://id.example") or a bare hostname. + let uri = if issuer.starts_with("https://") { + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')) + } else { + format!("https://{}/.well-known/jwks.json", issuer) + }; + JwksSourceContract::new(uri, 300, 3600).expect("valid test contract") +} + +fn make_offline_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + None, + test_contract(issuer), + ) + .unwrap() +} + +fn make_status_policy(issuer: &str) -> IssuerPolicy { + IssuerPolicy::new( + issuer.to_owned(), + vec![format!("https://relay.example/api")], + TokenClass::DedicatedNipFi, + FreshnessClass::CurrentStatus, + vec![JwtAlgorithm::ES256], + false, + 0, + 3600, + Some(60), + test_contract(issuer), + ) + .unwrap() +} + +fn make_jwks_config(issuer: &str) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: test_contract(issuer), + } +} + +#[test] +fn off_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::Off, ®istry, &[]).is_ok()); +} + +#[test] +fn deny_protected_mode_accepts_empty_registry() { + let registry = IssuerRegistry::new(); + assert!(validate_nip_fi_config(NipFiMode::DenyProtected, ®istry, &[]).is_ok()); +} + +#[test] +fn enforce_valid_config_passes() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]).is_ok() + ); +} + +#[test] +fn enforce_multiple_issuers_passes() { + let issuers = [ + "https://a.example", + "https://b.example", + "https://c.example", + ]; + let mut registry = IssuerRegistry::new(); + for iss in &issuers { + registry.insert(make_offline_policy(iss)); + } + let jwks: Vec<_> = issuers.iter().map(|i| make_jwks_config(i)).collect(); + assert!(validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_ok()); +} + +#[test] +fn enforce_empty_registry_rejects() { + let registry = IssuerRegistry::new(); + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::EmptyRegistry); +} + +#[test] +fn enforce_issuer_without_jwks_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(); + assert_eq!(err, NipFiStartupError::MissingJwksConfig); +} + +#[test] +fn enforce_unmatched_jwks_config_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let err = validate_nip_fi_config( + NipFiMode::Enforce, + ®istry, + &[make_jwks_config("https://other.example")], + ) + .unwrap_err(); + assert_eq!(err, NipFiStartupError::UnmatchedJwksConfig); +} + +/// A JWKS config whose contract differs from the policy contract must be +/// rejected — a mismatch means two independent copies of URI/timing have +/// drifted, violating the single-source-of-truth invariant. +#[test] +fn enforce_jwks_contract_mismatch_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + // Config carries a different refresh interval than the policy (300 vs 600). + let mismatched_config = IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 600, // differs from policy contract (300) + 3600, + ) + .unwrap(), + }; + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[mismatched_config]).unwrap_err(), + NipFiStartupError::JwksContractMismatch + ); +} + +/// Rejected regardless of whether a JWKS config is present — the verifier +/// has no status witness to satisfy the freshness guarantee. +#[test] +fn enforce_current_status_policy_always_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_status_policy(issuer)); + + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[]).unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); + assert_eq!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &[make_jwks_config(issuer)]) + .unwrap_err(), + NipFiStartupError::UnsupportedPosture + ); +} + +/// Duplicate JWKS configs for the same issuer must not silently succeed. +#[test] +fn enforce_duplicate_jwks_issuer_in_configs_rejects() { + let issuer = "https://id.example"; + let mut registry = IssuerRegistry::new(); + registry.insert(make_offline_policy(issuer)); + + let jwks = vec![make_jwks_config(issuer), make_jwks_config(issuer)]; + assert!( + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks).is_err(), + "duplicate JWKS configs must not pass" + ); +} diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 7ac2cbe3766..aa3b5796a0f 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -49,9 +49,13 @@ use std::fmt; /// the key-source trait. Combined with the crate-private [`AssertionKeySet`] /// constructor, this makes the accepted issuer→JWKS authority impossible to /// synthesize outside the crate's trusted configuration path. -mod sealed { +pub(crate) mod sealed { /// Private marker preventing external implementations of the key source. pub trait Sealed {} + + // Blanket seal for `Arc` so `Arc` satisfies + // the sealed supertrait without requiring callers to implement it. + impl Sealed for std::sync::Arc {} } /// One issuer's key source: a JWKS snapshot bound to the exact `iss` it @@ -64,7 +68,7 @@ mod sealed { /// construction seam: [`verify`] takes no snapshot argument, and this type has /// no public constructor, so an external consumer cannot build a snapshot that /// labels issuer B's JWKS as issuer A. Building a snapshot (and the source that -/// serves it) is the trusted configuration act PR 3's JWKS runtime performs at +/// serves it) is the trusted configuration act the `jwks` runtime performs at /// startup, not a per-request or external input. /// /// The crate-private constructor is a live regression: an external crate that @@ -90,7 +94,7 @@ impl AssertionKeySet { /// generation and a required key-snapshot hard deadline. Rejects a zero /// generation, an empty issuer, an empty or oversized key set /// ([`MAX_JWKS_KEYS`]), or a non-positive deadline. Crate-private: only the - /// trusted in-crate configuration path (PR 3's JWKS runtime) may bind key + /// trusted in-crate configuration path (the `jwks` runtime) may bind key /// material to an issuer. /// /// Bounding the key count here is the pre-lookup control (NIP-FI.md:166-171): @@ -101,13 +105,6 @@ impl AssertionKeySet { /// finite key-snapshot bound into `revalidation_dependencies` /// (NIP-FI.md:240-249). /// - /// Its only current callers are the in-crate `cfg(test)` verifier suite; - /// PR 3's JWKS runtime is the intended non-test consumer. Until it lands the - /// non-test lib build sees no caller, so this narrowly allows `dead_code` - /// for this one constructor rather than deferring it or widening the lint. - /// `expect` would misfire: under `cfg(test)` the lint does not trigger, so - /// the expectation would be unfulfilled and fail `-D warnings`. - #[allow(dead_code)] pub(crate) fn new( issuer: String, generation: u64, @@ -139,6 +136,13 @@ impl AssertionKeySet { pub const fn generation(&self) -> u64 { self.generation } + + /// The snapshot hard deadline. Test-only accessor for deadline-crossing + /// oracles; not compiled into production builds. + #[cfg(test)] + pub(crate) fn hard_deadline(&self) -> chrono::DateTime { + self.hard_deadline + } } impl fmt::Debug for AssertionKeySet { @@ -153,7 +157,7 @@ impl fmt::Debug for AssertionKeySet { /// instead asks this source for the snapshot bound to the token's /// signature-authenticated `iss`. A request-path caller therefore cannot /// relabel one issuer's JWKS as another's — the cross-issuer bypass at the old -/// `verify(token, key_set)` seam. Configuring the source (PR 3's JWKS runtime) +/// `verify(token, key_set)` seam. Configuring the source (the `jwks` runtime) /// is a trusted startup act, not per-request input. /// /// This trait is sealed via a private supertrait, so it cannot be implemented @@ -180,8 +184,27 @@ pub trait IssuerKeySource: sealed::Sealed { fn key_set(&self, issuer: &str) -> Option; } +/// Forwarding implementation so a single `Arc` can be cheaply cloned and +/// shared across multiple [`FederatedAssertionVerifier`] instances while all +/// of them observe every refresh committed to the shared source. +/// +/// This is the canonical sharing path for `ProductionJwksSource`, which is +/// not itself `Clone` (its internal `RwLock`-protected state is not cheaply +/// copyable). Wrap it in `Arc` at startup, then pass `Arc::clone(&source)` to +/// each verifier — all verifiers read from the same underlying cache and see +/// key rotations as soon as `get_snapshot` commits them. +/// +/// The blanket seal (`impl Sealed for Arc`) in the `sealed` +/// module ensures this forwarding impl remains crate-owned: an external crate +/// still cannot implement `IssuerKeySource` for its own type. +impl IssuerKeySource for std::sync::Arc { + fn key_set(&self, issuer: &str) -> Option { + (**self).key_set(issuer) + } +} + /// A fixed issuer→snapshot key source for the in-crate verifier tests, -/// standing in for PR 3's JWKS runtime. It is `cfg(test)`-only — not behind a +/// standing in for the `jwks` runtime. It is `cfg(test)`-only — not behind a /// downstream-selectable Cargo feature — so no dependent crate can enable it to /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source @@ -352,7 +375,7 @@ impl FederatedAssertionVerifier { // is `evidence_rejected` (403), and this defers a valid one as // `authorization_unavailable` (503) so a missing witness never // masquerades as rejected evidence, nor invalid input as unavailable - // (NIP-FI.md:459-476). PR 3 adds the witness path additively. + // (NIP-FI.md:459-476). if policy.freshness() == FreshnessClass::CurrentStatus { return Err(VerifierError::StatusWitnessUnavailable); } @@ -726,8 +749,8 @@ fn parse_nostr_pubkey_claim( } } -/// Capture only the claim names the policy reads into a canonical set. For PR 1 -/// the closed set is the `scope` claim, split on ASCII space; unchecked claims +/// Capture only the claim names the policy reads into a canonical set. The +/// closed set is the `scope` claim, split on ASCII space; unchecked claims /// never enter the result. fn capture_capabilities( _policy: &IssuerPolicy, diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 316681e0afc..990a3310e40 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -28,6 +28,17 @@ const TEST_KID: &str = "test-key-1"; const ISSUER: &str = "https://issuer.example"; const AUDIENCE: &str = "https://relay.example"; +/// A canonical JWKS contract for the default test issuer. Used wherever a +/// `JwksSourceContract` is required but JWKS behavior is not under test. +fn test_jwks_contract() -> crate::nip_fi::jwks::JwksSourceContract { + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .expect("valid test contract") +} + // A second, independent P-256 key: issuer B's real signing key, used to prove // that a token signed by B and claiming `iss=A` cannot mint an A identity. const TEST_EC_PKCS8_PEM_B: &str = "-----BEGIN PRIVATE KEY-----\n\ @@ -102,11 +113,18 @@ fn access_token_policy_with(subject_class: SubjectClassContract) -> IssuerPolicy 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } fn dedicated_policy(issuer: &str) -> IssuerPolicy { + let contract = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')), + 300, + 3600, + ) + .expect("valid test contract"); IssuerPolicy::new( issuer.to_owned(), vec![AUDIENCE.to_owned()], @@ -117,6 +135,7 @@ fn dedicated_policy(issuer: &str) -> IssuerPolicy { 60, 3600, None, + contract, ) .expect("valid policy") } @@ -132,6 +151,7 @@ fn dedicated_policy_with_audiences(audiences: Vec) -> IssuerPolicy { 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -147,6 +167,7 @@ fn dedicated_policy_with_algorithms(algorithms: Vec) -> IssuerPolicy 60, 3600, None, + test_jwks_contract(), ) .expect("valid policy") } @@ -688,6 +709,7 @@ fn missing_nostr_pubkey_denies_under_attested_key_policy() { 60, 3600, None, + test_jwks_contract(), ) .unwrap(); let verifier = verifier_with(policy); @@ -1087,6 +1109,7 @@ fn current_status_policy() -> IssuerPolicy { 60, 3600, Some(120), // maximum_status_age required for current-status + test_jwks_contract(), ) .expect("valid current-status policy") } @@ -1366,6 +1389,7 @@ fn assertion_policy_id_is_deterministic_and_semantic() { 120, // different skew => different semantics 3600, None, + test_jwks_contract(), ) .unwrap(); assert_ne!(p1.id(), changed.id()); @@ -1391,6 +1415,7 @@ fn offline_policy_rejects_inapplicable_maximum_status_age() { 60, 3600, Some(120), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(err, IssuerPolicyError::InapplicableMaximumStatusAge); @@ -1409,6 +1434,7 @@ fn offline_policy_accepts_absent_maximum_status_age() { 60, 3600, None, + test_jwks_contract(), ) .is_ok()); } @@ -1427,6 +1453,7 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { 60, 3600, None, + test_jwks_contract(), ) .unwrap_err(); assert_eq!(missing, IssuerPolicyError::MissingMaximumStatusAge); @@ -1440,6 +1467,7 @@ fn current_status_policy_still_requires_positive_maximum_status_age() { 60, 3600, Some(0), + test_jwks_contract(), ) .unwrap_err(); assert_eq!(zero, IssuerPolicyError::InvalidTimeBounds); @@ -1533,7 +1561,185 @@ fn assertion_policy_id_is_invariant_under_subject_class_value_permutation_and_du assert_eq!(base.id(), permuted.id()); } -// ---- Canonical scope capture --------------------------------------------- +// ---- JwksSourceContract in AssertionPolicyId ------------------------------ +// +// Per the NIP-FI spec ("Policy identity and snapshots"): `assertion_policy_id` +// covers "authenticated key/status-source contracts" and "time rules". The +// three contract fields are immutable contract identity, not mutable state — +// changing any one of them changes which keys the runtime trusts or how long +// it trusts them, invalidating all prepared evidence against the old contract. +// Key rotation (JWKS content change) leaves all three unchanged and must NOT +// move the ID. + +/// Helper: build a policy with the given `JwksSourceContract`. +fn policy_with_contract(contract: crate::nip_fi::jwks::JwksSourceContract) -> IssuerPolicy { + IssuerPolicy::new( + ISSUER.to_owned(), + vec![AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + false, + 60, + 3600, + None, + contract, + ) + .expect("valid policy") +} + +#[test] +fn assertion_policy_id_moves_when_jwks_uri_changes() { + // The JWKS URI selects the authenticated key source. A different URI may + // serve different keys — the policy ID must change. + // + // Mutation (omit URI from hash): both policies hash identically despite + // different endpoints; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_uri = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks-alt.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_uri.id(), + "JWKS URI change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_refresh_interval_changes() { + // The refresh interval defines bounded refresh behavior. A longer interval + // allows stale keys to persist longer — the policy ID must change. + // + // Mutation (omit refresh_interval from hash): both policies hash + // identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_interval = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 600, // doubled + 3600, + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_interval.id(), + "refresh_interval_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_moves_when_hard_deadline_changes() { + // The hard deadline defines the source's accepted time rule; every + // per-snapshot deadline the verifier seals into `VerifiedAssertion` + // derives from this. A looser deadline extends the valid window beyond + // what the new policy intends — the policy ID must change. + // + // Mutation (omit key_snapshot_hard_deadline from hash): both policies + // hash identically; this test turns red. + let base = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let different_deadline = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 7200, // doubled + ) + .unwrap(), + ); + assert_ne!( + base.id(), + different_deadline.id(), + "key_snapshot_hard_deadline_seconds change must move assertion_policy_id" + ); +} + +#[test] +fn assertion_policy_id_is_stable_for_same_jwks_contract() { + // URI canonicalization is deterministic: the same validated URI, interval, + // and deadline always hash to the same policy ID regardless of call order. + let c1 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let c2 = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(); + let p1 = policy_with_contract(c1); + let p2 = policy_with_contract(c2); + assert_eq!( + p1.id(), + p2.id(), + "same JWKS contract must produce identical assertion_policy_id" + ); +} + +#[test] +fn identical_contract_produces_stable_assertion_policy_id() { + // `AssertionPolicyId` is derived from the contract fields only — not from + // JWKS key material. This means JWKS key additions/removals (runtime + // rotation) cannot change the policy ID; only changes to the contract + // itself (JWKS URI, refresh interval, hard deadline) would do so. + // + // This test verifies the structural invariant: two `IssuerPolicy` values + // built from identical contracts produce the same `AssertionPolicyId`, + // regardless of when or how many times the ID is derived. Because key + // material never flows into `derive_assertion_policy_id`, the ID is + // stable for the lifetime of a given contract. + let p1 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + let p2 = policy_with_contract( + crate::nip_fi::jwks::JwksSourceContract::new( + format!("{}/.well-known/jwks.json", ISSUER), + 300, + 3600, + ) + .unwrap(), + ); + // Identical contract → identical ID: key material is not part of the hash. + assert_eq!( + p1.id(), + p2.id(), + "identical contract must produce the same assertion_policy_id (key material is not hashed)" + ); +} #[test] fn scope_capture_is_canonical_under_order_and_duplicates() { diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..fe5b4bb80a6 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -19,344 +19,390 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { +/// Blocked classes are drawn from the IANA IPv4 and IPv6 Special-Purpose +/// Address Space registries (last updated 2025-10-09): ranges whose +/// `Globally Reachable` column is `False`, `None`, or absent, plus multicast +/// space. Within otherwise-denied envelopes, explicitly global entries are +/// carved out as exceptions (e.g., PCP/TURN/DNS-SD anycast inside 2001::/23). +/// IPv4 embedded in IPv4-mapped, IPv4-compatible, and NAT64 well-known +/// (64:ff9b::/96) space is evaluated recursively against the IPv4 table — +/// registry global=True for the IPv6 wrapper does not bypass the +/// embedded-address check. SIIT IPv4-translated (::ffff:0:0:0/96) follows the +/// same recursive path. The local-use NAT64 prefix (64:ff9b:1::/48) is blocked +/// wholesale as a non-global range; its embedded IPv4 payload is not decoded. +/// +/// Used for SSRF protection: rejects outbound targets in known non-public +/// address classes; addresses not covered by an explicit deny rule pass through. +/// Conservative posture: `None`/blank registry entries are treated as non-global. +/// +/// Registries retrieved 2026-08-31; registries last updated 2025-10-09: +/// https://www.iana.org/assignments/iana-ipv4-special-registry/ +/// https://www.iana.org/assignments/iana-ipv6-special-registry/ +/// +/// Compatibility alias: `is_private_ip` (see below). +/// +/// Callers: `buzz-auth` JWKS boundary, `buzz-workflow` webhook SSRF check, +/// desktop `link_preview` SSRF check. +pub fn is_not_global_unicast(ip: &std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { - let octets = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || octets[0] == 0 - || v4.is_broadcast() - // Carrier-Grade NAT (RFC 6598) — 100.64.0.0/10 - // Dangerous in cloud environments (AWS, GCP) where CGNAT can route to metadata services. - || (octets[0] == 100 && (octets[1] & 0xC0) == 64) - // Benchmarking (RFC 2544) — 198.18.0.0/15 - || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + let o = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || o[0] == 0 // 0.0.0.0/8 "This network" + || v4.is_broadcast() // 255.255.255.255 + || (o[0] == 100 && (o[1] & 0xC0) == 64) // 100.64.0.0/10 Shared/CGNAT + || (o[0] == 198 && (o[1] & 0xFE) == 18) // 198.18.0.0/15 Benchmarking + || (o[0] & 0xF0) == 0xE0 // 224.0.0.0/4 Multicast + || (o[0] & 0xF0) == 0xF0 // 240.0.0.0/4 Reserved + // 192.0.0.0/24 IETF Protocol Assignments. + // Globally reachable exceptions: 192.0.0.9 (PCP anycast, RFC 7723) + // and 192.0.0.10 (TURN anycast, RFC 8155). + || (o[0] == 192 && o[1] == 0 && o[2] == 0 + && o[3] != 9 && o[3] != 10) + || (o[0] == 192 && o[1] == 0 && o[2] == 2) // 192.0.2.0/24 TEST-NET-1 + // 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526). + // Registry global field is None/blank — conservative posture: block. + || (o[0] == 192 && o[1] == 88 && o[2] == 99) + || (o[0] == 198 && o[1] == 51 && o[2] == 100) // 198.51.100.0/24 TEST-NET-2 + || (o[0] == 203 && o[1] == 0 && o[2] == 113) // 203.0.113.0/24 TEST-NET-3 } std::net::IpAddr::V6(v6) => { - // Check IPv4-compatible and mapped addresses against IPv4 rules. + // IPv4-compatible and IPv4-mapped addresses are checked against IPv4 rules. if let Some(v4) = v6.to_ipv4() { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - let segments = v6.segments(); + let s = v6.segments(); - // NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4 - // destinations while rejecting embedded private/reserved addresses. + // NAT64 well-known prefix (RFC 6052): reachability follows the embedded + // IPv4 address (registry global=True, but SSRF policy checks payload). if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); } - // Legacy SIIT IPv4-translated addresses can route to the IPv4 value - // in their final four octets but are not recognized by `to_ipv4()`. + // SIIT IPv4-translated addresses (::ffff:0:0:0/96) route to the embedded + // IPv4 value and are not recognised by `to_ipv4()`. if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) { - return is_private_ip(&std::net::IpAddr::V4(v4)); + return is_not_global_unicast(&std::net::IpAddr::V4(v4)); + } + + if v6.is_loopback() || v6.is_unspecified() { + return true; } - v6.is_loopback() - || v6.is_unspecified() - || segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA - || segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local - || segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 - || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo - || segments[0] == 0x2002 // 2002::/16 6to4 - // RFC 3849 — documentation range, should never appear in production - || (segments[0] == 0x2001 && segments[1] == 0x0db8) + // 2001::/23 IETF Protocol Assignments envelope (registry global=False). + // All addresses within the /23 are non-global by default, with explicit + // globally-reachable exceptions carved out below. + // + // /23 check: segments[0]==0x2001 and top 7 bits of segments[1] are zero + // (i.e., segments[1] in [0x0000..0x01ff]). + if s[0] == 0x2001 && (s[1] >> 9) == 0 { + // Globally reachable exceptions inside 2001::/23 (registry global=True): + // 2001:1::1 PCP Anycast RFC 7723 + // 2001:1::2 TURN Anycast RFC 8155 + // 2001:1::3 DNS-SD SRP Anycast RFC 9665 + // 2001:3::/32 AMT RFC 7450 + // 2001:4:112::/48 AS112-v6 RFC 7535 + // 2001:20::/28 ORCHIDv2 RFC 7343 (segments[1] in 0x0020..0x002f) + // 2001:30::/28 DETs Prefix RFC 9374 (segments[1] in 0x0030..0x003f) + let is_global_exception = (s[1] == 1 + && s[2] == 0 + && s[3] == 0 + && s[4] == 0 + && s[5] == 0 + && s[6] == 0 + && matches!(s[7], 1..=3)) + || s[1] == 3 // 2001:3::/32 AMT + || (s[1] == 4 && s[2] == 0x0112) // 2001:4:112::/48 AS112-v6 + || (s[1] >> 4) == 0x0002 // 2001:20::/28 ORCHIDv2 + || (s[1] >> 4) == 0x0003; // 2001:30::/28 DETs + + if !is_global_exception { + return true; + } + } + + s[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA + || s[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local + || s[0] & 0xffc0 == 0xfec0 // fec0::/10 deprecated site-local (RFC 3879) + || s[0] & 0xff00 == 0xff00 // ff00::/8 multicast + // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) + || (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 1) + // 100::/64 Discard-Only (RFC 6666) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) + // 100:0:0:1::/64 Dummy IPv6 Prefix (RFC 9780) + || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 1) + // 2001:db8::/32 Documentation (RFC 3849) — outside 2001::/23 + || (s[0] == 0x2001 && s[1] == 0x0db8) + || s[0] == 0x2002 // 2002::/16 6to4 (RFC 3056) + // 3fff::/20 Documentation (RFC 9637) + || (s[0] == 0x3fff && (s[1] >> 12) == 0) + || s[0] == 0x5f00 // 5f00::/16 SRv6 SIDs (RFC 9252) } } } +/// Compatibility alias; prefer [`is_not_global_unicast`]. +#[inline] +pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { + is_not_global_unicast(ip) +} + #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; - #[test] - fn test_loopback_v4() { - assert!(is_private_ip(&"127.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_10() { - assert!(is_private_ip(&"10.0.0.1".parse::().unwrap())); - } - #[test] - fn test_private_172() { - assert!(is_private_ip(&"172.16.0.1".parse::().unwrap())); - } - #[test] - fn test_private_192() { - assert!(is_private_ip(&"192.168.1.1".parse::().unwrap())); - } - #[test] - fn test_link_local() { - assert!(is_private_ip(&"169.254.1.1".parse::().unwrap())); - } - #[test] - fn test_unspecified() { - assert!(is_private_ip(&"0.0.0.0".parse::().unwrap())); - } - #[test] - fn test_broadcast() { - assert!(is_private_ip(&"255.255.255.255".parse::().unwrap())); + fn blocked(s: &str) -> bool { + is_not_global_unicast(&s.parse::().unwrap()) } + #[test] - fn test_public_v4() { - assert!(!is_private_ip(&"8.8.8.8".parse::().unwrap())); + fn public_v4() { + assert!(!blocked("1.1.1.1")); + assert!(!blocked("8.8.8.8")); } + #[test] - fn test_loopback_v6() { - assert!(is_private_ip(&"::1".parse::().unwrap())); + fn public_v6_cloudflare() { + assert!(!blocked("2606:4700::1")); } + #[test] - fn test_unspecified_v6() { - assert!(is_private_ip(&"::".parse::().unwrap())); + fn loopback_and_unspecified() { + assert!(blocked("127.0.0.1")); + assert!(blocked("0.0.0.0")); + assert!(blocked("::1")); + assert!(blocked("::")); } + #[test] - fn test_ula_v6() { - assert!(is_private_ip(&"fd00::1".parse::().unwrap())); + fn private_rfc1918() { + assert!(blocked("10.0.0.1")); + assert!(blocked("172.16.0.1")); + assert!(blocked("192.168.1.1")); } + #[test] - fn test_link_local_v6() { - assert!(is_private_ip(&"fe80::1".parse::().unwrap())); + fn link_local() { + assert!(blocked("169.254.1.1")); + assert!(blocked("fe80::1")); } + #[test] - fn test_public_v6() { - assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); + fn broadcast() { + assert!(blocked("255.255.255.255")); } + #[test] - fn test_documentation_range_v6() { - // 2001:db8::/32 — RFC 3849 documentation range, must be blocked - assert!(is_private_ip(&"2001:db8::1".parse::().unwrap())); - assert!(is_private_ip( - &"2001:db8:ffff::1".parse::().unwrap() - )); + fn cgnat() { + assert!(blocked("100.64.0.1")); + assert!(blocked("100.127.255.254")); + assert!(!blocked("100.63.255.255")); + assert!(!blocked("100.128.0.0")); } + #[test] - fn test_ipv4_mapped_v6_private() { - // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to a private IPv4 - assert!(is_private_ip(&"::ffff:10.0.0.1".parse::().unwrap())); + fn benchmarking_v4() { + assert!(blocked("198.18.0.1")); + assert!(blocked("198.19.255.254")); + assert!(!blocked("198.17.255.255")); + assert!(!blocked("198.20.0.0")); } + #[test] - fn test_ipv4_mapped_v6_loopback() { - assert!(is_private_ip( - &"::ffff:127.0.0.1".parse::().unwrap() - )); + fn multicast_and_reserved_v4() { + assert!(blocked("224.0.0.0")); + assert!(blocked("239.255.255.255")); + assert!(blocked("240.0.0.0")); + assert!(blocked("254.255.255.255")); + assert!(!blocked("223.255.255.255")); } + + // Most of 192.0.0.0/24 is non-global; 192.0.0.9 (PCP, RFC 7723) and + // 192.0.0.10 (TURN, RFC 8155) are the only globally-reachable exceptions. #[test] - fn test_ipv4_mapped_v6_public() { - assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + fn ietf_protocol_assignments() { + assert!(blocked("192.0.0.0")); + assert!(blocked("192.0.0.1")); + assert!(blocked("192.0.0.170")); // NAT64/DNS64 discovery — non-global + assert!(blocked("192.0.0.255")); + assert!(!blocked("192.0.0.9")); // PCP Anycast (RFC 7723) — global + assert!(!blocked("192.0.0.10")); // TURN Anycast (RFC 8155) — global } + #[test] - fn test_ipv4_compatible_v6_private() { - assert!(is_private_ip(&"::10.0.0.1".parse::().unwrap())); - assert!(is_private_ip(&"::127.0.0.1".parse::().unwrap())); - assert!(is_private_ip( - &"::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip(&"::8.8.8.8".parse::().unwrap())); + fn documentation_v4() { + assert!(blocked("192.0.2.0")); + assert!(blocked("192.0.2.255")); + assert!(blocked("198.51.100.0")); + assert!(blocked("198.51.100.255")); + assert!(blocked("203.0.113.0")); + assert!(blocked("203.0.113.255")); + assert!(!blocked("192.0.1.255")); + assert!(!blocked("192.0.3.0")); + assert!(!blocked("198.51.99.255")); + assert!(!blocked("198.51.101.0")); + assert!(!blocked("203.0.112.255")); + assert!(!blocked("203.0.114.0")); } + + // Registry global field is None/blank; conservative posture: block. #[test] - fn test_nat64_well_known_prefix() { - let first = "64:ff9b::".parse().unwrap(); - let last = "64:ff9b::ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - let embedded = "64:ff9b::172.16.1.2".parse().unwrap(); - assert_eq!( - embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX), - Some("172.16.1.2".parse().unwrap()) - ); - assert!(is_private_ip( - &"64:ff9b::10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"64:ff9b::169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::().unwrap())); + fn deprecated_6to4_anycast_v4() { + assert!(blocked("192.88.99.0")); + assert!(blocked("192.88.99.1")); + assert!(blocked("192.88.99.255")); + assert!(!blocked("192.88.98.255")); + assert!(!blocked("192.88.100.0")); } + #[test] - fn test_ipv4_translated_prefix() { - let first = "0:0:0:0:ffff:0:0:0".parse().unwrap(); - let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap(); - assert_eq!( - embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX), - Some("0.0.0.0".parse().unwrap()) - ); - assert_eq!( - embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX), - Some("255.255.255.255".parse().unwrap()) - ); - assert!(is_private_ip( - &"::ffff:0:10.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:127.0.0.1".parse::().unwrap() - )); - assert!(is_private_ip( - &"::ffff:0:169.254.169.254".parse::().unwrap() - )); - assert!(!is_private_ip( - &"::ffff:0:8.8.8.8".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:fffe:ffff:ffff:ffff".parse::().unwrap() - )); - assert!(!is_private_ip( - &"0:0:0:0:ffff:1:0:0".parse::().unwrap() - )); + fn ula_v6() { + assert!(blocked("fd00::1")); + assert!(blocked("fc00::1")); } + #[test] - fn test_nat64_local_use_prefix_boundaries() { - assert!(is_private_ip(&"64:ff9b:1::".parse::().unwrap())); - assert!(is_private_ip( - &"64:ff9b:1:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"64:ff9b::ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"64:ff9b:2::".parse::().unwrap())); + fn multicast_v6() { + assert!(blocked("ff02::1")); + assert!(blocked("ff02::2")); + assert!(blocked("ffff::1")); + assert!(!blocked("fe00::1")); } + #[test] - fn test_teredo_prefix_boundaries() { - assert!(is_private_ip(&"2001::".parse::().unwrap())); - assert!(is_private_ip( - &"2001:0:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2001:1::1".parse::().unwrap())); + fn ietf_protocol_assignments_v6_interior() { + assert!(blocked("2001::")); + assert!(blocked("2001:2::1")); + assert!(blocked("2001:10::1")); + assert!(blocked("2001:db8::1")); // Documentation — outside /23 but blocked separately + assert!(blocked("2001:1ff:ffff::1")); + assert!(!blocked("2001:200::1")); } + #[test] - fn test_6to4_prefix_boundaries() { - assert!(is_private_ip(&"2002::".parse::().unwrap())); - assert!(is_private_ip( - &"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip( - &"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - .parse::() - .unwrap() - )); - assert!(!is_private_ip(&"2003::1".parse::().unwrap())); + fn ietf_protocol_assignments_v6_global_exceptions() { + // PCP/TURN/DNS-SD anycast /128s — registry global=True + assert!(!blocked("2001:1::1")); // PCP Anycast (RFC 7723) + assert!(!blocked("2001:1::2")); // TURN Anycast (RFC 8155) + assert!(!blocked("2001:1::3")); // DNS-SD SRP Anycast (RFC 9665) + assert!(blocked("2001:1::4")); // not an exception + assert!(blocked("2001:1:1::1")); // not an exception + + // 2001:3::/32 AMT — registry global=True + assert!(!blocked("2001:3::1")); + assert!(!blocked("2001:3:ffff::1")); + assert!(blocked("2001:4::1")); + + // 2001:4:112::/48 AS112-v6 — registry global=True + assert!(!blocked("2001:4:112::1")); + assert!(!blocked("2001:4:112:ffff::1")); + assert!(blocked("2001:4:113::1")); + + // 2001:20::/28 ORCHIDv2 — registry global=True + assert!(!blocked("2001:20::1")); + assert!(!blocked("2001:2f::1")); + assert!(blocked("2001:10::1")); + + // 2001:30::/28 DETs — registry global=True + assert!(!blocked("2001:30::1")); + assert!(!blocked("2001:3f::1")); + assert!(!blocked("2001:3::1")); // AMT exception — distinct check } - // CGNAT (RFC 6598) — 100.64.0.0/10 #[test] - fn test_cgnat_start() { - // 100.64.0.1 — start of CGNAT range - assert!(is_private_ip(&"100.64.0.1".parse::().unwrap())); + fn documentation_v6() { + assert!(blocked("2001:db8::1")); + assert!(blocked("2001:db8:ffff::1")); } + #[test] - fn test_cgnat_end() { - // 100.127.255.254 — end of CGNAT range - assert!(is_private_ip(&"100.127.255.254".parse::().unwrap())); + fn six_to_four_v6() { + assert!(blocked("2002::")); + assert!(blocked("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("2003::1")); } + #[test] - fn test_cgnat_below_range() { - // 100.63.255.255 — just below CGNAT range (100.0–100.63 is public) - assert!(!is_private_ip(&"100.63.255.255".parse::().unwrap())); + fn discard_only_v6() { + assert!(blocked("100::1")); + assert!(blocked("100::ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:1::1")); // outside both discard and dummy ranges } + #[test] - fn test_cgnat_above_range() { - // 100.128.0.0 — just above CGNAT range (100.128+ is public) - assert!(!is_private_ip(&"100.128.0.0".parse::().unwrap())); + fn dummy_prefix_v6() { + assert!(blocked("100:0:0:1::")); + assert!(blocked("100:0:0:1:ffff:ffff:ffff:ffff")); + assert!(!blocked("100:0:0:2::1")); } - // Benchmarking (RFC 2544) — 198.18.0.0/15 #[test] - fn test_benchmarking_start() { - assert!(is_private_ip(&"198.18.0.1".parse::().unwrap())); + fn nat64_local_use_v6() { + assert!(blocked("64:ff9b:1::")); + assert!(blocked("64:ff9b:1:ffff:ffff:ffff:ffff:ffff")); + assert!(!blocked("64:ff9b:2::")); } + #[test] - fn test_benchmarking_end() { - assert!(is_private_ip(&"198.19.255.254".parse::().unwrap())); + fn documentation_3fff_v6() { + assert!(blocked("3fff::1")); + assert!(blocked("3fff:0fff::1")); + assert!(!blocked("3fff:1000::1")); + assert!(!blocked("3ffe::1")); } + #[test] - fn test_benchmarking_below_range() { - // 198.17.255.255 — just below benchmarking range - assert!(!is_private_ip(&"198.17.255.255".parse::().unwrap())); + fn srv6_sids_v6() { + assert!(blocked("5f00::1")); + assert!(blocked("5f00:ffff::1")); + assert!(!blocked("5e00::1")); + assert!(!blocked("5fff::1")); // 5fff ≠ 5f00 — outside /16 } + #[test] - fn test_benchmarking_above_range() { - // 198.20.0.0 — just above benchmarking range - assert!(!is_private_ip(&"198.20.0.0".parse::().unwrap())); + fn nat64_well_known_v6() { + assert!(blocked("64:ff9b::10.0.0.1")); // private embedded + assert!(blocked("64:ff9b::127.0.0.1")); // loopback embedded + assert!(blocked("64:ff9b::169.254.169.254")); // link-local embedded + assert!(!blocked("64:ff9b::8.8.8.8")); // public embedded — policy follows payload + assert!(!blocked("64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff")); // different prefix + assert!(!blocked("64:ff9b::1:0:0")); // outside /96 } - // IPv6 multicast — ff00::/8 #[test] - fn test_ipv6_multicast_all_nodes() { - // ff02::1 — all-nodes multicast - assert!(is_private_ip(&"ff02::1".parse::().unwrap())); + fn ipv4_translated_v6() { + assert!(blocked("::ffff:0:10.0.0.1")); + assert!(blocked("::ffff:0:127.0.0.1")); + assert!(!blocked("::ffff:0:8.8.8.8")); + assert!(!blocked("0:0:0:0:fffe:ffff:ffff:ffff")); // outside prefix } + #[test] - fn test_ipv6_multicast_all_routers() { - // ff02::2 — all-routers multicast - assert!(is_private_ip(&"ff02::2".parse::().unwrap())); + fn ipv4_mapped_v6() { + assert!(blocked("::ffff:10.0.0.1")); + assert!(blocked("::ffff:127.0.0.1")); + assert!(!blocked("::ffff:8.8.8.8")); } + #[test] - fn test_ipv6_multicast_high() { - // ffff::1 — still in ff00::/8 - assert!(is_private_ip(&"ffff::1".parse::().unwrap())); + fn ipv4_compatible_v6() { + assert!(blocked("::10.0.0.1")); + assert!(blocked("::127.0.0.1")); + assert!(!blocked("::8.8.8.8")); } + #[test] - fn test_ipv6_not_multicast() { - // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + fn deprecated_site_local_fec0() { + // fec0::/10 — deprecated IPv6 site-local (RFC 3879); blocked as non-global. + assert!(blocked("fec0::1")); + assert!(blocked("feff::1")); // fec0::/10 boundary } } diff --git a/crates/buzz-db/TESTING.md b/crates/buzz-db/TESTING.md new file mode 100644 index 00000000000..2cf6c7828b2 --- /dev/null +++ b/crates/buzz-db/TESTING.md @@ -0,0 +1,63 @@ +# PostgreSQL-backed tests in buzz-db + +The dedicated PostgreSQL CI lane discovers tests and Cargo packages by +structure rather than by exact lists. Follow this checklist so a new database +test is run automatically and remains safe under parallel execution. + +## Adding a test + +1. Put the test in a module whose name ends in `postgres_tests`. +2. Mark it `#[ignore = "requires Postgres"]` so infrastructure-free unit-test + jobs stay fast. +3. Connect through `crate::test_support::database_url()`. The CI wrapper sets + this helper's environment to a unique database for each test process; never + hard-code the shared development database. +4. Keep tests that need infrastructure beyond PostgreSQL and Redis in an + `external_infra*_tests` module. The PostgreSQL lane excludes those tests. +5. Run `scripts/test-postgres-test-discovery.sh` after adding or moving the + test. The same guard runs in CI immediately after changed-path detection. + +The wrapper isolates destructive tests by dropping the entire per-test +database after the process exits. It does not `DELETE` rows or `TRUNCATE` +shared tables, so tests may run concurrently without coordinating cleanup. + +## Choose the schema intentionally + +Most tests use the committed desired-state schema from `schema/schema.sql`. +That is the default and is appropriate for data-access behavior. + +Tests in `migration::postgres_tests` receive an empty database and own the +embedded migration lifecycle. A test outside that module that intentionally +depends on migration-created triggers or seed rows must prefix its function +name with `migration_schema_`; it also receives an empty database with +`BUZZ_TEST_SCHEMA_MODE=migration`. + +Helpers that normally run migrations honor `BUZZ_TEST_SCHEMA_MODE=desired` in +the default lane. Do not rerun migrations against a desired-state database. +When behavior should match in both schema paths, add explicit desired-state and +migration-applied coverage rather than making the bootstrap implicit. + +Tests that inspect cluster-wide PostgreSQL state or open least-privilege +sessions include `cluster_global_` in the function name. Migration-backed cases +use `migration_schema_cluster_global_`. Nextest serializes this small group +because separate databases still share `pg_stat_activity` and roles. + +## Run the lane locally + +Start native PostgreSQL and Redis, activate Hermit, and run: + +```bash +. ./bin/activate-hermit +scripts/test-postgres-test-discovery.sh +scripts/postgres-test-run.sh +``` + +Set `BUZZ_POSTGRES_ADMIN_URL` to a PostgreSQL maintenance database owned by a +role that can create and drop databases. Set `PGHOST`, `PGPORT`, `PGUSER`, and +`PGPASSWORD` for desired-state bootstrap, plus `REDIS_URL` for Redis-backed +tests. The complete privilege-boundary inventory also needs `CREATEROLE` and +membership in `pg_read_all_stats`, or an ephemeral superuser as CI uses. + +The runner creates one desired-state source database per invocation and clones +it for ordinary tests. Migration-mode tests start empty. Cleanup retries +transient disconnect races before reporting a warning. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index ea81bc354b8..e096960ad44 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -27,6 +27,9 @@ mod store; /// Database error types. pub mod error; +#[cfg(test)] +mod test_support; + pub use runtime::{ insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, ReadSession, }; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 66ac1d2f8f0..2adc1264eeb 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -183,7 +183,7 @@ async fn reject_legacy_nip_rs_cardinality_ambiguity(conn: &mut PgConnection) -> } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use std::{ collections::BTreeSet, diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 73e5e4fe569..693d75d66b2 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -1112,4 +1112,5 @@ impl Db { } #[cfg(test)] -mod tests; +#[path = "tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index afe1d20b305..0d4774ccad6 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -426,14 +426,15 @@ mod tests { } } - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + // This timeout also bounds the pool's initial connection. Leave enough + // headroom for a cold PostgreSQL start under the lane's eight workers; + // the assertion below cares about classification, not a 75 ms budget. let database_url = std::env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(1) - .acquire_timeout(Duration::from_millis(75)) + .acquire_timeout(Duration::from_secs(1)) .connect(&database_url) .await .expect("connect size-one test pool"); @@ -490,8 +491,6 @@ mod tests { ); } - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] async fn advisory_lock_records_success_contention_timeout_and_error() { let database_url = std::env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials @@ -633,4 +632,18 @@ mod tests { "lock timer must include the holder wait: {contention:?}" ); } + + mod postgres_tests { + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + super::pool_acquire_records_success_timeout_and_error_with_wait_time().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn advisory_lock_records_success_contention_timeout_and_error() { + super::advisory_lock_records_success_contention_timeout_and_error().await; + } + } } diff --git a/crates/buzz-db/src/runtime/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs index cf9b46ddd8b..2194c8bb30a 100644 --- a/crates/buzz-db/src/runtime/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -789,20 +789,14 @@ pub async fn run_probe(writer: PgPool, fence: Arc) { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - - fn test_db_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) - } - /// A private scratch database with migrations applied: the probe tests /// mutate the singleton heartbeat row (rewind/rotate), which must never /// race the shared dev database or each other. async fn scratch_db() -> (PgPool, PgPool, String) { - let admin = PgPool::connect(&test_db_url()) + let admin = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect admin"); let name = format!("fence_probe_{}", uuid::Uuid::new_v4().simple()); @@ -810,7 +804,7 @@ mod tests { .execute(&admin) .await .expect("create scratch db"); - let base = test_db_url(); + let base = crate::test_support::database_url(); let idx = base.rfind('/').expect("db url has a path segment"); let pool = PgPool::connect(&format!("{}/{}", &base[..idx], name)) .await @@ -973,17 +967,27 @@ mod tests { /// sessions, per the agreed classification. #[tokio::test] #[ignore = "requires Postgres"] - async fn sample_writer_sees_open_transactions_and_ignores_idle() { - let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + async fn migration_schema_cluster_global_sample_writer_sees_open_transactions_and_ignores_idle() + { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); + crate::migration::run_migrations(&pool) + .await + .expect("apply migration schema"); // A plain idle session: pinned connection, no transaction. - let idle_pool = PgPool::connect(&test_db_url()).await.expect("connect idle"); + let idle_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect idle"); let _idle_conn = idle_pool.acquire().await.expect("idle conn"); let before = sample_writer(&pool).await.expect("sample without tx"); // Now hold a transaction open on a second connection. - let tx_pool = PgPool::connect(&test_db_url()).await.expect("connect tx"); + let tx_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect tx"); let mut tx = tx_pool.begin().await.expect("begin"); sqlx::query("SELECT 1") .execute(&mut *tx) @@ -1016,12 +1020,16 @@ mod tests { /// never silently `MIN()` the hidden row away. #[tokio::test] #[ignore = "requires Postgres"] - async fn sample_writer_fails_closed_when_activity_is_masked() { - let admin = PgPool::connect(&test_db_url()).await.expect("connect"); + async fn cluster_global_sample_writer_fails_closed_when_activity_is_masked() { + let admin = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); // Hold a transaction open as the privileged user: this is the row // the unprivileged probe must notice it cannot classify. - let tx_pool = PgPool::connect(&test_db_url()).await.expect("connect tx"); + let tx_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect tx"); let mut tx = tx_pool.begin().await.expect("begin"); sqlx::query("SELECT 1") .execute(&mut *tx) @@ -1038,7 +1046,7 @@ mod tests { .await .expect("create unprivileged role"); - let base = test_db_url(); + let base = crate::test_support::database_url(); let unpriv_url = { let rest = base.strip_prefix("postgres://").expect("pg url"); let at = rest.rfind('@').expect("credentials in url"); @@ -1079,7 +1087,9 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn aurora_identity_probe_reports_false_on_plain_postgres() { - let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); let mut conn = pool.acquire().await.expect("conn"); assert!( !reader_supports_aurora_identity(&mut conn) @@ -1100,7 +1110,7 @@ mod tests { /// same database observes a token/epoch that resolves that entry. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_commits_tokens_and_sessions_prove_coverage() { + async fn cluster_global_probe_commits_tokens_and_sessions_prove_coverage() { let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); @@ -1155,7 +1165,7 @@ mod tests { /// epoch — fails the epoch check instead of proving stale coverage. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_rotates_epoch_on_same_epoch_token_regression() { + async fn cluster_global_probe_rotates_epoch_on_same_epoch_token_regression() { let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 8349fb53874..16580e23f62 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -11,6 +11,11 @@ async fn setup_db() -> Db { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") { + crate::migration::run_migrations(&pool) + .await + .expect("apply migration schema"); + } Db::from_pool(pool) } @@ -28,7 +33,7 @@ async fn make_community(pool: &PgPool) -> Uuid { #[tokio::test] #[ignore = "requires Postgres"] -async fn database_guard_covers_legacy_writer_and_nip09_deletion() { +async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs index f38231787bf..c5a7ea542f5 100644 --- a/crates/buzz-db/src/store/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -455,7 +455,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs index 6b213d5cce8..d2780a498c0 100644 --- a/crates/buzz-db/src/store/allowlist.rs +++ b/crates/buzz-db/src/store/allowlist.rs @@ -113,7 +113,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/api_token.rs b/crates/buzz-db/src/store/api_token.rs index ec380d9e5e7..41d4dcbad29 100644 --- a/crates/buzz-db/src/store/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -606,7 +606,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { //! Row-44 conformance: API token lookups MUST be keyed on //! `(community_id, token_hash)`, not on `token_hash` alone. The storage //! UNIQUE index is a *storage* guarantee; the WHERE clause here is the @@ -625,10 +625,8 @@ mod tests { use crate::{ApiTokenRecord, Db}; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let pool = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB"); Db::from_pool(pool) diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index 810c8c0aa1f..102ca44f96f 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -176,13 +176,11 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index a93ffb36c6a..c581f529f72 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -910,15 +910,13 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::user::ensure_user; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index f0fd3332acd..dd526a0a46c 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -1379,7 +1379,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::channel::{ChannelType, ChannelVisibility}; use crate::migration; @@ -1387,10 +1387,8 @@ mod tests { use nostr::Keys; use sqlx::postgres::PgPoolOptions; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } @@ -1573,8 +1571,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn accessible_channel_ids_are_not_truncated_at_one_thousand() { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -1612,8 +1609,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn get_members_returns_full_roster_beyond_1000() { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -1720,11 +1716,15 @@ mod tests { .await .expect("insert large roster"); + // Migration 0032's roster guard requires canonical four-field p tags + // whose roles exactly match channel_members, including the creator's + // owner row created by create_test_channel. + let creator_hex = hex::encode(&creator); let stale_tags: Vec = std::iter::once(serde_json::json!(["d", channel.id.to_string()])) .chain(std::iter::once(serde_json::json!([ "p", - hex::encode(&creator), + creator_hex, "", "owner" ]))) @@ -1736,7 +1736,7 @@ mod tests { std::iter::once(serde_json::json!(["d", channel.id.to_string()])) .chain(std::iter::once(serde_json::json!([ "p", - hex::encode(&creator), + creator_hex, "", "owner" ]))) @@ -1747,8 +1747,14 @@ mod tests { .collect(); let other_complete_tags: Vec = std::iter::once(serde_json::json!(["d", channel.id.to_string()])) + .chain(std::iter::once(serde_json::json!([ + "p", + hex::encode(&creator), + "", + "owner" + ]))) .chain( - (0..=1_500) + (1..=extra_members) .map(|n| serde_json::json!(["p", format!("{n:064x}"), "", "member"])), ) .collect(); @@ -1793,6 +1799,10 @@ mod tests { // The same channel UUID in another tenant is deliberately valid. A // complete snapshot there must not mask this tenant's stale head. let other_community_id = make_test_community(&pool).await; + // Insert directly because create_test_channel generates a fresh UUID, + // while this test needs the same channel ID in both tenants. Direct + // insertion skips the helper's creator membership, so add the owner + // row explicitly below. sqlx::query( r#" INSERT INTO channels @@ -1806,16 +1816,29 @@ mod tests { .execute(&pool) .await .expect("insert same channel id in other tenant"); + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) + VALUES ($1, $2, $3, 'owner', NOW()) + "#, + ) + .bind(other_community_id) + .bind(channel.id) + .bind(&creator) + .execute(&pool) + .await + .expect("insert other-tenant owner"); sqlx::query( r#" INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member', NOW() + (n || ' seconds')::interval - FROM generate_series(0, 1500) n + FROM generate_series(1, $3) n "#, ) .bind(other_community_id) .bind(channel.id) + .bind(extra_members) .execute(&pool) .await .expect("insert complete other-tenant roster"); @@ -2398,7 +2421,7 @@ mod tests { let snapshot_pool = PgPoolOptions::new() .max_connections(1) .acquire_timeout(std::time::Duration::from_secs(1)) - .connect(TEST_DB_URL) + .connect(&crate::test_support::database_url()) .await .expect("connect one-connection pool"); let relay_keys = Keys::generate(); @@ -2470,7 +2493,7 @@ mod tests { /// until it is released. Verified by mutation — dropping the lock from either /// function makes that call return immediately and fails this test. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn membership_writes_serialize_on_the_shared_channel_lock() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2541,7 +2564,7 @@ mod tests { /// holder then demotes the remover and commits. Once the key is released the /// remover must re-read its (now unprivileged) role and be rejected. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn remove_member_rejects_an_actor_demoted_while_it_waited() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2627,7 +2650,7 @@ mod tests { /// Two owners on purpose, so the last-owner guard can never be what /// decides the outcome — only role resolution can. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn kicked_owner_rejoins_as_member_not_owner() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2679,7 +2702,7 @@ mod tests { /// The other side of the same boundary: reactivation may reach an elevated /// role, but only because a *currently* elevated granter asked for it. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn removed_owner_is_restored_only_by_a_current_owner() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2736,7 +2759,7 @@ mod tests { } async fn admin_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) + crate::test_support::database_url() } /// Create a fresh scratch database on the same server and optionally run migrations. diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 5e8462345bb..dd8a2e58bc8 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -546,7 +546,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { //! Pin the load-bearing contract for `Db::communities_of_channels`: //! a channel id that does NOT exist MUST be absent from the result //! map, never mapped to a default. The relay-side read-row emitter @@ -557,11 +557,8 @@ mod tests { use super::*; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_db() -> Db { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -847,8 +844,8 @@ mod tests { let db = setup_db().await; let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - // Create 3 communities for this owner (the max). - for i in 0..3 { + // Fill the configured default ownership limit. + for i in 0..crate::relay_members::MAX_COMMUNITIES_PER_OWNER { let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); assert!(matches!( db.create_community_with_owner(&host, &owner) @@ -858,7 +855,7 @@ mod tests { )); } - let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + let host = format!("limit-test-overflow-{}.example", Uuid::new_v4().simple()); assert_eq!( db.create_community_with_owner(&host, &owner) .await diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index c7fcdc09f66..d34b39f14b1 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -3355,7 +3355,9 @@ mod postgres_tests { }) .await .expect("connect deletion test DB"); - db.migrate().await.expect("migrate deletion test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate deletion test DB"); + } let store = db.deletion_store(); (db, store) } diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 60e6b05ef9b..d685e44485e 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1740,7 +1740,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-db/src/store/feed.rs b/crates/buzz-db/src/store/feed.rs index 01e4fef32be..7047bbdd9b7 100644 --- a/crates/buzz-db/src/store/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -536,7 +536,7 @@ impl Db { // -- Tests -------------------------------------------------------------------- #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; @@ -1120,14 +1120,11 @@ mod tests { /// `insert_mentions` must index every p-tag even past Postgres's /// bind-parameter statement cap. /// - /// Relay-signed kind 39002 member snapshots carry one p-tag per channel - /// member, and a multi-row INSERT binds 6 parameters per row — a single - /// statement tops out at ~10.9k rows against the 65,535-parameter limit. - /// Clients discover their channels via `{kinds:[39002], "#p":[me]}`, so a - /// failed insert silently breaks discovery for the whole channel. + /// A multi-row INSERT binds 6 parameters per p-tag, so a single statement + /// tops out at ~10.9k rows against the 65,535-parameter limit. #[tokio::test] #[ignore = "requires Postgres"] - async fn insert_mentions_indexes_rosters_past_bind_parameter_cap() { + async fn insert_mentions_indexes_p_tags_past_bind_parameter_cap() { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let channel = insert_test_channel(&pool, community).await; @@ -1148,7 +1145,15 @@ mod tests { let tags: Vec = (1..=mention_count) .map(|n| Tag::parse(["p", &format!("{n:064x}"), "", "member"]).expect("p tag")) .collect(); - let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await; + let event = store_feed_event( + &pool, + community, + KIND_STREAM_MESSAGE, + "", + Some(channel), + tags, + ) + .await; let indexed: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2", @@ -1160,7 +1165,7 @@ mod tests { .expect("count indexed mentions"); assert_eq!( indexed as usize, mention_count, - "every roster p-tag must land in event_mentions" + "every p-tag must land in event_mentions" ); } } diff --git a/crates/buzz-db/src/store/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs index 5afea1e4fda..bc4e70e151e 100644 --- a/crates/buzz-db/src/store/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -231,7 +231,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 5ac7c93af9a..94e550185d5 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -814,7 +814,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use chrono::Duration; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/product_feedback.rs b/crates/buzz-db/src/store/product_feedback.rs index 8a0ef36bea5..e732c44d4b9 100644 --- a/crates/buzz-db/src/store/product_feedback.rs +++ b/crates/buzz-db/src/store/product_feedback.rs @@ -137,7 +137,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[tokio::test] diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 9133b82e716..710ffb931c6 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -1463,7 +1463,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::migration; use std::sync::Arc; @@ -1476,9 +1476,11 @@ mod tests { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); - migration::run_migrations(&pool) - .await - .expect("run migrations"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + migration::run_migrations(&pool) + .await + .expect("run migrations"); + } pool } diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs index 1f14adf176d..6494d856639 100644 --- a/crates/buzz-db/src/store/reaction.rs +++ b/crates/buzz-db/src/store/reaction.rs @@ -687,7 +687,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{ error::DbError, diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 438543da583..7662077911d 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -2043,7 +2043,7 @@ impl crate::Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 1424829933f..6c90c31b944 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -429,29 +429,21 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::relay_members::is_relay_member; use sha2::Digest; use sqlx::PgPool; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_pool() -> PgPool { - PgPool::connect(&test_database_url()) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } - fn test_database_url() -> String { - std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_owned()) - } - async fn create_scratch_database(prefix: &str) -> (PgPool, String, String) { - let admin_url = test_database_url(); + let admin_url = crate::test_support::database_url(); let admin = PgPool::connect(&admin_url) .await .expect("connect to test database server"); diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 0a20b011ebd..9a5b6f91a24 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -968,7 +968,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { #[test] fn owner_limit_defaults_when_unset_or_invalid() { assert_eq!( @@ -1359,8 +1359,8 @@ mod tests { let owner = test_pubkey(); let transferee = test_pubkey(); - // Give the transferee 3 communities (the max). - for _ in 0..3 { + // Fill the configured default ownership limit. + for _ in 0..MAX_COMMUNITIES_PER_OWNER { let c = make_test_community(&pool).await; bootstrap_owner(&pool, c, &transferee) .await diff --git a/crates/buzz-db/src/store/relay_operators.rs b/crates/buzz-db/src/store/relay_operators.rs index 3670a2f142b..204e88fd95a 100644 --- a/crates/buzz-db/src/store/relay_operators.rs +++ b/crates/buzz-db/src/store/relay_operators.rs @@ -326,7 +326,7 @@ impl crate::Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; diff --git a/crates/buzz-db/src/store/reminder.rs b/crates/buzz-db/src/store/reminder.rs index 20f503f4008..2d2dde18c11 100644 --- a/crates/buzz-db/src/store/reminder.rs +++ b/crates/buzz-db/src/store/reminder.rs @@ -239,7 +239,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::event::insert_event; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 5d5b13259e2..80f422b57ca 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -586,7 +586,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{event, migration, replaceable}; use sqlx::postgres::PgPoolOptions; @@ -601,6 +601,11 @@ mod tests { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") { + migration::run_migrations(&pool) + .await + .expect("apply migration schema"); + } Db::from_pool(pool) } @@ -994,7 +999,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn nip_rs_transaction_operation_restores_hard_delete_opt_in() { + async fn migration_schema_nip_rs_transaction_operation_restores_hard_delete_opt_in() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -1444,7 +1449,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn mesh_status_replacement_keeps_one_physical_row() { + async fn migration_schema_mesh_status_replacement_keeps_one_physical_row() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -1715,7 +1720,8 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() { + async fn migration_schema_nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction( + ) { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index d7a2d239eff..0cf4e91f342 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -1152,7 +1152,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{ channel::{ChannelType, ChannelVisibility}, diff --git a/crates/buzz-db/src/store/usage.rs b/crates/buzz-db/src/store/usage.rs index 97235f0b26e..ce581561bd5 100644 --- a/crates/buzz-db/src/store/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -476,17 +476,15 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use buzz_core::CommunityId; use nostr::Keys; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn get_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } @@ -497,7 +495,7 @@ mod tests { .execute(admin) .await .expect("create scratch db"); - let base = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let base = crate::test_support::database_url(); let idx = base.rfind('/').expect("db url has a path segment"); let scratch_url = format!("{}/{}", &base[..idx], name); let pool = PgPool::connect(&scratch_url) @@ -525,7 +523,7 @@ mod tests { // Postgres advisory locks are per-database; hardcoding the production // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB // races any live buzz-relay on the same database (see #3619). - let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin_url = crate::test_support::database_url(); let admin = PgPoolOptions::new() .max_connections(1) .connect(&admin_url) diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 140a722a21b..67f9cb341dc 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -512,15 +512,13 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::Db; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let pool = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB"); Db::from_pool(pool) diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 0ae1b623764..3ceed9ea32e 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -1686,7 +1686,7 @@ impl Db { // -- Tests -------------------------------------------------------------------- #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use chrono::TimeZone; diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs new file mode 100644 index 00000000000..7699313d636 --- /dev/null +++ b/crates/buzz-db/src/test_support.rs @@ -0,0 +1,9 @@ +const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + +/// Resolve the database URL shared by PostgreSQL-backed unit tests. +pub(crate) fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) +} diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index d963a7f261f..714cf7eaff2 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -1602,7 +1602,7 @@ fn print_json(value: &impl Serialize) -> Result<()> { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[test] @@ -1660,7 +1660,9 @@ mod tests { .await .expect("connect deletion engine test DB"); let db = Db::from_pool(pool); - db.migrate().await.expect("migrate deletion engine test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate deletion engine test DB"); + } let store = db.deletion_store(); let host = format!("{prefix}-{}.example", Uuid::new_v4().simple()); let community = db @@ -1817,8 +1819,6 @@ mod tests { ) } - #[tokio::test] - #[ignore = "requires Postgres"] async fn approved_stage_allows_post_inventory_row_churn_before_fencing() { let (db, services, claim) = claimed_test_deletion("deletion-row-churn").await; let frozen: FrozenInventory = serde_json::from_value( @@ -1880,8 +1880,6 @@ mod tests { /// then the worker died before the chunk stamp. Resume must re-delete the /// chunk (missing keys report as deleted — idempotent), stamp it, and /// finish the stage. - #[tokio::test] - #[ignore = "requires Postgres and S3-compatible storage"] async fn drained_stage_resumes_chunk_deleted_before_stamp() { let (_, mut services, claim) = claimed_test_deletion("deletion-chunk-resume").await; services.media = deletion_test_media_storage(); @@ -2136,8 +2134,6 @@ mod tests { assert!(scan_proves_absence(&[(9, Vec::new()), (0, Vec::new())])); } - #[tokio::test] - #[ignore = "requires Postgres and S3-compatible storage"] async fn final_storage_verification_rejects_late_target_binding() { let (_, mut services, claim) = claimed_test_deletion("deletion-late-binding").await; services.media = deletion_test_media_storage(); @@ -2162,6 +2158,26 @@ mod tests { .expect("empty tenant prefixes verify clean"); } + mod external_infra_s3_tests { + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn approved_stage_allows_post_inventory_row_churn_before_fencing() { + super::approved_stage_allows_post_inventory_row_churn_before_fencing().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn drained_stage_resumes_chunk_deleted_before_stamp() { + super::drained_stage_resumes_chunk_deleted_before_stamp().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn final_storage_verification_rejects_late_target_binding() { + super::final_storage_verification_rejects_late_target_binding().await; + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn stale_lease_during_failure_recording_is_lost_ownership() { @@ -2259,7 +2275,9 @@ mod tests { .await .expect("connect serving guard test DB"); let db = Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate serving guard test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate serving guard test DB"); + } let community = db .ensure_configured_community(&format!( "serving-guard-{}.example", diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 6cbfb45893d..17fff2a2429 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -510,15 +510,15 @@ impl AuthorityStore for PostgresAuthorityStore { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- fixed localhost-only test credential #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] - async fn readiness_requires_migrated_schema_dml_and_no_ddl() { + async fn cluster_global_readiness_requires_migrated_schema_dml_and_no_ddl() { let admin_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 2f0d128fc87..19f2153b95b 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1254,7 +1254,7 @@ fn summarize_body(body: &str, tags: &serde_json::Value) -> String { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use auth::ADMIN_API_PREFIX; use axum::{ @@ -1265,6 +1265,12 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; + fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| { + "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string() // sadscan:disable np.postgres.1 -- local test-only credentials + }) + } + /// Deterministic operator keypair for the default authorized test state. /// Rostered as a config operator in `test_state()` so `authorized()` can /// mint NIP-98 credentials that resolve to an Operator principal without a @@ -1992,6 +1998,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires PostgreSQL"] async fn nip98_mode_unrostered_signer_does_not_consume_a_replay_slot() { // Regression: the replay ID must be claimed only AFTER principal // resolution succeeds. A validly-signing but unrostered key (any @@ -2366,12 +2373,9 @@ mod tests { auth: crate::config::AdminAuth::Nip98, web_dir: None, }); - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) @@ -3270,12 +3274,9 @@ mod tests { // At the DB level: claim_report with two concurrent UUIDs on the same report_id. // FOR UPDATE row lock ensures serial execution; first commit wins, second // returns NotOpen. moderation_actions must have exactly 1 row. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3372,12 +3373,9 @@ mod tests { async fn same_request_id_retry_returns_existing_action() { // Two POST /reports/{id}/resolve calls with the same requestId UUID. // Both should return 200 with the same actionId. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3478,12 +3476,9 @@ mod tests { // // resolve_report_decision_atomic CASes on status='open'; if the report is // already 'processing', the transaction rolls back with no audit row. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3578,12 +3573,9 @@ mod tests { // After an enforcement action reaches mutation_committed step_marker, // attempting to cancel the action record must fail (cancel is only // legal pre-mutation). - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3776,12 +3768,9 @@ mod tests { async fn reports_default_lists_escalated_only() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3803,12 +3792,9 @@ mod tests { async fn reports_scope_all_lists_every_status() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3827,12 +3813,9 @@ mod tests { async fn reports_explicit_status_filter_overrides_default() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3852,12 +3835,9 @@ mod tests { async fn reopen_route_returns_report_to_open_and_writes_audit_row() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "resolved").await; let request_id = Uuid::new_v4(); @@ -3917,12 +3897,9 @@ mod tests { let operator_keys = nostr::Keys::generate(); let operator_bytes = operator_keys.public_key().to_bytes(); let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; // Unique per-invocation correlation: `reason` flows to the audit row's @@ -4014,12 +3991,9 @@ mod tests { // Only the operator is config-backed (Operator role); the target is a // fresh, mutable, non-config key. let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let target_keys = nostr::Keys::generate(); let target_hex = target_keys.public_key().to_hex(); @@ -4118,12 +4092,9 @@ mod tests { async fn resolve_route_rejects_adversarial_expiration_and_leaves_report_open() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); // 0, over-cap, i64::MAX magnitude, and a value that casts to a negative // i64 (wrapped-past-expiry) — all must reject before any state change. @@ -4187,12 +4158,9 @@ mod tests { async fn mixed_case_non_config_staffing_normalizes_to_one_row() { let operator_keys = nostr::Keys::generate(); let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let target_keys = nostr::Keys::generate(); let lower_hex = target_keys.public_key().to_hex(); @@ -4280,12 +4248,9 @@ mod tests { async fn reopen_route_rejects_non_terminal_report_with_409() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; let body = serde_json::json!({ "requestId": Uuid::new_v4() }).to_string(); @@ -4313,12 +4278,9 @@ mod tests { async fn cancel_route_returns_open_and_embeds_the_cancelled_action_dto() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; let community_id: Uuid = sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1") @@ -4435,12 +4397,9 @@ mod tests { // community fence — can block this: it is the sharper negative case. let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); // Two reports on the same admin.example community, each driven to // `processing` with its own distinct pre-mutation `failed` action. @@ -4582,12 +4541,9 @@ mod tests { // Simulate a crash after mutation_committed but before finalization. // Re-drive from persisted step state must produce exactly one // enforcement, one report transition, one audit chain, one reporter notice. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -4797,8 +4753,7 @@ mod tests { } async fn e2e_pool() -> sqlx::PgPool { - let url = std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let url = database_url(); sqlx::PgPool::connect(&url) .await .expect("connect to test DB") @@ -7345,8 +7300,7 @@ mod tests { // Our outbox row's created_at is ~10 s ago → trigger fires on insert_event. // This pool is fully isolated: no other pool or test is affected, and there // is no cleanup dependence (dropping the pool closes all its connections). - let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let db_url = database_url(); let floor_pool = sqlx::postgres::PgPoolOptions::new() .max_connections(4) .after_connect(|conn, _meta| { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a4c051c4833..33d62bcf942 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2549,7 +2549,7 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; use std::sync::Mutex; @@ -2824,8 +2824,6 @@ mod tests { /// replay of the same event id in the same community is rejected. The same /// id in a different community still succeeds, proving the key is scoped by /// server-resolved tenant rather than global process memory. - #[tokio::test] - #[ignore = "requires Redis"] async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() { let pool = redis_pool(); let pod_a = buzz_pubsub::RedisNip98ReplayGuard::new(pool.clone()); @@ -2853,8 +2851,6 @@ mod tests { /// rejection. A single guard instance, called twice with the same /// `TenantContext` and the same event id, MUST reject the second call. /// Bites if `try_mark`'s admit/reject mapping is reversed or no-op'd. - #[tokio::test] - #[ignore = "requires Redis"] async fn nip98_replay_guard_rejects_same_pod_same_community_replay() { let pool = redis_pool(); let pod = buzz_pubsub::RedisNip98ReplayGuard::new(pool); @@ -2871,6 +2867,20 @@ mod tests { assert_eq!(status, StatusCode::UNAUTHORIZED); } + mod external_infra_redis_tests { + #[tokio::test] + #[ignore = "requires Redis"] + async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() { + super::nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path().await; + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn nip98_replay_guard_rejects_same_pod_same_community_replay() { + super::nip98_replay_guard_rejects_same_pod_same_community_replay().await; + } + } + /// Attack 3 fail-closed guard: a stateless worker that loses Redis MUST /// reject the request, never admit it. The shared seen-set is the /// freshness fence; degrading to "best effort, allow on error" forfeits @@ -3878,8 +3888,6 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Build an AppState suitable for handler-level bridge tests. /// /// - `require_auth_token = false` → X-Pubkey dev-mode fallback active. @@ -3892,7 +3900,7 @@ mod tests { /// Returns `None` when local Postgres is not reachable. async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. config.redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); @@ -3900,7 +3908,9 @@ mod tests { config.require_auth_token = false; config.require_relay_membership = false; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 32d63f46008..40d4eea0352 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -462,7 +462,7 @@ pub fn generate_hook_hmac( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; fn make_request() -> HookCallbackRequest { diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 94fd7f8758e..638e3c7156b 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2435,8 +2435,6 @@ mod track_c_tests { } } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { let (state, pool) = finalize_test_state().await; let host = format!( @@ -2533,8 +2531,6 @@ mod track_c_tests { pool.close().await; } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn finalize_push_holds_serving_lease_through_post_cas_publication() { let (state, pool) = finalize_test_state().await; let host = format!("git-finalize-{}.example", uuid::Uuid::new_v4().simple()); @@ -2626,8 +2622,6 @@ mod track_c_tests { pool.close().await; } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { let (state, pool) = finalize_test_state().await; let host = format!( @@ -2668,6 +2662,26 @@ mod track_c_tests { pool.close().await; } + mod external_infra_minio_tests { + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { + super::repo_announcement_holds_serving_lease_until_pointer_is_seeded().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_holds_serving_lease_through_post_cas_publication() { + super::finalize_push_holds_serving_lease_through_post_cas_publication().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { + super::finalize_push_db_failure_after_cas_is_not_success_and_releases_lease().await; + } + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires @@ -3180,7 +3194,7 @@ mod track_c_tests { } #[cfg(test)] -mod sec005_read_gate_tests { +mod sec005_postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index e3d05165e0d..6714281f40f 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -541,7 +541,7 @@ fn claim_key_rate_limited( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Arc; use std::time::Duration; diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index b59fd840c6d..2c49ca6a5c3 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -502,7 +502,7 @@ pub async fn community_availability( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Arc; use axum::{ @@ -536,8 +536,6 @@ mod tests { Box::pin(async { Ok(true) }) } } - - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -575,7 +573,7 @@ mod tests { async fn operator_test_state(operator_keys: &[Keys]) -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_url = "wss://tenant.example".to_string(); config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); @@ -585,7 +583,9 @@ mod tests { .collect(); config.require_relay_membership = true; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index ae7adc98143..41d7900b7d1 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -1367,21 +1367,23 @@ async fn resume_workflow_after_approval( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; async fn persistence_test_context() -> (buzz_db::Db, TenantContext) { let url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = sqlx::PgPool::connect(&url) .await .expect("connect workflow persistence test database"); let db = buzz_db::Db::from_pool(pool); - db.migrate() - .await - .expect("migrate workflow persistence test database"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate() + .await + .expect("migrate workflow persistence test database"); + } let host = format!("workflow-cas-{}.example", Uuid::new_v4().simple()); let community = db .ensure_configured_community(&host) @@ -1509,7 +1511,9 @@ mod tests { )); let create_revision = create.id.to_hex(); - let mut updates = (0..64).map(|index| { + // Event IDs are hashes, so keep sampling instead of imposing a finite + // cutoff that makes this same-second ordering check probabilistic. + let mut updates = (0_u64..).map(|index| { workflow_event( &keys, workflow_id, @@ -1521,7 +1525,7 @@ mod tests { let update = updates .find(|candidate| candidate.id.as_bytes() < create.id.as_bytes()) .expect("find same-second update that wins NIP-33 ordering"); - let dominated_update = (64..256) + let dominated_update = (64_u64..) .map(|index| { workflow_event( &keys, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 19ea57e7dd7..8819ae99f5e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3291,7 +3291,7 @@ async fn ingest_event_inner( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Mutex; use super::*; @@ -3544,7 +3544,9 @@ mod tests { .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); let db = buzz_db::Db::from_pool(pool); - db.migrate().await.expect("migrate test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate test DB"); + } let store = buzz_deletion::store(&db); let host = format!("lane3-fence-{}.example", Uuid::new_v4().simple()); diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516d..56f0e78d3c1 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -484,7 +484,7 @@ async fn execute_relay_admin_command( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 5feb3f5774b..e762c14b1e7 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -46,6 +46,8 @@ pub mod subscription; pub mod telemetry; /// Row-zero host binding: resolve the request community from the connection host. pub mod tenant; +#[cfg(test)] +mod test_support; /// Relay-side tunnel session directory and routing. pub mod tunnel; /// Webhook secret generation and constant-time comparison. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d81602e2019..bb8715508e7 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -2098,8 +2098,6 @@ mod tests { assert!(tick_count.load(std::sync::atomic::Ordering::Relaxed) <= 1); } - #[tokio::test] - #[ignore = "requires Postgres"] async fn audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits() { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let pool = connect_audit_pool(&DbConfig { @@ -2159,6 +2157,14 @@ mod tests { .expect("release audit advisory lock"); } + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits() { + super::audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits().await; + } + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 3c4214694d2..a3fc4772284 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1554,8 +1554,6 @@ pub(crate) mod tests { Arc::new(state) } - #[tokio::test] - #[ignore = "requires Postgres"] async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let observer = sqlx::PgPool::connect(&database_url) @@ -1679,6 +1677,14 @@ pub(crate) mod tests { .expect("remove test community"); } + mod postgres_tests { + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { + super::audit_worker_retries_lock_timeout_until_original_entry_is_appended_once().await; + } + } + #[test] fn send_to_resets_grace_counter_on_success() { let (mgr, id, _rx, _ctrl_rx, _cancel, bp) = setup_conn(16); diff --git a/crates/buzz-relay/src/test_support.rs b/crates/buzz-relay/src/test_support.rs new file mode 100644 index 00000000000..6936a60ae4e --- /dev/null +++ b/crates/buzz-relay/src/test_support.rs @@ -0,0 +1,9 @@ +const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + +/// Resolve the database URL shared by PostgreSQL-backed relay tests. +pub(crate) fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 4ceb3b39308..17016ca0d84 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -772,7 +772,7 @@ mod tests { } #[cfg(test)] -mod integration_tests { +mod postgres_tests { //! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message` //! that mentions a channel member by name (`@Name`) in its author-written //! step template must emit both the legacy `p` tag and authenticated diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs similarity index 100% rename from crates/buzz-search/tests/fts_integration.rs rename to crates/buzz-search/tests/postgres_fts_integration.rs diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index bceb6d8bd8d..ee1c7467762 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -1047,7 +1047,7 @@ fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[test] diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c5f60de6908..57d30801c63 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ "**/welcome-agent-modal-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/voice-settings.spec.ts", + "**/voice-note.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index ba4d36ab4bb..4fa8cede143 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -161,8 +161,8 @@ tauri-utils = "2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. tokio = { version = "1", features = ["test-util"] } -# The relay's media validation, so the snapshot-sharing tests can prove the -# full export → sanitize → relay-accept → import contract end to end. +# The relay's media validation, so desktop-produced snapshots and voice notes +# can prove their full client-sanitize → relay-accept contract end to end. buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } # `filter::Targets` — the same directive set that backs `EnvFilter`'s static # directives, so `managed_agents::runtime::log_filter` can assert what the diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist index a801c8c0f86..34c4651d581 100644 --- a/desktop/src-tauri/Info.plist +++ b/desktop/src-tauri/Info.plist @@ -7,7 +7,7 @@ CFBundleName Waggle NSMicrophoneUsageDescription - Waggle needs microphone access for voice huddles. + Waggle needs microphone access for voice huddles and voice notes. NSCameraUsageDescription Waggle needs camera access to record animated avatars. NSLocalNetworkUsageDescription diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8da845c07d4..8cf8cc41747 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -8,12 +8,16 @@ use tokio_util::sync::CancellationToken; use crate::app_state::AppState; use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message}; +use super::media_filename::sanitize_filename; use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes, transcode_heic_path_to_jpeg_bytes_with_cancellation, }; use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; +use super::media_voice_note::{ + is_voice_note_filename, prepare_voice_note_for_upload, voice_note_mp4_filename, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -134,24 +138,6 @@ const BLOCKED_MIME: &[&str] = &[ "application/x-apple-diskimage", ]; -/// Sanitize a filename for use as a display label in the imeta `filename` field. -/// -/// Strips any directory components (keeps only the final path segment), removes -/// control characters, and bounds length to 255. Mirrors the relay's filename -/// validation so a sanitized name always passes ingest. Returns a fallback when -/// the result would be empty. -pub(crate) fn sanitize_filename(name: &str) -> String { - // Keep only the final path segment — defend against `../` and absolute paths - // regardless of separator style. - let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); - let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect(); - if cleaned.is_empty() { - "file".to_string() - } else { - cleaned - } -} - /// Return true when a PNG/WebP payload declares animation. /// /// Animated payloads use structural sanitizers so frame timing, looping, and @@ -724,8 +710,12 @@ pub(super) async fn upload_media_bytes_inner( let heic_by_extension = filename .as_deref() .is_some_and(|name| has_heic_extension(std::path::Path::new(name))); + let is_voice_note = is_voice_note_filename(filename.as_deref()); - let (body, poster_bytes) = if is_video_file(&data) { + let (body, poster_bytes) = if is_voice_note { + emit_media_upload_phase(&app, progress_id.as_deref(), "processing-audio"); + prepare_voice_note_for_upload(data, cancellation).await? + } else if is_video_file(&data) { emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video"); // Video: write to temp → transcode + extract poster → read results. // All blocking I/O runs off the async runtime via spawn_blocking. @@ -790,7 +780,14 @@ pub(super) async fn upload_media_bytes_inner( } } - descriptor.filename = filename.as_deref().map(sanitize_filename); + descriptor.filename = filename.as_deref().map(|name| { + let upload_name = if is_voice_note { + voice_note_mp4_filename(name) + } else { + name.to_string() + }; + sanitize_filename(&upload_name) + }); Ok(descriptor) } @@ -981,18 +978,4 @@ mod tests { reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE )); } - - #[test] - fn test_sanitize_filename() { - assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); - // Strips directory components and traversal. - assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); - assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt"); - assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx"); - // Empty / separator-only falls back. - assert_eq!(sanitize_filename(""), "file"); - assert_eq!(sanitize_filename("/"), "file"); - // Control chars removed. - assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt"); - } } diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d2..54d0052e5a2 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -1,11 +1,13 @@ use futures_util::StreamExt; use sha2::{Digest, Sha256}; use tauri::State; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; use crate::commands::clipboard::with_clipboard; use crate::commands::export_util::save_bytes_with_dialog; -use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename}; +use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth}; +use crate::commands::media_filename::sanitize_filename; use crate::commands::{ personas::{ parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, @@ -18,7 +20,7 @@ use crate::commands::{ use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. -const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +pub(super) const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; /// Download request timeout. const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -29,7 +31,7 @@ const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60) /// - URL scheme is `https` (or `http` for localhost dev) /// - URL origin matches the relay base URL /// - URL path matches `/media/{hash}.{ext}` -fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { +pub(super) fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { let parsed = url::Url::parse(url).map_err(|_| "invalid URL".to_string())?; let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL".to_string())?; @@ -139,32 +141,6 @@ pub async fn download_file( save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await } -/// Fetch relay media bytes for the composer image editor. -/// -/// The editor composites the image onto a canvas and needs pixel access. -/// Handing the webview raw bytes over IPC (which it wraps in a same-origin -/// `blob:` URL) keeps the canvas un-tainted without involving CORS — and -/// therefore without any media-proxy header or origin-gate changes. -/// -/// Same SSRF validation, size cap, and content policy as the download -/// commands above. -/// -/// Returns `tauri::ipc::Response` so the bytes cross IPC as a raw buffer -/// instead of a JSON number array (which would be ~3x the size to -/// serialize and deserialize at the 50 MiB cap). -#[tauri::command] -pub async fn fetch_media_bytes( - url: String, - state: State<'_, AppState>, -) -> Result { - let relay_base = relay_api_base_url_with_override(&state); - validate_download_url(&url, &relay_base)?; - - let bytes = fetch_blob_bytes(&url, &state).await?; - detect_and_validate_mime(&bytes)?; - Ok(tauri::ipc::Response::new(bytes)) -} - /// Copy an image from a relay media URL directly to the system clipboard. /// /// Fetches the image, decodes it to RGBA8, and writes it to the clipboard via @@ -255,7 +231,7 @@ pub async fn copy_text_to_clipboard( /// HTTP client, enforcing the download size cap. The caller is responsible for /// validating the URL origin and for any content-type checks on the result. async fn fetch_blob_bytes(url: &str, state: &State<'_, AppState>) -> Result, String> { - fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES).await + fetch_blob_bytes_with_cap(url, state, MAX_DOWNLOAD_BYTES, None).await } /// The command-facing error for a media-fetch response status, or `None` if @@ -277,10 +253,11 @@ fn redirect_refusal_error(status: reqwest::StatusCode) -> Option { } /// Core streaming fetcher with a caller-supplied byte cap. -async fn fetch_blob_bytes_with_cap( +pub(super) async fn fetch_blob_bytes_with_cap( url: &str, state: &State<'_, AppState>, cap: u64, + cancellation: Option<&CancellationToken>, ) -> Result, String> { // Fetch bytes via the no-redirect media client (goes through the VPN tunnel). // A no-redirect client keeps the minted media auth token from being @@ -296,7 +273,16 @@ async fn fetch_blob_bytes_with_cap( req = req.header("authorization", auth); } - let resp = req.send().await.map_err(|e| classify_request_error(&e))?; + let request = req.send(); + let resp = if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), + result = request => result, + } + } else { + request.await + } + .map_err(|e| classify_request_error(&e))?; if let Some(err) = redirect_refusal_error(resp.status()) { return Err(err); @@ -321,7 +307,18 @@ async fn fetch_blob_bytes_with_cap( // even when Content-Length is missing or dishonest. let mut bytes = Vec::new(); let mut stream = resp.bytes_stream(); - while let Some(chunk) = stream.next().await { + loop { + let next = if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), + next = stream.next() => next, + } + } else { + stream.next().await + }; + let Some(chunk) = next else { + break; + }; let chunk = chunk.map_err(|e| classify_request_error(&e))?; if bytes.len() as u64 + chunk.len() as u64 > cap { return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); @@ -482,7 +479,7 @@ pub async fn fetch_snapshot_bytes( ensure_declared_size_within_cap(expected_size, kind)?; // ── Bounded fetch ───────────────────────────────────────────────────── - let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?; + let bytes = fetch_blob_bytes_with_cap(&url, &state, cap, None).await?; // ── Post-fetch validation ───────────────────────────────────────────── // 1. Byte length must equal the declared imeta size. diff --git a/desktop/src-tauri/src/commands/media_fetch_cancellation.rs b/desktop/src-tauri/src/commands/media_fetch_cancellation.rs new file mode 100644 index 00000000000..6d29e0cd9d1 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_fetch_cancellation.rs @@ -0,0 +1,125 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tokio_util::sync::CancellationToken; + +use crate::app_state::AppState; +use crate::commands::media::detect_and_validate_mime; +use crate::commands::media_download::{ + fetch_blob_bytes_with_cap, validate_download_url, MAX_DOWNLOAD_BYTES, +}; +use crate::relay::relay_api_base_url_with_override; + +#[derive(Default)] +struct MediaFetchCancellations { + tokens: HashMap, +} + +impl MediaFetchCancellations { + fn begin(&mut self, request_id: &str) -> CancellationToken { + if let Some(cancel) = self.tokens.get(request_id).cloned() { + return cancel; + } + let cancel = CancellationToken::new(); + self.tokens.insert(request_id.to_string(), cancel.clone()); + cancel + } + + fn cancel(&mut self, request_id: &str) { + self.tokens + .entry(request_id.to_string()) + .or_default() + .cancel(); + } + + fn finish(&mut self, request_id: &str) { + self.tokens.remove(request_id); + } +} + +static MEDIA_FETCH_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(MediaFetchCancellations::default())); + +pub(super) fn begin_media_fetch(request_id: Option<&str>) -> Option { + let request_id = request_id?; + MEDIA_FETCH_CANCELLATIONS + .lock() + .ok() + .map(|mut fetches| fetches.begin(request_id)) +} + +pub(super) fn finish_media_fetch(request_id: Option<&str>) { + let Some(request_id) = request_id else { + return; + }; + if let Ok(mut fetches) = MEDIA_FETCH_CANCELLATIONS.lock() { + fetches.finish(request_id); + } +} + +/// Cancel a renderer-owned relay media fetch, including an in-flight body. +#[tauri::command] +pub fn cancel_media_fetch(request_id: String) { + if let Ok(mut fetches) = MEDIA_FETCH_CANCELLATIONS.lock() { + fetches.cancel(&request_id); + } +} + +/// Release renderer ownership after the fetch promise settles. +#[tauri::command] +pub fn release_media_fetch(request_id: String) { + finish_media_fetch(Some(&request_id)); +} + +/// Fetch relay media bytes with renderer-owned cancellation. +#[tauri::command] +pub async fn fetch_media_bytes( + url: String, + request_id: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let cancellation = begin_media_fetch(request_id.as_deref()); + let result = async { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + let bytes = + fetch_blob_bytes_with_cap(&url, &state, MAX_DOWNLOAD_BYTES, cancellation.as_ref()) + .await?; + detect_and_validate_mime(&bytes)?; + Ok(tauri::ipc::Response::new(bytes)) + } + .await; + finish_media_fetch(request_id.as_deref()); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let mut fetches = MediaFetchCancellations::default(); + fetches.cancel("cancel-before-begin"); + + let cancellation = fetches.begin("cancel-before-begin"); + + assert!(cancellation.is_cancelled()); + fetches.finish("cancel-before-begin"); + assert!(fetches.tokens.is_empty()); + } + + #[test] + fn cancellation_reaches_active_owner() { + let mut fetches = MediaFetchCancellations::default(); + let cancellation = fetches.begin("active-fetch"); + + fetches.cancel("active-fetch"); + + assert!(cancellation.is_cancelled()); + fetches.finish("active-fetch"); + assert!(fetches.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/media_filename.rs b/desktop/src-tauri/src/commands/media_filename.rs new file mode 100644 index 00000000000..0f6bb2bd736 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_filename.rs @@ -0,0 +1,29 @@ +/// Sanitize a filename for use as a display label in the imeta `filename` field. +/// +/// Strips directory components, removes control characters, and bounds length +/// to 255 so the resulting name always passes relay ingest validation. +pub(crate) fn sanitize_filename(name: &str) -> String { + let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); + let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect(); + if cleaned.is_empty() { + "file".to_string() + } else { + cleaned + } +} + +#[cfg(test)] +mod tests { + use super::sanitize_filename; + + #[test] + fn strips_paths_controls_and_empty_names() { + assert_eq!(sanitize_filename("report.pdf"), "report.pdf"); + assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); + assert_eq!(sanitize_filename("/abs/path/notes.txt"), "notes.txt"); + assert_eq!(sanitize_filename(r"C:\Users\me\doc.docx"), "doc.docx"); + assert_eq!(sanitize_filename(""), "file"); + assert_eq!(sanitize_filename("/"), "file"); + assert_eq!(sanitize_filename("a\nb\tc.txt"), "abc.txt"); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 3fb7eda5f07..30f9269f533 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -271,6 +271,91 @@ fn transcode_to_mp4_with_cancellation( Ok(output) } +/// Package a voice-note audio file in the relay's existing canonical video +/// envelope. The tiny H.264 track satisfies the deployed video validator while +/// the AAC track remains the only user-facing content in the voice-note player. +/// +/// Returns the path to a temp MP4. Caller must clean up. +pub(super) fn transcode_voice_note_to_mp4_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, +) -> Result { + let ffmpeg = find_ffmpeg()?; + let output = std::env::temp_dir().join(format!("buzz-voice-note-{}.mp4", uuid::Uuid::new_v4())); + + let result = run_ffmpeg_with_cancellation( + ffmpeg_command(&ffmpeg) + .args([ + "-y", + "-nostdin", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "color=c=black:s=16x16:r=1", + ]) + .arg("-i") + .arg(source) + .args([ + "-map", + "0:v:0", + "-map", + "1:a:0", + "-shortest", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-sn", + "-dn", + "-fflags", + "+bitexact", + "-flags:v", + "+bitexact", + "-flags:a", + "+bitexact", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-tune", + "stillimage", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "96k", + "-movflags", + "+faststart", + "-metadata", + "encoder=", + ]) + .arg(&output) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()), + FFMPEG_TIMEOUT, + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; + + if !result.status.success() { + let _ = std::fs::remove_file(&output); + let stderr = String::from_utf8_lossy(&result.stderr); + let detail = stderr + .lines() + .rev() + .find(|line| !line.is_empty() && !line.starts_with(" ")) + .unwrap_or("unknown error"); + return Err(format!("Voice note conversion failed: {detail}")); + } + + Ok(output) +} + /// Transcode a HEIC/HEIF still image to JPEG via ffmpeg. /// /// The Tauri webview / Chromium cannot decode HEIC, so iPhone photos uploaded @@ -655,6 +740,67 @@ mod tests { } } + #[test] + fn test_voice_note_envelope_passes_relay_video_validation() { + if find_ffmpeg().is_err() { + eprintln!("skipping voice-note round-trip: ffmpeg not found"); + return; + } + + let source = + std::env::temp_dir().join(format!("buzz-voice-test-{}.wav", uuid::Uuid::new_v4())); + let sample_rate = 24_000u32; + let sample_bytes = sample_rate as usize * 2; + let mut wav = Vec::with_capacity(44 + sample_bytes); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + sample_bytes as u32).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&(sample_bytes as u32).to_le_bytes()); + wav.resize(44 + sample_bytes, 0); + std::fs::write(&source, wav).expect("write voice-note fixture"); + + let output = match transcode_voice_note_to_mp4_with_cancellation(&source, None) { + Ok(output) => output, + Err(error) => { + eprintln!("skipping voice-note round-trip: {error}"); + let _ = std::fs::remove_file(&source); + return; + } + }; + let relay_config = buzz_media_pkg::MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + s3_region: "us-east-1".to_string(), + s3_addressing_style: buzz_media_pkg::S3AddressingStyle::Path, + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }; + let metadata = buzz_media_pkg::validation::validate_video_file(&output, &relay_config) + .expect("relay rejected the canonical voice-note envelope"); + let _ = std::fs::remove_file(&source); + let _ = std::fs::remove_file(&output); + + assert!(metadata.has_audio); + assert_eq!((metadata.width, metadata.height), (16, 16)); + assert!(metadata.duration_secs > 0.0); + } + /// Round-trip transcode test, gated on ffmpeg being present so CI without /// ffmpeg doesn't fail. Generates a HEIC via ffmpeg, then transcodes it /// back to JPEG and asserts the output is a valid JPEG. diff --git a/desktop/src-tauri/src/commands/media_voice_note.rs b/desktop/src-tauri/src/commands/media_voice_note.rs new file mode 100644 index 00000000000..a71d0157ab0 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_voice_note.rs @@ -0,0 +1,85 @@ +use tokio_util::sync::CancellationToken; + +use super::media_transcode::transcode_voice_note_to_mp4_with_cancellation; + +const VOICE_NOTE_MAX_INPUT_BYTES: usize = 128 * 1024 * 1024; + +pub(super) fn is_voice_note_filename(filename: Option<&str>) -> bool { + filename.is_some_and(|name| { + let lower = name.to_ascii_lowercase(); + lower.starts_with("voice-note-") && lower.ends_with(".wav") + }) +} + +pub(super) fn voice_note_mp4_filename(filename: &str) -> String { + filename + .strip_suffix(".wav") + .or_else(|| filename.strip_suffix(".WAV")) + .map_or_else(|| format!("{filename}.mp4"), |stem| format!("{stem}.mp4")) +} + +pub(super) async fn prepare_voice_note_for_upload( + data: Vec, + cancellation: Option<&CancellationToken>, +) -> Result<(Vec, Option>), String> { + validate_voice_note_input_size(data.len())?; + let cancellation = cancellation.cloned(); + tokio::task::spawn_blocking(move || { + let detected = infer::get(&data) + .ok_or_else(|| "Voice note has an unrecognized audio format.".to_string())?; + if !detected.mime_type().starts_with("audio/") { + return Err("Voice note upload did not contain audio.".to_string()); + } + + let tmp_input = + std::env::temp_dir().join(format!("buzz-voice-input-{}", uuid::Uuid::new_v4())); + let result = (|| { + std::fs::write(&tmp_input, &data) + .map_err(|error| format!("failed to prepare voice note: {error}"))?; + let output = + transcode_voice_note_to_mp4_with_cancellation(&tmp_input, cancellation.as_ref())?; + let bytes = std::fs::read(&output) + .map_err(|error| format!("failed to read prepared voice note: {error}")); + let _ = std::fs::remove_file(&output); + bytes.map(|bytes| (bytes, None)) + })(); + let _ = std::fs::remove_file(&tmp_input); + result + }) + .await + .map_err(|error| format!("voice note task failed: {error}"))? +} + +fn validate_voice_note_input_size(size: usize) -> Result<(), String> { + if size > VOICE_NOTE_MAX_INPUT_BYTES { + return Err(format!( + "Voice note exceeds the maximum input size of {VOICE_NOTE_MAX_INPUT_BYTES} bytes." + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + is_voice_note_filename, validate_voice_note_input_size, voice_note_mp4_filename, + VOICE_NOTE_MAX_INPUT_BYTES, + }; + + #[test] + fn voice_note_filenames_are_scoped_and_rewritten_for_video_upload() { + assert!(is_voice_note_filename(Some("voice-note-123.wav"))); + assert!(!is_voice_note_filename(Some("meeting.wav"))); + assert!(!is_voice_note_filename(Some("voice-note-123.mp4"))); + assert_eq!( + voice_note_mp4_filename("voice-note-123.wav"), + "voice-note-123.mp4" + ); + } + + #[test] + fn voice_note_input_size_is_bounded_before_transcoding() { + assert!(validate_voice_note_input_size(VOICE_NOTE_MAX_INPUT_BYTES).is_ok()); + assert!(validate_voice_note_input_size(VOICE_NOTE_MAX_INPUT_BYTES + 1).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7ec94e99e82..d022ffa51a7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -35,11 +35,14 @@ mod managed_agent_definition; pub(crate) mod media; mod media_animated; mod media_download; +mod media_fetch_cancellation; +mod media_filename; mod media_gif; mod media_raw; mod media_snapshot_png; mod media_transcode; mod media_upload_progress; +mod media_voice_note; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; #[cfg(feature = "mesh-llm")] @@ -102,6 +105,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_fetch_cancellation::*; pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; diff --git a/desktop/src-tauri/src/commands/qr_download.rs b/desktop/src-tauri/src/commands/qr_download.rs index 74a777b4528..5f5c399e783 100644 --- a/desktop/src-tauri/src/commands/qr_download.rs +++ b/desktop/src-tauri/src/commands/qr_download.rs @@ -1,7 +1,7 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use crate::commands::export_util::save_bytes_with_dialog; -use crate::commands::media::sanitize_filename; +use crate::commands::media_filename::sanitize_filename; use crate::commands::personas::PNG_MAGIC; fn decode_png_data_url(data_url: &str) -> Result, String> { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e2282d32d1e..db26e0778eb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -620,6 +620,8 @@ pub fn run() { save_png_data_url, download_file, fetch_media_bytes, + cancel_media_fetch, + release_media_fetch, copy_image_to_clipboard, copy_text_to_clipboard, read_clipboard_text, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index d53e8dbbdaa..8b56ec8af99 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -201,6 +201,8 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); + const [acceptsMainAttachments, setAcceptsMainAttachments] = + React.useState(true); const isNonMemberView = activeChannel !== null && !activeChannel.isMember && @@ -328,6 +330,7 @@ export const ChannelPane = React.memo(function ChannelPane({ hasMainComposerOverlay && !isComposerDisabled && !isMainDeferredEditPending && + acceptsMainAttachments && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( @@ -763,6 +766,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onAutoSubmitComplete={handleAutoSubmitComplete} isSending={isSending} mediaController={mainComposerMedia} + onAttachmentAcceptanceChange={setAcceptsMainAttachments} onDeferredEditPendingChange={setMainDeferredEditPending} onCancelEdit={onCancelEdit} onEditLastOwnMessage={handleEditLastOwnMainMessage} @@ -793,8 +797,6 @@ export const ChannelPane = React.memo(function ChannelPane({ } showTopBorder={false} /> - {/* The reserved bottom rail keeps accessory fades from moving - the conversation while content remains responsive. */} { + assert.equal( + isVoiceNoteFile( + new File([new Uint8Array([1])], "voice-note-123.wav", { + type: "audio/wav", + }), + ), + true, + ); + assert.equal( + isVoiceNoteFile( + new File([new Uint8Array([1])], "meeting.wav", { type: "audio/wav" }), + ), + false, + ); +}); + +test("resolveAudioAttachment accepts audio imeta and preserves metadata", () => { + assert.deepEqual( + resolveAudioAttachment( + { + duration: 12.5, + filename: "voice-note.webm", + m: "audio/webm;codecs=opus", + size: 2048, + }, + "https://relay.example/media/voice-note.webm", + "Voice note", + ), + { + duration: 12.5, + filename: "voice-note.webm", + href: "https://relay.example/media/voice-note.webm", + size: 2048, + }, + ); +}); + +test("generic audio renders without triggering voice-note exclusivity", () => { + const entry = { + duration: 42, + filename: "meeting.mp3", + m: "audio/mpeg", + }; + assert.equal(isVoiceNoteAttachment(entry), false); + assert.equal(isAudioAttachment(entry), true); + assert.deepEqual( + resolveAudioAttachment( + entry, + "https://relay.example/media/meeting.mp3", + "meeting.mp3", + ), + { + duration: 42, + filename: "meeting.mp3", + href: "https://relay.example/media/meeting.mp3", + size: undefined, + }, + ); +}); + +test("resolveAudioAttachment leaves non-audio files on the generic path", () => { + assert.equal( + resolveAudioAttachment( + { filename: "notes.pdf", m: "application/pdf" }, + "https://relay.example/media/notes.pdf", + "notes.pdf", + ), + null, + ); +}); + +test("packaged MP4 voice notes still resolve to the audio player", () => { + const entry = { + duration: 7.2, + filename: "voice-note-123.mp4", + m: "video/mp4", + }; + assert.equal(isVoiceNoteAttachment(entry), true); + assert.deepEqual( + resolveAudioAttachment( + entry, + "https://relay.example/media/hash.mp4", + "voice-note-123.mp4", + ), + { + duration: 7.2, + filename: "voice-note-123.mp4", + href: "https://relay.example/media/hash.mp4", + size: undefined, + }, + ); + assert.equal( + isVoiceNoteAttachment({ filename: "meeting.mp4", m: "video/mp4" }), + false, + ); +}); + +test("formatVoiceNoteDuration formats minutes and seconds", () => { + assert.equal(formatVoiceNoteDuration(0), "0:00"); + assert.equal(formatVoiceNoteDuration(65.9), "1:05"); +}); + +test("voice notes have a five-minute recording limit", () => { + assert.equal(VOICE_NOTE_MAX_DURATION_SECONDS, 300); + assert.equal( + formatVoiceNoteDuration(VOICE_NOTE_MAX_DURATION_SECONDS), + "5:00", + ); +}); + +test("nextVoiceNotePlaybackRate follows the voice-note speed cycle", () => { + assert.equal(nextVoiceNotePlaybackRate(1), 1.5); + assert.equal(nextVoiceNotePlaybackRate(1.5), 2); + assert.equal(nextVoiceNotePlaybackRate(2), 0.5); + assert.equal(nextVoiceNotePlaybackRate(0.5), 1); + assert.equal(nextVoiceNotePlaybackRate(99), 1); +}); + +test("waveformPeaks produces normalized accessible-height bars", () => { + const peaks = waveformPeaks(new Float32Array([0, 0.25, -0.5, 1]), 2); + assert.deepEqual(peaks, [0.25, 1]); + assert.deepEqual(waveformPeaks(new Float32Array(), 2), [0.12, 0.12]); +}); + +test("voiceNoteBarHeight keeps quiet samples circular", () => { + assert.equal(voiceNoteBarHeight(0), 3); + assert.equal(voiceNoteBarHeight(0.12), 3); + assert.equal(voiceNoteBarHeight(0.16), 3); + assert.equal(voiceNoteBarHeight(1), 20); +}); + +test("summarizeWaveform bounds retained data regardless of clip length", () => { + const longClip = new Float32Array(48_000 * 300).map(() => 0.5); + const summary = summarizeWaveform(longClip); + assert.equal(summary.length, WAVEFORM_SUMMARY_RESOLUTION); + assert.ok( + summary.length < longClip.length, + "summary must be far smaller than the decoded clip", + ); + + const short = new Float32Array([0.2, 0.9]); + assert.equal(summarizeWaveform(short).length, 2); + assert.equal(summarizeWaveform(new Float32Array()).length, 1); +}); + +test("resampling the summary matches pooling the raw samples", () => { + const samples = new Float32Array([0, 0.25, -0.5, 1, -0.3, 0.8]); + const summary = summarizeWaveform(samples, 6); + assert.deepEqual( + Array.from(waveformPeaks(summary, 2)), + Array.from(waveformPeaks(samples, 2)), + ); +}); diff --git a/desktop/src/features/messages/lib/audioAttachment.ts b/desktop/src/features/messages/lib/audioAttachment.ts new file mode 100644 index 00000000000..c69565f7cfd --- /dev/null +++ b/desktop/src/features/messages/lib/audioAttachment.ts @@ -0,0 +1,143 @@ +export type AudioAttachmentImetaEntry = { + duration?: number; + filename?: string; + m?: string; + size?: number; +}; + +export type ResolvedAudioAttachment = { + duration?: number; + filename: string; + href: string; + size?: number; +}; + +export const VOICE_NOTE_MAX_DURATION_SECONDS = 5 * 60; + +export function isVoiceNoteFile(file: File): boolean { + const filename = file.name.toLowerCase(); + return ( + file.type.startsWith("audio/") && + filename.startsWith("voice-note-") && + filename.endsWith(".wav") + ); +} + +export function isVoiceNoteAttachment( + entry: AudioAttachmentImetaEntry | undefined, +): boolean { + const mime = entry?.m?.toLowerCase() ?? ""; + const filename = entry?.filename?.toLowerCase() ?? ""; + if (!filename.startsWith("voice-note-")) return false; + if (mime.startsWith("audio/")) return true; + return mime === "video/mp4" && filename.endsWith(".mp4"); +} + +export function isAudioAttachment( + entry: AudioAttachmentImetaEntry | undefined, +): boolean { + const mime = entry?.m?.toLowerCase() ?? ""; + return mime.startsWith("audio/") || isVoiceNoteAttachment(entry); +} + +export function resolveAudioAttachment( + entry: AudioAttachmentImetaEntry | undefined, + href: string | undefined, + childText: string, +): ResolvedAudioAttachment | null { + if (!href || !entry || !isAudioAttachment(entry)) return null; + + return { + duration: entry.duration, + filename: + entry.filename || + childText.trim() || + href.split("/").pop() || + "voice-note", + href, + size: entry.size, + }; +} + +export function formatVoiceNoteDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const rounded = Math.floor(seconds); + const minutes = Math.floor(rounded / 60); + return `${minutes}:${String(rounded % 60).padStart(2, "0")}`; +} + +export const VOICE_NOTE_PLAYBACK_RATES = [1, 1.5, 2, 0.5] as const; + +export function nextVoiceNotePlaybackRate(currentRate: number): number { + const currentIndex = VOICE_NOTE_PLAYBACK_RATES.indexOf( + currentRate as (typeof VOICE_NOTE_PLAYBACK_RATES)[number], + ); + return VOICE_NOTE_PLAYBACK_RATES[ + (currentIndex + 1) % VOICE_NOTE_PLAYBACK_RATES.length + ]; +} + +const QUIET_LEVEL_THRESHOLD = 0.16; + +// Waveform cards keep only this many peak buckets, not the full decoded clip. +// 256 matches the maximum bar count a card can display, so a resampled envelope +// is visually indistinguishable while retaining a fixed ~1KB regardless of clip +// duration (a 5-minute 48kHz mono note would otherwise pin ~57MB per card). +export const WAVEFORM_SUMMARY_RESOLUTION = 256; + +// Reduce decoded PCM to a bounded peak envelope via max-pooling. Downstream +// display resamples this envelope to the (smaller) bar count; because 256 far +// exceeds the bars a card renders, the resampled result is visually +// indistinguishable from pooling the original samples directly. +export function summarizeWaveform( + samples: Float32Array, + resolution: number = WAVEFORM_SUMMARY_RESOLUTION, +): Float32Array { + const buckets = Math.max(1, Math.min(resolution, samples.length || 1)); + const summary = new Float32Array(buckets); + if (samples.length === 0) return summary; + for (let index = 0; index < buckets; index += 1) { + const start = Math.floor((index * samples.length) / buckets); + const end = Math.max( + start + 1, + Math.floor(((index + 1) * samples.length) / buckets), + ); + let peak = 0; + for (let sampleIndex = start; sampleIndex < end; sampleIndex += 1) { + peak = Math.max(peak, Math.abs(samples[sampleIndex] ?? 0)); + } + summary[index] = peak; + } + return summary; +} + +export function voiceNoteBarHeight(level: number): number { + const audibleLevel = Math.max( + 0, + (Math.min(1, level) - QUIET_LEVEL_THRESHOLD) / (1 - QUIET_LEVEL_THRESHOLD), + ); + return 3 + Math.round(audibleLevel * 17); +} + +export function waveformPeaks( + samples: Float32Array, + barCount: number, +): number[] { + if (barCount <= 0) return []; + if (samples.length === 0) return Array.from({ length: barCount }, () => 0.12); + + const peaks = Array.from({ length: barCount }, (_, index) => { + const start = Math.floor((index * samples.length) / barCount); + const end = Math.max( + start + 1, + Math.floor(((index + 1) * samples.length) / barCount), + ); + let peak = 0; + for (let sampleIndex = start; sampleIndex < end; sampleIndex += 1) { + peak = Math.max(peak, Math.abs(samples[sampleIndex] ?? 0)); + } + return peak; + }); + const maximum = Math.max(...peaks, 0.001); + return peaks.map((peak) => Math.max(0.12, Math.min(1, peak / maximum))); +} diff --git a/desktop/src/features/messages/lib/audioMediaLoadScheduler.test.mjs b/desktop/src/features/messages/lib/audioMediaLoadScheduler.test.mjs new file mode 100644 index 00000000000..cc85259829b --- /dev/null +++ b/desktop/src/features/messages/lib/audioMediaLoadScheduler.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getAudioMediaLoadSchedulerSnapshot, + MAX_CONCURRENT_AUDIO_MEDIA_LOADS, + resetAudioMediaLoadScheduler, + scheduleAudioMediaLoad, +} from "./audioMediaLoadScheduler.ts"; + +function abortablePendingTask(onStart) { + return (signal) => + new Promise((resolve, reject) => { + onStart({ resolve, signal }); + signal.addEventListener( + "abort", + () => reject(new DOMException("cancelled", "AbortError")), + { once: true }, + ); + }); +} + +test.afterEach(async () => { + resetAudioMediaLoadScheduler(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(getAudioMediaLoadSchedulerSnapshot(), { + active: 0, + queued: 0, + }); +}); + +test("hard-caps active work and removes queued work on cancellation", async () => { + const starts = []; + const handles = Array.from({ length: 7 }, () => + scheduleAudioMediaLoad(abortablePendingTask((start) => starts.push(start))), + ); + const settlements = handles.map((handle) => + handle.promise.catch((error) => error), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(starts.length, MAX_CONCURRENT_AUDIO_MEDIA_LOADS); + assert.deepEqual(getAudioMediaLoadSchedulerSnapshot(), { + active: MAX_CONCURRENT_AUDIO_MEDIA_LOADS, + queued: 7 - MAX_CONCURRENT_AUDIO_MEDIA_LOADS, + }); + + for (const handle of handles) handle.cancel(); + await Promise.all(settlements); + assert.equal(starts.length, MAX_CONCURRENT_AUDIO_MEDIA_LOADS); + assert.ok(starts.every(({ signal }) => signal.aborted)); + assert.deepEqual(getAudioMediaLoadSchedulerSnapshot(), { + active: 0, + queued: 0, + }); +}); + +test("settled work promotes only enough queued work to refill the cap", async () => { + const starts = []; + const handles = Array.from({ length: 5 }, () => + scheduleAudioMediaLoad(abortablePendingTask((start) => starts.push(start))), + ); + const settlements = handles.map((handle) => + handle.promise.catch((error) => error), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + starts[0].resolve("first"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(starts.length, MAX_CONCURRENT_AUDIO_MEDIA_LOADS + 1); + assert.deepEqual(getAudioMediaLoadSchedulerSnapshot(), { + active: MAX_CONCURRENT_AUDIO_MEDIA_LOADS, + queued: 1, + }); + + for (const handle of handles) handle.cancel(); + await Promise.all(settlements); +}); diff --git a/desktop/src/features/messages/lib/audioMediaLoadScheduler.ts b/desktop/src/features/messages/lib/audioMediaLoadScheduler.ts new file mode 100644 index 00000000000..c238ed1a363 --- /dev/null +++ b/desktop/src/features/messages/lib/audioMediaLoadScheduler.ts @@ -0,0 +1,115 @@ +export const MAX_CONCURRENT_AUDIO_MEDIA_LOADS = 3; + +type InternalTask = { + controller: AbortController; + reject: (reason?: unknown) => void; + resolve: (value: unknown) => void; + run: (signal: AbortSignal) => Promise; + settled: boolean; + started: boolean; +}; + +export type AudioMediaLoadHandle = { + cancel: () => void; + promise: Promise; +}; + +const queuedTasks: InternalTask[] = []; +const activeTasks = new Set(); + +function abortError(): DOMException { + return new DOMException("Audio media load cancelled", "AbortError"); +} + +function settleQueuedCancellation(task: InternalTask): void { + const index = queuedTasks.indexOf(task); + if (index >= 0) queuedTasks.splice(index, 1); + if (task.settled) return; + task.settled = true; + task.reject(abortError()); +} + +function pumpAudioMediaLoads(): void { + while ( + activeTasks.size < MAX_CONCURRENT_AUDIO_MEDIA_LOADS && + queuedTasks.length > 0 + ) { + const task = queuedTasks.shift(); + if (!task || task.settled) continue; + if (task.controller.signal.aborted) { + settleQueuedCancellation(task); + continue; + } + + task.started = true; + activeTasks.add(task); + void task + .run(task.controller.signal) + .then(task.resolve, task.reject) + .finally(() => { + task.settled = true; + activeTasks.delete(task); + pumpAudioMediaLoads(); + }); + } +} + +/** + * Run expensive audio transfer/decode work behind one shared hard cap. + * + * Cancelling a queued task removes it without starting it. Cancelling an + * active task aborts its owned signal; the slot is released when the task's + * abort-aware work has torn down. + */ +export function scheduleAudioMediaLoad( + run: (signal: AbortSignal) => Promise, +): AudioMediaLoadHandle { + let resolvePromise: (value: T) => void = () => {}; + let rejectPromise: (reason?: unknown) => void = () => {}; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + const scheduledTask: InternalTask = { + controller: new AbortController(), + reject: rejectPromise, + resolve: (value) => resolvePromise(value as T), + run, + settled: false, + started: false, + }; + queuedTasks.push(scheduledTask); + pumpAudioMediaLoads(); + + return { + cancel: () => { + if (scheduledTask.settled || scheduledTask.controller.signal.aborted) { + return; + } + scheduledTask.controller.abort(); + if (!scheduledTask.started) { + settleQueuedCancellation(scheduledTask); + pumpAudioMediaLoads(); + } + }, + promise, + }; +} + +/** Test/diagnostic snapshot of the scheduler's exact ownership counts. */ +export function getAudioMediaLoadSchedulerSnapshot(): { + active: number; + queued: number; +} { + return { active: activeTasks.size, queued: queuedTasks.length }; +} + +/** Cancel all community-scoped audio work during a relay boundary switch. */ +export function resetAudioMediaLoadScheduler(): void { + for (const task of [...queuedTasks, ...activeTasks]) { + if (task.settled || task.controller.signal.aborted) continue; + task.controller.abort(); + if (!task.started) settleQueuedCancellation(task); + } + pumpAudioMediaLoads(); +} diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index e1af5be6bcd..7c27b22107e 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -188,6 +188,17 @@ test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suff ); }); +test("formatImetaMediaLine: packaged voice note MP4 stays on the audio-card link path", () => { + assert.equal( + formatImetaMediaLine({ + url: "https://relay.example/media/hash.mp4", + type: "video/mp4", + filename: "voice-note-123.mp4", + }), + "\n[voice-note-123.mp4](https://relay.example/media/hash.mp4)", + ); +}); + test("formatImetaMediaLine: generic mime → [filename](url) link", () => { assert.equal( formatImetaMediaLine({ diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index f02b9906ca2..425b66bd9ae 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -288,7 +288,11 @@ export function formatImetaMediaLine( const lower = filename?.toLowerCase(); const isSnapshotPng = lower?.endsWith(".agent.png") || lower?.endsWith(".team.png"); - if (type.startsWith("video/")) { + const isPackagedVoiceNote = + type.toLowerCase() === "video/mp4" && + lower?.startsWith("voice-note-") && + lower.endsWith(".mp4"); + if (type.startsWith("video/") && !isPackagedVoiceNote) { const line = `![video](${url})`; return options.spoiler ? `\n||${line}||` : `\n${line}`; } diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 4b9d2c6cce7..c1bcd6b6ff7 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -8,6 +8,7 @@ import { import { uploadMediaFile } from "@/shared/api/tauriMedia"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; +import { isVoiceNoteFile } from "./audioAttachment"; import { useFilePicker } from "./useFilePicker"; import { isVideoFile, videoMimeForFile } from "./videoFileType"; @@ -169,7 +170,8 @@ export function useMediaUpload({ deferUploadsUntilSend && (!e2eConfig || e2eConfig.mock?.deferredComposerUploads === true); const shouldQueueFile = React.useCallback( - (file: File) => queueUntilSend && isVideoFile(file), + (file: File) => + queueUntilSend && (isVideoFile(file) || isVoiceNoteFile(file)), [queueUntilSend], ); const [uploadState, setUploadState] = React.useState({ @@ -320,9 +322,10 @@ export function useMediaUpload({ const attachments = files.map((file) => { const id = nextQueuedAttachmentIdRef.current; nextQueuedAttachmentIdRef.current += 1; - const previewUrl = file.type.startsWith("image/") - ? URL.createObjectURL(file) - : undefined; + const previewUrl = + file.type.startsWith("image/") || file.type.startsWith("audio/") + ? URL.createObjectURL(file) + : undefined; if (isVideoFile(file)) { void captureVideoPosterFrame(file).then((poster) => { if (poster) updateQueuedVideoPoster(id, poster.posterUrl); @@ -887,8 +890,8 @@ export function useMediaUpload({ /** * True while any attachment upload is in flight. * - * Send paths must gate on this: with `deferUploadsUntilSend`, only videos - * are queued locally, so an in-flight photo/file is in neither + * Send paths must gate on this: with `deferUploadsUntilSend`, videos and + * audio are queued locally, so an in-flight photo/file is in neither * `pendingImeta` nor `queuedAttachments`. Sending mid-flight would publish * the message without that attachment and land the descriptor in an * already-cleared composer. diff --git a/desktop/src/features/messages/lib/useVoiceNoteRecorder.test.mjs b/desktop/src/features/messages/lib/useVoiceNoteRecorder.test.mjs new file mode 100644 index 00000000000..a8962eacd02 --- /dev/null +++ b/desktop/src/features/messages/lib/useVoiceNoteRecorder.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +class FakeTrack { + stopped = false; + stop() { + this.stopped = true; + } +} + +class FakeStream { + track = new FakeTrack(); + getTracks() { + return [this.track]; + } +} + +class FakeRecorder extends dom.window.EventTarget { + static isTypeSupported() { + return true; + } + mimeType = "audio/webm"; + state = "inactive"; + start() { + this.state = "recording"; + } + stop() { + if (this.state === "inactive") return; + this.state = "inactive"; + this.dispatchEvent( + new dom.window.MessageEvent("dataavailable", { + data: new Blob([new Uint8Array([1])], { type: this.mimeType }), + }), + ); + this.dispatchEvent(new dom.window.Event("stop")); + } +} + +const decodeResolvers = []; +class FakeAudioContext { + close() { + return Promise.resolve(); + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + getByteTimeDomainData() {}, + }; + } + createMediaStreamSource() { + return { connect() {} }; + } + decodeAudioData() { + return new Promise((resolve) => decodeResolvers.push(resolve)); + } +} + +const streams = []; +const acquireFakeStream = async () => { + const stream = new FakeStream(); + streams.push(stream); + return stream; +}; +let getUserMediaImpl = acquireFakeStream; +before(() => { + Object.assign(globalThis, { + AudioContext: FakeAudioContext, + document: dom.window.document, + DOMException: dom.window.DOMException, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + MediaRecorder: FakeRecorder, + window: dom.window, + }); + Object.defineProperty(dom.window.navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: (...args) => getUserMediaImpl(...args), + }, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + dom.window.MediaRecorder = FakeRecorder; + dom.window.AudioContext = FakeAudioContext; +}); + +after(() => dom.window.close()); + +test("permission acquisition is visible, cancellable, and releases a late stream", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useVoiceNoteRecorder } = await import("./useVoiceNoteRecorder.ts"); + const { result, unmount } = renderHook(() => useVoiceNoteRecorder()); + const stream = new FakeStream(); + let resolvePermission; + getUserMediaImpl = () => + new Promise((resolve) => { + resolvePermission = resolve; + }); + + try { + let startPromise; + act(() => { + startPromise = result.current.start(); + }); + assert.equal(result.current.status, "requesting"); + + act(() => result.current.cancel()); + assert.equal(result.current.status, "idle"); + + await act(async () => { + resolvePermission(stream); + await startPromise; + }); + assert.equal(stream.track.stopped, true); + assert.equal(result.current.status, "idle"); + } finally { + getUserMediaImpl = acquireFakeStream; + unmount(); + cleanup(); + } +}); + +test("remains usable after Strict Mode replays the mount effect", async () => { + const { StrictMode, createElement } = await import("react"); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useVoiceNoteRecorder } = await import("./useVoiceNoteRecorder.ts"); + const { result, unmount } = renderHook(() => useVoiceNoteRecorder(), { + wrapper: ({ children }) => createElement(StrictMode, null, children), + }); + + try { + await act(() => result.current.start()); + assert.equal(result.current.status, "recording"); + assert.equal(streams.at(-1).track.stopped, false); + } finally { + unmount(); + cleanup(); + } +}); + +test("a cancelled decode cannot stop or attach over a newer recording", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useVoiceNoteRecorder } = await import("./useVoiceNoteRecorder.ts"); + const { result, unmount } = renderHook(() => useVoiceNoteRecorder()); + + try { + await act(() => result.current.start()); + let firstFinish; + await act(async () => { + firstFinish = result.current.stop(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.equal(decodeResolvers.length, 1); + + act(() => result.current.cancel()); + assert.equal(await firstFinish, null); + await act(() => result.current.start()); + const secondTrack = streams.at(-1).track; + + await act(async () => { + decodeResolvers.shift()({ + duration: 1, + getChannelData: () => new Float32Array([0]), + numberOfChannels: 1, + sampleRate: 8_000, + }); + await Promise.resolve(); + }); + + assert.equal(secondTrack.stopped, false); + assert.equal(result.current.status, "recording"); + } finally { + unmount(); + cleanup(); + } +}); diff --git a/desktop/src/features/messages/lib/useVoiceNoteRecorder.ts b/desktop/src/features/messages/lib/useVoiceNoteRecorder.ts new file mode 100644 index 00000000000..3ebb86356e4 --- /dev/null +++ b/desktop/src/features/messages/lib/useVoiceNoteRecorder.ts @@ -0,0 +1,264 @@ +import * as React from "react"; + +import { encodeVoiceNoteWav } from "./voiceNoteWav"; + +const MIME_CANDIDATES = [ + "audio/webm;codecs=opus", + "audio/ogg;codecs=opus", + "audio/mp4", + "audio/webm", +] as const; + +function supportedMimeType(): string | undefined { + if (typeof MediaRecorder === "undefined") return undefined; + return MIME_CANDIDATES.find((type) => MediaRecorder.isTypeSupported(type)); +} + +export type VoiceNoteRecording = { + duration: number; + file: File; +}; + +type RecordingSession = { + cancelled: boolean; + chunks: Blob[]; + context: AudioContext | null; + recorder: MediaRecorder | null; + resolveStop: ((recording: VoiceNoteRecording | null) => void) | null; + startedAt: number; + stream: MediaStream | null; +}; + +function releaseSessionAudio(session: RecordingSession) { + session.stream?.getTracks().forEach((track) => { + track.stop(); + }); + session.stream = null; + const context = session.context; + session.context = null; + if (context) void context.close().catch(() => undefined); +} + +export function useVoiceNoteRecorder() { + const mountedRef = React.useRef(true); + const sessionRef = React.useRef(null); + const [status, setStatus] = React.useState< + "idle" | "requesting" | "recording" | "processing" + >("idle"); + const [elapsedSeconds, setElapsedSeconds] = React.useState(0); + const [levels, setLevels] = React.useState([]); + const [error, setError] = React.useState(null); + + const cancel = React.useCallback(() => { + const session = sessionRef.current; + if (!session) return; + session.cancelled = true; + sessionRef.current = null; + session.resolveStop?.(null); + session.resolveStop = null; + const recorder = session.recorder; + if (recorder && recorder.state !== "inactive") recorder.stop(); + releaseSessionAudio(session); + if (mountedRef.current) { + setStatus("idle"); + setElapsedSeconds(0); + } + }, []); + + const start = React.useCallback(async () => { + if (status !== "idle" || sessionRef.current) return; + setError(null); + if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { + setError("Voice recording is not available in this environment."); + return; + } + + const session: RecordingSession = { + cancelled: false, + chunks: [], + context: null, + recorder: null, + resolveStop: null, + startedAt: 0, + stream: null, + }; + sessionRef.current = session; + setStatus("requesting"); + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }, + }); + session.stream = stream; + if ( + session.cancelled || + !mountedRef.current || + sessionRef.current !== session + ) { + releaseSessionAudio(session); + return; + } + + const mimeType = supportedMimeType(); + const recorder = mimeType + ? new MediaRecorder(stream, { mimeType }) + : new MediaRecorder(stream); + session.recorder = recorder; + const context = new AudioContext(); + session.context = context; + const analyser = context.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.72; + context.createMediaStreamSource(stream).connect(analyser); + session.startedAt = performance.now(); + setElapsedSeconds(0); + setLevels([]); + + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) session.chunks.push(event.data); + }); + recorder.addEventListener("stop", () => { + void (async () => { + const actualMime = recorder.mimeType || mimeType || "audio/webm"; + const blob = new Blob(session.chunks, { type: actualMime }); + session.chunks = []; + let recording: VoiceNoteRecording | null = null; + if (!session.cancelled && blob.size > 0) { + try { + const encoded = await blob.arrayBuffer(); + const decoded = await context.decodeAudioData(encoded.slice(0)); + if ( + !session.cancelled && + mountedRef.current && + sessionRef.current === session + ) { + const channels = Array.from( + { length: decoded.numberOfChannels }, + (_, index) => decoded.getChannelData(index), + ); + const wav = encodeVoiceNoteWav(channels, decoded.sampleRate); + const wavBuffer = new ArrayBuffer(wav.byteLength); + new Uint8Array(wavBuffer).set(wav); + recording = { + duration: decoded.duration, + file: new File([wavBuffer], `voice-note-${Date.now()}.wav`, { + type: "audio/wav", + }), + }; + } + } catch { + if ( + !session.cancelled && + mountedRef.current && + sessionRef.current === session + ) { + setError("Buzz could not prepare this voice note for upload."); + } + } + } + releaseSessionAudio(session); + if (sessionRef.current === session) { + sessionRef.current = null; + if (mountedRef.current) { + setStatus("idle"); + setElapsedSeconds(0); + } + } + session.resolveStop?.(recording); + session.resolveStop = null; + })(); + }); + recorder.addEventListener("error", () => { + if (mountedRef.current && sessionRef.current === session) { + setError("The voice recording was interrupted."); + } + }); + recorder.start(250); + setStatus("recording"); + + const samples = new Uint8Array(analyser.fftSize); + const levelTimer = window.setInterval(() => { + if ( + recorder.state !== "recording" || + session.cancelled || + sessionRef.current !== session + ) { + window.clearInterval(levelTimer); + return; + } + analyser.getByteTimeDomainData(samples); + let sumSquares = 0; + for (const sample of samples) { + const centered = (sample - 128) / 128; + sumSquares += centered * centered; + } + const rms = Math.sqrt(sumSquares / samples.length); + const level = Math.min(1, rms * 5.5); + if (!mountedRef.current) return; + setLevels((previous) => [...previous, level]); + setElapsedSeconds((performance.now() - session.startedAt) / 1000); + }, 90); + } catch (cause) { + releaseSessionAudio(session); + if ( + session.cancelled || + !mountedRef.current || + sessionRef.current !== session + ) { + return; + } + sessionRef.current = null; + setStatus("idle"); + const denied = + cause instanceof DOMException && + (cause.name === "NotAllowedError" || cause.name === "SecurityError"); + setError( + denied + ? "Allow Buzz to access your microphone to record a voice note." + : "Buzz could not start the voice recorder.", + ); + } + }, [status]); + + const stop = React.useCallback( + (discard = false): Promise => { + if (discard) { + cancel(); + return Promise.resolve(null); + } + const session = sessionRef.current; + const recorder = session?.recorder; + if (!session || !recorder || recorder.state === "inactive") { + return Promise.resolve(null); + } + setStatus("processing"); + return new Promise((resolve) => { + session.resolveStop = resolve; + recorder.stop(); + }); + }, + [cancel], + ); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancel(); + }; + }, [cancel]); + + return { + cancel, + elapsedSeconds, + error, + levels, + start, + status, + stop, + }; +} diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs index f22cee06ca3..bc93e4567a9 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs +++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs @@ -119,6 +119,23 @@ test("hasVideoAttachment uses the Markdown renderer's video classification", () ); }); +test("packaged MP4 voice notes are not video-review roots", () => { + const voiceNote = message({ + body: "[voice-note-123.mp4](https://relay/media/voice.mp4)", + tags: [ + [ + "imeta", + "url https://relay/media/voice.mp4", + "m video/mp4", + "filename voice-note-123.mp4", + ], + ], + }); + + assert.equal(hasVideoAttachment(voiceNote), false); + assert.equal(hasRenderedVideoAttachment(voiceNote), false); +}); + test("buildVideoReviewCommentsByRootId includes nested descendants", () => { const video = message({ id: "video", diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index a63843c1ce3..9e626ab1f83 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -1,10 +1,14 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import type { TimelineMessage } from "@/features/messages/types"; +import { isVoiceNoteAttachment } from "@/features/messages/lib/audioAttachment"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { isVideoMedia } from "@/shared/ui/markdown/mediaEntry"; -import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; +import { + parseImetaTags, + type ParsedImetaEntry, +} from "@/shared/ui/markdown/parseImeta"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; type SendVideoReviewComment = ( @@ -32,6 +36,13 @@ type MarkdownAstNode = { url?: string; }; +function isReviewVideo( + url: string, + entry: ParsedImetaEntry | undefined, +): boolean { + return isVideoMedia(url, entry?.m) && !isVoiceNoteAttachment(entry); +} + function markdownImageUrls(body: string): string[] { if (!body.includes("![")) return []; @@ -73,7 +84,7 @@ export function hasRenderedVideoAttachment( ): boolean { const imetaByUrl = parseImetaTags(message.tags ?? []); return markdownImageUrls(message.body).some((src) => - isVideoMedia(src, imetaByUrl.get(src)?.m), + isReviewVideo(src, imetaByUrl.get(src)), ); } @@ -82,13 +93,13 @@ export function hasVideoAttachment( ): boolean { const imetaByUrl = parseImetaTags(message.tags ?? []); if ( - [...imetaByUrl.values()].some((entry) => isVideoMedia(entry.url, entry.m)) + [...imetaByUrl.values()].some((entry) => isReviewVideo(entry.url, entry)) ) { return true; } for (const src of markdownImageUrls(message.body)) { - if (isVideoMedia(src, imetaByUrl.get(src)?.m)) return true; + if (isReviewVideo(src, imetaByUrl.get(src))) return true; } return false; diff --git a/desktop/src/features/messages/lib/voiceNoteWav.test.mjs b/desktop/src/features/messages/lib/voiceNoteWav.test.mjs new file mode 100644 index 00000000000..47e56490095 --- /dev/null +++ b/desktop/src/features/messages/lib/voiceNoteWav.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { encodeVoiceNoteWav } from "./voiceNoteWav.ts"; + +test("encodeVoiceNoteWav emits canonical mono PCM with no metadata chunks", () => { + const bytes = encodeVoiceNoteWav( + [new Float32Array([0, 0.5, -0.5, 1])], + 48_000, + 24_000, + ); + const view = new DataView(bytes.buffer); + const ascii = (start, length) => + String.fromCharCode(...bytes.slice(start, start + length)); + + assert.equal(ascii(0, 4), "RIFF"); + assert.equal(ascii(8, 4), "WAVE"); + assert.equal(ascii(12, 4), "fmt "); + assert.equal(ascii(36, 4), "data"); + assert.equal(view.getUint32(4, true), bytes.length - 8); + assert.equal(view.getUint16(20, true), 1); + assert.equal(view.getUint16(22, true), 1); + assert.equal(view.getUint32(24, true), 24_000); + assert.equal(view.getUint16(34, true), 16); + assert.equal(bytes.length, 48); +}); + +test("encodeVoiceNoteWav mixes stereo into mono", () => { + const bytes = encodeVoiceNoteWav( + [new Float32Array([1]), new Float32Array([-1])], + 24_000, + 24_000, + ); + assert.equal(new DataView(bytes.buffer).getInt16(44, true), 0); +}); + +test("encodeVoiceNoteWav rejects empty recordings", () => { + assert.throws(() => encodeVoiceNoteWav([], 48_000), /empty voice note/); +}); diff --git a/desktop/src/features/messages/lib/voiceNoteWav.ts b/desktop/src/features/messages/lib/voiceNoteWav.ts new file mode 100644 index 00000000000..0f41c88da77 --- /dev/null +++ b/desktop/src/features/messages/lib/voiceNoteWav.ts @@ -0,0 +1,67 @@ +const DEFAULT_OUTPUT_SAMPLE_RATE = 24_000; + +function writeAscii(view: DataView, offset: number, value: string) { + for (let index = 0; index < value.length; index += 1) { + view.setUint8(offset + index, value.charCodeAt(index)); + } +} + +export function encodeVoiceNoteWav( + channels: readonly Float32Array[], + inputSampleRate: number, + outputSampleRate = DEFAULT_OUTPUT_SAMPLE_RATE, +): Uint8Array { + const inputLength = channels[0]?.length ?? 0; + if ( + channels.length === 0 || + inputLength === 0 || + !Number.isFinite(inputSampleRate) || + inputSampleRate <= 0 || + !Number.isFinite(outputSampleRate) || + outputSampleRate <= 0 + ) { + throw new Error("Cannot encode an empty voice note"); + } + + const frameCount = Math.max( + 1, + Math.floor((inputLength * outputSampleRate) / inputSampleRate), + ); + const bytes = new Uint8Array(44 + frameCount * 2); + const view = new DataView(bytes.buffer); + writeAscii(view, 0, "RIFF"); + view.setUint32(4, bytes.length - 8, true); + writeAscii(view, 8, "WAVE"); + writeAscii(view, 12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, outputSampleRate, true); + view.setUint32(28, outputSampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeAscii(view, 36, "data"); + view.setUint32(40, frameCount * 2, true); + + const ratio = inputSampleRate / outputSampleRate; + for (let outputIndex = 0; outputIndex < frameCount; outputIndex += 1) { + const sourcePosition = outputIndex * ratio; + const leftIndex = Math.min(inputLength - 1, Math.floor(sourcePosition)); + const rightIndex = Math.min(inputLength - 1, leftIndex + 1); + const mix = sourcePosition - leftIndex; + let sample = 0; + for (const channel of channels) { + const left = channel[leftIndex] ?? 0; + const right = channel[rightIndex] ?? left; + sample += left + (right - left) * mix; + } + sample = Math.max(-1, Math.min(1, sample / channels.length)); + view.setInt16( + 44 + outputIndex * 2, + sample < 0 ? sample * 0x8000 : sample * 0x7fff, + true, + ); + } + + return bytes; +} diff --git a/desktop/src/features/messages/ui/AudioMessageAttachment.tsx b/desktop/src/features/messages/ui/AudioMessageAttachment.tsx new file mode 100644 index 00000000000..52853865bf0 --- /dev/null +++ b/desktop/src/features/messages/ui/AudioMessageAttachment.tsx @@ -0,0 +1,604 @@ +import * as React from "react"; +import { AlertCircle, Download, Loader2, X } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { toast } from "sonner"; + +import { + formatVoiceNoteDuration, + isVoiceNoteAttachment, + nextVoiceNotePlaybackRate, + resolveAudioAttachment, + summarizeWaveform, + voiceNoteBarHeight, + waveformPeaks, + type AudioAttachmentImetaEntry, +} from "@/features/messages/lib/audioAttachment"; +import { scheduleAudioMediaLoad } from "@/features/messages/lib/audioMediaLoadScheduler"; +import { invokeTauri } from "@/shared/api/tauri"; +import { fetchMediaBytes } from "@/shared/api/tauriMedia"; +import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentMedia, + AttachmentTitle, +} from "@/shared/ui/attachment"; +import { useSmoothCorners } from "@/shared/ui/smoothCorners"; +import { MorphingPlayPauseIcon } from "./MorphingPlayPauseIcon"; + +const PLAY_EVENT = "buzz-voice-note-play"; +const INITIAL_BAR_COUNT = 38; +const BAR_KEYS = Array.from( + { length: 256 }, + (_, index) => `voice-note-bar-${index}`, +); + +function dotPeaks(count: number): number[] { + return Array.from({ length: count }, () => 0); +} + +function playbackRateLabel(rate: number): string { + return `${rate === 0.5 ? ".5" : rate}×`; +} + +export function renderAudioMessageAttachment( + entry: AudioAttachmentImetaEntry | undefined, + href: string | undefined, + label: string, + downloadUrl?: string, +) { + const attachment = resolveAudioAttachment(entry, href, label); + return attachment ? ( + + ) : null; +} + +function audioMimeForUrl(url: string): string { + const pathname = url.split("?", 1)[0]?.toLowerCase() ?? ""; + if (pathname.endsWith(".mp4")) return "audio/mp4"; + if (pathname.endsWith(".mp3")) return "audio/mpeg"; + if (pathname.endsWith(".ogg")) return "audio/ogg"; + return "audio/wav"; +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +async function decodeSamples( + url: string, + signal: AbortSignal, +): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`Audio fetch failed (${response.status})`); + const bytes = await response.arrayBuffer(); + if (signal.aborted) { + throw new DOMException("Audio decode cancelled", "AbortError"); + } + const context = new AudioContext(); + let rejectCancellation: ((reason?: unknown) => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + const onAbort = () => { + void context.close().catch(() => undefined); + rejectCancellation?.( + new DOMException("Audio decode cancelled", "AbortError"), + ); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + const buffer = await Promise.race([ + context.decodeAudioData(bytes), + cancellation, + ]); + if (signal.aborted) { + throw new DOMException("Audio decode cancelled", "AbortError"); + } + return buffer.getChannelData(0); + } finally { + signal.removeEventListener("abort", onAbort); + await context.close().catch(() => undefined); + } +} + +export function AudioMessageAttachment({ + composer = false, + duration: taggedDuration, + downloadUrl, + filename, + href, + onRemove, +}: { + composer?: boolean; + duration?: number; + downloadUrl?: string; + filename: string; + href: string; + onRemove?: () => void; +}) { + const audioRef = React.useRef(null); + const playbackId = React.useId(); + const mediaRef = React.useRef(null); + const playbackRateRef = React.useRef(null); + const waveformRef = React.useRef(null); + const progressWaveformRef = React.useRef(null); + const progressFrameRef = React.useRef(null); + const shouldReduceMotion = useReducedMotion(); + const [playbackHref, setPlaybackHref] = React.useState( + composer || href.startsWith("blob:") || href.startsWith("data:") + ? href + : undefined, + ); + const [loadRequest, setLoadRequest] = React.useState< + { attempt: number; href: string } | undefined + >( + composer || href.startsWith("blob:") || href.startsWith("data:") + ? { attempt: 0, href } + : undefined, + ); + const [barCount, setBarCount] = React.useState(INITIAL_BAR_COUNT); + const [duration, setDuration] = React.useState(taggedDuration ?? 0); + const [currentTime, setCurrentTime] = React.useState(0); + const [isPlaying, setIsPlaying] = React.useState(false); + const [playbackRate, setPlaybackRate] = React.useState(1); + const [playbackError, setPlaybackError] = React.useState(false); + const [waveformError, setWaveformError] = React.useState(false); + // A Play click before the source is fetched is remembered here so playback + // starts automatically once loading resolves, instead of silently no-opping. + const [pendingPlay, setPendingPlay] = React.useState(false); + const [waveformSummary, setWaveformSummary] = React.useState< + Float32Array | undefined + >(); + const [peaks, setPeaks] = React.useState(() => dotPeaks(INITIAL_BAR_COUNT)); + const [waveformReady, setWaveformReady] = React.useState(false); + useSmoothCorners(mediaRef); + useSmoothCorners(playbackRateRef); + + React.useEffect(() => { + const localHref = + composer || href.startsWith("blob:") || href.startsWith("data:"); + if (localHref) { + setLoadRequest({ attempt: 0, href }); + return; + } + + setLoadRequest(undefined); + const waveform = waveformRef.current; + if (!waveform || typeof IntersectionObserver === "undefined") { + setLoadRequest({ attempt: 0, href }); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + setLoadRequest({ attempt: 0, href }); + observer.disconnect(); + }, + { rootMargin: "240px 0px" }, + ); + observer.observe(waveform); + return () => observer.disconnect(); + }, [composer, href]); + + React.useEffect(() => { + if (loadRequest?.href !== href) { + setPlaybackHref(undefined); + return; + } + if (composer || href.startsWith("blob:") || href.startsWith("data:")) { + setPlaybackHref(href); + return; + } + + let active = true; + let objectUrl: string | undefined; + setPlaybackHref(undefined); + const load = scheduleAudioMediaLoad((signal) => + fetchMediaBytes(href, signal), + ); + void load.promise + .then((bytes) => { + if (!active) return; + objectUrl = URL.createObjectURL( + new Blob([bytes], { type: audioMimeForUrl(href) }), + ); + setPlaybackHref(objectUrl); + }) + .catch((error: unknown) => { + if (active && !isAbortError(error)) { + setPlaybackHref(rewriteRelayUrl(href)); + } + }); + return () => { + active = false; + load.cancel(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [composer, href, loadRequest]); + + React.useEffect(() => { + const waveform = waveformRef.current; + if (!waveform) return; + const updateCount = () => { + setBarCount( + Math.min(256, Math.max(1, Math.floor((waveform.clientWidth + 2) / 5))), + ); + }; + updateCount(); + const observer = new ResizeObserver(updateCount); + observer.observe(waveform); + return () => observer.disconnect(); + }, []); + + React.useEffect(() => { + if (!playbackHref) return; + let active = true; + setWaveformReady(false); + setWaveformError(false); + setWaveformSummary(undefined); + const load = scheduleAudioMediaLoad((signal) => + decodeSamples(playbackHref, signal), + ); + void load.promise + .then((samples) => { + if (!active) return; + setWaveformSummary(summarizeWaveform(samples)); + }) + .catch((error: unknown) => { + if (active && !isAbortError(error)) setWaveformError(true); + }); + return () => { + active = false; + load.cancel(); + }; + }, [playbackHref]); + + React.useEffect(() => { + setPeaks( + waveformSummary + ? waveformPeaks(waveformSummary, barCount) + : dotPeaks(barCount), + ); + if (waveformSummary) setWaveformReady(true); + }, [barCount, waveformSummary]); + + React.useEffect(() => { + const handleOtherPlayback = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail !== playbackId) audioRef.current?.pause(); + }; + window.addEventListener(PLAY_EVENT, handleOtherPlayback); + return () => window.removeEventListener(PLAY_EVENT, handleOtherPlayback); + }, [playbackId]); + + const paintProgress = React.useCallback((time: number, knownDuration = 0) => { + const audio = audioRef.current; + const progressWaveform = progressWaveformRef.current; + if (!progressWaveform) return; + const audioDuration = + knownDuration > 0 + ? knownDuration + : audio && Number.isFinite(audio.duration) + ? audio.duration + : 0; + const ratio = audioDuration > 0 ? time / audioDuration : 0; + const remaining = Math.max(0, Math.min(1, 1 - ratio)) * 100; + progressWaveform.style.clipPath = `inset(0 ${remaining}% 0 0)`; + }, []); + + React.useEffect(() => { + if (!isPlaying) { + if (progressFrameRef.current !== null) { + window.cancelAnimationFrame(progressFrameRef.current); + progressFrameRef.current = null; + } + return; + } + + const paintFrame = () => { + const audio = audioRef.current; + if (!audio || audio.paused) { + progressFrameRef.current = null; + return; + } + paintProgress(audio.currentTime); + progressFrameRef.current = window.requestAnimationFrame(paintFrame); + }; + progressFrameRef.current = window.requestAnimationFrame(paintFrame); + return () => { + if (progressFrameRef.current !== null) { + window.cancelAnimationFrame(progressFrameRef.current); + progressFrameRef.current = null; + } + }; + }, [isPlaying, paintProgress]); + + const startPlayback = React.useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + window.dispatchEvent(new CustomEvent(PLAY_EVENT, { detail: playbackId })); + void audio.play().catch(() => { + setIsPlaying(false); + setPlaybackError(true); + }); + }, [playbackId]); + + const togglePlayback = React.useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (!playbackHref) { + // Source not fetched yet: request the load and remember the intent so + // playback begins as soon as it arrives, rather than dropping the click. + setPendingPlay(true); + setLoadRequest((request) => + request?.href === href ? request : { attempt: 0, href }, + ); + return; + } + if (audio.paused) { + startPlayback(); + } else { + setPendingPlay(false); + audio.pause(); + } + }, [href, playbackHref, startPlayback]); + + // Fulfill a Play click that landed before the source finished loading. + React.useEffect(() => { + if (!pendingPlay || !playbackHref) return; + setPendingPlay(false); + startPlayback(); + }, [pendingPlay, playbackHref, startPlayback]); + + // Drop a pending intent if playback itself fails, so the button leaves its + // loading state and the user can retry. Waveform decode failure is unrelated + // to playback and must not cancel the intent. + React.useEffect(() => { + if (playbackError) setPendingPlay(false); + }, [playbackError]); + + const retryPlayback = React.useCallback(() => { + setPlaybackError(false); + setWaveformError(false); + setPlaybackHref(undefined); + setLoadRequest((request) => ({ + attempt: request?.href === href ? request.attempt + 1 : 0, + href, + })); + }, [href]); + + const timeLabel = isPlaying + ? formatVoiceNoteDuration(Math.max(0, duration - currentTime)) + : formatVoiceNoteDuration(duration); + const nextPlaybackRate = nextVoiceNotePlaybackRate(playbackRate); + + const waveformBars = React.useCallback( + (active: boolean) => + peaks.map((peak, index) => ( +