diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 9078f91..1ca8940 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -12,10 +12,26 @@ on: - release - debug +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.profile }} + cancel-in-progress: true + +env: + # Keep these ordinary CI/manual-build pins aligned with ci.yml. The + # release-*.yml workflows intentionally use coordinated release tags. + RATSPEAK_RSRETICULUM_REF: 1285e06cf684e79924be9ca2ef1b341f045d8285 + RATSPEAK_RSLXMF_REF: 2e124b10060dc9854151d0ffeed5167ad0f68589 + RATSPEAK_RSLXST_REF: 47827c9e2adb1fbdff84b12e687581f9c8991344 + RATSPEAK_LRGP_REF: 58909b23b1ea0657e7668274e9c3bee4a511e353 + jobs: build: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 180 strategy: fail-fast: false matrix: @@ -23,21 +39,33 @@ jobs: - os: ubuntu-22.04 artifact_name: ratspeak-linux-amd64 build_command: cargo tauri build --bundles deb appimage rpm + debug_build_command: cargo tauri build --debug --bundles deb appimage rpm artifact_path: | Ratspeak/src-tauri/target/release/bundle/deb/*.deb Ratspeak/src-tauri/target/release/bundle/appimage/*.AppImage Ratspeak/src-tauri/target/release/bundle/rpm/*.rpm + debug_artifact_path: | + Ratspeak/src-tauri/target/debug/bundle/deb/*.deb + Ratspeak/src-tauri/target/debug/bundle/appimage/*.AppImage + Ratspeak/src-tauri/target/debug/bundle/rpm/*.rpm - os: ubuntu-22.04-arm artifact_name: ratspeak-linux-arm64 build_command: cargo tauri build --bundles deb + debug_build_command: cargo tauri build --debug --bundles deb artifact_path: | Ratspeak/src-tauri/target/release/bundle/deb/*.deb + debug_artifact_path: | + Ratspeak/src-tauri/target/debug/bundle/deb/*.deb - os: windows-latest artifact_name: ratspeak-windows build_command: cargo tauri build + debug_build_command: cargo tauri build --debug artifact_path: | Ratspeak/src-tauri/target/release/bundle/msi/*.msi Ratspeak/src-tauri/target/release/bundle/nsis/*.exe + debug_artifact_path: | + Ratspeak/src-tauri/target/debug/bundle/msi/*.msi + Ratspeak/src-tauri/target/debug/bundle/nsis/*.exe steps: # Check out repos into $GITHUB_WORKSPACE as siblings so Cargo # `../rsReticulum` path deps resolve correctly. @@ -50,20 +78,34 @@ jobs: uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RATSPEAK_RSRETICULUM_REF }} path: rsReticulum - name: Checkout rsLXMF sibling uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsLXMF + ref: ${{ env.RATSPEAK_RSLXMF_REF }} path: rsLXMF + - name: Checkout rsLXST sibling + uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXST + ref: ${{ env.RATSPEAK_RSLXST_REF }} + path: rsLXST + - name: Checkout lrgp-rs sibling uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/lrgp-rs + ref: ${{ env.RATSPEAK_LRGP_REF }} path: lrgp-rs + - name: Validate ordinary workflow dependency pins + working-directory: Ratspeak + run: bash scripts/ci/check-workflow-dependency-pins.sh + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -74,6 +116,7 @@ jobs: workspaces: | Ratspeak Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' && inputs.profile == 'release' }} - name: Install Linux system deps if: startsWith(matrix.os, 'ubuntu-') @@ -87,6 +130,8 @@ jobs: librsvg2-dev \ libudev-dev \ libpcsclite-dev \ + libasound2-dev \ + libdbus-1-dev \ pkg-config \ patchelf \ libfuse2 \ @@ -101,14 +146,20 @@ jobs: working-directory: Ratspeak run: bash dashboard/build-css.sh - - name: Build Tauri desktop app + - name: Build Tauri desktop app (release) + if: ${{ inputs.profile == 'release' }} working-directory: Ratspeak/src-tauri run: ${{ matrix.build_command }} + - name: Build Tauri desktop app (debug) + if: ${{ inputs.profile == 'debug' }} + working-directory: Ratspeak/src-tauri + run: ${{ matrix.debug_build_command }} + - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact_name }} - path: ${{ matrix.artifact_path }} - if-no-files-found: warn - retention-days: 14 + path: ${{ inputs.profile == 'debug' && matrix.debug_artifact_path || matrix.artifact_path }} + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 183371d..a82438e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,26 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + RATSPEAK_RSRETICULUM_REF: 1285e06cf684e79924be9ca2ef1b341f045d8285 + RATSPEAK_RSLXMF_REF: 2e124b10060dc9854151d0ffeed5167ad0f68589 + RATSPEAK_RSLXST_REF: 47827c9e2adb1fbdff84b12e687581f9c8991344 + RATSPEAK_LRGP_REF: 58909b23b1ea0657e7668274e9c3bee4a511e353 jobs: test: name: Test runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@v5 with: @@ -17,24 +32,29 @@ jobs: - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RATSPEAK_RSRETICULUM_REF }} path: rsReticulum - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsLXMF + ref: ${{ env.RATSPEAK_RSLXMF_REF }} path: rsLXMF - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsLXST + ref: ${{ env.RATSPEAK_RSLXST_REF }} path: rsLXST - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/lrgp-rs + ref: ${{ env.RATSPEAK_LRGP_REF }} path: lrgp-rs - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: cache-bin: false workspaces: Ratspeak + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install system deps run: | sudo apt-get update @@ -49,15 +69,32 @@ jobs: libasound2-dev \ libdbus-1-dev \ pkg-config - - run: cargo test --workspace + - name: Validate shell scripts + run: bash -n dashboard/build-css.sh scripts/release/*.sh + working-directory: Ratspeak + - name: Validate ordinary workflow dependency pins + run: bash scripts/ci/check-workflow-dependency-pins.sh + working-directory: Ratspeak + - name: Build dashboard CSS + run: bash dashboard/build-css.sh + working-directory: Ratspeak + - name: Test Rust workspace + run: cargo test --workspace --locked + working-directory: Ratspeak + - name: Test dashboard behavior + run: | + for test in dashboard/scripts/test_*.js; do + node "$test" + done working-directory: Ratspeak - name: Check Tauri desktop shell - run: cargo check --manifest-path src-tauri/Cargo.toml + run: cargo check --manifest-path src-tauri/Cargo.toml --locked working-directory: Ratspeak lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 45 steps: - uses: actions/checkout@v5 with: @@ -65,18 +102,22 @@ jobs: - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RATSPEAK_RSRETICULUM_REF }} path: rsReticulum - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsLXMF + ref: ${{ env.RATSPEAK_RSLXMF_REF }} path: rsLXMF - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/rsLXST + ref: ${{ env.RATSPEAK_RSLXST_REF }} path: rsLXST - uses: actions/checkout@v5 with: repository: ${{ github.repository_owner }}/lrgp-rs + ref: ${{ env.RATSPEAK_LRGP_REF }} path: lrgp-rs - uses: dtolnay/rust-toolchain@stable with: @@ -85,6 +126,7 @@ jobs: with: cache-bin: false workspaces: Ratspeak + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install system deps run: | sudo apt-get update @@ -101,5 +143,206 @@ jobs: pkg-config - run: cargo fmt --all -- --check working-directory: Ratspeak - - run: cargo clippy --workspace -- -D warnings + - name: Clippy (workspace, all targets and features) + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings working-directory: Ratspeak + - name: Clippy (Tauri application shell) + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features --locked -- -D warnings + working-directory: Ratspeak + + msrv: + name: Rust 1.85 MSRV + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + path: Ratspeak + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RATSPEAK_RSRETICULUM_REF }} + path: rsReticulum + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXMF + ref: ${{ env.RATSPEAK_RSLXMF_REF }} + path: rsLXMF + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXST + ref: ${{ env.RATSPEAK_RSLXST_REF }} + path: rsLXST + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/lrgp-rs + ref: ${{ env.RATSPEAK_LRGP_REF }} + path: lrgp-rs + - uses: dtolnay/rust-toolchain@1.85.0 + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: | + Ratspeak + Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install system deps + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libudev-dev \ + libpcsclite-dev \ + libasound2-dev \ + libdbus-1-dev \ + pkg-config + - name: Clippy (workspace, all targets and features) + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + working-directory: Ratspeak + - name: Clippy (Tauri application shell) + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features --locked -- -D warnings + working-directory: Ratspeak + + platform-check: + name: Platform check (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v5 + with: + path: Ratspeak + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RATSPEAK_RSRETICULUM_REF }} + path: rsReticulum + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXMF + ref: ${{ env.RATSPEAK_RSLXMF_REF }} + path: rsLXMF + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXST + ref: ${{ env.RATSPEAK_RSLXST_REF }} + path: rsLXST + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/lrgp-rs + ref: ${{ env.RATSPEAK_LRGP_REF }} + path: lrgp-rs + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: | + Ratspeak + Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Check desktop shell + run: cargo check --manifest-path Ratspeak/src-tauri/Cargo.toml --locked + + mobile-rust-lint: + name: Mobile gate (${{ matrix.target }}, ${{ matrix.toolchain }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: aarch64-linux-android + toolchain: stable + native_android_lint: true + - os: ubuntu-latest + target: aarch64-linux-android + toolchain: 1.85.0 + native_android_lint: false + - os: macos-latest + target: aarch64-apple-ios-sim + toolchain: stable + native_android_lint: false + - os: macos-latest + target: aarch64-apple-ios-sim + toolchain: 1.85.0 + native_android_lint: false + steps: + - uses: actions/checkout@v5 + with: + path: Ratspeak + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsReticulum + ref: ${{ env.RATSPEAK_RSRETICULUM_REF }} + path: rsReticulum + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXMF + ref: ${{ env.RATSPEAK_RSLXMF_REF }} + path: rsLXMF + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/rsLXST + ref: ${{ env.RATSPEAK_RSLXST_REF }} + path: rsLXST + - uses: actions/checkout@v5 + with: + repository: ${{ github.repository_owner }}/lrgp-rs + ref: ${{ env.RATSPEAK_LRGP_REF }} + path: lrgp-rs + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + targets: ${{ matrix.target }} + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: nttld/setup-ndk@v1 + if: matrix.target == 'aarch64-linux-android' + id: setup-ndk + with: + ndk-version: r27d + add-to-path: false + - uses: actions/setup-java@v5 + if: matrix.native_android_lint + with: + distribution: temurin + java-version: '17' + cache: gradle + cache-dependency-path: Ratspeak/src-tauri/gen/android/**/*.gradle* + - name: Clippy Android + if: matrix.target == 'aarch64-linux-android' + working-directory: Ratspeak + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + AR_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar + CC_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang + CXX_aarch64_linux_android: ${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang++ + TAURI_ANDROID_PROJECT_PATH: ${{ github.workspace }}/Ratspeak/src-tauri/gen/android + TAURI_ANDROID_PACKAGE_UNESCAPED: org.ratspeak.android + WRY_ANDROID_PACKAGE: org.ratspeak.android + run: cargo clippy --manifest-path src-tauri/Cargo.toml --target ${{ matrix.target }} --all-targets --all-features --locked -- -D warnings + - name: Compile Kotlin and run Android lint + if: matrix.native_android_lint + working-directory: Ratspeak/src-tauri/gen/android + run: | + test -s tauri.settings.gradle + test -s app/tauri.build.gradle.kts + ./gradlew :app:compileArm64DebugKotlin :app:lintArm64Debug --warning-mode all --no-daemon + - name: Clippy iOS simulator + if: matrix.target == 'aarch64-apple-ios-sim' + working-directory: Ratspeak + run: cargo clippy --manifest-path src-tauri/Cargo.toml --target ${{ matrix.target }} --all-targets --all-features --locked -- -D warnings diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index f973f9e..0923575 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -15,6 +15,11 @@ on: description: "Release tag to publish when manually dispatching." required: false type: string + prerelease: + description: "Mark the GitHub Release as a prerelease." + required: true + default: true + type: boolean upload_play: description: "Upload the signed AAB to Google Play." required: true @@ -24,16 +29,21 @@ on: permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: - RATSPEAK_RSRETICULUM_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXMF_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXST_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_LRGP_REF: ratspeak-v1.1.0-rc.1 + RATSPEAK_RSRETICULUM_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXMF_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXST_REF: ratspeak-v1.0.26 + RATSPEAK_LRGP_REF: ratspeak-v1.0.26 jobs: build: name: Android runs-on: ubuntu-22.04 + timeout-minutes: 150 steps: - name: Checkout Ratspeak uses: actions/checkout@v5 @@ -101,6 +111,7 @@ jobs: workspaces: | Ratspeak Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" --locked @@ -129,6 +140,11 @@ jobs: unset TAURI_DEV_HOST cargo tauri android build --target aarch64 armv7 x86_64 --apk --split-per-abi + - name: Lint Android release variant + shell: bash + working-directory: Ratspeak/src-tauri/gen/android + run: ./gradlew :app:lintArm64Release --warning-mode all + - name: Build signed Android AAB if: ${{ github.event_name == 'workflow_dispatch' && inputs.upload_play }} shell: bash @@ -181,7 +197,8 @@ jobs: find dist/android -type f -name '*.apk' -print0 | sort -z | while IFS= read -r -d '' artifact; do - sha256sum "$artifact" >> "$checksum_file" + hash="$(sha256sum "$artifact" | cut -d ' ' -f 1)" + printf '%s %s\n' "$hash" "$(basename "$artifact")" >> "$checksum_file" done test -s "$checksum_file" @@ -194,17 +211,20 @@ jobs: Ratspeak/dist/android/*.apk Ratspeak/checksums-android.txt if-no-files-found: error - retention-days: 30 + retention-days: 7 - name: Publish GitHub Release if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_github_release) }} uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - prerelease: ${{ contains(github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name, '-') }} + prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Fix direct delivery for peers over Bluetooth - - General fixes and improvements + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars + - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling + - Opt-in hub hosting with graphical room, access, moderation, and discovery controls + - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists + - Privacy-bounded Activity diagnostics plus broader Reticulum, LXMF, and LXST reliability fixes files: | Ratspeak/dist/android/*.apk Ratspeak/checksums-android.txt diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5ddff2c..8b0fd73 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -15,20 +15,30 @@ on: description: "Release tag to publish when manually dispatching." required: false type: string + prerelease: + description: "Mark the GitHub Release as a prerelease." + required: true + default: true + type: boolean permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: - RATSPEAK_RSRETICULUM_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXMF_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXST_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_LRGP_REF: ratspeak-v1.1.0-rc.1 + RATSPEAK_RSRETICULUM_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXMF_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXST_REF: ratspeak-v1.0.26 + RATSPEAK_LRGP_REF: ratspeak-v1.0.26 jobs: build: name: Linux ${{ matrix.arch }} runs-on: ${{ matrix.runner }} + timeout-minutes: 150 strategy: fail-fast: false matrix: @@ -93,6 +103,7 @@ jobs: workspaces: | Ratspeak Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install Linux system deps shell: bash @@ -193,12 +204,11 @@ jobs: fi if [ -n "$RPM_NAME" ]; then - mapfile -t rpms < <(find src-tauri/target/release/bundle/rpm -maxdepth 1 -type f -name '*.rpm' | sort || true) + mapfile -t rpms < <(find src-tauri/target/release/bundle/rpm -maxdepth 1 -type f -name '*.rpm' | sort) rpm="${rpms[0]:-}" - if [ -n "$rpm" ]; then - rpm_artifact="Ratspeak-${release_version}-${RPM_NAME}" - cp "$rpm" "$out_dir/$rpm_artifact" - fi + test -n "$rpm" + rpm_artifact="Ratspeak-${release_version}-${RPM_NAME}" + cp "$rpm" "$out_dir/$rpm_artifact" fi - name: Upload artifacts @@ -208,12 +218,13 @@ jobs: path: | Ratspeak/dist/linux/* if-no-files-found: error - retention-days: 30 + retention-days: 1 package: name: Linux package needs: build runs-on: ubuntu-22.04 + timeout-minutes: 20 steps: - name: Download Linux artifacts uses: actions/download-artifact@v7 @@ -245,12 +256,18 @@ jobs: amd64_deb="Ratspeak/dist/linux/Ratspeak-${release_version}-linux-amd64.deb" arm64_deb="Ratspeak/dist/linux/Ratspeak-${release_version}-linux-arm64.deb" + appimage="Ratspeak/dist/linux/Ratspeak-${release_version}-linux-x86_64.AppImage" + rpm="Ratspeak/dist/linux/Ratspeak-${release_version}-linux-x86_64.rpm" test -f "$amd64_deb" test -f "$arm64_deb" + test -f "$appimage" + test -f "$rpm" amd64_arch="$(dpkg-deb --field "$amd64_deb" Architecture)" arm64_arch="$(dpkg-deb --field "$arm64_deb" Architecture)" test "$amd64_arch" = "amd64" test "$arm64_arch" = "arm64" + artifact_count="$(find Ratspeak/dist/linux -maxdepth 1 -type f | wc -l | tr -d ' ')" + test "$artifact_count" = "4" find Ratspeak/dist/linux -maxdepth 1 -type f -print | sort - name: Write Linux checksums @@ -263,7 +280,8 @@ jobs: find dist/linux -type f -print0 | sort -z | while IFS= read -r -d '' artifact; do - sha256sum "$artifact" >> "$checksum_file" + hash="$(sha256sum "$artifact" | cut -d ' ' -f 1)" + printf '%s %s\n' "$hash" "$(basename "$artifact")" >> "$checksum_file" done test -s "$checksum_file" @@ -275,17 +293,20 @@ jobs: Ratspeak/dist/linux/* Ratspeak/checksums-linux.txt if-no-files-found: error - retention-days: 30 + retention-days: 7 - name: Publish GitHub Release if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_github_release) }} uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - prerelease: ${{ contains(github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name, '-') }} + prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Fix direct delivery for peers over Bluetooth - - General fixes and improvements + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars + - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling + - Opt-in hub hosting with graphical room, access, moderation, and discovery controls + - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists + - Privacy-bounded Activity diagnostics plus broader Reticulum, LXMF, and LXST reliability fixes files: | Ratspeak/dist/linux/* Ratspeak/checksums-linux.txt diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index 62cd7d9..56e9ad8 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -15,16 +15,22 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: - RATSPEAK_RSRETICULUM_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXMF_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXST_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_LRGP_REF: ratspeak-v1.1.0-rc.1 + RATSPEAK_RSRETICULUM_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXMF_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXST_REF: ratspeak-v1.0.26 + RATSPEAK_LRGP_REF: ratspeak-v1.0.26 + TOOLCHAINS: com.apple.dt.toolchain.XcodeDefault jobs: build: name: iOS runs-on: macos-latest + timeout-minutes: 150 steps: - name: Checkout Ratspeak uses: actions/checkout@v5 @@ -71,6 +77,7 @@ jobs: workspaces: | Ratspeak Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" --locked @@ -148,7 +155,7 @@ jobs: Ratspeak/src-tauri/gen/apple/build/**/*.ipa Ratspeak/src-tauri/gen/apple/build/**/*.app if-no-files-found: error - retention-days: 30 + retention-days: 7 - name: Upload to TestFlight if: ${{ inputs.build_mode == 'testflight' }} diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 42cc470..17a93e9 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -15,6 +15,11 @@ on: description: "Release tag to publish when manually dispatching." required: false type: string + prerelease: + description: "Mark the GitHub Release as a prerelease." + required: true + default: true + type: boolean notarize: description: "Sign and notarize with Developer ID credentials" required: true @@ -24,16 +29,21 @@ on: permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: - RATSPEAK_RSRETICULUM_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXMF_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXST_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_LRGP_REF: ratspeak-v1.1.0-rc.1 + RATSPEAK_RSRETICULUM_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXMF_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXST_REF: ratspeak-v1.0.26 + RATSPEAK_LRGP_REF: ratspeak-v1.0.26 jobs: build: name: macOS runs-on: macos-latest + timeout-minutes: 150 steps: - name: Checkout Ratspeak uses: actions/checkout@v5 @@ -80,6 +90,7 @@ jobs: workspaces: | Ratspeak Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" --locked @@ -236,7 +247,8 @@ jobs: find dist/macos -type f -print0 | sort -z | while IFS= read -r -d '' artifact; do - shasum -a 256 "$artifact" >> "$checksum_file" + hash="$(shasum -a 256 "$artifact" | cut -d ' ' -f 1)" + printf '%s %s\n' "$hash" "$(basename "$artifact")" >> "$checksum_file" done test -s "$checksum_file" @@ -248,17 +260,20 @@ jobs: Ratspeak/dist/macos/* Ratspeak/checksums-macos.txt if-no-files-found: error - retention-days: 30 + retention-days: 7 - name: Publish GitHub Release if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_github_release) }} uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - prerelease: ${{ contains(github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name, '-') }} + prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Fix direct delivery for peers over Bluetooth - - General fixes and improvements + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars + - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling + - Opt-in hub hosting with graphical room, access, moderation, and discovery controls + - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists + - Privacy-bounded Activity diagnostics plus broader Reticulum, LXMF, and LXST reliability fixes files: | Ratspeak/dist/macos/* Ratspeak/checksums-macos.txt diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index f05e13c..8b94348 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -15,20 +15,30 @@ on: description: "Release tag to publish when manually dispatching." required: false type: string + prerelease: + description: "Mark the GitHub Release as a prerelease." + required: true + default: true + type: boolean permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + env: - RATSPEAK_RSRETICULUM_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXMF_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_RSLXST_REF: ratspeak-v1.1.0-rc.1 - RATSPEAK_LRGP_REF: ratspeak-v1.1.0-rc.1 + RATSPEAK_RSRETICULUM_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXMF_REF: ratspeak-v1.0.26 + RATSPEAK_RSLXST_REF: ratspeak-v1.0.26 + RATSPEAK_LRGP_REF: ratspeak-v1.0.26 jobs: build: name: Windows runs-on: windows-latest + timeout-minutes: 150 steps: - name: Checkout Ratspeak uses: actions/checkout@v5 @@ -73,6 +83,7 @@ jobs: workspaces: | Ratspeak Ratspeak/src-tauri + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install Tauri CLI run: cargo install tauri-cli --version "^2.0" --locked @@ -161,15 +172,20 @@ jobs: Sort-Object Name | ForEach-Object { $hash = (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLowerInvariant() - $path = $_.FullName.Replace((Get-Location).Path + [System.IO.Path]::DirectorySeparatorChar, "").Replace("\", "/") - "$hash $path" + "$hash $($_.Name)" } if (-not $lines) { throw "No Windows artifacts found for checksums." } - $lines | Set-Content -Encoding ascii $checksumFile + $checksumPath = Join-Path (Get-Location) $checksumFile + $contents = ($lines -join "`n") + "`n" + [System.IO.File]::WriteAllText( + $checksumPath, + $contents, + [System.Text.Encoding]::ASCII + ) - name: Upload artifacts uses: actions/upload-artifact@v7 @@ -179,17 +195,20 @@ jobs: Ratspeak/dist/windows/* Ratspeak/checksums-windows.txt if-no-files-found: error - retention-days: 30 + retention-days: 7 - name: Publish GitHub Release if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_github_release) }} uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - prerelease: ${{ contains(github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name, '-') }} + prerelease: ${{ github.event_name == 'push' || inputs.prerelease }} body: | - - Fix direct delivery for peers over Bluetooth - - General fixes and improvements + - Public Channels beta: connect, discover, share, and join live RRC hubs with local history, unread counts, mentions, presence, and identity avatars + - Bidirectional RRC messages and actions with authenticated reconnect/rejoin and safer malformed-peer handling + - Opt-in hub hosting with graphical room, access, moderation, and discovery controls + - Mobile Channels polish for keyboard resizing, safe areas, Back navigation, sheets, and partial member lists + - Privacy-bounded Activity diagnostics plus broader Reticulum, LXMF, and LXST reliability fixes files: | Ratspeak/dist/windows/* Ratspeak/checksums-windows.txt diff --git a/.gitignore b/.gitignore index d19e7cb..d77f13f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ src-tauri/target/ # Maintainer-only scripts are local by default. CI still needs release helpers. /scripts/* +!/scripts/ci/ +!/scripts/ci/check-workflow-dependency-pins.sh !/scripts/release/ !/scripts/release/assert-no-tauri-dev-url.sh !/scripts/release/setup-android-signing.sh diff --git a/Cargo.lock b/Cargo.lock index 11d6db9..6f30a0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,9 +33,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -48,9 +48,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -62,7 +62,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -79,18 +79,18 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" @@ -106,9 +106,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -203,7 +203,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -232,13 +232,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -272,9 +272,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -300,7 +300,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -308,8 +308,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn 2.0.117", + "shlex 1.3.0", + "syn 2.0.119", ] [[package]] @@ -340,9 +340,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "hex-conservative", ] @@ -355,9 +355,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -455,7 +455,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bluez-generated", "dbus", "dbus-tokio", @@ -464,7 +464,7 @@ dependencies = [ "log", "serde", "serde-xml-rs", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "uuid", ] @@ -480,9 +480,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -491,9 +491,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -506,9 +506,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9a11621cb2c8c024e444734292482b1ad86fb50ded066cf46252e46643c8748" dependencies = [ "async-trait", - "bitflags 2.11.0", + "bitflags 2.13.1", "bluez-async", - "dashmap 6.1.0", + "dashmap 6.2.1", "dbus", "futures", "jni 0.19.0", @@ -519,7 +519,7 @@ dependencies = [ "objc2-foundation 0.2.2", "once_cell", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "uuid", @@ -529,15 +529,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -547,9 +547,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -569,7 +569,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -590,9 +590,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -617,7 +617,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -641,14 +641,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -695,26 +695,26 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -722,6 +722,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -734,9 +761,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -768,12 +795,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.1" @@ -806,7 +827,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation", "core-graphics-types", "foreign-types", @@ -819,7 +840,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "core-foundation", "libc", ] @@ -837,9 +858,9 @@ dependencies = [ [[package]] name = "coreaudio-sys" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" dependencies = [ "bindgen", ] @@ -911,18 +932,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -940,23 +961,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -966,7 +970,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -977,7 +981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1020,7 +1024,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1041,7 +1045,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -1057,12 +1061,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1076,20 +1080,21 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ + "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1100,18 +1105,18 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1129,9 +1134,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1149,15 +1154,15 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "dbus" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "futures-channel", "futures-util", "libc", "libdbus-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1201,7 +1206,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1214,19 +1219,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1245,7 +1237,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1296,7 +1288,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1304,13 +1296,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1333,7 +1325,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1343,12 +1335,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser 0.36.0", - "foldhash 0.2.0", - "html5ever 0.38.0", + "cssparser", + "foldhash", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -1429,20 +1421,20 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg", ] @@ -1477,7 +1469,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1509,11 +1501,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1542,9 +1533,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1599,12 +1590,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1623,13 +1608,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1647,21 +1632,11 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1674,9 +1649,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1684,15 +1659,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1701,9 +1676,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1720,32 +1695,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1758,15 +1733,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1876,17 +1842,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1895,7 +1850,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1912,16 +1867,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", ] [[package]] @@ -1962,7 +1915,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -1990,7 +1943,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2005,9 +1958,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gobject-sys" @@ -2069,7 +2022,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2100,18 +2053,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -2173,18 +2117,6 @@ dependencies = [ "digest", ] -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -2192,14 +2124,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2207,9 +2139,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2217,9 +2149,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2236,9 +2168,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2271,7 +2203,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -2308,18 +2240,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2327,9 +2258,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -2340,9 +2271,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2354,15 +2285,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -2374,15 +2305,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", @@ -2393,12 +2324,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2449,12 +2374,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2490,19 +2415,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "itertools" @@ -2606,7 +2521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2626,23 +2541,22 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2674,23 +2588,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.13.1", - "selectors 0.24.0", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2700,12 +2602,6 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -2738,9 +2634,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -2779,9 +2675,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "libc", ] @@ -2840,34 +2736,35 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lrgp" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cozy-chess", "hex", - "rand 0.8.5", + "rand 0.8.7", "rmp-serde", "rmpv", "rusqlite", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", ] [[package]] name = "lxmf-core" -version = "1.0.1" +version = "1.1.0" dependencies = [ "base64 0.22.1", "bytes", "hex", - "rand 0.8.5", + "rand 0.8.7", + "rmp", "rmp-serde", "rmpv", "rns-crypto", @@ -2878,37 +2775,38 @@ dependencies = [ "rns-wire", "serde", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", + "unicode-normalization", ] [[package]] name = "lxst-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "half", "opus-rs", "rmpv", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "lxst-rns" -version = "0.1.1" +version = "0.1.2" dependencies = [ "bytes", "lxst-core", "rns-link", "rns-transport", "rns-wire", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] [[package]] name = "lxst-telephony" -version = "0.1.1" +version = "0.1.2" dependencies = [ "bytes", "lxst-core", @@ -2919,26 +2817,22 @@ dependencies = [ "rns-runtime", "rns-transport", "rns-wire", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac-notification-sys" -version = "0.6.12" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ "cc", + "log", "objc2 0.6.4", "objc2-foundation 0.3.2", "time", + "uuid", ] [[package]] @@ -2956,20 +2850,6 @@ dependencies = [ "libc", ] -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -2977,27 +2857,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] [[package]] -name = "match_token" -version = "0.1.0" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "md-5" version = "0.10.6" @@ -3010,9 +2882,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -3057,20 +2929,20 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "muda" -version = "0.17.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -3081,10 +2953,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.18.1", "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "thiserror 2.0.19", + "windows-sys 0.61.2", ] [[package]] @@ -3093,7 +2965,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -3107,7 +2979,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", @@ -3163,18 +3035,12 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -3187,9 +3053,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.16.1" +version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ "futures-lite", "log", @@ -3199,6 +3065,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -3210,16 +3085,16 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.7", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" @@ -3229,7 +3104,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3243,11 +3118,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -3281,7 +3155,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3316,31 +3190,52 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-bluetooth" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a644b62ffb826a5277f536cf0f701493de420b13d40e700c452c36567771111" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -3351,13 +3246,45 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3379,7 +3306,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3391,7 +3318,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3404,7 +3331,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3415,7 +3342,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3427,9 +3354,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", + "block2 0.6.2", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -3439,7 +3385,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit", @@ -3567,7 +3513,7 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "pcsc-sys", ] @@ -3595,105 +3541,25 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_generator", + "phf_shared", ] [[package]] @@ -3703,34 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -3739,38 +3578,11 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", + "syn 2.0.119", ] [[package]] @@ -3779,27 +3591,27 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3842,9 +3654,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" @@ -3853,8 +3665,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.13.1", - "quick-xml 0.38.4", + "indexmap 2.14.0", + "quick-xml", "serde", "time", ] @@ -3872,6 +3684,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3916,16 +3741,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3952,7 +3767,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -3979,30 +3794,15 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -4014,9 +3814,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4057,23 +3857,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.7.3" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4082,9 +3868,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4092,23 +3878,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.0", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -4131,15 +3907,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -4160,64 +3927,49 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "ratspeak-core" -version = "1.1.0" +version = "1.0.26" dependencies = [ "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", ] [[package]] name = "ratspeak-db" -version = "1.1.0" +version = "1.0.26" dependencies = [ "hex", "lrgp", "r2d2", "r2d2_sqlite", "ratspeak-core", + "rmpv", "rusqlite", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] [[package]] name = "ratspeak-runtime" -version = "1.1.0" +version = "1.0.26" dependencies = [ "argon2", "bytes", + "ciborium", "cpal", + "crossbeam-channel", "hex", - "indexmap 2.13.1", + "indexmap 2.14.0", "jni 0.19.0", "lrgp", "lxmf-core", @@ -4243,7 +3995,7 @@ dependencies = [ "serde", "serde_json", "serialport", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "uuid", @@ -4252,12 +4004,13 @@ dependencies = [ [[package]] name = "ratspeak-tauri" -version = "1.1.0" +version = "1.0.26" dependencies = [ "base64 0.22.1", "bytes", "glob", "hex", + "libc", "lrgp", "lxmf-core", "mime_guess", @@ -4283,6 +4036,8 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-subscriber", + "url", ] [[package]] @@ -4297,7 +4052,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", ] [[package]] @@ -4308,34 +4063,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4345,9 +4100,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4356,15 +4111,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -4426,41 +4181,43 @@ dependencies = [ [[package]] name = "rns-crypto" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aes", "cbc", "ed25519-dalek", "hkdf", "hmac", - "rand 0.8.5", + "rand 0.8.7", "rand_core 0.6.4", "sha2", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "x25519-dalek", "zeroize", ] [[package]] name = "rns-identity" -version = "1.0.1" +version = "1.1.0" dependencies = [ "hex", - "rand 0.8.5", + "rand 0.8.7", "rmp-serde", "rmpv", "rns-crypto", "rns-wire", "serde", - "thiserror 2.0.18", + "serde_bytes", + "thiserror 2.0.19", "tracing", + "windows-sys 0.61.2", "zeroize", ] [[package]] name = "rns-interface" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bluer", "btleplug", @@ -4470,18 +4227,20 @@ dependencies = [ "if-addrs", "jni 0.19.0", "libc", + "md-5", "objc2 0.5.2", "objc2-core-bluetooth", "objc2-foundation 0.2.2", - "rand 0.8.5", + "rand 0.8.7", "rns-crypto", + "rns-identity", "rns-transport", "rns-wire", "serde", "serde_json", "serialport", "socket2 0.5.10", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "uuid", @@ -4490,22 +4249,22 @@ dependencies = [ [[package]] name = "rns-link" -version = "1.0.1" +version = "1.1.0" dependencies = [ "hex", - "rand 0.8.5", + "rand 0.8.7", "rmpv", "rns-crypto", "rns-identity", "rns-wire", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "zeroize", ] [[package]] name = "rns-protocol" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bzip2", "hex", @@ -4514,14 +4273,14 @@ dependencies = [ "rns-crypto", "rns-link", "rns-wire", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] [[package]] name = "rns-ratkey" -version = "1.0.1" +version = "1.1.0" dependencies = [ "aes", "bip39", @@ -4536,7 +4295,7 @@ dependencies = [ "rsa", "serde", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", "toml 0.8.2", "tracing", "x509-cert", @@ -4545,7 +4304,7 @@ dependencies = [ [[package]] name = "rns-runtime" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bytes", "hex", @@ -4564,18 +4323,18 @@ dependencies = [ "serde", "sha2", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] [[package]] name = "rns-transport" -version = "1.0.1" +version = "1.1.0" dependencies = [ "bytes", "hex", - "rand 0.8.5", + "rand 0.8.7", "rmp-serde", "rmpv", "rns-crypto", @@ -4583,18 +4342,18 @@ dependencies = [ "rns-wire", "serde", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] [[package]] name = "rns-wire" -version = "1.0.1" +version = "1.1.0" dependencies = [ "rns-crypto", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -4623,7 +4382,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4633,9 +4392,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4652,7 +4411,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -4661,9 +4420,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -4712,9 +4471,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -4731,7 +4490,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4740,48 +4499,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.0", - "cssparser 0.36.0", - "derive_more 2.1.1", + "bitflags 2.13.1", + "cssparser", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -4789,9 +4530,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4817,7 +4558,7 @@ checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d" dependencies = [ "log", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "xml", ] @@ -4833,22 +4574,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -4859,14 +4600,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -4877,13 +4618,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -4906,17 +4647,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.1", + "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -4925,14 +4666,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ - "darling 0.23.0", + "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4954,7 +4695,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4963,7 +4704,7 @@ version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "cfg-if", "core-foundation", "core-foundation-sys", @@ -4976,16 +4717,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -5006,12 +4737,27 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5034,21 +4780,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -5058,9 +4798,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -5074,9 +4814,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5132,9 +4872,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spki" @@ -5158,19 +4898,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -5179,30 +4906,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -5232,7 +4947,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5257,6 +4972,16 @@ name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -5265,9 +4990,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -5291,7 +5016,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5309,15 +5034,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.8" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "block2 0.6.2", "core-foundation", "core-graphics", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -5328,13 +5054,14 @@ dependencies = [ "libc", "log", "ndk 0.9.0", - "ndk-context", "ndk-sys 0.6.0+11769913", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation 0.3.2", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", @@ -5347,13 +5074,13 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5364,9 +5091,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -5403,7 +5130,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tray-icon", "url", @@ -5415,9 +5142,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -5431,31 +5158,30 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "url", "uuid", @@ -5464,23 +5190,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.6.0" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -5500,22 +5226,22 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ "log", "notify-rust", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "url", ] [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -5529,7 +5255,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", "url", "webkit2gtk", "webview2-com", @@ -5538,9 +5264,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -5564,9 +5290,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.0" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -5575,14 +5301,12 @@ dependencies = [ "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", "plist", "proc-macro2", "quote", @@ -5594,8 +5318,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -5604,23 +5328,22 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] name = "tauri-winrt-notification" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.18", + "thiserror 2.0.19", "windows 0.61.3", "windows-version", ] @@ -5632,7 +5355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -5640,23 +5363,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "tendril" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", ] [[package]] @@ -5670,11 +5381,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -5685,25 +5396,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -5716,15 +5436,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -5742,9 +5462,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -5773,14 +5493,14 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "tokio" -version = "1.51.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -5788,27 +5508,27 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -5818,9 +5538,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -5847,7 +5567,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5856,6 +5576,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -5889,7 +5624,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5900,7 +5635,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -5909,30 +5644,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -5951,20 +5686,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5998,7 +5733,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6008,13 +5743,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -6026,10 +5791,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.18.1", "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", + "thiserror 2.0.19", + "windows-sys 0.61.2", ] [[package]] @@ -6046,9 +5811,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -6063,11 +5828,11 @@ dependencies = [ [[package]] name = "unescaper" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -6134,15 +5899,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "url" @@ -6169,12 +5928,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -6183,17 +5936,23 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", - "rand 0.10.0", + "rand 0.10.2", "serde_core", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -6251,12 +6010,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6265,27 +6018,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -6296,9 +6040,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6306,9 +6050,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6316,48 +6060,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.1", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -6371,23 +6093,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.1", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6395,14 +6105,14 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -6471,7 +6181,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6480,7 +6190,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", "windows 0.61.3", "windows-core 0.61.2", ] @@ -6641,7 +6351,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6652,7 +6362,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6663,7 +6373,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6674,7 +6384,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6790,15 +6500,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6832,30 +6533,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -6886,12 +6570,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6904,12 +6582,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6922,24 +6594,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6952,12 +6612,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6970,12 +6624,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6988,12 +6636,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -7006,12 +6648,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -7026,12 +6662,15 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -7048,91 +6687,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.1", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.1", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.1", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -7142,9 +6699,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -7173,7 +6730,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "url", "webkit2gtk", "webkit2gtk-sys", @@ -7231,15 +6788,15 @@ dependencies = [ [[package]] name = "xml" -version = "1.2.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7254,15 +6811,15 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zbus" -version = "5.15.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -7287,7 +6844,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.2", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -7295,14 +6852,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -7310,40 +6867,40 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow 1.0.2", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -7356,28 +6913,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7410,51 +6967,51 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.10.1" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.2", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.1" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 1.0.2", + "syn 2.0.119", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index 7455724..c8446a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -resolver = "2" +resolver = "3" exclude = ["src-tauri"] members = [ "crates/ratspeak-core", @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "1.1.0" +version = "1.0.26" edition = "2024" license = "AGPL-3.0-or-later" rust-version = "1.85" @@ -47,6 +47,7 @@ ratspeak-tauri = { path = "crates/ratspeak-tauri" } base64 = "0.22" # Serialization +ciborium = "0.2" rmpv = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index fa219c8..2d57f18 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ # Ratspeak Ratspeak is a native desktop and mobile client for E2EE conversations over -Reticulum, a new type of mesh networking. Ratspeak gives you messaging, file/image sharing, voice calls (experimental), LoRa capability, WiFi, BLE, TCP, offline messaging, turn-based games, and more. +Reticulum, a new type of mesh networking. Ratspeak gives you messaging, file/image sharing, voice calls and voice messages (experimental), Channels, LoRa capability, WiFi, BLE, TCP, offline messaging, turn-based games, and more. -[Docs](https://ratspeak.org/docs.html) | -[Build from source](https://ratspeak.org/docs.html#getting-started/building-from-source) | +[Docs](https://docs.ratspeak.org/) | +[Build from source](https://docs.ratspeak.org/docs/reference/building-from-source) | [rsReticulum](https://github.com/ratspeak/rsReticulum) | [rsLXMF](https://github.com/ratspeak/rsLXMF) | [rsLXST](https://github.com/ratspeak/rsLXST) @@ -16,11 +16,11 @@ Reticulum, a new type of mesh networking. Ratspeak gives you messaging, file/ima [](LICENSE) [](https://www.rust-lang.org) -[](#feature-status) +[](#current-state) -###### *Note: Ratspeak is currently in ALPHA. If you are looking for a more stable experience, I recommend waiting until v1.1.0 is released.* +###### *Note: Ratspeak is currently in ALPHA. If you are looking for a more stable experience, waiting for a later stable release is recommended.* @@ -30,7 +30,7 @@ Ratspeak is for private messaging when the normal internet is unavailable, untrusted, or not the path you want to depend on. When your cell tower is down, when natural disaster hits, or when you just want an alternative. When you know the current system is broken. It runs on -[Reticulum](https://github.com/ratspeak/rsReticulum) and [LXMF](https://https://github.com/ratspeak/rsLXMF), so conversations can happen +[Reticulum](https://github.com/ratspeak/rsReticulum) and [LXMF](https://github.com/ratspeak/rsLXMF), so conversations can happen over regular internet, LoRa radios, WiFi, Bluetooth, there is no limit - if it can move data it can be a part of the mesh. There is no Ratspeak account server, no central database, no hub where everything routes through by default. Your Reticulum identity is generated on @@ -38,24 +38,31 @@ your device and becomes your address on the mesh, no personal information needed ## Current State -Ratspeak is in experimental/alpha status. That means there are bugs, there are quirks, and things are not perfect. We stand by a strict contribute, don't complain policy. If something isn't working up to your standards, or at all, contribute by opening an issue and providing valuable feedback required to fix the issue. Code does not have emotion, so there's no reason a bug report should either. +Ratspeak is in experimental/alpha status. That means there are bugs, there are quirks, and things are not perfect. If something isn't working up to your standards, or at all, open an issue with the details needed to reproduce it. Useful, direct feedback helps us fix things faster. Supported app targets are macOS, Windows, Linux, Android, and iOS. Public desktop and Android packages will be linked from [ratspeak.org/download.html](https://ratspeak.org/download.html) as they are -released. iOS does not have a public download yet; and macOS is unsigned, with Window's .MSIX needing signing for BLE Peering support. These will come once LLC formation is complete and I have the patience to deal with Apple and signing-certificates. +released. iOS does not have a public download yet; macOS is unsigned, and the +Windows MSIX still needs signing for BLE Peering support. These distribution +lanes are in progress. ## What You Get - Account-free messaging over Reticulum. - Full offline messaging support. +- Shared Channels, including local history, member presence, and optional hub + hosting. - Local Network, TCP, RNode/LoRa support, Bluetooth Peering, and more. - Contacts, discovered peers, path requests, interface status, propagation status, and transport health in the app. +- Activity tools for understanding network and messaging events without + leaving the app. - Experimental peer-to-peer voice calls over [LXST](https://github.com/ratspeak/rsLXST) (contacts-only, 0-hop, native microphone/speaker). -- Chess and Tic-Tac-Toe. -- I'm tired boss, this whole README is going to get a revamp. +- Experimental voice messages with local recording and playback. +- Chess, Tic-Tac-Toe, and Four in a Row. +- Built-in light/dark modes, selectable color themes, and adjustable text size. ## Install @@ -64,14 +71,14 @@ Use the download page when public builds are available: For setup help, see: -- [Install and Platform Setup](https://ratspeak.org/docs.html#getting-started/install-and-platform-setup) -- [Your First Session](https://ratspeak.org/docs.html#getting-started/your-first-session) -- [Troubleshooting](https://ratspeak.org/docs.html#reference/troubleshooting) +- [Install and Platform Setup](https://docs.ratspeak.org/docs/getting-started/install-and-platform-setup) +- [Your First Session](https://docs.ratspeak.org/docs/getting-started/your-first-session) +- [Troubleshooting](https://docs.ratspeak.org/docs/reference/troubleshooting) ## Build From Source The full build guide is here: -[Building from Source](https://ratspeak.org/docs.html#getting-started/building-from-source). +[Building from Source](https://docs.ratspeak.org/docs/reference/building-from-source). It covers desktop prerequisites, Android APKs, iOS signing, and the required sibling checkout layout. diff --git a/VERSION b/VERSION index 9084fa2..4e3d43d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.0.26c diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4972822 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.85.0" diff --git a/crates/ratspeak-core/src/emitter.rs b/crates/ratspeak-core/src/emitter.rs index 7ea8909..9aebed4 100644 --- a/crates/ratspeak-core/src/emitter.rs +++ b/crates/ratspeak-core/src/emitter.rs @@ -4,13 +4,67 @@ use serde_json::Value; +/// Non-sensitive failure classification for an event-bus enqueue attempt. +/// +/// Variants deliberately carry no source error or payload context so callers +/// can report recorder health without retaining application data. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum EmitError { + #[error("event emit was rejected")] + Rejected, + #[error("event emitter is unavailable")] + Unavailable, +} + pub trait Emitter: Send + Sync { - fn emit(&self, event: &str, payload: Value); + /// Attempts to enqueue an event for broadcast. + fn try_emit(&self, event: &str, payload: Value) -> Result<(), EmitError>; + + /// Best-effort compatibility adapter for existing callers. + fn emit(&self, event: &str, payload: Value) { + let _ = self.try_emit(event, payload); + } } /// Drops every emit. Useful for headless tests where there's no IPC peer. pub struct NoopEmitter; impl Emitter for NoopEmitter { - fn emit(&self, _event: &str, _payload: Value) {} + fn try_emit(&self, _event: &str, _payload: Value) -> Result<(), EmitError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + struct RejectingEmitter { + attempts: AtomicUsize, + } + + impl Emitter for RejectingEmitter { + fn try_emit(&self, _event: &str, _payload: Value) -> Result<(), EmitError> { + self.attempts.fetch_add(1, Ordering::Relaxed); + Err(EmitError::Rejected) + } + } + + #[test] + fn noop_try_emit_succeeds() { + assert_eq!(NoopEmitter.try_emit("ignored", Value::Null), Ok(())); + } + + #[test] + fn best_effort_adapter_attempts_and_suppresses_failure() { + let emitter = RejectingEmitter { + attempts: AtomicUsize::new(0), + }; + + emitter.emit("test", Value::Null); + + assert_eq!(emitter.attempts.load(Ordering::Relaxed), 1); + } } diff --git a/crates/ratspeak-core/src/lib.rs b/crates/ratspeak-core/src/lib.rs index bce3b15..9ab6150 100644 --- a/crates/ratspeak-core/src/lib.rs +++ b/crates/ratspeak-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod notification; pub mod radio; pub mod types; -pub use emitter::{Emitter, NoopEmitter}; +pub use emitter::{EmitError, Emitter, NoopEmitter}; pub use errors::CoreError; pub use notification::{NativeNotification, NativeNotificationKind, NativeNotifier, NoopNotifier}; +pub use types::{LXMF_DELIVERY_APP_NAME, LXMF_PROPAGATION_APP_NAME, hex_to_array16}; diff --git a/crates/ratspeak-core/src/notification.rs b/crates/ratspeak-core/src/notification.rs index d243240..b8e8ce8 100644 --- a/crates/ratspeak-core/src/notification.rs +++ b/crates/ratspeak-core/src/notification.rs @@ -1,6 +1,7 @@ #[derive(Clone, Debug, PartialEq, Eq)] pub enum NativeNotificationKind { Message, + Channel, Game, Call, } @@ -45,6 +46,21 @@ impl NativeNotification { } } + pub fn channel( + title: impl Into, + body: impl Into, + thread_id: impl Into, + notification_id: i32, + ) -> Self { + Self { + kind: NativeNotificationKind::Channel, + title: title.into(), + body: body.into(), + thread_id: Some(thread_id.into()), + notification_id: Some(notification_id), + } + } + pub fn call( title: impl Into, body: impl Into, diff --git a/crates/ratspeak-core/src/types.rs b/crates/ratspeak-core/src/types.rs index bbcd46a..bfd651e 100644 --- a/crates/ratspeak-core/src/types.rs +++ b/crates/ratspeak-core/src/types.rs @@ -36,3 +36,50 @@ pub const MAX_DISCOVERED_PROPAGATION_NODES: usize = 512; /// 48h matches the RNS path-table expiry convention (`PATHFINDER_E`). pub const PROPAGATION_NODE_TTL_SECS: u64 = 48 * 3600; + +/// LXMF destination app-names (wire strings shared by runtime, db, and tauri). +pub const LXMF_DELIVERY_APP_NAME: &str = "lxmf.delivery"; +pub const LXMF_PROPAGATION_APP_NAME: &str = "lxmf.propagation"; + +/// Parse a 32-char hex string into 16 bytes. Byte-wise so malformed +/// (non-ASCII) input yields `None` instead of a slice panic. +pub fn hex_to_array16(s: &str) -> Option<[u8; 16]> { + fn nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } + } + let bytes = s.as_bytes(); + if bytes.len() != 32 { + return None; + } + let mut out = [0u8; 16]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = (nibble(bytes[i * 2])? << 4) | nibble(bytes[i * 2 + 1])?; + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::hex_to_array16; + + #[test] + fn hex_to_array16_parses_and_rejects() { + assert_eq!(hex_to_array16("00ff00FF00ff00ff00ff00ff00ff00Aa"), { + let mut v = [0x00, 0xff].repeat(8); + v[15] = 0xaa; + let mut a = [0u8; 16]; + a.copy_from_slice(&v); + Some(a) + }); + assert!(hex_to_array16("").is_none()); + assert!(hex_to_array16("00ff00ff00ff00ff00ff00ff00ff00f").is_none()); + assert!(hex_to_array16("zzff00ff00ff00ff00ff00ff00ff00ff").is_none()); + // 32 bytes of multibyte UTF-8: must be None, not a char-boundary panic. + assert!(hex_to_array16("αααααααααααααααα").is_none()); + } +} diff --git a/crates/ratspeak-db/Cargo.toml b/crates/ratspeak-db/Cargo.toml index 5e1a4b2..f379787 100644 --- a/crates/ratspeak-db/Cargo.toml +++ b/crates/ratspeak-db/Cargo.toml @@ -18,6 +18,7 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +rmpv = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } hex = { workspace = true } diff --git a/crates/ratspeak-db/src/db.rs b/crates/ratspeak-db/src/db.rs index 4c39bda..fb4319f 100644 --- a/crates/ratspeak-db/src/db.rs +++ b/crates/ratspeak-db/src/db.rs @@ -3,18 +3,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; -use rusqlite::{Connection, params}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use tokio::task::JoinError; pub type DbPool = Pool; -const SCHEMA_VERSION: i64 = 32; +const SCHEMA_VERSION: i64 = 42; -pub const PEER_SERVICE_LXMF_DELIVERY: &str = "lxmf.delivery"; +pub const PEER_SERVICE_LXMF_DELIVERY: &str = ratspeak_core::LXMF_DELIVERY_APP_NAME; pub const PEER_SERVICE_LXST_TELEPHONY: &str = "lxst.telephony"; pub const PEER_SERVICE_RATSPEAK_CLIENT: &str = "ratspeak.client"; pub const PEER_SERVICE_RATSPEAK_GAMES: &str = "ratspeak.games"; pub const PEER_SERVICE_RATSPEAK_CHAT: &str = "ratspeak.chat"; +pub const LXMF_COMPRESSION_SUPPORT_SUPPORTED: &str = "supported"; +pub const LXMF_COMPRESSION_SUPPORT_UNSUPPORTED: &str = "unsupported"; const IDENTITY_SELECT_COLUMNS: &str = "hash, lxmf_hash, @@ -48,26 +50,20 @@ fn now_ts() -> f64 { .as_secs_f64() } +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + pub fn init_pool(data_dir: &Path) -> Result> { let ratspeak_dir = data_dir.join(".ratspeak"); std::fs::create_dir_all(&ratspeak_dir)?; - // Legacy name migrations from earlier product names. let db_path = ratspeak_dir.join("ratspeak.db"); - for old_name in &["netresist.db", "meshglobe.db"] { - let old_path = ratspeak_dir.join(old_name); - if old_path.exists() && !db_path.exists() { - std::fs::rename(&old_path, &db_path)?; - } - } - - for old_dir in &[".netresist", ".meshglobe"] { - let old = data_dir.join(old_dir); - if old.is_dir() && !ratspeak_dir.exists() { - std::fs::rename(&old, &ratspeak_dir)?; - } - } - let manager = SqliteConnectionManager::file(&db_path).with_init(|conn| { conn.execute_batch( "PRAGMA journal_mode=WAL; @@ -78,7 +74,7 @@ pub fn init_pool(data_dir: &Path) -> Result Result<(), Box ''; + +-- Channels service-state persists user intent and connection conveniences. +-- desired_* is the durable scheduler input; actual Link and JOIN state remains +-- runtime-owned. A separate bounded append log stores accepted transcript +-- observations without routing them through the LXMF conversation store. +CREATE TABLE IF NOT EXISTS channel_hubs ( + identity_id TEXT NOT NULL, + destination_hash TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + added_at REAL NOT NULL, + last_connected REAL NOT NULL DEFAULT 0, + desired_connected INTEGER NOT NULL DEFAULT 0 + CHECK (desired_connected IN (0, 1)), + PRIMARY KEY (identity_id, destination_hash), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + desired_joined INTEGER NOT NULL DEFAULT 0 + CHECK (desired_joined IN (0, 1)), + join_key_required INTEGER NOT NULL DEFAULT 0 + CHECK (join_key_required IN (0, 1)), + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash) + REFERENCES channel_hubs(identity_id, destination_hash) ON DELETE CASCADE +); + +-- Recoverable client join keys are encrypted to the owning Reticulum identity +-- before reaching SQLite. Keep ciphertext separate from ordinary room metadata +-- so it can never leak through the bookmark API or its Debug/Serialize shapes. +CREATE TABLE IF NOT EXISTS channel_room_secrets ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + seal_scheme TEXT NOT NULL, + seal_version INTEGER NOT NULL CHECK (seal_version > 0), + ciphertext BLOB NOT NULL CHECK (length(ciphertext) > 0), + updated_at REAL NOT NULL, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash, room_name) + REFERENCES channel_rooms(identity_id, hub_destination_hash, room_name) + ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_channel_hubs_identity_recent + ON channel_hubs(identity_id, last_connected DESC); +-- The first scheduler deliberately budgets one live hub. Enforce that in the +-- durable layer so concurrent callers or a crash cannot create two winners. +CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_hubs_identity_desired + ON channel_hubs(identity_id) WHERE desired_connected = 1; +CREATE INDEX IF NOT EXISTS idx_channel_rooms_identity_hub + ON channel_rooms(identity_id, hub_destination_hash, room_name); + +-- Hub registry: the rooms this node hosts and the operator policy on them. +-- Durable policy only; relayed traffic never lands here. Join keys persist as +-- a verifiable digest, never as a recoverable key. +CREATE TABLE IF NOT EXISTS channel_hub_rooms ( + identity_id TEXT NOT NULL, + room_name TEXT NOT NULL, + topic TEXT NOT NULL DEFAULT '', + key_salt TEXT NOT NULL DEFAULT '', + key_mac TEXT NOT NULL DEFAULT '', + key_pepper_id TEXT NOT NULL DEFAULT '', + moderated INTEGER NOT NULL DEFAULT 0, + invite_only INTEGER NOT NULL DEFAULT 0, + topic_ops_only INTEGER NOT NULL DEFAULT 0, + no_outside_msgs INTEGER NOT NULL DEFAULT 0, + private INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + last_used REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, room_name), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); + +-- Per-room grants. kind is op|voice|ban|invite; expires_at is 0 for permanent +-- grants and an absolute unix time for invites. +CREATE TABLE IF NOT EXISTS channel_hub_grants ( + identity_id TEXT NOT NULL, + room_name TEXT NOT NULL, + kind TEXT NOT NULL, + subject TEXT NOT NULL, + granted_at REAL NOT NULL, + expires_at REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, room_name, kind, subject), + FOREIGN KEY (identity_id, room_name) + REFERENCES channel_hub_rooms(identity_id, room_name) ON DELETE CASCADE +); + +-- Hub-level identity bans (/kline). +CREATE TABLE IF NOT EXISTS channel_hub_klines ( + identity_id TEXT NOT NULL, + subject TEXT NOT NULL, + banned_at REAL NOT NULL, + PRIMARY KEY (identity_id, subject), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); CREATE INDEX IF NOT EXISTS idx_contacts_identity ON contacts(identity_id); CREATE INDEX IF NOT EXISTS idx_contacts_identity_name ON contacts(identity_id, display_name); @@ -343,6 +448,197 @@ CREATE INDEX IF NOT EXISTS idx_blocked_identity ON blocked_contacts(identity_id) CREATE INDEX IF NOT EXISTS idx_identities_active ON identities(is_active) WHERE is_active = 1; "#; +// Kept outside `SCHEMA_SQL` so migrations and fresh initialization execute the +// exact same DDL. History deliberately has no bookmark foreign key: forgetting +// a saved hub or room must not silently erase the user's local transcript. +const CHANNEL_HISTORY_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS channel_history ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL + CHECK (kind IN ('message', 'notice', 'action', 'join', 'part', 'error', 'system')), + timestamp_ms INTEGER NOT NULL CHECK (timestamp_ms >= 0), + recorded_at_ms INTEGER NOT NULL CHECK (recorded_at_ms >= 0), + source_hash TEXT, + nickname TEXT, + text TEXT NOT NULL, + ours INTEGER NOT NULL CHECK (ours IN (0, 1)), + mentioned INTEGER NOT NULL DEFAULT 0 CHECK (mentioned IN (0, 1)), + UNIQUE (identity_id, hub_destination_hash, room_name, event_id), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_channel_history_room_sequence + ON channel_history( + identity_id, hub_destination_hash, room_name, sequence DESC + ); +CREATE INDEX IF NOT EXISTS idx_channel_history_identity_sequence + ON channel_history(identity_id, sequence DESC); +CREATE INDEX IF NOT EXISTS idx_channel_history_identity_unread + ON channel_history(identity_id, ours, sequence); +CREATE INDEX IF NOT EXISTS idx_channel_history_recorded_at + ON channel_history(recorded_at_ms); +"#; + +// Read position, delivery policy, and the last authenticated room topic survive +// history retention and bookmark removal. The sequence is deliberately not a +// foreign key: history rows may be pruned while the monotonic cursor remains +// valid for later appends. +const CHANNEL_ROOM_STATE_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS channel_room_state ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + last_read_sequence INTEGER NOT NULL DEFAULT 0 CHECK (last_read_sequence >= 0), + notification_level TEXT NOT NULL DEFAULT 'mentions' + CHECK (notification_level IN ('all', 'mentions', 'mute')), + topic TEXT NOT NULL DEFAULT '' + CHECK (length(CAST(topic AS BLOB)) <= 512), + updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0), + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); +"#; + +// Identity-bearing roster observations are kept separately from transcript +// rows. A hub can supply a member in its initial roster without emitting an +// individual JOIN event; retaining that identity ensures a canonical avatar +// that was already shown cannot regress to an anonymous placeholder after an +// app restart. Explicit history clearing removes these rows too, while their +// age follows the configurable known-identity cache lifetime. +const CHANNEL_PARTICIPANT_OBSERVATION_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS channel_participant_observations ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + participant_identity_hash TEXT NOT NULL, + nickname TEXT, + last_observed_at_ms INTEGER NOT NULL CHECK (last_observed_at_ms >= 0), + PRIMARY KEY ( + identity_id, hub_destination_hash, room_name, participant_identity_hash + ), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_channel_participant_observations_room_recent + ON channel_participant_observations( + identity_id, hub_destination_hash, room_name, last_observed_at_ms DESC + ); +CREATE INDEX IF NOT EXISTS idx_channel_participant_observations_age + ON channel_participant_observations(last_observed_at_ms); +"#; + +// Estimated payload usage is materialized per room so the hot append path can +// enforce byte ceilings without summing thousands of transcript rows after +// every message. The estimate intentionally includes a fixed allowance for +// SQLite row/index metadata; it bounds retained content, while SQLite may keep +// freed pages at its high-water mark for later reuse. +const CHANNEL_HISTORY_USAGE_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS channel_history_room_usage ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + event_count INTEGER NOT NULL CHECK (event_count >= 0), + payload_bytes INTEGER NOT NULL CHECK (payload_bytes >= 0), + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE +); + +CREATE TRIGGER IF NOT EXISTS channel_history_usage_after_insert +AFTER INSERT ON channel_history +BEGIN + INSERT INTO channel_history_room_usage ( + identity_id, hub_destination_hash, room_name, event_count, payload_bytes + ) VALUES ( + NEW.identity_id, + NEW.hub_destination_hash, + NEW.room_name, + 1, + 128 + + length(CAST(NEW.identity_id AS BLOB)) + + length(CAST(NEW.hub_destination_hash AS BLOB)) + + length(CAST(NEW.room_name AS BLOB)) + + length(CAST(NEW.event_id AS BLOB)) + + length(CAST(NEW.kind AS BLOB)) + + length(CAST(COALESCE(NEW.source_hash, '') AS BLOB)) + + length(CAST(COALESCE(NEW.nickname, '') AS BLOB)) + + length(CAST(NEW.text AS BLOB)) + ) + ON CONFLICT (identity_id, hub_destination_hash, room_name) + DO UPDATE SET + event_count = event_count + 1, + payload_bytes = payload_bytes + excluded.payload_bytes; +END; + +CREATE TRIGGER IF NOT EXISTS channel_history_usage_after_delete +AFTER DELETE ON channel_history +BEGIN + UPDATE channel_history_room_usage + SET + event_count = event_count - 1, + payload_bytes = payload_bytes - ( + 128 + + length(CAST(OLD.identity_id AS BLOB)) + + length(CAST(OLD.hub_destination_hash AS BLOB)) + + length(CAST(OLD.room_name AS BLOB)) + + length(CAST(OLD.event_id AS BLOB)) + + length(CAST(OLD.kind AS BLOB)) + + length(CAST(COALESCE(OLD.source_hash, '') AS BLOB)) + + length(CAST(COALESCE(OLD.nickname, '') AS BLOB)) + + length(CAST(OLD.text AS BLOB)) + ) + WHERE identity_id = OLD.identity_id + AND hub_destination_hash = OLD.hub_destination_hash + AND room_name = OLD.room_name; + + DELETE FROM channel_history_room_usage + WHERE identity_id = OLD.identity_id + AND hub_destination_hash = OLD.hub_destination_hash + AND room_name = OLD.room_name + AND event_count = 0; +END; +"#; + +const CHANNEL_HISTORY_USAGE_REBUILD_SQL: &str = r#" +DELETE FROM channel_history_room_usage; +INSERT INTO channel_history_room_usage ( + identity_id, hub_destination_hash, room_name, event_count, payload_bytes +) +SELECT + identity_id, + hub_destination_hash, + room_name, + COUNT(*), + SUM( + 128 + + length(CAST(identity_id AS BLOB)) + + length(CAST(hub_destination_hash AS BLOB)) + + length(CAST(room_name AS BLOB)) + + length(CAST(event_id AS BLOB)) + + length(CAST(kind AS BLOB)) + + length(CAST(COALESCE(source_hash, '') AS BLOB)) + + length(CAST(COALESCE(nickname, '') AS BLOB)) + + length(CAST(text AS BLOB)) + ) +FROM channel_history +GROUP BY identity_id, hub_destination_hash, room_name; +"#; + +fn reconcile_channel_history_usage(conn: &Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch(CHANNEL_HISTORY_USAGE_SCHEMA_SQL)?; + // Some migration fixtures (and sufficiently old interrupted installs) + // reach this step before the base schema has recreated `identities`. + // Creating the FK table is valid, but touching it is not until the parent + // exists. `init_schema` reconciles again immediately after `SCHEMA_SQL`. + if table_exists(conn, "identities")? { + conn.execute_batch(CHANNEL_HISTORY_USAGE_REBUILD_SQL)?; + } + Ok(()) +} + /// Run one schema-version step inside an explicit transaction so a crash /// mid-step (especially multi-statement table rebuilds) rolls back atomically /// instead of leaving a half-migrated schema with the version un-bumped. @@ -355,10 +651,18 @@ fn migration_step( match apply(conn) { Ok(()) => conn.execute_batch("COMMIT"), Err(e) => { - if let Err(rollback_err) = conn.execute_batch("ROLLBACK") { - tracing::error!(to_version, error = %rollback_err, "migration rollback failed"); + if conn.execute_batch("ROLLBACK").is_err() { + tracing::error!( + to_version, + reason = "rollback_failed", + "migration rollback failed" + ); } - tracing::error!(to_version, error = %e, "migration step failed; rolled back"); + tracing::error!( + to_version, + reason = "migration_failed", + "migration step failed; rolled back" + ); Err(e) } } @@ -1247,6 +1551,253 @@ fn run_migrations(conn: &Connection, from_version: i64) -> Result<(), rusqlite:: })?; } + if from_version < 33 { + migration_step(conn, 33, |conn| { + if table_exists(conn, "identity_activity")? { + let cols = get_column_names(conn, "identity_activity").unwrap_or_default(); + if !cols.iter().any(|c| c == "lxmf_compression_support") { + conn.execute_batch( + "ALTER TABLE identity_activity + ADD COLUMN lxmf_compression_support TEXT NOT NULL DEFAULT '';", + )?; + } + } + conn.execute_batch("UPDATE schema_version SET version = 33;")?; + tracing::info!("Migrated to schema version 33 (LXMF peer compression capability)"); + Ok(()) + })?; + } + + if from_version < 34 { + migration_step(conn, 34, |conn| { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS channel_hubs ( + identity_id TEXT NOT NULL, + destination_hash TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + added_at REAL NOT NULL, + last_connected REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, destination_hash), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash) + REFERENCES channel_hubs(identity_id, destination_hash) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_channel_hubs_identity_recent + ON channel_hubs(identity_id, last_connected DESC); + CREATE INDEX IF NOT EXISTS idx_channel_rooms_identity_hub + ON channel_rooms(identity_id, hub_destination_hash, room_name); + UPDATE schema_version SET version = 34;", + )?; + tracing::info!("Migrated to schema version 34 (Channels bookmarks)"); + Ok(()) + })?; + } + + if from_version < 35 { + migration_step(conn, 35, |conn| { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS channel_hub_rooms ( + identity_id TEXT NOT NULL, + room_name TEXT NOT NULL, + topic TEXT NOT NULL DEFAULT '', + key_salt TEXT NOT NULL DEFAULT '', + key_mac TEXT NOT NULL DEFAULT '', + key_pepper_id TEXT NOT NULL DEFAULT '', + moderated INTEGER NOT NULL DEFAULT 0, + invite_only INTEGER NOT NULL DEFAULT 0, + topic_ops_only INTEGER NOT NULL DEFAULT 0, + no_outside_msgs INTEGER NOT NULL DEFAULT 0, + private INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + last_used REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, room_name), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS channel_hub_grants ( + identity_id TEXT NOT NULL, + room_name TEXT NOT NULL, + kind TEXT NOT NULL, + subject TEXT NOT NULL, + granted_at REAL NOT NULL, + expires_at REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, room_name, kind, subject), + FOREIGN KEY (identity_id, room_name) + REFERENCES channel_hub_rooms(identity_id, room_name) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS channel_hub_klines ( + identity_id TEXT NOT NULL, + subject TEXT NOT NULL, + banned_at REAL NOT NULL, + PRIMARY KEY (identity_id, subject), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + UPDATE schema_version SET version = 35;", + )?; + tracing::info!("Migrated to schema version 35 (RRC hub registry)"); + Ok(()) + })?; + } + + if from_version < 36 { + migration_step(conn, 36, |conn| { + if table_exists(conn, "channel_hubs")? { + let columns = get_column_names(conn, "channel_hubs").unwrap_or_default(); + if !columns.iter().any(|column| column == "desired_connected") { + conn.execute_batch( + "ALTER TABLE channel_hubs + ADD COLUMN desired_connected INTEGER NOT NULL DEFAULT 0 + CHECK (desired_connected IN (0, 1));", + )?; + } + conn.execute_batch( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_hubs_identity_desired + ON channel_hubs(identity_id) WHERE desired_connected = 1;", + )?; + } + if table_exists(conn, "channel_rooms")? { + let columns = get_column_names(conn, "channel_rooms").unwrap_or_default(); + if !columns.iter().any(|column| column == "desired_joined") { + conn.execute_batch( + "ALTER TABLE channel_rooms + ADD COLUMN desired_joined INTEGER NOT NULL DEFAULT 0 + CHECK (desired_joined IN (0, 1));", + )?; + } + } + conn.execute_batch("UPDATE schema_version SET version = 36;")?; + tracing::info!("Migrated to schema version 36 (Channels desired state)"); + Ok(()) + })?; + } + + if from_version < 37 { + migration_step(conn, 37, |conn| { + if table_exists(conn, "channel_rooms")? { + let columns = get_column_names(conn, "channel_rooms").unwrap_or_default(); + if !columns.iter().any(|column| column == "join_key_required") { + conn.execute_batch( + "ALTER TABLE channel_rooms + ADD COLUMN join_key_required INTEGER NOT NULL DEFAULT 0 + CHECK (join_key_required IN (0, 1));", + )?; + } + } + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS channel_room_secrets ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + seal_scheme TEXT NOT NULL, + seal_version INTEGER NOT NULL CHECK (seal_version > 0), + ciphertext BLOB NOT NULL CHECK (length(ciphertext) > 0), + updated_at REAL NOT NULL, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash, room_name) + REFERENCES channel_rooms( + identity_id, hub_destination_hash, room_name + ) ON DELETE CASCADE + ); + UPDATE schema_version SET version = 37;", + )?; + tracing::info!("Migrated to schema version 37 (sealed Channels join keys)"); + Ok(()) + })?; + } + + if from_version < 38 { + migration_step(conn, 38, |conn| { + conn.execute_batch(CHANNEL_HISTORY_SCHEMA_SQL)?; + conn.execute_batch("UPDATE schema_version SET version = 38;")?; + tracing::info!("Migrated to schema version 38 (bounded Channels history)"); + Ok(()) + })?; + } + + if from_version < 39 { + migration_step(conn, 39, |conn| { + reconcile_channel_history_usage(conn)?; + conn.execute_batch("UPDATE schema_version SET version = 39;")?; + tracing::info!("Migrated to schema version 39 (Channels history payload budgets)"); + Ok(()) + })?; + } + + if from_version < 40 { + migration_step(conn, 40, |conn| { + if table_exists(conn, "channel_history")? { + let columns = get_column_names(conn, "channel_history").unwrap_or_default(); + if !columns.iter().any(|column| column == "mentioned") { + conn.execute_batch( + "ALTER TABLE channel_history + ADD COLUMN mentioned INTEGER NOT NULL DEFAULT 0 + CHECK (mentioned IN (0, 1));", + )?; + } + } + conn.execute_batch(CHANNEL_ROOM_STATE_SCHEMA_SQL)?; + if table_exists(conn, "channel_history")? && table_exists(conn, "identities")? { + // Existing transcripts predate durable read tracking. Treat + // their current tail as read so an upgrade cannot generate a + // retroactive wall of unread rooms or mention alerts. + conn.execute_batch( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) + SELECT + identity_id, hub_destination_hash, room_name, + MAX(sequence), 'mentions', MAX(recorded_at_ms) + FROM channel_history + GROUP BY identity_id, hub_destination_hash, room_name + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO NOTHING;", + )?; + } + conn.execute_batch("UPDATE schema_version SET version = 40;")?; + tracing::info!( + "Migrated to schema version 40 (durable Channels read and mention state)" + ); + Ok(()) + })?; + } + + if from_version < 41 { + migration_step(conn, 41, |conn| { + conn.execute_batch(CHANNEL_PARTICIPANT_OBSERVATION_SCHEMA_SQL)?; + conn.execute_batch("UPDATE schema_version SET version = 41;")?; + tracing::info!( + "Migrated to schema version 41 (durable Channels participant identities)" + ); + Ok(()) + })?; + } + + if from_version < 42 { + migration_step(conn, 42, |conn| { + let columns = get_column_names(conn, "channel_room_state")?; + if !columns.iter().any(|column| column == "topic") { + conn.execute_batch( + "ALTER TABLE channel_room_state + ADD COLUMN topic TEXT NOT NULL DEFAULT '' + CHECK (length(CAST(topic AS BLOB)) <= 512);", + )?; + } + conn.execute_batch("UPDATE schema_version SET version = 42;")?; + tracing::info!("Migrated to schema version 42 (durable Channels room topics)"); + Ok(()) + })?; + } + Ok(()) } @@ -1434,6 +1985,16 @@ pub const RESET_TABLES: &[&str] = &[ "blocked_contacts", "identity_activity", "pending_blackholes", + "channel_history", + "channel_history_room_usage", + "channel_room_state", + "channel_participant_observations", + "channel_room_secrets", + "channel_rooms", + "channel_hubs", + "channel_hub_grants", + "channel_hub_rooms", + "channel_hub_klines", ]; /// Per-identity cascade for `delete_identity`. Static DELETEs (no format!() @@ -1462,6 +2023,46 @@ const IDENTITY_CASCADE: &[(&str, &str)] = &[ "pending_blackholes", "DELETE FROM pending_blackholes WHERE identity_id = ?1", ), + ( + "channel_history", + "DELETE FROM channel_history WHERE identity_id = ?1", + ), + ( + "channel_history_room_usage", + "DELETE FROM channel_history_room_usage WHERE identity_id = ?1", + ), + ( + "channel_room_state", + "DELETE FROM channel_room_state WHERE identity_id = ?1", + ), + ( + "channel_participant_observations", + "DELETE FROM channel_participant_observations WHERE identity_id = ?1", + ), + ( + "channel_room_secrets", + "DELETE FROM channel_room_secrets WHERE identity_id = ?1", + ), + ( + "channel_rooms", + "DELETE FROM channel_rooms WHERE identity_id = ?1", + ), + ( + "channel_hubs", + "DELETE FROM channel_hubs WHERE identity_id = ?1", + ), + ( + "channel_hub_grants", + "DELETE FROM channel_hub_grants WHERE identity_id = ?1", + ), + ( + "channel_hub_rooms", + "DELETE FROM channel_hub_rooms WHERE identity_id = ?1", + ), + ( + "channel_hub_klines", + "DELETE FROM channel_hub_klines WHERE identity_id = ?1", + ), ("contacts", "DELETE FROM contacts WHERE identity_id = ?1"), ("messages", "DELETE FROM messages WHERE identity_id = ?1"), ]; @@ -1656,32 +2257,41 @@ pub fn get_contact(pool: &DbPool, dest_hash: &str, identity_id: &str) -> Option< pub fn block_contact(pool: &DbPool, dest_hash: &str, display_name: &str, identity_id: &str) { let conn = match pool.get() { Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, %dest_hash, "block_contact: pool.get() failed"); + Err(_) => { + tracing::warn!( + reason = "pool_unavailable", + "block_contact: pool.get() failed" + ); return; } }; - if let Err(e) = conn.execute( + if conn.execute( "INSERT OR REPLACE INTO blocked_contacts (dest_hash, identity_id, display_name, blocked_at) VALUES (?1, ?2, ?3, ?4)", params![dest_hash, identity_id, display_name, now_ts()], - ) { - tracing::warn!(error = %e, %dest_hash, "block_contact: INSERT failed"); + ).is_err() { + tracing::warn!(reason = "insert_failed", "block_contact: INSERT failed"); } } pub fn unblock_contact(pool: &DbPool, dest_hash: &str, identity_id: &str) { let conn = match pool.get() { Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, %dest_hash, "unblock_contact: pool.get() failed"); + Err(_) => { + tracing::warn!( + reason = "pool_unavailable", + "unblock_contact: pool.get() failed" + ); return; } }; - if let Err(e) = conn.execute( - "DELETE FROM blocked_contacts WHERE dest_hash = ?1 AND identity_id = ?2", - params![dest_hash, identity_id], - ) { - tracing::warn!(error = %e, %dest_hash, "unblock_contact: DELETE failed"); + if conn + .execute( + "DELETE FROM blocked_contacts WHERE dest_hash = ?1 AND identity_id = ?2", + params![dest_hash, identity_id], + ) + .is_err() + { + tracing::warn!(reason = "delete_failed", "unblock_contact: DELETE failed"); } } @@ -1800,8 +2410,11 @@ pub fn enqueue_pending_blackhole( ) -> bool { let conn = match pool.get() { Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, %dest_hash, "enqueue_pending_blackhole: pool.get() failed"); + Err(_) => { + tracing::warn!( + reason = "pool_unavailable", + "enqueue_pending_blackhole: pool.get() failed" + ); return false; } }; @@ -1812,8 +2425,11 @@ pub fn enqueue_pending_blackhole( params![dest_hash, identity_id, reason_label, ttl_seconds, now_ts()], ) { Ok(_) => true, - Err(e) => { - tracing::warn!(error = %e, %dest_hash, "enqueue_pending_blackhole: INSERT failed"); + Err(_) => { + tracing::warn!( + reason = "insert_failed", + "enqueue_pending_blackhole: INSERT failed" + ); false } } @@ -2144,8 +2760,11 @@ pub fn search_messages( ) -> Vec { let conn = match pool.get() { Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, "search_messages: pool.get() failed"); + Err(_) => { + tracing::warn!( + reason = "pool_unavailable", + "search_messages: pool.get() failed" + ); return vec![]; } }; @@ -2238,8 +2857,11 @@ pub fn get_unread_breakdown( "; let mut stmt = match conn.prepare(sql) { Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "get_unread_breakdown: prepare failed"); + Err(_) => { + tracing::warn!( + reason = "prepare_failed", + "get_unread_breakdown: prepare failed" + ); return Vec::new(); } }; @@ -2264,8 +2886,8 @@ pub fn get_all_unread_counts_conn( "SELECT source, COUNT(*) as cnt FROM messages WHERE direction = 'inbound' AND state != 'read' AND identity_id = ?1 GROUP BY source" ) { Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "get_all_unread_counts_conn: prepare failed"); + Err(_) => { + tracing::warn!(reason = "prepare_failed", "get_all_unread_counts_conn: prepare failed"); return Default::default(); } }; @@ -2274,8 +2896,11 @@ pub fn get_all_unread_counts_conn( Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) }) .map(|rows| rows.filter_map(|r| r.ok()).collect()) - .unwrap_or_else(|e| { - tracing::warn!(error = %e, "get_all_unread_counts_conn: query_map failed"); + .unwrap_or_else(|_| { + tracing::warn!( + reason = "query_failed", + "get_all_unread_counts_conn: query_map failed" + ); Default::default() }) } @@ -2289,9 +2914,7 @@ pub fn cleanup_stale_outbound(pool: &DbPool, identity_id: &str) { "UPDATE messages SET state = 'failed' WHERE state IN ('sending', 'routing', 'propagating', 'sent') AND direction = 'outbound' AND identity_id = ?1", params![identity_id], ); - if let Ok(count) = result - && count > 0 - { + if let Some(count) = result.ok().filter(|count| *count > 0) { tracing::info!("Cleaned up {count} stale outbound message(s)"); } } @@ -2338,28 +2961,45 @@ pub fn get_hidden_conversations( .unwrap_or_default() } +fn query_message_file_refs(conn: &Connection, sql: &str, params: P) -> Vec +where + P: rusqlite::Params, +{ + let Ok(mut statement) = conn.prepare(sql) else { + return Vec::new(); + }; + let Ok(rows) = statement.query_map(params, |row| { + Ok(( + row.get::<_, String>(0).unwrap_or_default(), + row.get::<_, String>(1).unwrap_or_default(), + )) + }) else { + return Vec::new(); + }; + + let mut file_refs = Vec::new(); + for (attachment, image) in rows.flatten() { + if !attachment.is_empty() { + file_refs.push(attachment); + } + if !image.is_empty() { + file_refs.push(image); + } + } + file_refs +} + pub fn delete_conversation(pool: &DbPool, dest_hash: &str, identity_id: &str) -> Vec { let conn = match pool.get() { Ok(c) => c, Err(_) => return vec![], }; - let mut file_refs = Vec::new(); - if let Ok(mut stmt) = conn.prepare( - "SELECT attachment_stored_name, image_stored_name FROM messages WHERE (source = ?1 OR destination = ?1) AND identity_id = ?2" - ) - && let Ok(rows) = stmt.query_map(params![dest_hash, identity_id], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) - { - for r in rows.flatten() { - if !r.0.is_empty() { file_refs.push(r.0); } - if !r.1.is_empty() { file_refs.push(r.1); } - } - } + let file_refs = query_message_file_refs( + &conn, + "SELECT attachment_stored_name, image_stored_name FROM messages WHERE (source = ?1 OR destination = ?1) AND identity_id = ?2", + params![dest_hash, identity_id], + ); conn.execute( "DELETE FROM messages WHERE (source = ?1 OR destination = ?1) AND identity_id = ?2", @@ -2385,6 +3025,33 @@ pub fn get_setting(pool: &DbPool, key: &str) -> Option { .ok() } +/// Read a related set of settings from one SQLite snapshot. Missing keys are +/// omitted; database failures are surfaced instead of being confused with an +/// unset preference. +pub fn get_settings( + pool: &DbPool, + keys: &[&str], +) -> Result, String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let mut values = std::collections::HashMap::with_capacity(keys.len()); + for key in keys { + let value = transaction + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| error.to_string())?; + if let Some(value) = value { + values.insert((*key).to_string(), value); + } + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(values) +} + pub fn set_setting(pool: &DbPool, key: &str, value: &str) { let _ = try_set_setting(pool, key, value); } @@ -2399,6 +3066,3875 @@ pub fn try_set_setting(pool: &DbPool, key: &str, value: &str) -> Result<(), Stri Ok(()) } +/// Persist a coherent group of settings in one transaction. This is the +/// boundary for controls whose fields are edited and applied as one unit. +pub fn try_set_settings(pool: &DbPool, values: &[(String, String)]) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + for (key, value) in values { + transaction + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .map_err(|error| error.to_string())?; + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod settings_tests { + use super::*; + use r2d2_sqlite::SqliteConnectionManager; + + fn test_pool() -> DbPool { + let manager = SqliteConnectionManager::memory(); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + pool + } + + #[test] + fn related_settings_round_trip_as_one_snapshot() { + let pool = test_pool(); + try_set_settings( + &pool, + &[ + ("hub_name".to_string(), "Mountain relay".to_string()), + ("hub_enabled".to_string(), "1".to_string()), + ], + ) + .unwrap(); + + let values = get_settings(&pool, &["hub_name", "hub_enabled", "missing"]).unwrap(); + assert_eq!( + values.get("hub_name").map(String::as_str), + Some("Mountain relay") + ); + assert_eq!(values.get("hub_enabled").map(String::as_str), Some("1")); + assert!(!values.contains_key("missing")); + } + + #[test] + fn a_failed_settings_batch_rolls_back_every_field() { + let pool = test_pool(); + pool.get() + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_test_setting + BEFORE INSERT ON settings + WHEN NEW.key = 'reject' + BEGIN SELECT RAISE(ABORT, 'rejected'); END;", + ) + .unwrap(); + + let result = try_set_settings( + &pool, + &[ + ("first".to_string(), "saved-too-early".to_string()), + ("reject".to_string(), "no".to_string()), + ], + ); + assert!(result.is_err()); + assert_eq!(get_setting(&pool, "first"), None); + } +} + +/// Local Channels history is intentionally finite. These ceilings bound disk +/// growth without asking a constrained hub to become a backlog service. +pub const CHANNEL_HISTORY_RETENTION_DAYS: u64 = 90; +pub const CHANNEL_HISTORY_MAX_EVENTS_PER_ROOM: usize = 5_000; +pub const CHANNEL_HISTORY_MAX_EVENTS_PER_IDENTITY: usize = 50_000; +pub const CHANNEL_HISTORY_MAX_EVENTS_GLOBAL: usize = 200_000; +pub const CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_ROOM: usize = 8 * 1024 * 1024; +pub const CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_IDENTITY: usize = 64 * 1024 * 1024; +pub const CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_GLOBAL: usize = 256 * 1024 * 1024; +pub const CHANNEL_HISTORY_DEFAULT_PAGE_SIZE: usize = 100; +pub const CHANNEL_HISTORY_MAX_PAGE_SIZE: usize = 200; +pub const CHANNEL_PARTICIPANT_MAX_RESULTS: usize = 200; +pub const CHANNEL_PARTICIPANT_MAX_TRANSIENT_PER_ROOM: usize = 100; +pub const CHANNEL_PARTICIPANT_HARD_MAX_PER_ROOM: usize = 500; +pub const CHANNEL_PARTICIPANT_MAX_OBSERVATION_BATCH: usize = 256; +pub const CHANNEL_HISTORY_MAX_APPEND_BATCH: usize = 256; + +pub const CHANNEL_HISTORY_MAX_ROOM_BYTES: usize = 256; +const CHANNEL_HISTORY_MAX_EVENT_ID_BYTES: usize = 128; +const CHANNEL_HISTORY_MAX_NICKNAME_BYTES: usize = 256; +const CHANNEL_HISTORY_MAX_TEXT_BYTES: usize = 64 * 1024; +const JAVASCRIPT_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const MILLIS_PER_DAY: i64 = 24 * 60 * 60 * 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelHistoryKind { + Message, + Notice, + Action, + Join, + Part, + Error, + System, +} + +impl ChannelHistoryKind { + fn as_storage(self) -> &'static str { + match self { + Self::Message => "message", + Self::Notice => "notice", + Self::Action => "action", + Self::Join => "join", + Self::Part => "part", + Self::Error => "error", + Self::System => "system", + } + } + + fn from_storage(value: &str) -> Option { + match value { + "message" => Some(Self::Message), + "notice" => Some(Self::Notice), + "action" => Some(Self::Action), + "join" => Some(Self::Join), + "part" => Some(Self::Part), + "error" => Some(Self::Error), + "system" => Some(Self::System), + _ => None, + } + } + + fn allows_mention(self) -> bool { + matches!(self, Self::Message | Self::Action) + } +} + +/// An accepted transcript observation waiting to enter the local append log. +/// +/// `timestamp_ms` is peer-provided display metadata. Retention uses the local +/// insertion clock, and ordering/pagination uses the SQLite sequence. +#[derive(Clone, PartialEq, Eq)] +pub struct NewChannelHistoryEvent { + pub hub_destination_hash: String, + pub room_name: String, + pub event_id: String, + pub kind: ChannelHistoryKind, + pub timestamp_ms: u64, + pub source_hash: Option, + pub nickname: Option, + pub text: String, + pub ours: bool, + /// Computed locally when the event is accepted. Never trust a remote + /// sender to classify its own message as a mention. + pub mentioned: bool, +} + +/// A cryptographically identified room participant observed through the +/// authenticated hub Link. This is durable identity metadata, not a claim +/// that the participant is currently online. +#[derive(Clone, PartialEq, Eq)] +pub struct NewChannelParticipantObservation { + pub hub_destination_hash: String, + pub room_name: String, + pub identity_hash: String, + pub nickname: Option, +} + +impl std::fmt::Debug for NewChannelParticipantObservation { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NewChannelParticipantObservation") + .field("hub_destination_hash", &self.hub_destination_hash) + .field("room_name", &self.room_name) + .field("identity_hash", &self.identity_hash) + .field("nickname_present", &self.nickname.is_some()) + .finish() + } +} + +// Transcript text and nicknames can be private. Keep them out of routine +// diagnostics even if a caller logs a failed batch. +impl std::fmt::Debug for NewChannelHistoryEvent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NewChannelHistoryEvent") + .field("hub_destination_hash", &self.hub_destination_hash) + .field("room_name", &self.room_name) + .field("event_id", &self.event_id) + .field("kind", &self.kind) + .field("timestamp_ms", &self.timestamp_ms) + .field("source_present", &self.source_hash.is_some()) + .field("text", &"") + .field("ours", &self.ours) + .field("mentioned", &self.mentioned) + .finish() + } +} + +/// One stored transcript item. The opaque decimal sequence is serialized as a +/// string so JavaScript cannot round a 64-bit SQLite cursor. +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelHistoryEvent { + pub sequence: String, + pub hub_destination_hash: String, + pub room_name: String, + pub event_id: String, + pub kind: ChannelHistoryKind, + pub timestamp_ms: u64, + pub recorded_at_ms: u64, + pub source_hash: Option, + /// Presentation-only LXMF destination derived by the command layer. It is + /// intentionally not duplicated in the local history table. + pub source_lxmf_hash: Option, + pub nickname: Option, + pub text: String, + pub ours: bool, + pub mentioned: bool, +} + +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelHistoryPage { + pub items: Vec, + pub next_before: Option, + /// Last sequence in this page. Clients can use it as an exclusive forward + /// cursor to catch up without reloading or trusting peer timestamps. + pub next_after: Option, + pub has_more: bool, +} + +/// One non-local participant observed in retained room history. +/// +/// This is deliberately not an online-presence claim. It powers a local +/// "Seen here" affordance when a peer is absent from the current hub roster. +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelParticipantSummary { + pub identity_hash: Option, + /// Presentation-only LXMF destination derived by the command layer. + pub lxmf_hash: Option, + pub nickname: Option, + /// Local receipt time of the newest retained event for this participant. + pub last_seen_at_ms: u64, +} + +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelParticipantPage { + pub participants: Vec, + pub omitted_count: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChannelHistoryAppendOutcome { + pub inserted: usize, + pub duplicates: usize, + pub pruned: usize, + pub latest_sequence: Option, + /// Exact batch positions committed by this transaction. This lets the + /// writer emit native notifications only after a new row exists, without + /// replaying alerts for deduplicated retries. + pub inserted_events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelHistoryInsertedEvent { + pub batch_index: usize, + pub sequence: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelRoomNotificationLevel { + All, + #[default] + Mentions, + Mute, +} + +impl ChannelRoomNotificationLevel { + pub fn as_storage(self) -> &'static str { + match self { + Self::All => "all", + Self::Mentions => "mentions", + Self::Mute => "mute", + } + } + + fn from_storage(value: &str) -> Option { + match value { + "all" => Some(Self::All), + "mentions" => Some(Self::Mentions), + "mute" => Some(Self::Mute), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelRoomReadState { + pub hub_destination_hash: String, + pub room_name: String, + pub last_read_sequence: String, + pub notification_level: ChannelRoomNotificationLevel, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelRoomUnread { + pub hub_destination_hash: String, + pub room_name: String, + pub unread_count: u64, + pub mention_count: u64, + pub notification_level: ChannelRoomNotificationLevel, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct ChannelUnreadSummary { + pub rooms: Vec, + /// All unread retained room traffic, including muted rooms. + pub unread_total: u64, + /// All unread exact mentions, including muted rooms. + pub mention_total: u64, + /// Events allowed to request attention by each room's policy. + pub attention_total: u64, +} + +#[derive(Clone, Copy)] +struct ChannelHistoryRetentionPolicy { + max_age_ms: i64, + max_events_per_room: usize, + max_events_per_identity: usize, + max_events_global: usize, + max_payload_bytes_per_room: usize, + max_payload_bytes_per_identity: usize, + max_payload_bytes_global: usize, +} + +const CHANNEL_HISTORY_RETENTION: ChannelHistoryRetentionPolicy = ChannelHistoryRetentionPolicy { + max_age_ms: CHANNEL_HISTORY_RETENTION_DAYS as i64 * MILLIS_PER_DAY, + max_events_per_room: CHANNEL_HISTORY_MAX_EVENTS_PER_ROOM, + max_events_per_identity: CHANNEL_HISTORY_MAX_EVENTS_PER_IDENTITY, + max_events_global: CHANNEL_HISTORY_MAX_EVENTS_GLOBAL, + max_payload_bytes_per_room: CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_ROOM, + max_payload_bytes_per_identity: CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_IDENTITY, + max_payload_bytes_global: CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_GLOBAL, +}; + +fn is_canonical_channel_hash(value: &str) -> bool { + value.len() == 32 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_channel_history_scope( + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result<(), String> { + if !is_canonical_channel_hash(identity_id) { + return Err("invalid Channels history identity".into()); + } + if !is_canonical_channel_hash(hub_destination_hash) { + return Err("invalid Channels history hub destination".into()); + } + if room_name.is_empty() + || room_name.len() > CHANNEL_HISTORY_MAX_ROOM_BYTES + || room_name.trim() != room_name + || room_name.to_lowercase() != room_name + { + return Err("invalid normalized Channels history room".into()); + } + Ok(()) +} + +pub fn validate_channel_history_event( + identity_id: &str, + event: &NewChannelHistoryEvent, +) -> Result<(), String> { + validate_channel_history_scope(identity_id, &event.hub_destination_hash, &event.room_name)?; + if event.event_id.is_empty() + || event.event_id.len() > CHANNEL_HISTORY_MAX_EVENT_ID_BYTES + || event.event_id.chars().any(char::is_control) + { + return Err("invalid Channels history event id".into()); + } + if event.timestamp_ms > JAVASCRIPT_MAX_SAFE_INTEGER { + return Err("Channels history timestamp exceeds the safe display range".into()); + } + if event + .source_hash + .as_deref() + .is_some_and(|source| !is_canonical_channel_hash(source)) + { + return Err("invalid Channels history source".into()); + } + if event + .nickname + .as_deref() + .is_some_and(|nickname| nickname.len() > CHANNEL_HISTORY_MAX_NICKNAME_BYTES) + { + return Err("Channels history nickname is too long".into()); + } + if event.text.len() > CHANNEL_HISTORY_MAX_TEXT_BYTES { + return Err("Channels history text is too long".into()); + } + if event.mentioned && (event.ours || !event.kind.allows_mention()) { + return Err("invalid Channels history mention classification".into()); + } + Ok(()) +} + +pub fn validate_channel_participant_observation( + identity_id: &str, + observation: &NewChannelParticipantObservation, +) -> Result<(), String> { + validate_channel_history_scope( + identity_id, + &observation.hub_destination_hash, + &observation.room_name, + )?; + if !is_canonical_channel_hash(&observation.identity_hash) + || observation.identity_hash == identity_id + { + return Err("invalid Channels participant identity".into()); + } + if observation.nickname.as_deref().is_some_and(|nickname| { + nickname.is_empty() + || nickname.trim() != nickname + || nickname.len() > CHANNEL_HISTORY_MAX_NICKNAME_BYTES + }) { + return Err("invalid Channels participant nickname".into()); + } + Ok(()) +} + +fn parse_channel_history_cursor(before: Option<&str>) -> Result, String> { + let Some(before) = before else { + return Ok(None); + }; + if before.is_empty() + || before == "0" + || before.starts_with('0') + || !before.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("invalid Channels history cursor".into()); + } + let sequence = before + .parse::() + .map_err(|_| "invalid Channels history cursor".to_string())?; + if sequence <= 0 { + return Err("invalid Channels history cursor".into()); + } + Ok(Some(sequence)) +} + +pub fn validate_channel_history_cursor(before: Option<&str>) -> Result<(), String> { + parse_channel_history_cursor(before).map(|_| ()) +} + +fn parse_channel_history_after_cursor(after: &str) -> Result { + if after.is_empty() + || (after.starts_with('0') && after != "0") + || !after.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("invalid Channels history forward cursor".into()); + } + let sequence = after + .parse::() + .map_err(|_| "invalid Channels history forward cursor".to_string())?; + if sequence < 0 { + return Err("invalid Channels history forward cursor".into()); + } + Ok(sequence) +} + +pub fn validate_channel_history_after_cursor(after: &str) -> Result<(), String> { + parse_channel_history_after_cursor(after).map(|_| ()) +} + +fn prune_expired_channel_history_at( + conn: &Connection, + now_ms: i64, + max_age_ms: i64, +) -> Result { + let cutoff = now_ms.saturating_sub(max_age_ms); + conn.execute( + "DELETE FROM channel_history WHERE recorded_at_ms < ?1", + params![cutoff], + ) + .map_err(|error| error.to_string()) +} + +fn prune_expired_channel_participant_observations_at( + conn: &Connection, + now_ms: i64, + max_age_ms: i64, +) -> Result { + let cutoff = now_ms.saturating_sub(max_age_ms); + conn.execute( + "DELETE FROM channel_participant_observations + WHERE last_observed_at_ms < ?1 + AND NOT EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_participant_observations.participant_identity_hash + )", + params![cutoff], + ) + .map_err(|error| error.to_string()) +} + +fn channel_participant_retention_ms(pool: &DbPool) -> Option { + get_prune_days(pool).map(|days| i64::from(days).saturating_mul(MILLIS_PER_DAY)) +} + +fn channel_participant_cutoff_ms(pool: &DbPool, now_ms: i64) -> Option { + channel_participant_retention_ms(pool).map(|max_age_ms| now_ms.saturating_sub(max_age_ms)) +} + +/// Remove age-expired rows across every identity. The runtime invokes this at +/// startup; append also performs the same pass so dormant identities are +/// eventually cleaned without server participation. +pub fn prune_expired_channel_history(pool: &DbPool) -> Result { + // Channel participants are identity metadata, so follow the same + // user-configurable lifetime as the known-identity cache (14 days by + // default). Read the setting before holding a pooled connection: test and + // embedded pools may intentionally have a single connection. + let participant_max_age_ms = channel_participant_retention_ms(pool); + let conn = pool.get().map_err(|error| error.to_string())?; + let now_ms = now_unix_ms(); + let history = + prune_expired_channel_history_at(&conn, now_ms, CHANNEL_HISTORY_RETENTION.max_age_ms)?; + let participants = match participant_max_age_ms { + Some(max_age_ms) => { + prune_expired_channel_participant_observations_at(&conn, now_ms, max_age_ms)? + } + None => 0, + }; + Ok(history.saturating_add(participants)) +} + +#[derive(Clone, Copy)] +enum ChannelHistoryRetentionScope<'a> { + Room { + identity_id: &'a str, + hub_destination_hash: &'a str, + room_name: &'a str, + }, + Identity(&'a str), + Global, +} + +fn channel_history_usage( + transaction: &rusqlite::Transaction<'_>, + scope: ChannelHistoryRetentionScope<'_>, +) -> Result<(i64, i64), String> { + match scope { + ChannelHistoryRetentionScope::Room { + identity_id, + hub_destination_hash, + room_name, + } => transaction + .query_row( + "SELECT event_count, payload_bytes + FROM channel_history_room_usage + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map(|usage| usage.unwrap_or((0, 0))) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Identity(identity_id) => transaction + .query_row( + "SELECT COALESCE(SUM(event_count), 0), + COALESCE(SUM(payload_bytes), 0) + FROM channel_history_room_usage + WHERE identity_id = ?1", + params![identity_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Global => transaction + .query_row( + "SELECT COALESCE(SUM(event_count), 0), + COALESCE(SUM(payload_bytes), 0) + FROM channel_history_room_usage", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| error.to_string()), + } +} + +fn channel_history_prune_query( + scope_clause: &str, + excess_count_parameter: usize, + excess_bytes_parameter: usize, +) -> String { + format!( + "DELETE FROM channel_history + WHERE sequence IN ( + SELECT sequence + FROM ( + SELECT + sequence, + payload_bytes, + ROW_NUMBER() OVER (ORDER BY sequence ASC) AS removal_count, + SUM(payload_bytes) OVER ( + ORDER BY sequence ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS removed_bytes + FROM ( + SELECT + sequence, + ( + 128 + + length(CAST(identity_id AS BLOB)) + + length(CAST(hub_destination_hash AS BLOB)) + + length(CAST(room_name AS BLOB)) + + length(CAST(event_id AS BLOB)) + + length(CAST(kind AS BLOB)) + + length(CAST(COALESCE(source_hash, '') AS BLOB)) + + length(CAST(COALESCE(nickname, '') AS BLOB)) + + length(CAST(text AS BLOB)) + ) AS payload_bytes + FROM channel_history + WHERE {scope_clause} + ) + ) + WHERE removal_count <= ?{excess_count_parameter} + OR removed_bytes - payload_bytes < ?{excess_bytes_parameter} + )" + ) +} + +/// Delete the smallest oldest prefix needed to satisfy both the row and +/// estimated-payload ceilings for one scope. Usage triggers keep the common +/// under-budget path O(number of rooms), not O(number of transcript rows). +fn prune_channel_history_scope( + transaction: &rusqlite::Transaction<'_>, + scope: ChannelHistoryRetentionScope<'_>, + max_events: usize, + max_payload_bytes: usize, +) -> Result { + let max_events = i64::try_from(max_events) + .map_err(|_| "Channels history event limit is too large".to_string())?; + let max_payload_bytes = i64::try_from(max_payload_bytes) + .map_err(|_| "Channels history payload limit is too large".to_string())?; + let (event_count, payload_bytes) = channel_history_usage(transaction, scope)?; + let excess_count = event_count.saturating_sub(max_events); + let excess_bytes = payload_bytes.saturating_sub(max_payload_bytes); + if excess_count == 0 && excess_bytes == 0 { + return Ok(0); + } + + match scope { + ChannelHistoryRetentionScope::Room { + identity_id, + hub_destination_hash, + room_name, + } => transaction + .execute( + &channel_history_prune_query( + "identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + 4, + 5, + ), + params![ + identity_id, + hub_destination_hash, + room_name, + excess_count, + excess_bytes + ], + ) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Identity(identity_id) => transaction + .execute( + &channel_history_prune_query("identity_id = ?1", 2, 3), + params![identity_id, excess_count, excess_bytes], + ) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Global => transaction + .execute( + &channel_history_prune_query("1 = 1", 1, 2), + params![excess_count, excess_bytes], + ) + .map_err(|error| error.to_string()), + } +} + +pub fn append_channel_history_events( + pool: &DbPool, + identity_id: &str, + events: &[NewChannelHistoryEvent], +) -> Result { + append_channel_history_events_at( + pool, + identity_id, + events, + now_unix_ms(), + CHANNEL_HISTORY_RETENTION, + ) +} + +fn append_channel_history_events_at( + pool: &DbPool, + identity_id: &str, + events: &[NewChannelHistoryEvent], + recorded_at_ms: i64, + retention: ChannelHistoryRetentionPolicy, +) -> Result { + if events.len() > CHANNEL_HISTORY_MAX_APPEND_BATCH { + return Err(format!( + "Channels history batch exceeds {CHANNEL_HISTORY_MAX_APPEND_BATCH} events" + )); + } + if recorded_at_ms < 0 + || retention.max_age_ms < 0 + || retention.max_events_per_room == 0 + || retention.max_events_per_identity == 0 + || retention.max_events_global == 0 + || retention.max_payload_bytes_per_room == 0 + || retention.max_payload_bytes_per_identity == 0 + || retention.max_payload_bytes_global == 0 + { + return Err("invalid Channels history retention policy".into()); + } + if events.is_empty() { + return Ok(ChannelHistoryAppendOutcome::default()); + } + for event in events { + validate_channel_history_event(identity_id, event)?; + } + + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let mut inserted = 0usize; + let mut inserted_events = Vec::new(); + let mut touched_rooms = std::collections::BTreeSet::new(); + for (batch_index, event) in events.iter().enumerate() { + let inserted_row = transaction + .execute( + "INSERT INTO channel_history + (identity_id, hub_destination_hash, room_name, event_id, + kind, timestamp_ms, recorded_at_ms, source_hash, nickname, + text, ours, mentioned) + VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 + ) + ON CONFLICT( + identity_id, hub_destination_hash, room_name, event_id + ) DO NOTHING", + params![ + identity_id, + event.hub_destination_hash, + event.room_name, + event.event_id, + event.kind.as_storage(), + event.timestamp_ms as i64, + recorded_at_ms, + event.source_hash, + event.nickname, + event.text, + event.ours as i64, + event.mentioned as i64, + ], + ) + .map_err(|error| error.to_string())?; + inserted = inserted.saturating_add(inserted_row); + if inserted_row > 0 { + inserted_events.push(ChannelHistoryInsertedEvent { + batch_index, + sequence: transaction.last_insert_rowid().to_string(), + }); + } + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) VALUES (?1, ?2, ?3, 0, 'mentions', ?4) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO NOTHING", + params![ + identity_id, + event.hub_destination_hash, + event.room_name, + recorded_at_ms + ], + ) + .map_err(|error| error.to_string())?; + touched_rooms.insert(( + event.hub_destination_hash.as_str(), + event.room_name.as_str(), + )); + } + + let cutoff = recorded_at_ms.saturating_sub(retention.max_age_ms); + let mut pruned = transaction + .execute( + "DELETE FROM channel_history WHERE recorded_at_ms < ?1", + params![cutoff], + ) + .map_err(|error| error.to_string())?; + for (hub_destination_hash, room_name) in touched_rooms { + pruned = pruned.saturating_add(prune_channel_history_scope( + &transaction, + ChannelHistoryRetentionScope::Room { + identity_id, + hub_destination_hash, + room_name, + }, + retention.max_events_per_room, + retention.max_payload_bytes_per_room, + )?); + } + pruned = pruned.saturating_add(prune_channel_history_scope( + &transaction, + ChannelHistoryRetentionScope::Identity(identity_id), + retention.max_events_per_identity, + retention.max_payload_bytes_per_identity, + )?); + pruned = pruned.saturating_add(prune_channel_history_scope( + &transaction, + ChannelHistoryRetentionScope::Global, + retention.max_events_global, + retention.max_payload_bytes_global, + )?); + let latest_sequence = transaction + .query_row( + "SELECT MAX(sequence) FROM channel_history WHERE identity_id = ?1", + params![identity_id], + |row| row.get::<_, Option>(0), + ) + .map_err(|error| error.to_string())? + .map(|sequence| sequence.to_string()); + transaction.commit().map_err(|error| error.to_string())?; + + Ok(ChannelHistoryAppendOutcome { + inserted, + duplicates: events.len().saturating_sub(inserted), + pruned, + latest_sequence, + inserted_events, + }) +} + +/// Remember canonical participant identities independently of transcript +/// events. Initial RRC rosters may contain identities without generating an +/// individual JOIN row, so this bounded projection preserves an avatar the UI +/// has already been able to derive. +pub fn remember_channel_participants( + pool: &DbPool, + identity_id: &str, + observations: &[NewChannelParticipantObservation], +) -> Result { + remember_channel_participants_at(pool, identity_id, observations, now_unix_ms()) +} + +fn remember_channel_participants_at( + pool: &DbPool, + identity_id: &str, + observations: &[NewChannelParticipantObservation], + observed_at_ms: i64, +) -> Result { + if observations.len() > CHANNEL_PARTICIPANT_MAX_OBSERVATION_BATCH { + return Err(format!( + "Channels participant batch exceeds {CHANNEL_PARTICIPANT_MAX_OBSERVATION_BATCH} observations" + )); + } + if observed_at_ms < 0 { + return Err("invalid Channels participant observation time".into()); + } + if observations.is_empty() { + return Ok(0); + } + for observation in observations { + validate_channel_participant_observation(identity_id, observation)?; + } + + let participant_cutoff_ms = channel_participant_cutoff_ms(pool, observed_at_ms); + + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let mut touched_rooms = std::collections::BTreeSet::new(); + let mut remembered = 0usize; + for observation in observations { + remembered = remembered.saturating_add( + transaction + .execute( + "INSERT INTO channel_participant_observations ( + identity_id, hub_destination_hash, room_name, + participant_identity_hash, nickname, last_observed_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name, + participant_identity_hash + ) DO UPDATE SET + nickname = CASE + WHEN excluded.last_observed_at_ms >= + channel_participant_observations.last_observed_at_ms + AND excluded.nickname IS NOT NULL + AND trim(excluded.nickname) <> '' + THEN excluded.nickname + ELSE channel_participant_observations.nickname + END, + last_observed_at_ms = MAX( + channel_participant_observations.last_observed_at_ms, + excluded.last_observed_at_ms + )", + params![ + identity_id, + observation.hub_destination_hash, + observation.room_name, + observation.identity_hash, + observation.nickname, + observed_at_ms, + ], + ) + .map_err(|error| error.to_string())?, + ); + touched_rooms.insert(( + observation.hub_destination_hash.as_str(), + observation.room_name.as_str(), + )); + } + + if let Some(cutoff) = participant_cutoff_ms { + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE last_observed_at_ms < ?1 + AND NOT EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_participant_observations.participant_identity_hash + )", + params![cutoff], + ) + .map_err(|error| error.to_string())?; + } + let transient_limit = i64::try_from(CHANNEL_PARTICIPANT_MAX_TRANSIENT_PER_ROOM) + .map_err(|_| "Channels transient participant limit is too large".to_string())?; + let hard_limit = i64::try_from(CHANNEL_PARTICIPANT_HARD_MAX_PER_ROOM) + .map_err(|_| "Channels participant hard limit is too large".to_string())?; + for (hub_destination_hash, room_name) in touched_rooms { + // Keep a bounded recent tail for channel-only sightings. Identities + // still present in Ratspeak's normal peer graph are exempt so a saved + // contact or conversation does not lose its room association merely + // because a busy hub has supplied 100 newer names. + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE rowid IN ( + SELECT rowid + FROM channel_participant_observations AS candidate + WHERE candidate.identity_id = ?1 + AND candidate.hub_destination_hash = ?2 + AND candidate.room_name = ?3 + AND NOT EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + candidate.participant_identity_hash + ) + ORDER BY last_observed_at_ms DESC, + participant_identity_hash DESC + LIMIT -1 OFFSET ?4 + )", + params![ + identity_id, + hub_destination_hash, + room_name, + transient_limit + ], + ) + .map_err(|error| error.to_string())?; + // Even protected user data needs a defensive per-room ceiling against + // a hostile or badly behaved hub. This is intentionally far above the + // transient allowance and only evicts the oldest association. + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE rowid IN ( + SELECT rowid + FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + ORDER BY last_observed_at_ms DESC, + participant_identity_hash DESC + LIMIT -1 OFFSET ?4 + )", + params![identity_id, hub_destination_hash, room_name, hard_limit], + ) + .map_err(|error| error.to_string())?; + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(remembered) +} + +fn channel_history_row(row: &rusqlite::Row<'_>) -> Result { + let sequence = row.get::<_, i64>(0)?; + let kind = row.get::<_, String>(4)?; + let kind = ChannelHistoryKind::from_storage(&kind).ok_or(rusqlite::Error::InvalidQuery)?; + let timestamp_ms = u64::try_from(row.get::<_, i64>(5)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + let recorded_at_ms = u64::try_from(row.get::<_, i64>(6)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 6, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + Ok(ChannelHistoryEvent { + sequence: sequence.to_string(), + hub_destination_hash: row.get(1)?, + room_name: row.get(2)?, + event_id: row.get(3)?, + kind, + timestamp_ms, + recorded_at_ms, + source_hash: row.get(7)?, + source_lxmf_hash: None, + nickname: row.get(8)?, + text: row.get(9)?, + ours: row.get::<_, i64>(10)? != 0, + mentioned: row.get::<_, i64>(11)? != 0, + }) +} + +/// Return one room page in display order (oldest to newest). `before` is an +/// exclusive opaque cursor obtained from a prior page. +pub fn list_channel_history( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + before: Option<&str>, + limit: usize, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + if limit == 0 || limit > CHANNEL_HISTORY_MAX_PAGE_SIZE { + return Err(format!( + "Channels history page size must be between 1 and {CHANNEL_HISTORY_MAX_PAGE_SIZE}" + )); + } + let before = parse_channel_history_cursor(before)?; + let query_limit = i64::try_from(limit.saturating_add(1)) + .map_err(|_| "Channels history page size is too large".to_string())?; + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT sequence, hub_destination_hash, room_name, event_id, kind, + timestamp_ms, recorded_at_ms, source_hash, nickname, text, + ours, mentioned + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND (?4 IS NULL OR sequence < ?4) + ORDER BY sequence DESC + LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let mut items = statement + .query_map( + params![ + identity_id, + hub_destination_hash, + room_name, + before, + query_limit + ], + channel_history_row, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let has_more = items.len() > limit; + items.truncate(limit); + items.reverse(); + let next_before = has_more + .then(|| items.first().map(|item| item.sequence.clone())) + .flatten(); + let next_after = items.last().map(|item| item.sequence.clone()); + Ok(ChannelHistoryPage { + items, + next_before, + next_after, + has_more, + }) +} + +/// Return the newest retained observation for each non-local participant in +/// one room. Identified peers group by identity hash across nickname changes; +/// nickname-only RRC observations group conservatively by exact nickname. +pub fn list_channel_participants( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + list_channel_participants_at( + pool, + identity_id, + hub_destination_hash, + room_name, + now_unix_ms(), + ) +} + +fn list_channel_participants_at( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + now_ms: i64, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + if now_ms < 0 { + return Err("invalid Channels participant query time".into()); + } + let participant_cutoff_ms = channel_participant_cutoff_ms(pool, now_ms); + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "WITH observations AS ( + SELECT sequence AS observation_order, source_hash, nickname, + recorded_at_ms, 0 AS source_rank, + CASE + WHEN source_hash IS NOT NULL THEN 'identity:' || source_hash + ELSE 'nickname:' || nickname + END AS participant_key + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND ours = 0 + AND (source_hash IS NULL OR source_hash <> ?1) + AND ( + ?4 IS NULL OR recorded_at_ms >= ?4 OR + ( + source_hash IS NOT NULL AND EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_history.source_hash + ) + ) + ) + AND kind IN ('message', 'action', 'join', 'part') + AND ( + source_hash IS NOT NULL OR + (nickname IS NOT NULL AND trim(nickname) <> '') + ) + UNION ALL + SELECT 0 AS observation_order, + participant_identity_hash AS source_hash, + nickname, + last_observed_at_ms AS recorded_at_ms, + 1 AS source_rank, + 'identity:' || participant_identity_hash AS participant_key + FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND participant_identity_hash <> ?1 + AND ( + ?4 IS NULL OR last_observed_at_ms >= ?4 OR + EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_participant_observations.participant_identity_hash + ) + ) + ), ranked AS ( + SELECT observation_order, source_hash, nickname, + recorded_at_ms, source_rank, participant_key, + ROW_NUMBER() OVER ( + PARTITION BY participant_key + ORDER BY recorded_at_ms DESC, source_rank DESC, + observation_order DESC + ) AS participant_rank + FROM observations + ) + SELECT ranked.source_hash, + COALESCE( + ( + SELECT named.nickname + FROM observations AS named + WHERE named.participant_key = ranked.participant_key + AND named.nickname IS NOT NULL + AND trim(named.nickname) <> '' + ORDER BY named.recorded_at_ms DESC, + named.source_rank DESC, + named.observation_order DESC + LIMIT 1 + ), + ranked.nickname + ) AS nickname, + ranked.recorded_at_ms, + ( + SELECT COUNT(*) FROM ranked AS counted + WHERE counted.participant_rank = 1 + ) AS participant_count + FROM ranked + WHERE ranked.participant_rank = 1 + ORDER BY ranked.recorded_at_ms DESC, ranked.observation_order DESC, + ranked.participant_key ASC + LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let mut total_count = 0usize; + let mut participants = statement + .query_map( + params![ + identity_id, + hub_destination_hash, + room_name, + participant_cutoff_ms, + i64::try_from(CHANNEL_PARTICIPANT_MAX_RESULTS) + .map_err(|_| "Channels participant limit is too large".to_string())? + ], + |row| { + let last_seen_at_ms = u64::try_from(row.get::<_, i64>(2)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + let participant_count = + usize::try_from(row.get::<_, i64>(3)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + Ok(( + ChannelParticipantSummary { + identity_hash: row.get(0)?, + lxmf_hash: None, + nickname: row.get(1)?, + last_seen_at_ms, + }, + participant_count, + )) + }, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let participants = participants + .drain(..) + .map(|(participant, count)| { + total_count = count; + participant + }) + .collect::>(); + Ok(ChannelParticipantPage { + omitted_count: total_count.saturating_sub(participants.len()), + participants, + }) +} + +/// Catch up from an exclusive append-log cursor in receive order. Cursor `0` +/// starts at the identity's first retained row, which lets a client that +/// loaded an empty room avoid a latest-page gap when the first burst arrives. +pub fn list_channel_history_after( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + after: &str, + limit: usize, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + if limit == 0 || limit > CHANNEL_HISTORY_MAX_PAGE_SIZE { + return Err(format!( + "Channels history page size must be between 1 and {CHANNEL_HISTORY_MAX_PAGE_SIZE}" + )); + } + let after = parse_channel_history_after_cursor(after)?; + let query_limit = i64::try_from(limit.saturating_add(1)) + .map_err(|_| "Channels history page size is too large".to_string())?; + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT sequence, hub_destination_hash, room_name, event_id, kind, + timestamp_ms, recorded_at_ms, source_hash, nickname, text, + ours, mentioned + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND sequence > ?4 + ORDER BY sequence ASC + LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let mut items = statement + .query_map( + params![ + identity_id, + hub_destination_hash, + room_name, + after, + query_limit + ], + channel_history_row, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let has_more = items.len() > limit; + items.truncate(limit); + let next_after = items.last().map(|item| item.sequence.clone()); + Ok(ChannelHistoryPage { + items, + next_before: None, + next_after, + has_more, + }) +} + +fn channel_room_read_state( + hub_destination_hash: &str, + room_name: &str, + stored: Option<(i64, String)>, +) -> Result { + let (last_read_sequence, notification_level) = stored.unwrap_or(( + 0, + ChannelRoomNotificationLevel::default().as_storage().into(), + )); + let notification_level = ChannelRoomNotificationLevel::from_storage(¬ification_level) + .ok_or_else(|| "invalid stored Channels notification level".to_string())?; + Ok(ChannelRoomReadState { + hub_destination_hash: hub_destination_hash.into(), + room_name: room_name.into(), + last_read_sequence: last_read_sequence.to_string(), + notification_level, + }) +} + +fn query_channel_room_read_state( + conn: &Connection, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result, String> { + conn.query_row( + "SELECT last_read_sequence, notification_level + FROM channel_room_state + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string()) +} + +pub fn get_channel_room_read_state( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let conn = pool.get().map_err(|error| error.to_string())?; + let stored = + query_channel_room_read_state(&conn, identity_id, hub_destination_hash, room_name)?; + channel_room_read_state(hub_destination_hash, room_name, stored) +} + +/// Advance one room's read position to a sequence proven to belong to that +/// exact identity/hub/room scope. Cursors are monotonic and sequence `0` is an +/// idempotent no-op for an empty room. +pub fn mark_channel_room_read( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + through: &str, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let through = parse_channel_history_after_cursor(through)?; + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let stored = + query_channel_room_read_state(&transaction, identity_id, hub_destination_hash, room_name)?; + let current = stored.as_ref().map_or(0, |(sequence, _)| *sequence); + + if through > current { + let belongs_to_room = transaction + .query_row( + "SELECT 1 + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND sequence = ?4", + params![identity_id, hub_destination_hash, room_name, through], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|error| error.to_string())? + .is_some(); + if !belongs_to_room { + return Err("Channels read cursor does not belong to this room".into()); + } + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, 'mentions', ?5) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO UPDATE SET + last_read_sequence = excluded.last_read_sequence, + updated_at_ms = excluded.updated_at_ms + WHERE excluded.last_read_sequence > + channel_room_state.last_read_sequence", + params![ + identity_id, + hub_destination_hash, + room_name, + through, + now_unix_ms() + ], + ) + .map_err(|error| error.to_string())?; + } + + let stored = + query_channel_room_read_state(&transaction, identity_id, hub_destination_hash, room_name)?; + let state = channel_room_read_state(hub_destination_hash, room_name, stored)?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(state) +} + +pub fn set_channel_room_notification_level( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + notification_level: ChannelRoomNotificationLevel, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) VALUES ( + ?1, ?2, ?3, + COALESCE(( + SELECT MAX(sequence) + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + ), 0), + ?4, ?5 + ) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO UPDATE SET + notification_level = excluded.notification_level, + updated_at_ms = excluded.updated_at_ms", + params![ + identity_id, + hub_destination_hash, + room_name, + notification_level.as_storage(), + now_unix_ms() + ], + ) + .map_err(|error| error.to_string())?; + let stored = + query_channel_room_read_state(&transaction, identity_id, hub_destination_hash, room_name)?; + let state = channel_room_read_state(hub_destination_hash, room_name, stored)?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(state) +} + +pub fn get_channel_unread_summary( + pool: &DbPool, + identity_id: &str, +) -> Result { + if !is_canonical_channel_hash(identity_id) { + return Err("invalid Channels unread identity".into()); + } + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT + state.hub_destination_hash, + state.room_name, + COUNT(history.sequence) AS unread_count, + COALESCE(SUM(history.mentioned), 0) AS mention_count, + state.notification_level + FROM channel_room_state AS state + LEFT JOIN channel_history AS history + ON history.identity_id = state.identity_id + AND history.hub_destination_hash = state.hub_destination_hash + AND history.room_name = state.room_name + AND history.ours = 0 + AND history.kind IN ('message', 'notice', 'action') + AND history.sequence > state.last_read_sequence + WHERE state.identity_id = ?1 + GROUP BY + state.hub_destination_hash, + state.room_name, + state.notification_level + ORDER BY + COALESCE(MAX(history.sequence), MAX(state.last_read_sequence)) DESC", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + let unread_count = row.get::<_, i64>(2)?; + let mention_count = row.get::<_, i64>(3)?; + let notification_level = row.get::<_, String>(4)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + unread_count, + mention_count, + notification_level, + )) + }) + .map_err(|error| error.to_string())?; + + let mut summary = ChannelUnreadSummary::default(); + for row in rows { + let (hub_destination_hash, room_name, unread_count, mention_count, notification_level) = + row.map_err(|error| error.to_string())?; + let unread_count = u64::try_from(unread_count) + .map_err(|_| "invalid stored Channels unread count".to_string())?; + let mention_count = u64::try_from(mention_count) + .map_err(|_| "invalid stored Channels mention count".to_string())?; + let notification_level = ChannelRoomNotificationLevel::from_storage(¬ification_level) + .ok_or_else(|| "invalid stored Channels notification level".to_string())?; + summary.unread_total = summary.unread_total.saturating_add(unread_count); + summary.mention_total = summary.mention_total.saturating_add(mention_count); + summary.attention_total = + summary + .attention_total + .saturating_add(match notification_level { + ChannelRoomNotificationLevel::All => unread_count, + ChannelRoomNotificationLevel::Mentions => mention_count, + ChannelRoomNotificationLevel::Mute => 0, + }); + summary.rooms.push(ChannelRoomUnread { + hub_destination_hash, + room_name, + unread_count, + mention_count, + notification_level, + }); + } + Ok(summary) +} + +/// Explicit history deletion is separate from bookmark removal. +pub fn clear_channel_room_history( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + let deleted = transaction + .execute( + "DELETE FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(deleted) +} + +pub fn clear_channel_history_for_identity( + pool: &DbPool, + identity_id: &str, +) -> Result { + if !is_canonical_channel_hash(identity_id) { + return Err("invalid Channels history identity".into()); + } + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + transaction + .execute( + "DELETE FROM channel_participant_observations WHERE identity_id = ?1", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + let deleted = transaction + .execute( + "DELETE FROM channel_history WHERE identity_id = ?1", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(deleted) +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct SavedChannelHub { + pub destination_hash: String, + pub label: String, + pub nickname: String, + pub added_at: f64, + pub last_connected: f64, + /// Durable scheduler intent, distinct from an observed live Link. + pub desired_connected: bool, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct SavedChannelRoom { + pub hub_destination_hash: String, + pub room_name: String, + pub added_at: f64, + pub last_joined: f64, + /// Durable scheduler intent, distinct from hub-confirmed membership. + pub desired_joined: bool, + /// Non-secret recovery hint. A desired protected room without ciphertext + /// must wait for user input instead of retrying a keyless JOIN forever. + pub join_key_required: bool, +} + +/// One room visible in the client-local Channels browser. This is the union of +/// bookmarks and retained history: forgetting a hub must not make its +/// separately retained transcript unreachable or imply that it was deleted. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct ChannelRoomIndexEntry { + pub hub_destination_hash: String, + pub room_name: String, + pub last_joined: f64, + pub latest_recorded_at_ms: Option, + pub saved: bool, + pub has_history: bool, + pub topic: Option, +} + +#[derive(Clone, PartialEq)] +pub struct StoredChannelRoomSecret { + pub hub_destination_hash: String, + pub room_name: String, + pub seal_scheme: String, + pub seal_version: u32, + pub ciphertext: Vec, + pub updated_at: f64, +} + +impl std::fmt::Debug for StoredChannelRoomSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredChannelRoomSecret") + .field("hub_destination_hash", &self.hub_destination_hash) + .field("room_name", &self.room_name) + .field("seal_scheme", &self.seal_scheme) + .field("seal_version", &self.seal_version) + .field("ciphertext", &"") + .field("updated_at", &self.updated_at) + .finish() + } +} + +pub fn list_saved_channel_hubs( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT destination_hash, label, nickname, added_at, last_connected, + desired_connected + FROM channel_hubs + WHERE identity_id = ?1 + ORDER BY last_connected DESC, label COLLATE NOCASE, destination_hash", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(SavedChannelHub { + destination_hash: row.get(0)?, + label: row.get(1)?, + nickname: row.get(2)?, + added_at: row.get(3)?, + last_connected: row.get(4)?, + desired_connected: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +pub fn save_channel_hub( + pool: &DbPool, + identity_id: &str, + destination_hash: &str, + label: &str, + nickname: &str, + connected: bool, +) -> Result<(), String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let now = now_ts(); + conn.execute( + "INSERT INTO channel_hubs + (identity_id, destination_hash, label, nickname, added_at, last_connected) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(identity_id, destination_hash) DO UPDATE SET + label = excluded.label, + nickname = excluded.nickname, + last_connected = CASE + WHEN excluded.last_connected > 0 THEN excluded.last_connected + ELSE channel_hubs.last_connected + END", + params![ + identity_id, + destination_hash, + label, + nickname, + now, + if connected { now } else { 0.0 } + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// Persist the one-hub scheduler target without conflating it with an +/// observed connection. Selecting a hub clears the previous winner in the +/// same transaction; the partial unique index is the final concurrency guard. +pub fn set_channel_hub_desired( + pool: &DbPool, + identity_id: &str, + destination_hash: &str, + nickname: &str, + desired: bool, +) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let tx = conn.transaction().map_err(|error| error.to_string())?; + if desired { + tx.execute( + "UPDATE channel_hubs SET desired_connected = 0 + WHERE identity_id = ?1 AND desired_connected != 0", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + } + let now = now_ts(); + tx.execute( + "INSERT INTO channel_hubs + (identity_id, destination_hash, label, nickname, added_at, + last_connected, desired_connected) + VALUES (?1, ?2, '', ?3, ?4, 0, ?5) + ON CONFLICT(identity_id, destination_hash) DO UPDATE SET + nickname = excluded.nickname, + desired_connected = excluded.desired_connected", + params![identity_id, destination_hash, nickname, now, desired as i64], + ) + .map_err(|error| error.to_string())?; + tx.commit().map_err(|error| error.to_string()) +} + +/// Rename an identity and retire the superseded name from its saved hub +/// bookmarks in one transaction. +/// +/// Bookmarks record whatever nickname the session connected as, so a rename +/// would otherwise keep offering — and broadcasting — the previous name. Only +/// bookmarks still holding the exact previous name are rewritten; a deliberate +/// per-hub alias differs from it and is left alone. +/// +/// The two writes must commit together: if the sweep were to fail after the +/// rename committed, a retry would read the already-updated name as the +/// "previous" one, skip the sweep on the equality guard, and strand the old +/// name in the bookmark permanently. +pub struct IdentityRenameOutcome { + /// The name this identity carried before the rename ("" if unset). + pub previous_name: String, + pub retired_bookmarks: usize, +} + +pub fn rename_identity_and_retire_alias( + pool: &DbPool, + identity_id: &str, + new_name: &str, +) -> Result { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let previous_name: String = transaction + .query_row( + "SELECT COALESCE(display_name, '') FROM identities WHERE hash = ?1", + params![identity_id], + |row| row.get(0), + ) + .unwrap_or_default(); + transaction + .execute( + "UPDATE identities SET display_name = ?1 WHERE hash = ?2", + params![new_name, identity_id], + ) + .map_err(|error| format!("display_name: {error}"))?; + let retired = if previous_name.is_empty() || previous_name == new_name { + 0 + } else { + transaction + .execute( + "UPDATE channel_hubs SET nickname = ?1 WHERE identity_id = ?2 AND nickname = ?3", + params![new_name, identity_id, previous_name], + ) + .map_err(|error| format!("hub_nickname: {error}"))? + }; + transaction.commit().map_err(|error| error.to_string())?; + Ok(IdentityRenameOutcome { + previous_name, + retired_bookmarks: retired, + }) +} + +/// One hosted room's durable policy. Grants ride along so a restore is a +/// single query pair rather than one query per room. +#[derive(Clone, PartialEq)] +pub struct HubRoomRow { + pub room_name: String, + pub topic: String, + pub key_salt: String, + pub key_mac: String, + pub key_pepper_id: String, + pub moderated: bool, + pub invite_only: bool, + pub topic_ops_only: bool, + pub no_outside_msgs: bool, + pub private: bool, + pub last_used: f64, + /// `(kind, subject hex, expires_at)`; kind is `op|voice|ban|invite`. + pub grants: Vec<(String, String, f64)>, +} + +/// Hand-written so a room key digest can never reach a log or a panic message. +impl std::fmt::Debug for HubRoomRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HubRoomRow") + .field("room_name", &self.room_name) + .field("keyed", &!self.key_mac.is_empty()) + .field("grants", &self.grants.len()) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone)] +pub enum HubRoomOp { + Upsert(Box), + Touched { room_name: String, last_used: f64 }, + Removed { room_name: String }, + ReplaceKlines(Vec), + GcInvites { before: f64 }, +} + +pub fn list_hub_rooms(pool: &DbPool, identity_id: &str) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut rooms: Vec = conn + .prepare( + "SELECT room_name, topic, key_salt, key_mac, key_pepper_id, moderated, + invite_only, topic_ops_only, no_outside_msgs, private, last_used + FROM channel_hub_rooms WHERE identity_id = ?1 ORDER BY room_name", + ) + .and_then(|mut stmt| { + stmt.query_map(params![identity_id], |row| { + Ok(HubRoomRow { + room_name: row.get(0)?, + topic: row.get(1)?, + key_salt: row.get(2)?, + key_mac: row.get(3)?, + key_pepper_id: row.get(4)?, + moderated: row.get::<_, i64>(5)? != 0, + invite_only: row.get::<_, i64>(6)? != 0, + topic_ops_only: row.get::<_, i64>(7)? != 0, + no_outside_msgs: row.get::<_, i64>(8)? != 0, + private: row.get::<_, i64>(9)? != 0, + last_used: row.get(10)?, + grants: Vec::new(), + }) + }) + .and_then(|rows| rows.collect::, _>>()) + }) + .map_err(|error| error.to_string())?; + + let mut grants: std::collections::HashMap> = + std::collections::HashMap::new(); + conn.prepare( + "SELECT room_name, kind, subject, expires_at + FROM channel_hub_grants WHERE identity_id = ?1", + ) + .and_then(|mut stmt| { + stmt.query_map(params![identity_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, f64>(3)?, + )) + }) + .and_then(|rows| rows.collect::, _>>()) + }) + .map_err(|error| error.to_string())? + .into_iter() + .for_each(|(room, kind, subject, expires)| { + grants + .entry(room) + .or_default() + .push((kind, subject, expires)); + }); + + for room in &mut rooms { + if let Some(found) = grants.remove(&room.room_name) { + room.grants = found; + } + } + Ok(rooms) +} + +pub fn list_hub_klines(pool: &DbPool, identity_id: &str) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + conn.prepare("SELECT subject FROM channel_hub_klines WHERE identity_id = ?1") + .and_then(|mut stmt| { + stmt.query_map(params![identity_id], |row| row.get::<_, String>(0)) + .and_then(|rows| rows.collect::, _>>()) + }) + .map_err(|error| error.to_string()) +} + +/// Apply a batch of registry writes in one transaction, in order. Ordering is +/// load-bearing: two writes to the same room must not reorder, so the caller +/// hands the whole batch over rather than spawning a task per op. +pub fn apply_hub_ops(pool: &DbPool, identity_id: &str, ops: &[HubRoomOp]) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let tx = conn.transaction().map_err(|error| error.to_string())?; + let now = now_ts(); + for op in ops { + match op { + HubRoomOp::Upsert(room) => { + tx.execute( + "INSERT INTO channel_hub_rooms + (identity_id, room_name, topic, key_salt, key_mac, key_pepper_id, + moderated, invite_only, topic_ops_only, no_outside_msgs, private, + created_at, last_used) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13) + ON CONFLICT(identity_id, room_name) DO UPDATE SET + topic = excluded.topic, + key_salt = excluded.key_salt, + key_mac = excluded.key_mac, + key_pepper_id = excluded.key_pepper_id, + moderated = excluded.moderated, + invite_only = excluded.invite_only, + topic_ops_only = excluded.topic_ops_only, + no_outside_msgs = excluded.no_outside_msgs, + private = excluded.private, + last_used = excluded.last_used", + params![ + identity_id, + room.room_name, + room.topic, + room.key_salt, + room.key_mac, + room.key_pepper_id, + room.moderated as i64, + room.invite_only as i64, + room.topic_ops_only as i64, + room.no_outside_msgs as i64, + room.private as i64, + now, + room.last_used + ], + ) + .map_err(|error| error.to_string())?; + // Grants are authoritative per room: replace wholesale so a + // revoked op or expired invite cannot survive as a stale row. + tx.execute( + "DELETE FROM channel_hub_grants WHERE identity_id = ?1 AND room_name = ?2", + params![identity_id, room.room_name], + ) + .map_err(|error| error.to_string())?; + for (kind, subject, expires_at) in &room.grants { + tx.execute( + "INSERT OR REPLACE INTO channel_hub_grants + (identity_id, room_name, kind, subject, granted_at, expires_at) + VALUES (?1,?2,?3,?4,?5,?6)", + params![identity_id, room.room_name, kind, subject, now, expires_at], + ) + .map_err(|error| error.to_string())?; + } + } + HubRoomOp::Touched { + room_name, + last_used, + } => { + tx.execute( + "UPDATE channel_hub_rooms SET last_used = ?1 + WHERE identity_id = ?2 AND room_name = ?3", + params![last_used, identity_id, room_name], + ) + .map_err(|error| error.to_string())?; + } + HubRoomOp::Removed { room_name } => { + tx.execute( + "DELETE FROM channel_hub_rooms WHERE identity_id = ?1 AND room_name = ?2", + params![identity_id, room_name], + ) + .map_err(|error| error.to_string())?; + } + HubRoomOp::ReplaceKlines(subjects) => { + tx.execute( + "DELETE FROM channel_hub_klines WHERE identity_id = ?1", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + for subject in subjects { + tx.execute( + "INSERT OR REPLACE INTO channel_hub_klines + (identity_id, subject, banned_at) VALUES (?1,?2,?3)", + params![identity_id, subject, now], + ) + .map_err(|error| error.to_string())?; + } + } + HubRoomOp::GcInvites { before } => { + tx.execute( + "DELETE FROM channel_hub_grants + WHERE identity_id = ?1 AND kind = 'invite' AND expires_at <= ?2", + params![identity_id, before], + ) + .map_err(|error| error.to_string())?; + } + } + } + tx.commit().map_err(|error| error.to_string()) +} + +pub fn remove_channel_hub( + pool: &DbPool, + identity_id: &str, + destination_hash: &str, +) -> Result { + let conn = pool.get().map_err(|error| error.to_string())?; + conn.execute( + "DELETE FROM channel_hubs WHERE identity_id = ?1 AND destination_hash = ?2", + params![identity_id, destination_hash], + ) + .map(|changed| changed > 0) + .map_err(|error| error.to_string()) +} + +pub fn list_saved_channel_rooms( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT hub_destination_hash, room_name, added_at, last_joined, + desired_joined, join_key_required + FROM channel_rooms + WHERE identity_id = ?1 AND hub_destination_hash = ?2 + ORDER BY last_joined DESC, room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id, hub_destination_hash], |row| { + Ok(SavedChannelRoom { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + added_at: row.get(2)?, + last_joined: row.get(3)?, + desired_joined: row.get::<_, i64>(4)? != 0, + join_key_required: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// Load all remembered rooms for one identity in one query. The service-state +/// snapshot is hub-keyed, so doing one query per saved hub would make startup +/// cost grow quadratically with a user's community list. +pub fn list_saved_channel_rooms_for_identity( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT hub_destination_hash, room_name, added_at, last_joined, + desired_joined, join_key_required + FROM channel_rooms + WHERE identity_id = ?1 + ORDER BY hub_destination_hash, last_joined DESC, + room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(SavedChannelRoom { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + added_at: row.get(2)?, + last_joined: row.get(3)?, + desired_joined: row.get::<_, i64>(4)? != 0, + join_key_required: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// Load the local room browser in one query. Bookmarks and history have +/// intentionally independent lifetimes, so neither side is allowed to hide +/// the other. +pub fn list_channel_room_index( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "WITH room_index AS ( + SELECT + hub_destination_hash, + room_name, + last_joined, + NULL AS latest_recorded_at_ms, + 1 AS saved, + 0 AS has_history + FROM channel_rooms + WHERE identity_id = ?1 + + UNION ALL + + SELECT + hub_destination_hash, + room_name, + 0.0 AS last_joined, + MAX(recorded_at_ms) AS latest_recorded_at_ms, + 0 AS saved, + 1 AS has_history + FROM channel_history + WHERE identity_id = ?1 + GROUP BY hub_destination_hash, room_name + ), grouped_rooms AS ( + SELECT + hub_destination_hash, + room_name, + MAX(last_joined) AS last_joined, + MAX(latest_recorded_at_ms) AS latest_recorded_at_ms, + MAX(saved) AS saved, + MAX(has_history) AS has_history + FROM room_index + GROUP BY hub_destination_hash, room_name + ) + SELECT + rooms.hub_destination_hash, + rooms.room_name, + rooms.last_joined, + rooms.latest_recorded_at_ms, + rooms.saved, + rooms.has_history, + NULLIF(state.topic, '') + FROM grouped_rooms AS rooms + LEFT JOIN channel_room_state AS state + ON state.identity_id = ?1 + AND state.hub_destination_hash = rooms.hub_destination_hash + AND state.room_name = rooms.room_name + ORDER BY + COALESCE( + rooms.latest_recorded_at_ms, + CAST(rooms.last_joined * 1000 AS INTEGER), + 0 + ) DESC, + rooms.hub_destination_hash, + rooms.room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(ChannelRoomIndexEntry { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + last_joined: row.get(2)?, + latest_recorded_at_ms: row.get(3)?, + saved: row.get::<_, i64>(4)? != 0, + has_history: row.get::<_, i64>(5)? != 0, + topic: row.get(6)?, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +pub fn save_channel_room( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + joined: bool, + topic: Option<&str>, +) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let now = now_ts(); + transaction + .execute( + "INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, last_joined) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(identity_id, hub_destination_hash, room_name) DO UPDATE SET + last_joined = CASE + WHEN excluded.last_joined > 0 THEN excluded.last_joined + ELSE channel_rooms.last_joined + END", + params![ + identity_id, + hub_destination_hash, + room_name, + now, + if joined { now } else { 0.0 } + ], + ) + .map_err(|error| error.to_string())?; + if let Some(topic) = topic { + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, topic, updated_at_ms + ) VALUES (?1, ?2, ?3, 0, 'mentions', ?4, ?5) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO UPDATE SET + topic = excluded.topic, + updated_at_ms = excluded.updated_at_ms", + params![ + identity_id, + hub_destination_hash, + room_name, + topic, + now_unix_ms() + ], + ) + .map_err(|error| error.to_string())?; + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(()) +} + +/// Persist desired room membership independently from the last JOIN observed. +/// A failed or disconnected session can therefore retain honest user intent +/// without claiming that the hub currently considers the identity a member. +pub fn set_channel_room_desired( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + desired: bool, +) -> Result<(), String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let now = now_ts(); + conn.execute( + "INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, + last_joined, desired_joined) + VALUES (?1, ?2, ?3, ?4, 0, ?5) + ON CONFLICT(identity_id, hub_destination_hash, room_name) DO UPDATE SET + desired_joined = excluded.desired_joined", + params![ + identity_id, + hub_destination_hash, + room_name, + now, + desired as i64 + ], + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +pub fn list_channel_room_secrets_for_identity( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT hub_destination_hash, room_name, seal_scheme, seal_version, + ciphertext, updated_at + FROM channel_room_secrets + WHERE identity_id = ?1 + ORDER BY hub_destination_hash, room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(StoredChannelRoomSecret { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + seal_scheme: row.get(2)?, + seal_version: row.get(3)?, + ciphertext: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// Atomically store identity-sealed ciphertext and the non-secret requirement +/// hint. Callers must do this only after authenticated JOIN confirmation. +pub fn save_channel_room_secret( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + seal_scheme: &str, + seal_version: u32, + ciphertext: &[u8], +) -> Result<(), String> { + if seal_scheme.is_empty() || seal_version == 0 || ciphertext.is_empty() { + return Err("invalid sealed channel room secret".into()); + } + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let now = now_ts(); + let changed = transaction + .execute( + "UPDATE channel_rooms SET join_key_required = 1 + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err("channel room does not exist".into()); + } + transaction + .execute( + "INSERT INTO channel_room_secrets + (identity_id, hub_destination_hash, room_name, seal_scheme, + seal_version, ciphertext, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(identity_id, hub_destination_hash, room_name) DO UPDATE SET + seal_scheme = excluded.seal_scheme, + seal_version = excluded.seal_version, + ciphertext = excluded.ciphertext, + updated_at = excluded.updated_at", + params![ + identity_id, + hub_destination_hash, + room_name, + seal_scheme, + i64::from(seal_version), + ciphertext, + now + ], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string()) +} + +/// Persist one-way knowledge that this room requires a join key. This never +/// modifies recoverable ciphertext: a mistyped replacement must not destroy a +/// previously confirmed key. +pub fn mark_channel_room_key_required( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result<(), String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let changed = conn + .execute( + "UPDATE channel_rooms SET join_key_required = 1 + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + if changed == 1 { + Ok(()) + } else { + Err("channel room does not exist".into()) + } +} + +/// Forget recoverable ciphertext while preserving whether reconnect must wait +/// for a replacement key. Rejection/corruption uses `required = true`. +pub fn remove_channel_room_secret( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + required: bool, +) -> Result { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let removed = transaction + .execute( + "DELETE FROM channel_room_secrets + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())? + > 0; + transaction + .execute( + "UPDATE channel_rooms SET join_key_required = ?1 + WHERE identity_id = ?2 AND hub_destination_hash = ?3 AND room_name = ?4", + params![ + required as i64, + identity_id, + hub_destination_hash, + room_name + ], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(removed) +} + +pub fn remove_channel_room( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + let conn = pool.get().map_err(|error| error.to_string())?; + conn.execute( + "DELETE FROM channel_rooms + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map(|changed| changed > 0) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod channel_history_tests { + use super::*; + use r2d2_sqlite::SqliteConnectionManager; + + const IDENTITY_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const IDENTITY_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const HUB_A: &str = "11111111111111111111111111111111"; + const HUB_B: &str = "22222222222222222222222222222222"; + + fn test_pool() -> DbPool { + let manager = SqliteConnectionManager::memory() + .with_init(|connection| connection.execute_batch("PRAGMA foreign_keys=ON;")); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + save_identity(&pool, IDENTITY_A, "", "A", "A"); + save_identity(&pool, IDENTITY_B, "", "B", "B"); + pool + } + + fn event(hub: &str, room: &str, id: &str) -> NewChannelHistoryEvent { + NewChannelHistoryEvent { + hub_destination_hash: hub.into(), + room_name: room.into(), + event_id: id.into(), + kind: ChannelHistoryKind::Message, + timestamp_ms: 1_700_000_000_000, + source_hash: Some(IDENTITY_B.into()), + nickname: Some("Field Rat".into()), + text: format!("message {id}"), + ours: false, + mentioned: false, + } + } + + fn ids(page: &ChannelHistoryPage) -> Vec<&str> { + page.items + .iter() + .map(|item| item.event_id.as_str()) + .collect() + } + + fn estimated_payload_bytes(identity_id: &str, event: &NewChannelHistoryEvent) -> usize { + 128 + identity_id.len() + + event.hub_destination_hash.len() + + event.room_name.len() + + event.event_id.len() + + event.kind.as_storage().len() + + event.source_hash.as_deref().map_or(0, str::len) + + event.nickname.as_deref().map_or(0, str::len) + + event.text.len() + } + + #[test] + fn history_is_deduplicated_identity_scoped_and_cursor_paginated() { + let pool = test_pool(); + save_channel_hub(&pool, IDENTITY_A, HUB_A, "Relay", "A", false).unwrap(); + save_channel_room( + &pool, + IDENTITY_A, + HUB_A, + "general", + false, + Some("General discussion"), + ) + .unwrap(); + + let events: Vec<_> = (1..=5) + .map(|index| event(HUB_A, "general", &format!("event-{index}"))) + .collect(); + let recorded_at_ms = now_unix_ms(); + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &events, + recorded_at_ms, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + assert_eq!(outcome.inserted, 5); + assert_eq!(outcome.duplicates, 0); + assert_eq!(outcome.pruned, 0); + assert!(outcome.latest_sequence.is_some()); + + let duplicate = event(HUB_A, "general", "event-3"); + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &[duplicate], + recorded_at_ms.saturating_add(1), + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + assert_eq!(outcome.inserted, 0); + assert_eq!(outcome.duplicates, 1); + + let newest = list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 2).unwrap(); + assert_eq!(ids(&newest), vec!["event-4", "event-5"]); + assert!(newest.has_more); + assert_eq!( + newest.next_after.as_deref(), + newest.items.last().map(|item| item.sequence.as_str()) + ); + let cursor = newest.next_before.as_deref().unwrap(); + assert!(cursor.bytes().all(|byte| byte.is_ascii_digit())); + + let middle = + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", Some(cursor), 2).unwrap(); + assert_eq!(ids(&middle), vec!["event-2", "event-3"]); + assert!(middle.has_more); + let oldest = list_channel_history( + &pool, + IDENTITY_A, + HUB_A, + "general", + middle.next_before.as_deref(), + 2, + ) + .unwrap(); + assert_eq!(ids(&oldest), vec!["event-1"]); + assert!(!oldest.has_more); + assert!(oldest.next_before.is_none()); + + let forward = + list_channel_history_after(&pool, IDENTITY_A, HUB_A, "general", "0", 2).unwrap(); + assert_eq!(ids(&forward), vec!["event-1", "event-2"]); + assert!(forward.has_more); + assert!(forward.next_before.is_none()); + let forward_cursor = forward.next_after.as_deref().unwrap(); + let forward = + list_channel_history_after(&pool, IDENTITY_A, HUB_A, "general", forward_cursor, 2) + .unwrap(); + assert_eq!(ids(&forward), vec!["event-3", "event-4"]); + assert!(forward.has_more); + let forward = list_channel_history_after( + &pool, + IDENTITY_A, + HUB_A, + "general", + forward.next_after.as_deref().unwrap(), + 2, + ) + .unwrap(); + assert_eq!(ids(&forward), vec!["event-5"]); + assert!(!forward.has_more); + + // The same event id is independent across identities, hubs, and rooms. + append_channel_history_events(&pool, IDENTITY_B, &[event(HUB_A, "general", "event-1")]) + .unwrap(); + append_channel_history_events(&pool, IDENTITY_A, &[event(HUB_B, "general", "event-1")]) + .unwrap(); + assert_eq!( + list_channel_history(&pool, IDENTITY_B, HUB_A, "general", None, 10) + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + list_channel_history(&pool, IDENTITY_A, HUB_B, "general", None, 10) + .unwrap() + .items + .len(), + 1 + ); + + // History is user data in its own right, not a child of a bookmark. + let indexed = list_channel_room_index(&pool, IDENTITY_A) + .unwrap() + .into_iter() + .find(|entry| entry.hub_destination_hash == HUB_A && entry.room_name == "general") + .expect("saved history room is indexed"); + assert!(indexed.saved); + assert!(indexed.has_history); + assert_eq!(indexed.topic.as_deref(), Some("General discussion")); + assert!(remove_channel_hub(&pool, IDENTITY_A, HUB_A).unwrap()); + assert_eq!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 10) + .unwrap() + .items + .len(), + 5 + ); + let index = list_channel_room_index(&pool, IDENTITY_A).unwrap(); + let retained = index + .iter() + .find(|entry| entry.hub_destination_hash == HUB_A && entry.room_name == "general") + .expect("forgotten bookmark history remains discoverable"); + assert!(!retained.saved); + assert!(retained.has_history); + assert_eq!(retained.topic.as_deref(), Some("General discussion")); + assert_eq!( + retained.latest_recorded_at_ms, + Some(u64::try_from(recorded_at_ms).unwrap()) + ); + } + + #[test] + fn participant_summaries_are_room_scoped_durable_and_not_presence_claims() { + let pool = test_pool(); + let mut identified_join = event(HUB_A, "general", "identified-join"); + identified_join.kind = ChannelHistoryKind::Join; + identified_join.nickname = Some("Ada".into()); + + let mut nickname_join = event(HUB_A, "general", "nickname-join"); + nickname_join.kind = ChannelHistoryKind::Join; + nickname_join.source_hash = None; + nickname_join.nickname = Some("Guest".into()); + + let mut nickname_part = event(HUB_A, "general", "nickname-part"); + nickname_part.kind = ChannelHistoryKind::Part; + nickname_part.source_hash = None; + nickname_part.nickname = Some("Guest".into()); + + let mut identified_part = event(HUB_A, "general", "identified-part"); + identified_part.kind = ChannelHistoryKind::Part; + identified_part.nickname = Some("Ada renamed".into()); + + let mut ours = event(HUB_A, "general", "ours"); + ours.source_hash = Some(IDENTITY_A.into()); + ours.nickname = Some("A".into()); + ours.ours = true; + + let mut notice = event(HUB_A, "general", "notice"); + notice.kind = ChannelHistoryKind::Notice; + notice.nickname = Some("Relay".into()); + + append_channel_history_events_at( + &pool, + IDENTITY_A, + &[ + identified_join, + nickname_join, + nickname_part, + identified_part, + ours, + notice, + ], + 1_700_000_123_456, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + append_channel_history_events_at( + &pool, + IDENTITY_A, + &[event(HUB_A, "other", "other-room")], + 1_700_000_123_456, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + + let page = + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "general", 1_700_000_123_456) + .unwrap(); + assert_eq!(page.omitted_count, 0); + let participants = page.participants; + assert_eq!(participants.len(), 2); + assert_eq!(participants[0].identity_hash.as_deref(), Some(IDENTITY_B)); + assert_eq!(participants[0].nickname.as_deref(), Some("Ada renamed")); + assert_eq!(participants[0].last_seen_at_ms, 1_700_000_123_456); + assert_eq!(participants[1].identity_hash, None); + assert_eq!(participants[1].nickname.as_deref(), Some("Guest")); + assert!(participants.iter().all(|participant| { + participant.nickname.as_deref() != Some("A") + && participant.nickname.as_deref() != Some("Relay") + })); + + let crowd = (0..=CHANNEL_PARTICIPANT_MAX_RESULTS) + .map(|index| { + let mut participant = event(HUB_A, "crowd", &format!("crowd-{index}")); + participant.source_hash = None; + participant.nickname = Some(format!("Guest {index}")); + participant + }) + .collect::>(); + append_channel_history_events_at( + &pool, + IDENTITY_A, + &crowd, + 1_700_000_123_457, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + let crowd_page = + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "crowd", 1_700_000_123_457) + .unwrap(); + assert_eq!( + crowd_page.participants.len(), + CHANNEL_PARTICIPANT_MAX_RESULTS + ); + assert_eq!(crowd_page.omitted_count, 1); + assert_eq!( + crowd_page.participants[0].nickname.as_deref(), + Some("Guest 200") + ); + } + + #[test] + fn roster_observations_preserve_identified_participants_without_transcript_rows() { + let pool = test_pool(); + let observation = NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "quiet".into(), + identity_hash: IDENTITY_B.into(), + nickname: Some("Ada".into()), + }; + assert_eq!( + remember_channel_participants_at( + &pool, + IDENTITY_A, + std::slice::from_ref(&observation), + 2_000, + ) + .unwrap(), + 1 + ); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "quiet", None, 10) + .unwrap() + .items + .is_empty() + ); + + // An older identity-only observation must not erase a nickname that + // was already associated with the canonical identity. + let mut identity_only = observation.clone(); + identity_only.nickname = None; + remember_channel_participants_at(&pool, IDENTITY_A, &[identity_only], 1_000).unwrap(); + let page = list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "quiet", 3_000).unwrap(); + assert_eq!(page.participants.len(), 1); + assert_eq!( + page.participants[0].identity_hash.as_deref(), + Some(IDENTITY_B) + ); + assert_eq!(page.participants[0].nickname.as_deref(), Some("Ada")); + assert_eq!(page.participants[0].last_seen_at_ms, 2_000); + + // The durable projection keeps a bounded channel-only tail while a + // peer still known elsewhere is exempt from that transient allowance. + assert_eq!( + touch_identity_activity_for_service( + &pool, + &[( + "dddddddddddddddddddddddddddddddd".into(), + 3.0, + Some("Ada".into()), + None, + )], + Some(IDENTITY_B), + PEER_SERVICE_LXMF_DELIVERY, + ), + 1 + ); + let known_peer = NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "observed-crowd".into(), + identity_hash: IDENTITY_B.into(), + nickname: Some("Ada".into()), + }; + remember_channel_participants_at(&pool, IDENTITY_A, &[known_peer], 2_500).unwrap(); + let crowd = (0..=CHANNEL_PARTICIPANT_MAX_RESULTS) + .map(|index| NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "observed-crowd".into(), + identity_hash: format!("{index:032x}"), + nickname: Some(format!("Peer {index}")), + }) + .collect::>(); + remember_channel_participants_at(&pool, IDENTITY_A, &crowd, 3_000).unwrap(); + let retained: i64 = pool + .get() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = 'observed-crowd'", + params![IDENTITY_A, HUB_A], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + retained, + i64::try_from(CHANNEL_PARTICIPANT_MAX_TRANSIENT_PER_ROOM + 1).unwrap() + ); + + assert_eq!( + clear_channel_room_history(&pool, IDENTITY_A, HUB_A, "quiet").unwrap(), + 0 + ); + assert!( + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "quiet", 3_000) + .unwrap() + .participants + .is_empty() + ); + } + + #[test] + fn participant_summaries_follow_the_known_identity_retention_setting() { + let pool = test_pool(); + let day_ms = MILLIS_PER_DAY; + let observed_at_ms = 20 * day_ms; + let query_at_ms = observed_at_ms + 15 * day_ms; + let mut historical = event(HUB_A, "retention", "historical-peer"); + historical.nickname = Some("Ada".into()); + append_channel_history_events_at( + &pool, + IDENTITY_A, + &[historical], + observed_at_ms, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + let roster_only = NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "retention".into(), + identity_hash: "cccccccccccccccccccccccccccccccc".into(), + nickname: Some("Grace".into()), + }; + remember_channel_participants_at(&pool, IDENTITY_A, &[roster_only], observed_at_ms) + .unwrap(); + + assert!( + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "retention", query_at_ms,) + .unwrap() + .participants + .is_empty(), + "the default 14-day known-identity lifetime also bounds Seen here" + ); + + assert_eq!( + touch_identity_activity_for_service( + &pool, + &[( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".into(), + query_at_ms as f64 / 1_000.0, + Some("Ada".into()), + None, + )], + Some(IDENTITY_B), + PEER_SERVICE_LXMF_DELIVERY, + ), + 1 + ); + let protected = + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "retention", query_at_ms) + .unwrap(); + assert_eq!(protected.participants.len(), 1); + assert_eq!( + protected.participants[0].identity_hash.as_deref(), + Some(IDENTITY_B), + "a peer still known elsewhere keeps its channel association" + ); + + set_setting(&pool, "known_identities_prune_days", "0"); + assert_eq!( + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "retention", query_at_ms,) + .unwrap() + .participants + .len(), + 2, + "disabling identity-age pruning still leaves the per-room cap in force" + ); + } + + #[test] + fn unread_mentions_are_sequence_scoped_monotonic_and_policy_aware() { + let pool = test_pool(); + let plain = event(HUB_A, "general", "plain"); + let mut mention = event(HUB_A, "general", "mention"); + mention.kind = ChannelHistoryKind::Action; + mention.text = "@A checks the signal".into(); + mention.mentioned = true; + let mut notice = event(HUB_A, "general", "notice"); + notice.kind = ChannelHistoryKind::Notice; + let mut presence = event(HUB_A, "general", "join"); + presence.kind = ChannelHistoryKind::Join; + let mut ours = event(HUB_A, "general", "ours"); + ours.ours = true; + ours.source_hash = Some(IDENTITY_A.into()); + + let outcome = append_channel_history_events( + &pool, + IDENTITY_A, + &[plain, mention, notice, presence, ours], + ) + .unwrap(); + assert_eq!(outcome.inserted, 5); + assert_eq!( + outcome + .inserted_events + .iter() + .map(|inserted| inserted.batch_index) + .collect::>(), + vec![0, 1, 2, 3, 4] + ); + + let summary = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(summary.unread_total, 3); + assert_eq!(summary.mention_total, 1); + assert_eq!( + summary.attention_total, 1, + "the default mentions policy should not nag for every room message" + ); + assert_eq!(summary.rooms.len(), 1); + assert_eq!( + summary.rooms[0].notification_level, + ChannelRoomNotificationLevel::Mentions + ); + + let state = set_channel_room_notification_level( + &pool, + IDENTITY_A, + HUB_A, + "general", + ChannelRoomNotificationLevel::All, + ) + .unwrap(); + assert_eq!(state.last_read_sequence, "0"); + assert_eq!( + get_channel_unread_summary(&pool, IDENTITY_A) + .unwrap() + .attention_total, + 3 + ); + + let page = list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 10).unwrap(); + let mention_sequence = page + .items + .iter() + .find(|item| item.event_id == "mention") + .unwrap() + .sequence + .clone(); + let state = + mark_channel_room_read(&pool, IDENTITY_A, HUB_A, "general", &mention_sequence).unwrap(); + assert_eq!(state.last_read_sequence, mention_sequence); + assert_eq!( + state.notification_level, + ChannelRoomNotificationLevel::All, + "advancing read state must preserve delivery policy" + ); + let summary = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(summary.unread_total, 1); + assert_eq!(summary.mention_total, 0); + + let wrong_room = mark_channel_room_read( + &pool, + IDENTITY_A, + HUB_A, + "other", + &page.items.last().unwrap().sequence, + ); + assert!( + wrong_room.is_err(), + "a global sequence from another room must never mark this room read" + ); + + let tail = page.items.last().unwrap().sequence.clone(); + mark_channel_room_read(&pool, IDENTITY_A, HUB_A, "general", &tail).unwrap(); + let regressed = mark_channel_room_read(&pool, IDENTITY_A, HUB_A, "general", "1").unwrap(); + assert_eq!( + regressed.last_read_sequence, tail, + "read cursors are monotonic" + ); + let cleared = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(cleared.unread_total, 0); + assert_eq!(cleared.rooms.len(), 1); + assert_eq!( + cleared.rooms[0].notification_level, + ChannelRoomNotificationLevel::All, + "zero-unread rooms remain addressable for notification controls" + ); + + set_channel_room_notification_level( + &pool, + IDENTITY_A, + HUB_A, + "general", + ChannelRoomNotificationLevel::Mute, + ) + .unwrap(); + let mut later = event(HUB_A, "general", "later"); + later.mentioned = true; + append_channel_history_events(&pool, IDENTITY_A, &[later]).unwrap(); + let summary = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(summary.unread_total, 1); + assert_eq!(summary.mention_total, 1); + assert_eq!(summary.attention_total, 0); + } + + #[test] + fn retention_uses_local_time_and_bounds_rooms_and_identities() { + let pool = test_pool(); + let retention = ChannelHistoryRetentionPolicy { + max_age_ms: 100, + max_events_per_room: 3, + max_events_per_identity: 5, + max_events_global: 100, + max_payload_bytes_per_room: 1_000_000, + max_payload_bytes_per_identity: 1_000_000, + max_payload_bytes_global: 1_000_000, + }; + let alpha: Vec<_> = (1..=4) + .map(|index| event(HUB_A, "alpha", &format!("a-{index}"))) + .collect(); + let outcome = + append_channel_history_events_at(&pool, IDENTITY_A, &alpha, 1_000, retention).unwrap(); + assert_eq!(outcome.inserted, 4); + assert_eq!(outcome.pruned, 1); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10).unwrap()), + vec!["a-2", "a-3", "a-4"] + ); + + let beta: Vec<_> = (1..=3) + .map(|index| event(HUB_A, "beta", &format!("b-{index}"))) + .collect(); + let outcome = + append_channel_history_events_at(&pool, IDENTITY_A, &beta, 1_010, retention).unwrap(); + assert_eq!(outcome.pruned, 1, "identity ceiling removes the oldest row"); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10).unwrap()), + vec!["a-3", "a-4"] + ); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "beta", None, 10).unwrap()), + vec!["b-1", "b-2", "b-3"] + ); + + // A forged remote timestamp cannot extend retention. Advancing only + // the local recording clock expires all five prior rows. + let mut fresh = event(HUB_A, "gamma", "fresh"); + fresh.timestamp_ms = 1; + let outcome = + append_channel_history_events_at(&pool, IDENTITY_A, &[fresh], 1_111, retention) + .unwrap(); + assert_eq!(outcome.pruned, 5); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "gamma", None, 10).unwrap()), + vec!["fresh"] + ); + } + + #[test] + fn retention_bounds_estimated_payload_per_room_identity_and_install() { + let pool = test_pool(); + let first = event(HUB_A, "alpha", "same-1"); + let second = event(HUB_A, "alpha", "same-2"); + let one_event_bytes = estimated_payload_bytes(IDENTITY_A, &first); + assert_eq!( + one_event_bytes, + estimated_payload_bytes(IDENTITY_A, &second) + ); + let room_policy = ChannelHistoryRetentionPolicy { + max_age_ms: 10_000, + max_events_per_room: 100, + max_events_per_identity: 100, + max_events_global: 100, + max_payload_bytes_per_room: one_event_bytes, + max_payload_bytes_per_identity: 1_000_000, + max_payload_bytes_global: 1_000_000, + }; + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &[first, second], + 1_000, + room_policy, + ) + .unwrap(); + assert_eq!(outcome.pruned, 1); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10).unwrap()), + vec!["same-2"] + ); + + clear_channel_history_for_identity(&pool, IDENTITY_A).unwrap(); + let alpha = event(HUB_A, "alpha", "one-a"); + let beta = event(HUB_A, "bravo", "one-b"); + let identity_budget = estimated_payload_bytes(IDENTITY_A, &alpha) + .max(estimated_payload_bytes(IDENTITY_A, &beta)); + let identity_policy = ChannelHistoryRetentionPolicy { + max_payload_bytes_per_room: 1_000_000, + max_payload_bytes_per_identity: identity_budget, + ..room_policy + }; + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &[alpha, beta], + 1_100, + identity_policy, + ) + .unwrap(); + assert_eq!(outcome.pruned, 1); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "bravo", None, 10).unwrap()), + vec!["one-b"] + ); + + clear_channel_history_for_identity(&pool, IDENTITY_A).unwrap(); + let old = event(HUB_A, "global", "old-one"); + let new = event(HUB_A, "global", "new-one"); + let global_budget = estimated_payload_bytes(IDENTITY_A, &old) + .max(estimated_payload_bytes(IDENTITY_B, &new)); + let global_policy = ChannelHistoryRetentionPolicy { + max_payload_bytes_per_identity: 1_000_000, + max_payload_bytes_global: global_budget, + ..identity_policy + }; + append_channel_history_events_at(&pool, IDENTITY_A, &[old], 1_200, global_policy).unwrap(); + let outcome = + append_channel_history_events_at(&pool, IDENTITY_B, &[new], 1_201, global_policy) + .unwrap(); + assert_eq!(outcome.pruned, 1); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "global", None, 10) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_B, HUB_A, "global", None, 10).unwrap()), + vec!["new-one"] + ); + } + + #[test] + fn explicit_clear_is_scoped_and_identity_delete_cascades() { + let pool = test_pool(); + append_channel_history_events( + &pool, + IDENTITY_A, + &[ + event(HUB_A, "general", "a-general"), + event(HUB_A, "other", "a-other"), + ], + ) + .unwrap(); + append_channel_history_events(&pool, IDENTITY_B, &[event(HUB_A, "general", "b-general")]) + .unwrap(); + + assert_eq!( + clear_channel_room_history(&pool, IDENTITY_A, HUB_A, "general").unwrap(), + 1 + ); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 10) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "other", None, 10) + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + list_channel_history(&pool, IDENTITY_B, HUB_A, "general", None, 10) + .unwrap() + .items + .len(), + 1 + ); + + delete_identity(&pool, IDENTITY_A, true).unwrap(); + let remaining: i64 = pool + .get() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_history WHERE identity_id = ?1", + params![IDENTITY_A], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining, 0); + let remaining_usage: i64 = pool + .get() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_history_room_usage WHERE identity_id = ?1", + params![IDENTITY_A], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining_usage, 0); + } + + #[test] + fn history_rejects_ambiguous_cursors_and_unbounded_inputs() { + let pool = test_pool(); + let valid = event(HUB_A, "general", "secret-event"); + assert!(!format!("{valid:?}").contains("message secret-event")); + append_channel_history_events(&pool, IDENTITY_A, &[valid]).unwrap(); + + for cursor in ["", "0", "01", "-1", "abc", "9223372036854775808"] { + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", Some(cursor), 10) + .is_err(), + "cursor `{cursor}` must be rejected" + ); + } + assert!(list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 0).is_err()); + for cursor in ["", "00", "01", "-1", "abc", "9223372036854775808"] { + assert!( + list_channel_history_after(&pool, IDENTITY_A, HUB_A, "general", cursor, 10) + .is_err(), + "forward cursor `{cursor}` must be rejected" + ); + } + assert!( + list_channel_history( + &pool, + IDENTITY_A, + HUB_A, + "general", + None, + CHANNEL_HISTORY_MAX_PAGE_SIZE + 1 + ) + .is_err() + ); + + let mut invalid = event(HUB_A, "General", "bad-room"); + assert!(append_channel_history_events(&pool, IDENTITY_A, &[invalid.clone()]).is_err()); + invalid.room_name = "general".into(); + invalid.hub_destination_hash = "ABCDEFABCDEFABCDEFABCDEFABCDEFAB".into(); + assert!(append_channel_history_events(&pool, IDENTITY_A, &[invalid]).is_err()); + + let oversized = vec![event(HUB_A, "general", "same"); CHANNEL_HISTORY_MAX_APPEND_BATCH + 1]; + assert!(append_channel_history_events(&pool, IDENTITY_A, &oversized).is_err()); + } +} + +#[cfg(test)] +mod channel_bookmark_tests { + use super::*; + use r2d2_sqlite::SqliteConnectionManager; + + fn test_pool() -> DbPool { + let manager = SqliteConnectionManager::memory() + .with_init(|connection| connection.execute_batch("PRAGMA foreign_keys=ON;")); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + pool + } + + #[test] + fn hubs_and_rooms_are_identity_scoped_and_hub_delete_cascades() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + + save_channel_hub( + &pool, + "identity-a", + "00112233445566778899aabbccddeeff", + "Mountain relay", + "Field Rat", + false, + ) + .unwrap(); + save_channel_room( + &pool, + "identity-a", + "00112233445566778899aabbccddeeff", + "field team", + true, + None, + ) + .unwrap(); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + assert_eq!(hubs.len(), 1); + assert_eq!(hubs[0].label, "Mountain relay"); + assert!( + list_saved_channel_hubs(&pool, "identity-b") + .unwrap() + .is_empty() + ); + let rooms = + list_saved_channel_rooms(&pool, "identity-a", "00112233445566778899aabbccddeeff") + .unwrap(); + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].room_name, "field team"); + assert!(rooms[0].last_joined > 0.0); + + assert!( + remove_channel_hub(&pool, "identity-a", "00112233445566778899aabbccddeeff").unwrap() + ); + assert!( + list_saved_channel_rooms(&pool, "identity-a", "00112233445566778899aabbccddeeff") + .unwrap() + .is_empty() + ); + } + + #[test] + fn desired_channel_state_is_single_hub_scoped_and_independent_of_recency() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + + set_channel_hub_desired(&pool, "identity-a", "aa", "alpha", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "general", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "quiet", false).unwrap(); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + assert_eq!(hubs.len(), 1); + assert!(hubs[0].desired_connected); + let rooms = list_saved_channel_rooms_for_identity(&pool, "identity-a").unwrap(); + assert_eq!(rooms.len(), 2); + assert!( + rooms + .iter() + .find(|room| room.room_name == "general") + .unwrap() + .desired_joined + ); + assert!( + !rooms + .iter() + .find(|room| room.room_name == "quiet") + .unwrap() + .desired_joined + ); + + // Selecting another hub atomically replaces the one scheduler winner + // but retains the first hub and its room intent for a later switch. + set_channel_hub_desired(&pool, "identity-a", "bb", "bravo", true).unwrap(); + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + assert_eq!(hubs.iter().filter(|hub| hub.desired_connected).count(), 1); + assert!( + hubs.iter() + .find(|hub| hub.destination_hash == "bb") + .unwrap() + .desired_connected + ); + assert!( + !hubs + .iter() + .find(|hub| hub.destination_hash == "aa") + .unwrap() + .desired_connected + ); + assert!( + list_saved_channel_rooms(&pool, "identity-a", "aa") + .unwrap() + .iter() + .any(|room| room.room_name == "general" && room.desired_joined) + ); + + // Updating recency and labels is orthogonal to scheduler intent. + save_channel_hub(&pool, "identity-a", "bb", "Relay B", "bravo", true).unwrap(); + save_channel_room(&pool, "identity-a", "bb", "ops", true, None).unwrap(); + assert!( + list_saved_channel_hubs(&pool, "identity-a") + .unwrap() + .iter() + .find(|hub| hub.destination_hash == "bb") + .unwrap() + .desired_connected + ); + assert!( + !list_saved_channel_rooms(&pool, "identity-a", "bb") + .unwrap() + .iter() + .find(|room| room.room_name == "ops") + .unwrap() + .desired_joined + ); + + set_channel_hub_desired(&pool, "identity-b", "cc", "charlie", true).unwrap(); + assert!( + list_saved_channel_hubs(&pool, "identity-b").unwrap()[0].desired_connected, + "the one-hub budget is identity-scoped" + ); + } + + #[test] + fn sealed_room_secrets_are_identity_scoped_redacted_and_forgettable() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + set_channel_hub_desired(&pool, "identity-a", "aa", "alpha", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "general", true).unwrap(); + set_channel_hub_desired(&pool, "identity-b", "bb", "bravo", true).unwrap(); + set_channel_room_desired(&pool, "identity-b", "bb", "general", true).unwrap(); + + let ciphertext = b"opaque-ciphertext-that-debug-must-hide"; + save_channel_room_secret( + &pool, + "identity-a", + "aa", + "general", + "rns_identity", + 1, + ciphertext, + ) + .unwrap(); + + let secrets = list_channel_room_secrets_for_identity(&pool, "identity-a").unwrap(); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0].ciphertext, ciphertext); + assert_eq!(secrets[0].seal_scheme, "rns_identity"); + assert_eq!(secrets[0].seal_version, 1); + let debug = format!("{:?}", secrets[0]); + assert!(debug.contains("")); + assert!(!debug.contains("opaque-ciphertext")); + assert!( + list_channel_room_secrets_for_identity(&pool, "identity-b") + .unwrap() + .is_empty() + ); + assert!(list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap()[0].join_key_required); + mark_channel_room_key_required(&pool, "identity-a", "aa", "general").unwrap(); + assert_eq!( + list_channel_room_secrets_for_identity(&pool, "identity-a").unwrap()[0].ciphertext, + ciphertext, + "learning that a key is required must not erase confirmed ciphertext" + ); + + set_channel_room_desired(&pool, "identity-a", "aa", "invited", true).unwrap(); + mark_channel_room_key_required(&pool, "identity-a", "aa", "invited").unwrap(); + let invited = list_saved_channel_rooms(&pool, "identity-a", "aa") + .unwrap() + .into_iter() + .find(|room| room.room_name == "invited") + .unwrap(); + assert!(invited.join_key_required); + assert_eq!( + list_channel_room_secrets_for_identity(&pool, "identity-a") + .unwrap() + .len(), + 1, + "key-required knowledge does not invent recoverable key material" + ); + + assert!(remove_channel_room_secret(&pool, "identity-a", "aa", "general", true).unwrap()); + assert!( + list_channel_room_secrets_for_identity(&pool, "identity-a") + .unwrap() + .is_empty() + ); + let room = &list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap()[0]; + assert!( + room.desired_joined, + "forgetting a key preserves room desire" + ); + assert!( + room.join_key_required, + "a rejected key must block keyless reconnect" + ); + } + + #[test] + fn removing_a_client_room_cascades_its_sealed_secret() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + set_channel_hub_desired(&pool, "identity-a", "aa", "alpha", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "general", true).unwrap(); + save_channel_room_secret( + &pool, + "identity-a", + "aa", + "general", + "rns_identity", + 1, + b"ciphertext", + ) + .unwrap(); + + assert!(remove_channel_room(&pool, "identity-a", "aa", "general").unwrap()); + assert!( + list_channel_room_secrets_for_identity(&pool, "identity-a") + .unwrap() + .is_empty() + ); + } + + #[test] + fn renaming_retires_the_old_name_but_keeps_deliberate_hub_aliases() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "Old Name"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + + // Bookmark carrying a copy of the identity name (auto-prefilled). + save_channel_hub(&pool, "identity-a", "aa", "Relay", "Old Name", true).unwrap(); + // Bookmark with a deliberate per-hub alias. + save_channel_hub(&pool, "identity-a", "bb", "Alias relay", "Radio Rat", true).unwrap(); + // Another identity that happens to use the same name. + save_channel_hub(&pool, "identity-b", "cc", "Other", "Old Name", true).unwrap(); + + let updated = rename_identity_and_retire_alias(&pool, "identity-a", "New Name").unwrap(); + assert_eq!( + updated.retired_bookmarks, 1, + "only the stale copy is rewritten" + ); + assert_eq!(updated.previous_name, "Old Name"); + assert_eq!( + get_identity(&pool, "identity-a") + .and_then(|identity| identity + .get("display_name") + .and_then(|value| value.as_str()) + .map(str::to_string)) + .unwrap_or_default(), + "New Name", + "the rename commits with the sweep" + ); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + let stale = hubs + .iter() + .find(|hub| hub.destination_hash == "aa") + .unwrap(); + let alias = hubs + .iter() + .find(|hub| hub.destination_hash == "bb") + .unwrap(); + assert_eq!( + stale.nickname, "New Name", + "superseded name must not survive" + ); + assert_eq!( + alias.nickname, "Radio Rat", + "a deliberate per-hub alias must keep working" + ); + + let other = list_saved_channel_hubs(&pool, "identity-b").unwrap(); + assert_eq!( + other[0].nickname, "Old Name", + "another identity's bookmarks are untouched" + ); + + // Renaming to the same name is a no-op sweep. + assert_eq!( + rename_identity_and_retire_alias(&pool, "identity-a", "New Name") + .unwrap() + .retired_bookmarks, + 0 + ); + } + + fn hub_room(name: &str) -> HubRoomRow { + HubRoomRow { + room_name: name.to_string(), + topic: "field ops".into(), + key_salt: "aabb".into(), + key_mac: "ccdd".into(), + key_pepper_id: "eeff".into(), + moderated: true, + invite_only: false, + topic_ops_only: true, + no_outside_msgs: true, + private: true, + last_used: 1234.0, + grants: vec![ + ("op".into(), "a".repeat(32), 0.0), + ("ban".into(), "b".repeat(32), 0.0), + ("invite".into(), "c".repeat(32), 9_000.0), + ], + } + } + + #[test] + fn hub_registry_round_trips_rooms_grants_and_klines() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + + apply_hub_ops( + &pool, + "identity-a", + &[ + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + HubRoomOp::ReplaceKlines(vec!["d".repeat(32)]), + ], + ) + .unwrap(); + + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + assert_eq!(rooms.len(), 1); + let room = &rooms[0]; + assert_eq!(room.room_name, "lobby"); + assert_eq!(room.topic, "field ops"); + assert_eq!(room.key_mac, "ccdd"); + // +p must survive a restart; the reference loses it. + assert!(room.private && room.moderated && room.topic_ops_only && room.no_outside_msgs); + assert_eq!(room.last_used, 1234.0); + let mut kinds: Vec<&str> = room.grants.iter().map(|(k, _, _)| k.as_str()).collect(); + kinds.sort(); + assert_eq!(kinds, vec!["ban", "invite", "op"]); + assert_eq!( + list_hub_klines(&pool, "identity-a").unwrap(), + vec!["d".repeat(32)] + ); + } + + #[test] + fn a_room_upsert_replaces_its_grants_wholesale() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Upsert(Box::new(hub_room("lobby")))], + ) + .unwrap(); + + // A revoked op must not survive as a stale row. + let mut room = hub_room("lobby"); + room.grants = vec![("voice".into(), "e".repeat(32), 0.0)]; + apply_hub_ops(&pool, "identity-a", &[HubRoomOp::Upsert(Box::new(room))]).unwrap(); + + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + assert_eq!(rooms[0].grants.len(), 1); + assert_eq!(rooms[0].grants[0].0, "voice"); + } + + #[test] + fn hub_registry_ops_apply_in_batch_order() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + // Touch-then-remove and remove-then-upsert must not reorder. + apply_hub_ops( + &pool, + "identity-a", + &[ + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + HubRoomOp::Touched { + room_name: "lobby".into(), + last_used: 4321.0, + }, + HubRoomOp::Removed { + room_name: "lobby".into(), + }, + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + ], + ) + .unwrap(); + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].last_used, 1234.0, "the final upsert wins"); + } + + #[test] + fn removing_a_room_cascades_its_grants() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Upsert(Box::new(hub_room("lobby")))], + ) + .unwrap(); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Removed { + room_name: "lobby".into(), + }], + ) + .unwrap(); + + assert!(list_hub_rooms(&pool, "identity-a").unwrap().is_empty()); + let orphans: i64 = pool + .get() + .unwrap() + .query_row("SELECT COUNT(*) FROM channel_hub_grants", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(orphans, 0, "grants must not outlive their room"); + } + + #[test] + fn gc_invites_drops_only_expired_invite_grants() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Upsert(Box::new(hub_room("lobby")))], + ) + .unwrap(); + + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::GcInvites { before: 10_000.0 }], + ) + .unwrap(); + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + let kinds: Vec<&str> = rooms[0].grants.iter().map(|(k, _, _)| k.as_str()).collect(); + assert!(!kinds.contains(&"invite"), "the expired invite is gone"); + assert!( + kinds.contains(&"op") && kinds.contains(&"ban"), + "permanent grants (expires_at 0) must never be collected" + ); + } + + #[test] + fn hub_registry_is_identity_scoped_and_cascades_with_the_identity() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + for id in ["identity-a", "identity-b"] { + apply_hub_ops( + &pool, + id, + &[ + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + HubRoomOp::ReplaceKlines(vec!["d".repeat(32)]), + ], + ) + .unwrap(); + } + + delete_identity(&pool, "identity-a", true).unwrap(); + assert!(list_hub_rooms(&pool, "identity-a").unwrap().is_empty()); + assert!(list_hub_klines(&pool, "identity-a").unwrap().is_empty()); + assert_eq!(list_hub_rooms(&pool, "identity-b").unwrap().len(), 1); + assert_eq!(list_hub_klines(&pool, "identity-b").unwrap().len(), 1); + } + + #[test] + fn a_hub_room_row_never_debug_prints_its_key() { + let rendered = format!("{:?}", hub_room("lobby")); + assert!(!rendered.contains("ccdd"), "the key MAC must not be logged"); + assert!( + !rendered.contains("aabb"), + "the key salt must not be logged" + ); + assert!(rendered.contains("keyed: true")); + } + + #[test] + fn a_rename_that_fails_leaves_the_old_name_recoverable() { + // The sweep must not commit ahead of the rename: if it did, a retry + // would read the new name as the "previous" one, skip the sweep on the + // equality guard, and strand the superseded name in the bookmark. + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "Old Name"); + save_channel_hub(&pool, "identity-a", "aa", "Relay", "Old Name", true).unwrap(); + + // Force the transaction to fail after the identities write by holding a + // schema-incompatible state: drop the table the sweep targets. + pool.get() + .unwrap() + .execute("DROP TABLE channel_hubs", []) + .unwrap(); + assert!(rename_identity_and_retire_alias(&pool, "identity-a", "New Name").is_err()); + + // The rename rolled back with it, so the retry still sees the old name + // as "previous" and can still retire it. + assert_eq!( + get_identity(&pool, "identity-a") + .and_then(|identity| identity + .get("display_name") + .and_then(|value| value.as_str()) + .map(str::to_string)) + .unwrap_or_default(), + "Old Name", + "a failed rename must not leave the new name committed" + ); + } +} + /// Overridable via `known_identities_prune_days` (0 disables). pub const DEFAULT_PRUNE_DAYS: u32 = 14; @@ -2437,6 +6973,14 @@ fn normalized_peer_services<'a>(services: impl IntoIterator) -> out } +fn normalized_lxmf_compression_support(value: &str) -> Option<&'static str> { + match value.trim() { + LXMF_COMPRESSION_SUPPORT_SUPPORTED => Some(LXMF_COMPRESSION_SUPPORT_SUPPORTED), + LXMF_COMPRESSION_SUPPORT_UNSUPPORTED => Some(LXMF_COMPRESSION_SUPPORT_UNSUPPORTED), + _ => None, + } +} + /// Same as `touch_identity_activity`, but records the service aspect that made /// the destination actionable for Ratspeak. pub fn touch_identity_activity_for_service( @@ -2458,6 +7002,7 @@ pub struct IdentityActivityUpdate { pub identity_hash: Option, pub services: Vec, pub clear_ratspeak_services: bool, + pub lxmf_compression_support: Option, } /// Same as `touch_identity_activity_for_service`, but merges multiple service @@ -2486,6 +7031,7 @@ pub fn touch_identity_activity_for_services( identity_hash: identity_hash.map(str::to_owned), services: services.clone(), clear_ratspeak_services, + lxmf_compression_support: None, }) .collect(); touch_identity_activity_updates(pool, &updates) @@ -2514,8 +7060,8 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit Err(_) => return 0, }; let mut stmt = match tx.prepare_cached( - "INSERT INTO identity_activity(dest_hash, identity_hash, last_seen, first_seen, announce_count, display_name, status, last_interface, services) - VALUES (?1, ?2, ?3, ?3, 1, COALESCE(?4, ''), COALESCE(?5, ''), COALESCE(?6, ''), ?7) + "INSERT INTO identity_activity(dest_hash, identity_hash, last_seen, first_seen, announce_count, display_name, status, last_interface, services, lxmf_compression_support) + VALUES (?1, ?2, ?3, ?3, 1, COALESCE(?4, ''), COALESCE(?5, ''), COALESCE(?6, ''), ?7, COALESCE(?8, '')) ON CONFLICT(dest_hash) DO UPDATE SET last_seen = MAX(excluded.last_seen, last_seen), announce_count = announce_count + 1, @@ -2535,7 +7081,11 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit WHEN excluded.last_interface != '' THEN excluded.last_interface ELSE last_interface END, - services = excluded.services", + services = excluded.services, + lxmf_compression_support = CASE + WHEN ?8 IS NOT NULL AND ?8 != '' THEN excluded.lxmf_compression_support + ELSE lxmf_compression_support + END", ) { Ok(s) => s, Err(_) => return 0, @@ -2561,6 +7111,10 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit } } let merged_services = merged.join(","); + let lxmf_compression_support = update + .lxmf_compression_support + .as_deref() + .and_then(normalized_lxmf_compression_support); let ok = stmt .execute(params![ update.dest_hash, @@ -2569,7 +7123,8 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit n, update.status.as_deref(), i, - merged_services + merged_services, + lxmf_compression_support, ]) .is_ok(); if ok { @@ -2581,6 +7136,38 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit touched } +pub fn get_identity_lxmf_compression_support(pool: &DbPool, dest_hash: &str) -> Option { + let conn = pool.get().ok()?; + let raw: String = conn + .query_row( + "SELECT COALESCE(lxmf_compression_support, '') FROM identity_activity WHERE dest_hash = ?1", + params![dest_hash], + |row| row.get(0), + ) + .ok()?; + normalized_lxmf_compression_support(&raw).map(str::to_owned) +} + +pub fn set_identity_lxmf_compression_support( + pool: &DbPool, + dest_hash: &str, + support: &str, +) -> bool { + let Some(support) = normalized_lxmf_compression_support(support) else { + return false; + }; + let conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + conn.execute( + "UPDATE identity_activity SET lxmf_compression_support = ?1 WHERE dest_hash = ?2", + params![support, dest_hash], + ) + .map(|rows| rows > 0) + .unwrap_or(false) +} + pub fn touch_identity_last_heard(pool: &DbPool, dest_hash: &str, timestamp: f64) -> bool { let conn = match pool.get() { Ok(c) => c, @@ -2650,8 +7237,11 @@ pub fn get_peers_by_hashes(pool: &DbPool, hashes: &[String], identity_id: &str) ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "get_peers_by_hashes: prepare failed"); + Err(_) => { + tracing::warn!( + reason = "prepare_failed", + "get_peers_by_hashes: prepare failed" + ); continue; } }; @@ -2749,8 +7339,11 @@ pub fn get_peers_snapshot(pool: &DbPool, cutoff_unix: f64, identity_id: &str) -> ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "get_peers_snapshot: prepare failed"); + Err(_) => { + tracing::warn!( + reason = "prepare_failed", + "get_peers_snapshot: prepare failed" + ); return vec![]; } }; @@ -2878,18 +7471,21 @@ pub fn delete_identity_activity(pool: &DbPool, hashes: &[String]) -> usize { .collect(); match tx.execute(&sql, params.as_slice()) { Ok(n) => deleted += n, - Err(e) => { + Err(_) => { // Continue on chunk failure; pruner retries next pass. tracing::warn!( - error = %e, chunk_len = chunk.len(), + reason = "delete_failed", "delete_identity_activity chunk failed; remaining chunks will still be attempted" ); } } } - if let Err(e) = tx.commit() { - tracing::error!(error = %e, "delete_identity_activity commit failed — deletions discarded"); + if tx.commit().is_err() { + tracing::error!( + reason = "commit_failed", + "delete_identity_activity commit failed — deletions discarded" + ); return 0; } deleted @@ -3146,45 +7742,19 @@ pub fn clear_all_messages(pool: &DbPool, identity_id: &str) -> Vec { Ok(c) => c, Err(_) => return vec![], }; - let mut file_refs = Vec::new(); - if identity_id.is_empty() { - if let Ok(mut stmt) = - conn.prepare("SELECT attachment_stored_name, image_stored_name FROM messages") - && let Ok(rows) = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) - { - for r in rows.flatten() { - if !r.0.is_empty() { - file_refs.push(r.0); - } - if !r.1.is_empty() { - file_refs.push(r.1); - } - } - } + let file_refs = if identity_id.is_empty() { + query_message_file_refs( + &conn, + "SELECT attachment_stored_name, image_stored_name FROM messages", + [], + ) } else { - if let Ok(mut stmt) = conn.prepare( + query_message_file_refs( + &conn, "SELECT attachment_stored_name, image_stored_name FROM messages WHERE identity_id = ?1", - ) && let Ok(rows) = stmt.query_map(params![identity_id], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) { - for r in rows.flatten() { - if !r.0.is_empty() { - file_refs.push(r.0); - } - if !r.1.is_empty() { - file_refs.push(r.1); - } - } - } - } + params![identity_id], + ) + }; if identity_id.is_empty() { conn.execute("DELETE FROM messages", []).ok(); } else { @@ -3205,25 +7775,11 @@ pub fn get_identity_file_refs(pool: &DbPool, identity_id: &str) -> Vec { if identity_id.is_empty() { return vec![]; } - let mut file_refs = Vec::new(); - if let Ok(mut stmt) = conn.prepare( + query_message_file_refs( + &conn, "SELECT attachment_stored_name, image_stored_name FROM messages WHERE identity_id = ?1", - ) && let Ok(rows) = stmt.query_map(params![identity_id], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) { - for r in rows.flatten() { - if !r.0.is_empty() { - file_refs.push(r.0); - } - if !r.1.is_empty() { - file_refs.push(r.1); - } - } - } - file_refs + params![identity_id], + ) } pub fn clear_all_contacts(pool: &DbPool, identity_id: &str) { @@ -3296,26 +7852,70 @@ pub fn backfill_identity_id(pool: &DbPool, identity_hash: &str) { params![identity_hash], ) .ok(); - tracing::info!( - "Backfilled identity_id={} on existing contacts/messages", - &identity_hash[..16.min(identity_hash.len())] - ); + tracing::info!("Backfilled identity_id on existing contacts/messages"); } -pub fn save_game_session(pool: &DbPool, session: &lrgp::session::Session) { +pub fn save_game_session(pool: &DbPool, session: &lrgp::session::Session) -> bool { let conn = match pool.get() { Ok(c) => c, - Err(_) => return, + Err(_) => return false, }; let metadata_json = serde_json::to_string(&session.metadata).unwrap_or_else(|_| "{}".into()); - conn.execute( - "INSERT OR REPLACE INTO app_sessions (session_id, identity_id, app_id, app_version, contact_hash, initiator, status, metadata, unread, created_at, updated_at, last_action_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + let written = conn.execute( + "INSERT INTO app_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(session_id, identity_id) DO UPDATE SET + app_id = excluded.app_id, + app_version = excluded.app_version, + contact_hash = CASE + WHEN app_sessions.contact_hash = '' THEN excluded.contact_hash + ELSE app_sessions.contact_hash + END, + initiator = CASE + WHEN app_sessions.initiator = '' THEN excluded.initiator + ELSE app_sessions.initiator + END, + status = excluded.status, + metadata = excluded.metadata, + unread = app_sessions.unread, + created_at = app_sessions.created_at, + updated_at = excluded.updated_at, + last_action_at = excluded.last_action_at + WHERE app_sessions.app_id = excluded.app_id + AND app_sessions.app_version = excluded.app_version + AND (app_sessions.contact_hash = '' OR app_sessions.contact_hash = excluded.contact_hash) + AND (app_sessions.initiator = '' OR app_sessions.initiator = excluded.initiator)", params![ - session.session_id, session.identity_id, session.app_id, session.app_version, - session.contact_hash, session.initiator, session.status, metadata_json, - session.unread, session.created_at, session.updated_at, session.last_action_at, + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + session.status, + metadata_json, + session.unread, + session.created_at, + session.updated_at, + session.last_action_at, ], - ).ok(); + ); + match written { + Ok(1) => true, + Ok(_) => { + tracing::warn!( + reason = "binding_conflict", + "Refusing to replace an established LRGP session binding" + ); + false + } + Err(_) => { + tracing::error!(reason = "storage_error", "Failed to persist LRGP session"); + false + } + } } pub fn get_game_session( @@ -3368,19 +7968,577 @@ pub fn list_game_sessions( .unwrap_or_default() } -pub fn save_game_action(pool: &DbPool, action: &lrgp::store::Action, envelope_mp: Option<&[u8]>) { +/// Load the durable LRGP session records exactly as the game engines expect +/// them. Unlike `list_game_sessions`, this intentionally returns the typed +/// storage model instead of the frontend projection so the runtime can +/// hydrate every local identity before accepting game traffic. +pub fn load_game_sessions(pool: &DbPool) -> Vec { let conn = match pool.get() { Ok(c) => c, - Err(_) => return, + Err(_) => return vec![], + }; + let mut stmt = match conn.prepare( + "SELECT session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at + FROM app_sessions", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + + stmt.query_map([], |row| { + let metadata_json: String = row.get(7)?; + let metadata = serde_json::from_str(&metadata_json).unwrap_or_default(); + Ok(lrgp::session::Session { + session_id: row.get(0)?, + identity_id: row.get(1)?, + app_id: row.get(2)?, + app_version: row.get::<_, i64>(3)?.try_into().unwrap_or(1), + contact_hash: row.get(4)?, + initiator: row.get(5)?, + status: row.get(6)?, + metadata, + unread: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + last_action_at: row.get(11)?, + }) + }) + .map(|rows| rows.filter_map(Result::ok).collect()) + .unwrap_or_default() +} + +pub fn save_game_action( + pool: &DbPool, + action: &lrgp::store::Action, + envelope_mp: Option<&[u8]>, +) -> bool { + let conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, }; conn.execute( - "INSERT OR REPLACE INTO app_actions (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO app_actions (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ action.session_id, action.identity_id, action.action_num, action.command, action.payload_json, action.sender, action.timestamp, envelope_mp, ], - ).ok(); + ).is_ok() +} + +/// Atomically allocate and append the next action number for a session. +/// +/// `COUNT(*)` followed by `INSERT OR REPLACE` can make two concurrent actions +/// choose the same number and silently overwrite one another. An immediate +/// transaction plus `MAX(action_num) + 1` serializes allocation and makes a +/// collision fail instead of replacing durable history. +#[allow(clippy::too_many_arguments)] +pub fn append_game_action( + pool: &DbPool, + session_id: &str, + identity_id: &str, + command: &str, + payload_json: &str, + sender: &str, + timestamp: f64, + envelope_mp: Option<&[u8]>, +) -> Option { + let mut conn = pool.get().ok()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .ok()?; + let action_num: i64 = tx + .query_row( + "SELECT COALESCE(MAX(action_num), -1) + 1 + FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) + .ok()?; + tx.execute( + "INSERT INTO app_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session_id, + identity_id, + action_num, + command, + payload_json, + sender, + timestamp, + envelope_mp, + ], + ) + .ok()?; + tx.commit().ok()?; + Some(action_num) +} + +/// Atomically persist a locally-applied LRGP state transition together with +/// the exact envelope needed to resume delivery after a process crash. +/// +/// This is the durable outbox boundary for games. Persisting the state without +/// the envelope can leave the local board ahead of the peer after a crash; +/// persisting the envelope without the state can make a resend impossible to +/// reconcile locally. Established app, participant, and initiator bindings are +/// immutable here even if a caller bypasses the router checks. +#[allow(clippy::too_many_arguments)] +pub fn persist_outbound_game_action( + pool: &DbPool, + session: &lrgp::session::Session, + command: &str, + payload_json: &str, + sender: &str, + timestamp: f64, + envelope_mp: &[u8], +) -> Option { + let envelope = lrgp::envelope::unpack_from_bytes(envelope_mp).ok()?; + let validated = lrgp::envelope::validate_envelope(&envelope).ok()?; + if validated.session_id != session.session_id + || validated.app_id != session.app_id + || validated.version != session.app_version + || validated.command != command + || sender != session.identity_id + { + return None; + } + + let mut conn = pool.get().ok()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .ok()?; + let existing: Option<(String, u32, String, String)> = tx + .query_row( + "SELECT app_id, app_version, contact_hash, initiator + FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session.session_id, session.identity_id], + |row| { + Ok(( + row.get(0)?, + row.get::<_, i64>(1)?.try_into().unwrap_or(0), + row.get(2)?, + row.get(3)?, + )) + }, + ) + .optional() + .ok()?; + if existing.is_some_and(|(app_id, version, contact_hash, initiator)| { + app_id != session.app_id + || version != session.app_version + || (!contact_hash.is_empty() && contact_hash != session.contact_hash) + || (!initiator.is_empty() && initiator != session.initiator) + }) { + return None; + } + + let nonce = validated.nonce; + let duplicate = { + let mut statement = tx + .prepare( + "SELECT envelope_mp FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND envelope_mp IS NOT NULL", + ) + .ok()?; + let rows = statement + .query_map(params![session.session_id, session.identity_id], |row| { + row.get::<_, Vec>(0) + }) + .ok()?; + rows.filter_map(Result::ok) + .any(|packed| packed_game_nonce(&packed).as_deref() == Some(nonce.as_slice())) + }; + if duplicate { + return None; + } + + let metadata = serde_json::to_string(&session.metadata).ok()?; + let session_written = tx + .execute( + "INSERT INTO app_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(session_id, identity_id) DO UPDATE SET + contact_hash = CASE + WHEN app_sessions.contact_hash = '' THEN excluded.contact_hash + ELSE app_sessions.contact_hash + END, + initiator = CASE + WHEN app_sessions.initiator = '' THEN excluded.initiator + ELSE app_sessions.initiator + END, + status = excluded.status, + metadata = excluded.metadata, + updated_at = excluded.updated_at, + last_action_at = excluded.last_action_at + WHERE app_sessions.app_id = excluded.app_id + AND app_sessions.app_version = excluded.app_version + AND (app_sessions.contact_hash = '' OR app_sessions.contact_hash = excluded.contact_hash) + AND (app_sessions.initiator = '' OR app_sessions.initiator = excluded.initiator)", + params![ + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + session.status, + metadata, + session.unread, + session.created_at, + session.updated_at, + session.last_action_at, + ], + ) + .ok()?; + if session_written != 1 { + return None; + } + + let action_num: i64 = tx + .query_row( + "SELECT COALESCE(MAX(action_num), -1) + 1 + FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session.session_id, session.identity_id], + |row| row.get(0), + ) + .ok()?; + tx.execute( + "INSERT INTO app_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session.session_id, + session.identity_id, + action_num, + command, + payload_json, + sender, + timestamp, + envelope_mp, + ], + ) + .ok()?; + tx.commit().ok()?; + Some(action_num) +} + +/// Reverse a not-submitted durable outbox entry and restore the matching +/// pre-dispatch session snapshot in one transaction. +pub fn rollback_outbound_game_action( + pool: &DbPool, + session_id: &str, + identity_id: &str, + action_num: i64, + snapshot: Option<&lrgp::session::Session>, +) -> bool { + if snapshot.is_some_and(|session| { + session.session_id != session_id || session.identity_id != identity_id + }) { + return false; + } + let mut conn = match pool.get() { + Ok(conn) => conn, + Err(_) => return false, + }; + let tx = match conn.transaction_with_behavior(TransactionBehavior::Immediate) { + Ok(tx) => tx, + Err(_) => return false, + }; + if tx + .execute( + "DELETE FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND action_num = ?3", + params![session_id, identity_id, action_num], + ) + .ok() + != Some(1) + { + return false; + } + + if let Some(session) = snapshot { + let metadata = match serde_json::to_string(&session.metadata) { + Ok(metadata) => metadata, + Err(_) => return false, + }; + if tx + .execute( + "UPDATE app_sessions SET + status = ?1, metadata = ?2, unread = ?3, + updated_at = ?4, last_action_at = ?5 + WHERE session_id = ?6 AND identity_id = ?7 + AND app_id = ?8 AND app_version = ?9 + AND contact_hash = ?10 AND initiator = ?11", + params![ + session.status, + metadata, + session.unread, + session.updated_at, + session.last_action_at, + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + ], + ) + .ok() + != Some(1) + { + return false; + } + } else { + let remaining_actions: i64 = match tx.query_row( + "SELECT COUNT(*) FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) { + Ok(count) => count, + Err(_) => return false, + }; + if remaining_actions != 0 + || tx + .execute( + "DELETE FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + ) + .ok() + != Some(1) + { + return false; + } + } + + tx.commit().is_ok() +} + +/// Persist an accepted inbound action, its session snapshot, and unread +/// transition as one transaction. The established contact is immutable: even +/// if a future caller bypasses LRGP participant authorization, storage refuses +/// to rebind a session to a different peer. +#[allow(clippy::too_many_arguments)] +pub fn persist_inbound_game_action( + pool: &DbPool, + session_id: &str, + identity_id: &str, + command: &str, + payload_json: &str, + sender: &str, + timestamp: f64, + envelope_mp: &[u8], + session: Option<&lrgp::session::Session>, +) -> Option { + let envelope = lrgp::envelope::unpack_from_bytes(envelope_mp).ok()?; + let validated = lrgp::envelope::validate_envelope(&envelope).ok()?; + if validated.session_id != session_id || validated.command != command { + return None; + } + if session.is_some_and(|next| { + next.session_id != session_id + || next.identity_id != identity_id + || next.app_id != validated.app_id + || next.app_version != validated.version + || next.contact_hash != sender + }) { + return None; + } + let incoming_nonce = validated.nonce; + let mut conn = pool.get().ok()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .ok()?; + let existing: Option<(i64, String, String, u32, String)> = tx + .query_row( + "SELECT unread, contact_hash, app_id, app_version, initiator FROM app_sessions + WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get::<_, i64>(3)?.try_into().unwrap_or(0), + row.get(4)?, + )) + }, + ) + .optional() + .ok()?; + + match &existing { + Some((_, contact_hash, app_id, app_version, _)) => { + if (!contact_hash.is_empty() && contact_hash != sender) + || app_id != &validated.app_id + || *app_version != validated.version + { + return None; + } + } + None if session.is_none() => return None, + None => {} + } + + let attempts_rebind = matches!( + (&existing, session), + ( + Some((_, established, established_app, established_version, established_initiator)), + Some(next), + ) if (!established.is_empty() && established != &next.contact_hash) + || established_app != &next.app_id + || *established_version != next.app_version + || (!established_initiator.is_empty() && established_initiator != &next.initiator) + ); + if attempts_rebind { + tracing::warn!( + session_id, + "Refusing to rebind an established LRGP session participant or app" + ); + return None; + } + let duplicate = { + let mut statement = tx + .prepare( + "SELECT envelope_mp FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND envelope_mp IS NOT NULL", + ) + .ok()?; + let packed = statement + .query_map(params![session_id, identity_id], |row| { + row.get::<_, Vec>(0) + }) + .ok()?; + packed.filter_map(Result::ok).any(|existing| { + packed_game_nonce(&existing).as_deref() == Some(incoming_nonce.as_slice()) + }) + }; + if duplicate { + return None; + } + + let action_num: i64 = tx + .query_row( + "SELECT COALESCE(MAX(action_num), -1) + 1 + FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) + .ok()?; + tx.execute( + "INSERT INTO app_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session_id, + identity_id, + action_num, + command, + payload_json, + sender, + timestamp, + envelope_mp, + ], + ) + .ok()?; + + let unread = existing + .as_ref() + .map(|(value, _, _, _, _)| value + 1) + .unwrap_or(1); + if let Some(session) = session { + let metadata = serde_json::to_string(&session.metadata).unwrap_or_else(|_| "{}".into()); + tx.execute( + "INSERT INTO app_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(session_id, identity_id) DO UPDATE SET + contact_hash = CASE + WHEN app_sessions.contact_hash = '' THEN excluded.contact_hash + ELSE app_sessions.contact_hash + END, + initiator = CASE + WHEN app_sessions.initiator = '' THEN excluded.initiator + ELSE app_sessions.initiator + END, + status = excluded.status, + metadata = excluded.metadata, + unread = excluded.unread, + updated_at = excluded.updated_at, + last_action_at = excluded.last_action_at + WHERE app_sessions.app_id = excluded.app_id + AND app_sessions.app_version = excluded.app_version + AND (app_sessions.contact_hash = '' OR app_sessions.contact_hash = excluded.contact_hash) + AND (app_sessions.initiator = '' OR app_sessions.initiator = excluded.initiator)", + params![ + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + session.status, + metadata, + unread, + session.created_at, + session.updated_at, + session.last_action_at, + ], + ) + .ok() + .filter(|written| *written == 1)?; + } else if existing.is_some() { + tx.execute( + "UPDATE app_sessions SET unread = ?1, last_action_at = ?2 + WHERE session_id = ?3 AND identity_id = ?4", + params![unread, timestamp, session_id, identity_id], + ) + .ok()?; + } + + tx.commit().ok()?; + Some(existing.is_some()) +} + +fn packed_game_nonce(envelope_mp: &[u8]) -> Option> { + lrgp::envelope::unpack_from_bytes(envelope_mp) + .ok()? + .get(lrgp::constants::KEY_NONCE) + .and_then(|value| match value { + rmpv::Value::Binary(bytes) => Some(bytes.clone()), + _ => None, + }) +} + +/// Whether this LRGP nonce has already been durably accepted for the local +/// session. Comparing the nonce rather than the full envelope prevents a +/// replay from evading restart protection by changing payload bytes while +/// retaining the same protocol nonce. +pub fn has_game_nonce(pool: &DbPool, session_id: &str, identity_id: &str, nonce: &[u8]) -> bool { + let conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + let mut statement = match conn.prepare( + "SELECT envelope_mp FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND envelope_mp IS NOT NULL", + ) { + Ok(statement) => statement, + Err(_) => return false, + }; + let packed = match statement.query_map(params![session_id, identity_id], |row| { + row.get::<_, Vec>(0) + }) { + Ok(rows) => rows, + Err(_) => return false, + }; + packed + .filter_map(Result::ok) + .any(|existing| packed_game_nonce(&existing).as_deref() == Some(nonce)) } /// Returns the packed LRGP envelope for the active identity's most recent @@ -3459,21 +8617,48 @@ pub fn mark_game_read(pool: &DbPool, session_id: &str, identity_id: &str) { .ok(); } -pub fn delete_game_session(pool: &DbPool, session_id: &str, identity_id: &str) { - let conn = match pool.get() { +pub fn delete_game_session(pool: &DbPool, session_id: &str, identity_id: &str) -> bool { + let mut conn = match pool.get() { Ok(c) => c, - Err(_) => return, + Err(_) => return false, }; - conn.execute( - "DELETE FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", - params![session_id, identity_id], - ) - .ok(); - conn.execute( - "DELETE FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", - params![session_id, identity_id], - ) - .ok(); + let Ok(tx) = conn.transaction_with_behavior(TransactionBehavior::Immediate) else { + return false; + }; + let status: Option = match tx + .query_row( + "SELECT status FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) + .optional() + { + Ok(status) => status, + Err(_) => return false, + }; + if !status.is_some_and(|status| matches!(status.as_str(), "completed" | "declined" | "expired")) + { + return false; + } + if tx + .execute( + "DELETE FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + ) + .is_err() + { + return false; + } + if tx + .execute( + "DELETE FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + ) + .is_err() + { + return false; + } + tx.commit().is_ok() } pub fn get_failed_messages_for_contact( @@ -3615,9 +8800,7 @@ fn row_to_app_session(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result) -> rusqlite::Result DbPool { + let manager = SqliteConnectionManager::memory(); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + pool + } + + fn session() -> lrgp::session::Session { + lrgp::session::Session { + session_id: "0123456789abcdef".into(), + identity_id: "11111111111111111111111111111111".into(), + app_id: "ttt".into(), + app_version: 1, + contact_hash: "22222222222222222222222222222222".into(), + initiator: "11111111111111111111111111111111".into(), + status: "active".into(), + metadata: HashMap::from([("board".into(), serde_json::json!("X________"))]), + unread: 2, + created_at: 10.0, + updated_at: 20.0, + last_action_at: 20.0, + } + } + + fn packed_envelope(nonce: [u8; lrgp::constants::NONCE_BYTES], command: &str) -> Vec { + let mut envelope = lrgp::envelope::Envelope::new(); + envelope.insert( + lrgp::constants::KEY_APP.into(), + rmpv::Value::String("ttt.1".into()), + ); + envelope.insert( + lrgp::constants::KEY_COMMAND.into(), + rmpv::Value::String(command.into()), + ); + envelope.insert( + lrgp::constants::KEY_SESSION.into(), + rmpv::Value::String("0123456789abcdef".into()), + ); + envelope.insert( + lrgp::constants::KEY_PAYLOAD.into(), + rmpv::Value::Map(Vec::new()), + ); + envelope.insert( + lrgp::constants::KEY_NONCE.into(), + rmpv::Value::Binary(nonce.to_vec()), + ); + lrgp::envelope::pack_to_bytes(&envelope).unwrap() + } + + #[test] + fn typed_sessions_round_trip_for_runtime_hydration() { + let pool = test_pool(); + let expected = session(); + assert!(save_game_session(&pool, &expected)); + + let loaded = load_game_sessions(&pool); + assert_eq!(loaded.len(), 1); + let actual = &loaded[0]; + assert_eq!(actual.session_id, expected.session_id); + assert_eq!(actual.identity_id, expected.identity_id); + assert_eq!(actual.contact_hash, expected.contact_hash); + assert_eq!(actual.metadata, expected.metadata); + assert_eq!(actual.unread, 2); + } + + #[test] + fn session_upsert_cannot_rebind_peer_or_initiator() { + let pool = test_pool(); + let established = session(); + assert!(save_game_session(&pool, &established)); + + let mut wrong_peer = established.clone(); + wrong_peer.contact_hash = "33333333333333333333333333333333".into(); + assert!(!save_game_session(&pool, &wrong_peer)); + + let mut wrong_initiator = established.clone(); + wrong_initiator.initiator = established.contact_hash.clone(); + assert!(!save_game_session(&pool, &wrong_initiator)); + + let stored = get_game_session(&pool, &established.session_id, &established.identity_id) + .expect("established session remains available"); + assert_eq!(stored["contact_hash"], established.contact_hash); + assert_eq!(stored["initiator"], established.initiator); + } + + #[test] + fn outbound_state_and_envelope_commit_and_roll_back_together() { + let pool = test_pool(); + let original = session(); + assert!(save_game_session(&pool, &original)); + + let mut advanced = original.clone(); + advanced + .metadata + .insert("board".into(), serde_json::json!("XO_______")); + advanced.updated_at = 30.0; + advanced.last_action_at = 30.0; + let envelope = packed_envelope([5; lrgp::constants::NONCE_BYTES], "move"); + let action_num = persist_outbound_game_action( + &pool, + &advanced, + "move", + "{}", + &advanced.identity_id, + 30.0, + &envelope, + ) + .expect("durable outbox commit"); + + assert_eq!(action_num, 0); + assert_eq!( + get_game_action_count(&pool, &advanced.session_id, &advanced.identity_id), + 1 + ); + assert_eq!( + get_last_outbound_envelope_for_session( + &pool, + &advanced.session_id, + &advanced.identity_id, + ), + Some(envelope) + ); + assert_eq!( + get_game_session(&pool, &advanced.session_id, &advanced.identity_id).unwrap()["state"], + "XO_______" + ); + + assert!(rollback_outbound_game_action( + &pool, + &advanced.session_id, + &advanced.identity_id, + action_num, + Some(&original), + )); + assert_eq!( + get_game_action_count(&pool, &advanced.session_id, &advanced.identity_id), + 0 + ); + assert_eq!( + get_game_session(&pool, &advanced.session_id, &advanced.identity_id).unwrap()["state"], + "X________" + ); + } + + #[test] + fn failed_new_challenge_removes_its_session_and_outbox_entry() { + let pool = test_pool(); + let mut challenge = session(); + challenge.status = "pending".into(); + let envelope = packed_envelope([6; lrgp::constants::NONCE_BYTES], "challenge"); + let action_num = persist_outbound_game_action( + &pool, + &challenge, + "challenge", + "{}", + &challenge.identity_id, + 10.0, + &envelope, + ) + .expect("durable challenge outbox commit"); + + assert!(rollback_outbound_game_action( + &pool, + &challenge.session_id, + &challenge.identity_id, + action_num, + None, + )); + assert!(get_game_session(&pool, &challenge.session_id, &challenge.identity_id).is_none()); + assert_eq!( + get_game_action_count(&pool, &challenge.session_id, &challenge.identity_id), + 0 + ); + } + + #[test] + fn append_allocates_without_replacing_and_tracks_nonces() { + let pool = test_pool(); + let s = session(); + let envelope_a = packed_envelope([1; lrgp::constants::NONCE_BYTES], "challenge"); + let envelope_b = packed_envelope([2; lrgp::constants::NONCE_BYTES], "accept"); + + let first = append_game_action( + &pool, + &s.session_id, + &s.identity_id, + "challenge", + "{}", + &s.identity_id, + 1.0, + Some(&envelope_a), + ); + let second = append_game_action( + &pool, + &s.session_id, + &s.identity_id, + "accept", + "{}", + &s.contact_hash, + 2.0, + Some(&envelope_b), + ); + + assert_eq!(first, Some(0)); + assert_eq!(second, Some(1)); + assert_eq!( + get_game_actions(&pool, &s.session_id, &s.identity_id).len(), + 2 + ); + assert!(has_game_nonce( + &pool, + &s.session_id, + &s.identity_id, + &[1; lrgp::constants::NONCE_BYTES] + )); + assert!(!has_game_nonce( + &pool, + &s.session_id, + &s.identity_id, + &[9; lrgp::constants::NONCE_BYTES] + )); + } + + #[test] + fn inbound_nonce_replay_is_rejected_without_partial_state() { + let pool = test_pool(); + let s = session(); + save_game_session(&pool, &s); + let first = packed_envelope([7; lrgp::constants::NONCE_BYTES], "move"); + let replay = packed_envelope([7; lrgp::constants::NONCE_BYTES], "resign"); + + assert_eq!( + persist_inbound_game_action( + &pool, + &s.session_id, + &s.identity_id, + "move", + "{}", + &s.contact_hash, + 21.0, + &first, + Some(&s), + ), + Some(true) + ); + assert_eq!( + persist_inbound_game_action( + &pool, + &s.session_id, + &s.identity_id, + "resign", + "{}", + &s.contact_hash, + 22.0, + &replay, + Some(&s), + ), + None + ); + assert_eq!( + get_game_actions(&pool, &s.session_id, &s.identity_id).len(), + 1 + ); + } + + #[test] + fn inbound_persistence_cannot_rebind_session_peer_or_app() { + let pool = test_pool(); + let established = session(); + save_game_session(&pool, &established); + + let mut wrong_peer = established.clone(); + wrong_peer.contact_hash = "33333333333333333333333333333333".into(); + let peer_envelope = packed_envelope([3; lrgp::constants::NONCE_BYTES], "move"); + assert_eq!( + persist_inbound_game_action( + &pool, + &established.session_id, + &established.identity_id, + "move", + "{}", + &wrong_peer.contact_hash, + 23.0, + &peer_envelope, + Some(&wrong_peer), + ), + None + ); + + let mut wrong_app = established.clone(); + wrong_app.app_id = "chess".into(); + let app_envelope = packed_envelope([4; lrgp::constants::NONCE_BYTES], "move"); + assert_eq!( + persist_inbound_game_action( + &pool, + &established.session_id, + &established.identity_id, + "move", + "{}", + &established.contact_hash, + 24.0, + &app_envelope, + Some(&wrong_app), + ), + None + ); + + let stored = get_game_session(&pool, &established.session_id, &established.identity_id) + .expect("established session remains available"); + assert_eq!(stored["app_id"], "ttt"); + assert_eq!(stored["contact_hash"], established.contact_hash); + assert!( + get_game_actions(&pool, &established.session_id, &established.identity_id).is_empty() + ); + } + + #[test] + fn inbound_persistence_requires_correlated_envelope_and_session_state() { + let pool = test_pool(); + let established = session(); + assert!(save_game_session(&pool, &established)); + let move_envelope = packed_envelope([8; lrgp::constants::NONCE_BYTES], "move"); + + // The command supplied to storage must be the command authenticated + // inside the exact packed envelope; callers cannot relabel an action. + assert_eq!( + persist_inbound_game_action( + &pool, + &established.session_id, + &established.identity_id, + "resign", + "{}", + &established.contact_hash, + 25.0, + &move_envelope, + Some(&established), + ), + None + ); + + // A state-less inbound record (the standard remote-error path) may + // update only an already established, participant-bound session. + let unknown_pool = test_pool(); + assert_eq!( + persist_inbound_game_action( + &unknown_pool, + &established.session_id, + &established.identity_id, + "move", + "{}", + &established.contact_hash, + 25.0, + &move_envelope, + None, + ), + None + ); + assert!( + get_game_actions( + &unknown_pool, + &established.session_id, + &established.identity_id, + ) + .is_empty() + ); + } + + #[test] + fn deleting_a_session_removes_actions_in_the_same_operation() { + let pool = test_pool(); + let mut s = session(); + s.status = "completed".into(); + save_game_session(&pool, &s); + append_game_action( + &pool, + &s.session_id, + &s.identity_id, + "move", + "{}", + &s.contact_hash, + 2.0, + None, + ); + + assert!(delete_game_session(&pool, &s.session_id, &s.identity_id)); + assert!(get_game_session(&pool, &s.session_id, &s.identity_id).is_none()); + assert!(get_game_actions(&pool, &s.session_id, &s.identity_id).is_empty()); + } + + #[test] + fn active_session_cannot_be_removed_as_history() { + let pool = test_pool(); + let s = session(); + assert!(save_game_session(&pool, &s)); + assert!(!delete_game_session(&pool, &s.session_id, &s.identity_id)); + assert!(get_game_session(&pool, &s.session_id, &s.identity_id).is_some()); + } +} + #[cfg(test)] mod unread_breakdown_tests { use super::*; @@ -4464,6 +10052,16 @@ mod migration_tests { "messages", "connection_history", "messages_fts", + "channel_hubs", + "channel_rooms", + "channel_room_secrets", + "channel_history", + "channel_history_room_usage", + "channel_room_state", + "channel_participant_observations", + "channel_hub_rooms", + "channel_hub_grants", + "channel_hub_klines", ] { let exists: i64 = conn .query_row( @@ -4480,6 +10078,15 @@ mod migration_tests { "idx_messages_identity_state", "idx_messages_source_identity", "idx_messages_dest_identity", + "idx_channel_hubs_identity_recent", + "idx_channel_rooms_identity_hub", + "idx_channel_history_room_sequence", + "idx_channel_history_identity_sequence", + "idx_channel_history_identity_unread", + "idx_channel_history_recorded_at", + "idx_channel_participant_observations_room_recent", + "idx_channel_participant_observations_age", + "idx_identity_activity_identity_hash", ] { let exists: i64 = conn .query_row( @@ -4490,6 +10097,19 @@ mod migration_tests { .unwrap(); assert!(exists > 0, "expected index `{index}` after init_schema"); } + + let activity_cols = get_column_names(&conn, "identity_activity").unwrap(); + assert!( + activity_cols + .iter() + .any(|c| c == "lxmf_compression_support"), + "fresh schema should include LXMF compression capability metadata" + ); + let room_state_cols = get_column_names(&conn, "channel_room_state").unwrap(); + assert!( + room_state_cols.iter().any(|column| column == "topic"), + "fresh schema should retain authenticated Channels room topics" + ); } #[test] @@ -4786,6 +10406,472 @@ mod migration_tests { .unwrap(); assert_eq!(kept, 1); } + + #[test] + fn migration_from_v33_adds_channel_bookmark_tables() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (33);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + for table in ["channel_hubs", "channel_rooms"] { + assert!(table_exists(&conn, table).unwrap()); + } + let version: i64 = conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn migration_from_v34_adds_channel_hub_registry_tables() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (34);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + for table in [ + "channel_hub_rooms", + "channel_hub_grants", + "channel_hub_klines", + ] { + assert!(table_exists(&conn, table).unwrap()); + } + let version: i64 = conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn migration_from_v35_adds_channel_desire_without_reclassifying_recents() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (35); + CREATE TABLE identities ( + hash TEXT PRIMARY KEY, + created_at REAL NOT NULL, + is_active INTEGER DEFAULT 0 + ); + INSERT INTO identities (hash, created_at) VALUES ('identity-a', 0); + CREATE TABLE channel_hubs ( + identity_id TEXT NOT NULL, + destination_hash TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + added_at REAL NOT NULL, + last_connected REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, destination_hash), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + CREATE TABLE channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash) + REFERENCES channel_hubs(identity_id, destination_hash) ON DELETE CASCADE + ); + INSERT INTO channel_hubs + (identity_id, destination_hash, label, nickname, added_at, last_connected) + VALUES ('identity-a', 'aa', 'Relay', 'rat', 1, 2); + INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, last_joined) + VALUES ('identity-a', 'aa', 'general', 1, 2);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + let rooms = list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap(); + assert_eq!(hubs.len(), 1); + assert_eq!(rooms.len(), 1); + assert!( + !hubs[0].desired_connected && !rooms[0].desired_joined, + "past recency is not proof of current user intent" + ); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v36_adds_identity_sealed_room_key_storage() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys=ON; + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (36); + CREATE TABLE identities ( + hash TEXT PRIMARY KEY, + created_at REAL NOT NULL, + is_active INTEGER DEFAULT 0 + ); + INSERT INTO identities (hash, created_at) VALUES ('identity-a', 0); + CREATE TABLE channel_hubs ( + identity_id TEXT NOT NULL, + destination_hash TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + added_at REAL NOT NULL, + last_connected REAL NOT NULL DEFAULT 0, + desired_connected INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, destination_hash), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + CREATE TABLE channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + desired_joined INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash) + REFERENCES channel_hubs(identity_id, destination_hash) ON DELETE CASCADE + ); + INSERT INTO channel_hubs + (identity_id, destination_hash, added_at, desired_connected) + VALUES ('identity-a', 'aa', 1, 1); + INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, + desired_joined) + VALUES ('identity-a', 'aa', 'general', 1, 1);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + assert!(table_exists(&conn, "channel_room_secrets").unwrap()); + assert!( + get_column_names(&conn, "channel_rooms") + .unwrap() + .iter() + .any(|column| column == "join_key_required") + ); + drop(conn); + let room = &list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap()[0]; + assert!(room.desired_joined); + assert!( + !room.join_key_required, + "migration must not infer key policy from past membership" + ); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v37_adds_bookmark_independent_channel_history() { + let migrated = empty_pool(); + { + let conn = migrated.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (37);", + ) + .unwrap(); + } + init_schema(&migrated).unwrap(); + + let fresh = empty_pool(); + init_schema(&fresh).unwrap(); + assert_eq!( + get_column_names(&migrated.get().unwrap(), "channel_history").unwrap(), + get_column_names(&fresh.get().unwrap(), "channel_history").unwrap() + ); + + let conn = migrated.get().unwrap(); + let foreign_tables: Vec = conn + .prepare("PRAGMA foreign_key_list(channel_history)") + .unwrap() + .query_map([], |row| row.get(2)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + foreign_tables, + vec!["identities"], + "history must survive removal of channel hub and room bookmarks" + ); + for index in [ + "idx_channel_history_room_sequence", + "idx_channel_history_identity_sequence", + "idx_channel_history_recorded_at", + ] { + assert!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND name = ?1", + [index], + |row| row.get::<_, i64>(0), + ) + .unwrap() + > 0, + "missing migrated history index `{index}`" + ); + } + drop(conn); + assert_eq!(read_schema_version(&migrated), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v38_backfills_history_usage_and_installs_triggers() { + let pool = empty_pool(); + init_schema(&pool).unwrap(); + save_identity(&pool, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "A", "A"); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "DROP TRIGGER channel_history_usage_after_insert; + DROP TRIGGER channel_history_usage_after_delete; + DROP TABLE channel_history_room_usage; + UPDATE schema_version SET version = 38;", + ) + .unwrap(); + conn.execute( + "INSERT INTO channel_history ( + identity_id, hub_destination_hash, room_name, event_id, + kind, timestamp_ms, recorded_at_ms, source_hash, nickname, + text, ours + ) VALUES (?1, ?2, 'general', 'old', 'message', 1, 1, NULL, NULL, 'hello', 0)", + params![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "11111111111111111111111111111111" + ], + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + let conn = pool.get().unwrap(); + let (event_count, payload_bytes): (i64, i64) = conn + .query_row( + "SELECT event_count, payload_bytes + FROM channel_history_room_usage + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = 'general'", + params![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "11111111111111111111111111111111" + ], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(event_count, 1); + assert!(payload_bytes > 5); + conn.execute("DELETE FROM channel_history WHERE event_id = 'old'", []) + .unwrap(); + let usage_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM channel_history_room_usage", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(usage_rows, 0, "delete trigger should remove empty usage"); + drop(conn); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v39_marks_existing_history_read_and_adds_mentions() { + const IDENTITY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HUB: &str = "11111111111111111111111111111111"; + let pool = empty_pool(); + init_schema(&pool).unwrap(); + save_identity(&pool, IDENTITY, "", "A", "A"); + { + let conn = pool.get().unwrap(); + conn.execute( + "INSERT INTO channel_history ( + identity_id, hub_destination_hash, room_name, event_id, + kind, timestamp_ms, recorded_at_ms, source_hash, nickname, + text, ours + ) VALUES ( + ?1, ?2, 'general', 'old', 'message', 1, 1, NULL, NULL, + 'hello', 0 + )", + params![IDENTITY, HUB], + ) + .unwrap(); + conn.execute_batch( + "DROP TABLE channel_room_state; + ALTER TABLE channel_history DROP COLUMN mentioned; + UPDATE schema_version SET version = 39;", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + let columns = get_column_names(&pool.get().unwrap(), "channel_history").unwrap(); + assert!(columns.iter().any(|column| column == "mentioned")); + let state = get_channel_room_read_state(&pool, IDENTITY, HUB, "general").unwrap(); + assert_ne!(state.last_read_sequence, "0"); + assert_eq!( + state.notification_level, + ChannelRoomNotificationLevel::Mentions + ); + assert_eq!( + get_channel_unread_summary(&pool, IDENTITY) + .unwrap() + .unread_total, + 0, + "upgrades must not reinterpret old transcript rows as unread" + ); + + append_channel_history_events( + &pool, + IDENTITY, + &[NewChannelHistoryEvent { + hub_destination_hash: HUB.into(), + room_name: "general".into(), + event_id: "new".into(), + kind: ChannelHistoryKind::Message, + timestamp_ms: 2, + source_hash: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into()), + nickname: Some("B".into()), + text: "@A hello".into(), + ours: false, + mentioned: true, + }], + ) + .unwrap(); + let summary = get_channel_unread_summary(&pool, IDENTITY).unwrap(); + assert_eq!(summary.unread_total, 1); + assert_eq!(summary.mention_total, 1); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v41_adds_durable_room_topics() { + let pool = empty_pool(); + init_schema(&pool).unwrap(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "ALTER TABLE channel_room_state DROP COLUMN topic; + UPDATE schema_version SET version = 41;", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + let conn = pool.get().unwrap(); + let columns = get_column_names(&conn, "channel_room_state").unwrap(); + assert!(columns.iter().any(|column| column == "topic")); + let default_topic: String = conn + .query_row( + "SELECT dflt_value + FROM pragma_table_info('channel_room_state') + WHERE name = 'topic'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(default_topic, "''"); + drop(conn); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migrated_and_fresh_sealed_room_key_schemas_match() { + let migrated = empty_pool(); + { + let conn = migrated.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (36); + CREATE TABLE channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + desired_joined INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name) + );", + ) + .unwrap(); + } + init_schema(&migrated).unwrap(); + let fresh = empty_pool(); + init_schema(&fresh).unwrap(); + + for table in ["channel_rooms", "channel_room_secrets"] { + let columns = |pool: &DbPool| { + let conn = pool.get().unwrap(); + get_column_names(&conn, table).unwrap() + }; + assert_eq!( + columns(&migrated), + columns(&fresh), + "migrated and fresh `{table}` columns diverged" + ); + } + } + + /// The migrated schema and the fresh schema must agree; the DDL is + /// duplicated between them by house convention, so drift is easy. + #[test] + fn migrated_and_fresh_hub_registry_schemas_match() { + let migrated = empty_pool(); + { + let conn = migrated.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (34);", + ) + .unwrap(); + } + init_schema(&migrated).unwrap(); + let fresh = empty_pool(); + init_schema(&fresh).unwrap(); + + for table in [ + "channel_hub_rooms", + "channel_hub_grants", + "channel_hub_klines", + ] { + let columns = |pool: &DbPool| -> Vec { + let conn = pool.get().unwrap(); + get_column_names(&conn, table).unwrap() + }; + assert_eq!( + columns(&migrated), + columns(&fresh), + "{table} drifted between the migration and the fresh schema" + ); + } + } } #[cfg(test)] @@ -4886,6 +10972,16 @@ mod peers_snapshot_tests { .unwrap() } + fn lxmf_compression_support_for(pool: &DbPool, hash: &str) -> String { + let conn = pool.get().unwrap(); + conn.query_row( + "SELECT lxmf_compression_support FROM identity_activity WHERE dest_hash = ?1", + params![hash], + |row| row.get::<_, String>(0), + ) + .unwrap() + } + #[test] fn touch_identity_activity_merges_multiple_services_once_and_clears_ratspeak() { let pool = test_pool(); @@ -4935,6 +11031,7 @@ mod peers_snapshot_tests { identity_hash: Some("11111111111111111111111111111111".into()), services: vec![PEER_SERVICE_LXMF_DELIVERY.into()], clear_ratspeak_services: true, + lxmf_compression_support: None, }, IdentityActivityUpdate { dest_hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), @@ -4945,6 +11042,7 @@ mod peers_snapshot_tests { identity_hash: Some("22222222222222222222222222222222".into()), services: vec![PEER_SERVICE_LXST_TELEPHONY.into()], clear_ratspeak_services: false, + lxmf_compression_support: None, }, ], ); @@ -4963,6 +11061,67 @@ mod peers_snapshot_tests { ); } + #[test] + fn touch_identity_activity_updates_merges_lxmf_compression_support() { + let pool = test_pool(); + let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + touch_identity_activity_updates( + &pool, + &[IdentityActivityUpdate { + dest_hash: hash.into(), + timestamp: 100.0, + display_name: Some("Alice".into()), + status: None, + last_interface: None, + identity_hash: None, + services: vec![PEER_SERVICE_LXMF_DELIVERY.into()], + clear_ratspeak_services: false, + lxmf_compression_support: Some(LXMF_COMPRESSION_SUPPORT_UNSUPPORTED.into()), + }], + ); + assert_eq!( + get_identity_lxmf_compression_support(&pool, hash).as_deref(), + Some(LXMF_COMPRESSION_SUPPORT_UNSUPPORTED) + ); + + touch_identity_activity_updates( + &pool, + &[IdentityActivityUpdate { + dest_hash: hash.into(), + timestamp: 101.0, + display_name: None, + status: None, + last_interface: None, + identity_hash: None, + services: vec![PEER_SERVICE_LXMF_DELIVERY.into()], + clear_ratspeak_services: false, + lxmf_compression_support: None, + }], + ); + assert_eq!( + lxmf_compression_support_for(&pool, hash), + LXMF_COMPRESSION_SUPPORT_UNSUPPORTED + ); + + assert!(set_identity_lxmf_compression_support( + &pool, + hash, + LXMF_COMPRESSION_SUPPORT_SUPPORTED + )); + assert_eq!( + get_identity_lxmf_compression_support(&pool, hash).as_deref(), + Some(LXMF_COMPRESSION_SUPPORT_SUPPORTED) + ); + assert!(!set_identity_lxmf_compression_support( + &pool, hash, "unknown" + )); + assert_eq!( + lxmf_compression_support_for(&pool, hash), + LXMF_COMPRESSION_SUPPORT_SUPPORTED + ); + } + fn add_contact(pool: &DbPool, hash: &str, display_name: &str) { add_contact_for(pool, "me", hash, display_name); } @@ -5448,4 +11607,78 @@ mod pending_blackhole_tests { ); assert_eq!(active.get("status").and_then(|v| v.as_str()), Some("")); } + + #[test] + fn migration_from_v32_adds_lxmf_compression_support_column() { + let mgr = SqliteConnectionManager::memory(); + let pool = r2d2::Pool::builder().max_size(1).build(mgr).unwrap(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (32); + + CREATE TABLE identity_activity ( + dest_hash TEXT PRIMARY KEY, + identity_hash TEXT NOT NULL DEFAULT '', + last_seen REAL NOT NULL, + first_seen REAL NOT NULL, + announce_count INTEGER NOT NULL DEFAULT 1, + display_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + last_interface TEXT NOT NULL DEFAULT '', + services TEXT NOT NULL DEFAULT '' + ); + INSERT INTO identity_activity + (dest_hash, identity_hash, last_seen, first_seen, announce_count, display_name, status, last_interface, services) + VALUES + ('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 10.0, + 5.0, + 3, + 'Peer', + 'Ready', + 'RNode', + 'lxmf.delivery'); + "#, + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + let activity_cols = get_column_names(&conn, "identity_activity").unwrap(); + assert!( + activity_cols + .iter() + .any(|c| c == "lxmf_compression_support") + ); + let row: (String, String, String, String) = conn + .query_row( + "SELECT display_name, status, services, lxmf_compression_support + FROM identity_activity + WHERE dest_hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + row, + ( + "Peer".into(), + "Ready".into(), + "lxmf.delivery".into(), + "".into() + ) + ); + let version: i64 = conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } } diff --git a/crates/ratspeak-db/src/static_nodes.rs b/crates/ratspeak-db/src/static_nodes.rs index bdf508b..b71cd71 100644 --- a/crates/ratspeak-db/src/static_nodes.rs +++ b/crates/ratspeak-db/src/static_nodes.rs @@ -62,9 +62,9 @@ pub fn node_for(hash: &[u8; 16]) -> Option<&'static StaticPropNode> { fn parse_nodes_json() -> Vec { let raw: Vec = match serde_json::from_str(NODES_JSON) { Ok(v) => v, - Err(e) => { + Err(_) => { tracing::warn!( - error = %e, + reason = "parse_failed", "static nodes.json failed to parse; bundled list is empty for this session" ); return Vec::new(); @@ -75,15 +75,18 @@ fn parse_nodes_json() -> Vec { .filter_map(|r| { let bytes = match hex::decode(&r.hash) { Ok(b) => b, - Err(e) => { - tracing::warn!(hash = %r.hash, error = %e, "static node hash is not valid hex; skipping"); + Err(_) => { + tracing::warn!( + reason = "invalid_hex", + "static node hash is not valid hex; skipping" + ); return None; } }; if bytes.len() != 16 { tracing::warn!( - hash = %r.hash, bytes = bytes.len(), + reason = "invalid_length", "static node hash is not 16 bytes; skipping" ); return None; diff --git a/crates/ratspeak-runtime/Cargo.toml b/crates/ratspeak-runtime/Cargo.toml index dd4a9ac..6952d1a 100644 --- a/crates/ratspeak-runtime/Cargo.toml +++ b/crates/ratspeak-runtime/Cargo.toml @@ -50,6 +50,7 @@ cpal = { workspace = true, optional = true } # Async + serialization tokio = { workspace = true } bytes = { workspace = true } +ciborium = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } rmpv = { workspace = true } @@ -57,6 +58,7 @@ thiserror = { workspace = true } tracing = { workspace = true } # Utilities +crossbeam-channel = "0.5" indexmap = "2" hex = { workspace = true } uuid = { version = "1", features = ["v4"] } diff --git a/crates/ratspeak-runtime/src/activity/admission.rs b/crates/ratspeak-runtime/src/activity/admission.rs new file mode 100644 index 0000000..764b7fd --- /dev/null +++ b/crates/ratspeak-runtime/src/activity/admission.rs @@ -0,0 +1,354 @@ +//! Lock-free pre-ingress rate admission and the FIFO's reserved-tail permit. + +use std::array; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Instant; + +use super::schema::{ActivitySeverity, CaptureProfile, RateDomain}; + +pub(super) const INGRESS_CAPACITY: usize = 1_024; +pub(super) const RESERVED_PRIORITY_SLOTS: usize = 64; +pub(super) const LOW_PRIORITY_LIMIT: usize = INGRESS_CAPACITY - RESERVED_PRIORITY_SLOTS; + +const NANOS_PER_SECOND: u64 = 1_000_000_000; +const NORMAL_RATE_PER_SECOND: u64 = 50; +const TRACE_RATE_PER_SECOND: u64 = 100; +const AMBIENT_RATE_PER_SECOND: u64 = 5; +// Stats polling delivers newly observed paths and announces in short batches. +// Keep the five-per-second sustained sampler while allowing one ordinary poll +// batch through without manufacturing loss from the polling boundary itself. +const AMBIENT_BURST_CAPACITY: u64 = 25; + +pub(super) trait MonotonicClock: Send + Sync { + fn now_tick(&self) -> u64; +} + +pub(super) struct ProcessClock { + origin: Instant, +} + +impl ProcessClock { + pub(super) fn new() -> Arc { + Arc::new(Self { + origin: Instant::now(), + }) + } +} + +impl MonotonicClock for ProcessClock { + fn now_tick(&self) -> u64 { + self.origin.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64 + } +} + +/// A lock-free Generic Cell Rate Algorithm bucket. Its burst tolerance is +/// token-bucket equivalent: `capacity` events may pass at one instant, then +/// one token replenishes per interval. +struct GcraBucket { + theoretical_arrival: AtomicU64, + interval: u64, + burst_window: u64, +} + +impl GcraBucket { + fn per_second(rate: u64, capacity: u64) -> Self { + debug_assert!(rate > 0); + debug_assert!(capacity > 0); + let interval = NANOS_PER_SECOND / rate; + Self { + theoretical_arrival: AtomicU64::new(0), + interval, + burst_window: interval.saturating_mul(capacity), + } + } + + fn reset(&self, now: u64) { + self.theoretical_arrival.store(now, Ordering::Relaxed); + } + + fn try_take(&self, now: u64) -> bool { + let mut observed = self.theoretical_arrival.load(Ordering::Relaxed); + loop { + let base = observed.max(now); + let Some(next) = base.checked_add(self.interval) else { + return false; + }; + let deadline = now.saturating_add(self.burst_window); + if next > deadline { + return false; + } + match self.theoretical_arrival.compare_exchange_weak( + observed, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return true, + Err(actual) => observed = actual, + } + } + } +} + +struct ProfileBuckets { + global: GcraBucket, + domains: [GcraBucket; RateDomain::COUNT], +} + +impl ProfileBuckets { + fn per_second(rate: u64) -> Self { + Self { + global: GcraBucket::per_second(rate, rate), + domains: array::from_fn(|_| GcraBucket::per_second(rate, rate)), + } + } + + fn reset(&self, now: u64) { + self.global.reset(now); + for domain in &self.domains { + domain.reset(now); + } + } + + fn try_take(&self, now: u64, domain: RateDomain) -> bool { + // A failed domain admission does not consume a global token. The + // reverse race can conservatively consume a domain token if another + // thread wins the global CAS; no unsafe refund is attempted. + self.domains[domain.index()].try_take(now) && self.global.try_take(now) + } +} + +pub(super) struct RateAdmission { + clock: Arc, + normal: ProfileBuckets, + trace: ProfileBuckets, + ambient: [GcraBucket; RateDomain::COUNT], +} + +impl RateAdmission { + pub(super) fn new(clock: Arc) -> Self { + Self { + clock, + normal: ProfileBuckets::per_second(NORMAL_RATE_PER_SECOND), + trace: ProfileBuckets::per_second(TRACE_RATE_PER_SECOND), + ambient: array::from_fn(|_| { + GcraBucket::per_second(AMBIENT_RATE_PER_SECOND, AMBIENT_BURST_CAPACITY) + }), + } + } + + pub(super) fn reset(&self, profile: CaptureProfile) { + let now = self.clock.now_tick(); + match profile { + CaptureProfile::Normal => self.normal.reset(now), + CaptureProfile::Trace => self.trace.reset(now), + } + for ambient in &self.ambient { + ambient.reset(now); + } + } + + pub(super) fn allow( + &self, + profile: CaptureProfile, + severity: ActivitySeverity, + domain: RateDomain, + ambient: bool, + ) -> bool { + if severity == ActivitySeverity::Error { + return true; + } + let now = self.clock.now_tick(); + if ambient && !self.ambient[domain.index()].try_take(now) { + return false; + } + match profile { + CaptureProfile::Normal => self.normal.try_take(now, domain), + CaptureProfile::Trace => self.trace.try_take(now, domain), + } + } +} + +/// At most 960 low-priority envelopes can hold one of these permits. The +/// permit moves through the channel with its draft and releases immediately +/// after receive or on any failed send/drop path. +pub(super) struct LowPermitPool { + in_use: AtomicUsize, +} + +impl LowPermitPool { + pub(super) fn new() -> Arc { + Arc::new(Self { + in_use: AtomicUsize::new(0), + }) + } + + pub(super) fn try_acquire(self: &Arc) -> Option { + let mut observed = self.in_use.load(Ordering::Relaxed); + loop { + if observed >= LOW_PRIORITY_LIMIT { + return None; + } + match self.in_use.compare_exchange_weak( + observed, + observed + 1, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + return Some(LowPermit { + pool: Arc::clone(self), + }); + } + Err(actual) => observed = actual, + } + } + } + + #[cfg(test)] + fn in_use(&self) -> usize { + self.in_use.load(Ordering::Relaxed) + } +} + +pub(super) struct LowPermit { + pool: Arc, +} + +impl Drop for LowPermit { + fn drop(&mut self) { + let previous = self.pool.in_use.fetch_sub(1, Ordering::Relaxed); + debug_assert!(previous > 0, "low-priority permit count underflow"); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Barrier; + use std::thread; + + use super::*; + + #[derive(Default)] + struct FakeClock(AtomicU64); + + impl FakeClock { + fn advance(&self, nanos: u64) { + self.0.fetch_add(nanos, Ordering::Relaxed); + } + } + + impl MonotonicClock for FakeClock { + fn now_tick(&self) -> u64 { + self.0.load(Ordering::Relaxed) + } + } + + #[test] + fn normal_and_trace_bursts_are_exact_and_replenish() { + let clock = Arc::new(FakeClock::default()); + let rate = RateAdmission::new(clock.clone()); + rate.reset(CaptureProfile::Normal); + for _ in 0..NORMAL_RATE_PER_SECOND { + assert!(rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Info, + RateDomain::Network, + false + )); + } + assert!(!rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Info, + RateDomain::Network, + false + )); + clock.advance(NANOS_PER_SECOND / NORMAL_RATE_PER_SECOND); + assert!(rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Info, + RateDomain::Network, + false + )); + + rate.reset(CaptureProfile::Trace); + for _ in 0..TRACE_RATE_PER_SECOND { + assert!(rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Warning, + RateDomain::Channels, + false + )); + } + assert!(!rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Warning, + RateDomain::Channels, + false + )); + } + + #[test] + fn ambient_bucket_allows_poll_bursts_but_sustains_five_per_second() { + let clock = Arc::new(FakeClock::default()); + let rate = RateAdmission::new(clock.clone()); + rate.reset(CaptureProfile::Trace); + for _ in 0..AMBIENT_BURST_CAPACITY { + assert!(rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Info, + RateDomain::Network, + true + )); + } + assert!(!rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Info, + RateDomain::Network, + true + )); + clock.advance(NANOS_PER_SECOND / AMBIENT_RATE_PER_SECOND); + assert!(rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Info, + RateDomain::Network, + true + )); + for _ in 0..2_000 { + assert!(rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Error, + RateDomain::Network, + true + )); + } + } + + #[test] + fn concurrent_low_permits_never_enter_the_reserved_tail() { + let pool = LowPermitPool::new(); + const WORKERS: usize = 32; + const PER_WORKER: usize = LOW_PRIORITY_LIMIT / WORKERS; + let start = Arc::new(Barrier::new(WORKERS + 1)); + let mut workers = Vec::with_capacity(WORKERS); + for _ in 0..WORKERS { + let pool = Arc::clone(&pool); + let start = Arc::clone(&start); + workers.push(thread::spawn(move || { + let permits: Vec<_> = (0..PER_WORKER) + .map(|_| pool.try_acquire().expect("first 960 must fit")) + .collect(); + start.wait(); + permits + })); + } + start.wait(); + assert_eq!(pool.in_use(), LOW_PRIORITY_LIMIT); + assert!(pool.try_acquire().is_none()); + for worker in workers { + drop(worker.join().expect("permit worker should finish")); + } + assert_eq!(pool.in_use(), 0); + } +} diff --git a/crates/ratspeak-runtime/src/activity/catalog.rs b/crates/ratspeak-runtime/src/activity/catalog.rs new file mode 100644 index 0000000..4839d20 --- /dev/null +++ b/crates/ratspeak-runtime/src/activity/catalog.rs @@ -0,0 +1,3345 @@ +//! Sealed, event-specific Activity constructors. +//! +//! Producer modules call functions in this catalog with concrete domain +//! inputs. They cannot select a classification, add an arbitrary attribute, +//! or supply a free-form event/summary code. + +#![allow( + dead_code, + reason = "the reviewed catalog includes variants reserved for later semantic coverage" +)] + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use super::classified::{ + ActivityDraft, ActivityRejectReason, ClassifiedEndpoint, CoalescingPolicy, CorrelationId, + ExactValue, NavigationAction, +}; +use super::schema::{ + ActivityAttributeKey, ActivityDirection, ActivityOutcome, ActivitySeverity, EndpointClass, + IdentifierKind, RateDomain, kinds, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ObservationTime { + unix_ms: u64, + elapsed_ms: u64, +} + +impl ObservationTime { + pub(super) const fn new(unix_ms: u64, elapsed_ms: u64) -> Self { + Self { + unix_ms, + elapsed_ms, + } + } + + pub(super) const fn unix_ms(self) -> u64 { + self.unix_ms + } + + pub(super) const fn elapsed_ms(self) -> u64 { + self.elapsed_ms + } + + pub(super) const fn unstamped() -> Self { + Self::new(0, 0) + } +} + +/// Recorder-owned wall/monotonic observation clock. `ObservationTime` never +/// leaves the private Activity implementation, so domain producers cannot +/// fabricate timestamps or choose another clock domain. +pub(super) trait ActivityClock: Send + Sync { + fn observe(&self) -> ObservationTime; +} + +pub(super) struct SystemActivityClock { + origin: Instant, +} + +impl SystemActivityClock { + pub(super) fn new() -> Self { + Self { + origin: Instant::now(), + } + } +} + +impl ActivityClock for SystemActivityClock { + fn observe(&self) -> ObservationTime { + let unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64; + let elapsed_ms = self.origin.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + ObservationTime::new(unix_ms, elapsed_ms) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct DestinationHash([u8; 16]); + +impl DestinationHash { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MessageId([u8; 32]); + +impl MessageId { + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct LinkId([u8; 16]); + +impl LinkId { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IdentityHash([u8; 16]); + +impl IdentityHash { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +fn decode_fixed_hex(value: &str) -> Result<[u8; N], ActivityRejectReason> { + if value.len() != N.saturating_mul(2) { + return Err(ActivityRejectReason::InvalidIdentifier); + } + let bytes = hex::decode(value).map_err(|_| ActivityRejectReason::InvalidIdentifier)?; + bytes + .try_into() + .map_err(|_| ActivityRejectReason::InvalidIdentifier) +} + +/// Random opaque room-session token assigned by Channels outside Activity. It +/// must never be derived from the human-authored room label. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ChannelRoomToken([u8; 16]); + +impl ChannelRoomToken { + pub fn random() -> Self { + Self(rns_crypto::random::random_16()) + } + + #[cfg(test)] + pub(super) const fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } +} + +/// Random volatile token assigned to one RRC envelope identifier. The RRC +/// message id is only used as an in-memory lookup key by Channels; Activity +/// receives this unrelated 256-bit token. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ChannelMessageToken([u8; 32]); + +impl ChannelMessageToken { + pub fn random() -> Self { + Self(rns_crypto::random::random_32()) + } + + #[cfg(test)] + pub(super) const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +/// Random, session-local lookup key into navigation state owned outside +/// Activity. There is no constructor from labels, paths, or arbitrary bytes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct NavigationToken([u8; 16]); + +impl NavigationToken { + pub fn random() -> Self { + Self(rns_crypto::random::random_16()) + } + + #[cfg(test)] + pub(super) const fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } +} + +/// Validated private TCP endpoint input. It is zeroized when moved into a +/// draft and has no `Debug`, `Clone`, or serialization implementation. +pub struct TcpEndpoint(ClassifiedEndpoint); + +impl TcpEndpoint { + pub fn new(value: String) -> Result { + ClassifiedEndpoint::network(EndpointClass::Tcp, value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AppRuntimeTransition { + Started, + Ready, + Unavailable, + Stopped, +} + +pub fn app_runtime(time: ObservationTime, transition: AppRuntimeTransition) -> ActivityDraft { + let (kind, severity, outcome) = match transition { + AppRuntimeTransition::Started => ( + kinds::APP_RUNTIME_STARTED, + ActivitySeverity::Info, + ActivityOutcome::Started, + ), + AppRuntimeTransition::Ready => ( + kinds::APP_RUNTIME_READY, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + AppRuntimeTransition::Unavailable => ( + kinds::APP_RUNTIME_UNAVAILABLE, + ActivitySeverity::Error, + ActivityOutcome::Failed, + ), + AppRuntimeTransition::Stopped => ( + kinds::APP_RUNTIME_STOPPED, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + }; + ActivityDraft::new( + kind, + severity, + ActivityDirection::Local, + outcome, + time.unix_ms, + time.elapsed_ms, + CoalescingPolicy::Never, + ) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceClass { + Auto, + BackboneClient, + BackboneServer, + BluetoothPeer, + RNode, + TcpClient, + TcpServer, + Unknown, +} + +impl InterfaceClass { + const fn code(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::BackboneClient => "backbone_client", + Self::BackboneServer => "backbone_server", + Self::BluetoothPeer => "ble_peer", + Self::RNode => "rnode", + Self::TcpClient => "tcp_client", + Self::TcpServer => "tcp_server", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceDegradationReason { + CapabilityUnverified, + MulticastUnavailable, + PeripheralUnavailable, +} + +impl InterfaceDegradationReason { + const fn code(self) -> &'static str { + match self { + Self::CapabilityUnverified => "capability_unverified", + Self::MulticastUnavailable => "multicast_unavailable", + Self::PeripheralUnavailable => "peripheral_unavailable", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceTimeoutReason { + Setup, + Pairing, + Startup, +} + +impl InterfaceTimeoutReason { + const fn code(self) -> &'static str { + match self { + Self::Setup => "setup_timed_out", + Self::Pairing => "pairing_timed_out", + Self::Startup => "startup_timed_out", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceFailureReason { + CapabilityRejected, + Configure, + Connect, + Listen, + Remove, + Resume, + Runtime, + Update, +} + +impl InterfaceFailureReason { + const fn code(self) -> &'static str { + match self { + Self::CapabilityRejected => "capability_rejected", + Self::Configure => "configure_failed", + Self::Connect => "connect_failed", + Self::Listen => "listen_failed", + Self::Remove => "remove_failed", + Self::Resume => "resume_failed", + Self::Runtime => "runtime_failed", + Self::Update => "update_failed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceRollback { + ConfigRestored, + RestartFailed, + WriteFailed, +} + +impl InterfaceRollback { + const fn code(self) -> &'static str { + match self { + Self::ConfigRestored => "config_restored", + Self::RestartFailed => "restart_failed", + Self::WriteFailed => "write_failed", + } + } +} + +pub enum InterfaceTransition { + Configured, + Connecting, + Cancelled, + Online, + Offline, + Degraded { + reason: InterfaceDegradationReason, + }, + Paused, + Removed, + Failed { + reason: InterfaceFailureReason, + rollback: Option, + }, + TimedOut { + reason: InterfaceTimeoutReason, + }, +} + +pub struct InterfaceActivity { + pub time: ObservationTime, + pub class: InterfaceClass, + pub transition: InterfaceTransition, + pub endpoint: Option, +} + +pub fn interface_activity(input: InterfaceActivity) -> Result { + let (kind, severity, outcome, reason, rollback) = match input.transition { + InterfaceTransition::Configured => ( + kinds::INTERFACE_CONFIGURED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Connecting => ( + kinds::INTERFACE_CONNECTING, + ActivitySeverity::Info, + ActivityOutcome::Started, + None, + None, + ), + InterfaceTransition::Cancelled => ( + kinds::INTERFACE_CANCELLED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Online => ( + kinds::INTERFACE_ONLINE, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Offline => ( + kinds::INTERFACE_OFFLINE, + ActivitySeverity::Warning, + ActivityOutcome::Degraded, + None, + None, + ), + InterfaceTransition::Degraded { reason } => ( + kinds::INTERFACE_DEGRADED, + ActivitySeverity::Warning, + ActivityOutcome::Degraded, + Some(reason.code()), + None, + ), + InterfaceTransition::Paused => ( + kinds::INTERFACE_PAUSED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Removed => ( + kinds::INTERFACE_REMOVED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Failed { reason, rollback } => ( + kinds::INTERFACE_FAILED, + ActivitySeverity::Error, + ActivityOutcome::Failed, + Some(reason.code()), + rollback, + ), + InterfaceTransition::TimedOut { reason } => ( + kinds::INTERFACE_TIMED_OUT, + ActivitySeverity::Error, + ActivityOutcome::TimedOut, + Some(reason.code()), + None, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + ActivityDirection::Local, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .operational_code(ActivityAttributeKey::InterfaceClass, input.class.code())?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + if let Some(rollback) = rollback { + draft = draft.operational_code(ActivityAttributeKey::State, rollback.code())?; + } + if let Some(endpoint) = input.endpoint { + draft = draft.sensitive_endpoint(ActivityAttributeKey::Endpoint, endpoint.0); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PathRequestMethod { + Automatic, + ContactRefresh, + Manual, +} + +impl PathRequestMethod { + const fn code(self) -> &'static str { + match self { + Self::Automatic => "automatic", + Self::ContactRefresh => "contact_refresh", + Self::Manual => "manual", + } + } +} + +pub struct RnsPathRequested { + pub time: ObservationTime, + pub destination: Option, + pub count: Option, + pub method: PathRequestMethod, +} + +pub fn rns_path_requested(input: RnsPathRequested) -> Result { + let mut draft = ActivityDraft::new( + kinds::RNS_PATH_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .operational_code(ActivityAttributeKey::Method, input.method.code())?; + if let Some(destination) = input.destination { + draft = draft.protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &destination.0, + )?; + } + if let Some(count) = input.count { + draft = draft.exact(ActivityAttributeKey::Count, ExactValue::Unsigned(count)); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnnounceMethod { + InterfaceOnline, + LxmfDelivery, + LxstService, + Manual, + Startup, + Transport, +} + +impl AnnounceMethod { + const fn code(self) -> &'static str { + match self { + Self::InterfaceOnline => "interface_online", + Self::LxmfDelivery => "lxmf_delivery", + Self::LxstService => "lxst_service", + Self::Manual => "manual", + Self::Startup => "startup", + Self::Transport => "transport", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnnounceFailureReason { + NoInterfaceTransmission, + NotReady, + QueueFailed, + TransportUnavailable, +} + +impl AnnounceFailureReason { + const fn code(self) -> &'static str { + match self { + Self::NoInterfaceTransmission => "no_interface_transmission", + Self::NotReady => "not_ready", + Self::QueueFailed => "queue_failed", + Self::TransportUnavailable => "transport_unavailable", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnnounceSuppressionReason { + Cooldown, + InterfaceRestart, + RateLimit, +} + +impl AnnounceSuppressionReason { + const fn code(self) -> &'static str { + match self { + Self::Cooldown => "cooldown", + Self::InterfaceRestart => "interface_restart", + Self::RateLimit => "rate_limit", + } + } +} + +pub enum RnsAnnounceTransition { + Sent { + method: AnnounceMethod, + }, + Failed { + method: AnnounceMethod, + reason: AnnounceFailureReason, + }, + Held { + count: u64, + }, + IngressBurstStarted, + IngressBurstCleared, + Suppressed { + reason: AnnounceSuppressionReason, + }, + Observed { + destination: DestinationHash, + hops: u8, + }, +} + +pub struct RnsAnnounceActivity { + pub time: ObservationTime, + pub transition: RnsAnnounceTransition, + pub interface: Option, +} + +pub fn rns_announce_activity( + input: RnsAnnounceActivity, +) -> Result { + let (kind, severity, direction, outcome, coalescing) = match input.transition { + RnsAnnounceTransition::Sent { .. } => ( + kinds::RNS_ANNOUNCE_SENT, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Failed { .. } => ( + kinds::RNS_ANNOUNCE_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Held { .. } => ( + kinds::RNS_ANNOUNCE_HELD, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Progress, + CoalescingPolicy::AdjacentEquivalent, + ), + RnsAnnounceTransition::IngressBurstStarted => ( + kinds::RNS_ANNOUNCE_INGRESS_BURST_STARTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Degraded, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::IngressBurstCleared => ( + kinds::RNS_ANNOUNCE_INGRESS_BURST_CLEARED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Suppressed { .. } => ( + kinds::RNS_ANNOUNCE_SUPPRESSED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Dropped, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Observed { .. } => ( + kinds::RNS_ANNOUNCE_OBSERVED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::AdjacentEquivalent, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + coalescing, + ); + match input.transition { + RnsAnnounceTransition::Sent { method } => { + draft = draft.operational_code(ActivityAttributeKey::Method, method.code())?; + } + RnsAnnounceTransition::Failed { method, reason } => { + draft = draft + .operational_code(ActivityAttributeKey::Method, method.code())? + .operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + RnsAnnounceTransition::Held { count } => { + draft = draft.exact( + ActivityAttributeKey::QueueCount, + ExactValue::Unsigned(count), + ); + } + RnsAnnounceTransition::Suppressed { reason } => { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + RnsAnnounceTransition::Observed { destination, hops } => { + draft = draft + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &destination.0, + )? + .exact( + ActivityAttributeKey::Hops, + ExactValue::Unsigned(u64::from(hops)), + ); + } + RnsAnnounceTransition::IngressBurstStarted | RnsAnnounceTransition::IngressBurstCleared => { + } + } + if let Some(interface) = input.interface { + draft = draft.operational_code(ActivityAttributeKey::InterfaceClass, interface.code())?; + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PathEvidence { + Announce, + Cached, + PathResponse, + Transport, +} + +impl PathEvidence { + const fn code(self) -> &'static str { + match self { + Self::Announce => "announce", + Self::Cached => "cached", + Self::PathResponse => "path_response", + Self::Transport => "transport", + } + } +} + +pub struct RnsPathDiscovered { + pub time: ObservationTime, + pub destination: DestinationHash, + pub hops: u8, + pub evidence: PathEvidence, + pub endpoint: Option, + pub correlation_id: Option, +} + +pub fn rns_path_discovered( + input: RnsPathDiscovered, +) -> Result { + rns_path_event(kinds::RNS_PATH_DISCOVERED, input) +} + +pub fn rns_path_observed(input: RnsPathDiscovered) -> Result { + rns_path_event(kinds::RNS_PATH_OBSERVED, input) +} + +fn rns_path_event( + kind: super::schema::ActivityKindCode, + input: RnsPathDiscovered, +) -> Result { + let mut draft = ActivityDraft::new( + kind, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::AdjacentEquivalent, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .exact( + ActivityAttributeKey::Hops, + ExactValue::Unsigned(u64::from(input.hops)), + ) + .operational_code(ActivityAttributeKey::Validation, input.evidence.code())?; + if let Some(endpoint) = input.endpoint { + draft = draft.sensitive_endpoint(ActivityAttributeKey::Endpoint, endpoint.0); + } + if let Some(correlation_id) = input.correlation_id { + draft = draft.with_correlation(correlation_id); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelEnvelopeKind { + Action, + Error, + Hello, + Join, + Joined, + Message, + Part, + Parted, + Ping, + Pong, + Notice, + Resource, + Welcome, +} + +impl ChannelEnvelopeKind { + const fn code(self) -> &'static str { + match self { + Self::Action => "action", + Self::Error => "error", + Self::Hello => "hello", + Self::Join => "join", + Self::Joined => "joined", + Self::Message => "message", + Self::Part => "part", + Self::Parted => "parted", + Self::Ping => "ping", + Self::Pong => "pong", + Self::Notice => "notice", + Self::Resource => "resource", + Self::Welcome => "welcome", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SourceValidation { + Accepted, + Duplicate, + Malformed, + NonHub, + Unsupported, + WrongSource, +} + +impl SourceValidation { + const fn code(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::Duplicate => "duplicate", + Self::Malformed => "malformed", + Self::NonHub => "non_hub", + Self::Unsupported => "unsupported", + Self::WrongSource => "wrong_source", + } + } +} + +pub struct ChannelsEnvelopeActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub room: Option, + pub message: Option, + pub envelope_kind: Option, + pub encoded_bytes: u32, + pub validation: SourceValidation, + pub correlation_id: CorrelationId, +} + +pub fn channels_envelope_sent( + input: ChannelsEnvelopeActivity, +) -> Result { + channels_envelope(input, ActivityDirection::Outbound) +} + +pub fn channels_envelope_received( + input: ChannelsEnvelopeActivity, +) -> Result { + channels_envelope(input, ActivityDirection::Inbound) +} + +fn channels_envelope( + input: ChannelsEnvelopeActivity, + direction: ActivityDirection, +) -> Result { + let (kind, severity, outcome, coalescing, duplicate) = match input.validation { + SourceValidation::Accepted => ( + if direction == ActivityDirection::Outbound { + kinds::CHANNELS_ENVELOPE_SENT + } else { + kinds::CHANNELS_ENVELOPE_RECEIVED + }, + ActivitySeverity::Info, + ActivityOutcome::Success, + CoalescingPolicy::AdjacentEquivalent, + false, + ), + SourceValidation::Duplicate | SourceValidation::Unsupported => ( + kinds::CHANNELS_ENVELOPE_RECEIVED, + ActivitySeverity::Info, + ActivityOutcome::Dropped, + CoalescingPolicy::Never, + matches!(input.validation, SourceValidation::Duplicate), + ), + SourceValidation::Malformed | SourceValidation::NonHub | SourceValidation::WrongSource => ( + kinds::CHANNELS_ENVELOPE_REJECTED, + ActivitySeverity::Warning, + ActivityOutcome::Rejected, + CoalescingPolicy::Never, + false, + ), + }; + let draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + coalescing, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)?; + let mut draft = draft + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(u64::from(input.encoded_bytes)), + ) + .exact( + ActivityAttributeKey::Duplicate, + ExactValue::Boolean(duplicate), + ) + .operational_code(ActivityAttributeKey::Validation, input.validation.code())?; + if let Some(room) = input.room { + draft = + draft.protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)?; + } + if let Some(message) = input.message { + draft = draft.protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &message.0, + )?; + } + if let Some(envelope_kind) = input.envelope_kind { + draft = draft.operational_code(ActivityAttributeKey::Method, envelope_kind.code())?; + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelSessionFailureReason { + AuthenticationFailed, + HubRejected, + IdentificationFailed, + InvalidAnnounce, + MalformedWelcome, + PathLookupFailed, + SendFailed, + TransportUnavailable, + UnsupportedVersion, + WelcomeTimedOut, + WrongSource, +} + +impl ChannelSessionFailureReason { + const fn code(self) -> &'static str { + match self { + Self::AuthenticationFailed => "authentication_failed", + Self::HubRejected => "hub_rejected", + Self::IdentificationFailed => "identification_failed", + Self::InvalidAnnounce => "invalid_announce", + Self::MalformedWelcome => "malformed_welcome", + Self::PathLookupFailed => "path_lookup_failed", + Self::SendFailed => "send_failed", + Self::TransportUnavailable => "transport_unavailable", + Self::UnsupportedVersion => "unsupported_version", + Self::WelcomeTimedOut => "welcome_timed_out", + Self::WrongSource => "wrong_source", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelSessionCloseReason { + Local, + Remote, + SendFailed, + StreamEnded, + Timeout, + TransportUnavailable, +} + +impl ChannelSessionCloseReason { + const fn code(self) -> &'static str { + match self { + Self::Local => "local", + Self::Remote => "remote", + Self::SendFailed => "send_failed", + Self::StreamEnded => "stream_ended", + Self::Timeout => "timeout", + Self::TransportUnavailable => "transport_unavailable", + } + } +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct ChannelNegotiatedCapabilities { + pub actions: bool, + pub direct_notices: bool, + pub resource_envelopes: bool, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct ChannelNegotiatedLimits { + pub max_nick_bytes: Option, + pub max_room_bytes: Option, + pub max_message_bytes: Option, + pub max_rooms: Option, + pub rate_per_minute: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelSessionTransition { + ConnectRequested, + Cancelled, + PathRequested, + PathDiscovered { + hops: u8, + }, + PathTimedOut, + LinkRequested, + LinkAuthenticated { + link: LinkId, + }, + LinkIdentificationSent { + link: LinkId, + }, + HelloSent { + encoded_bytes: u32, + }, + WelcomeValidated { + encoded_bytes: u32, + }, + WelcomeRejected { + reason: ChannelSessionFailureReason, + }, + Failed { + reason: ChannelSessionFailureReason, + }, + Negotiated { + protocol_version: u64, + capabilities: ChannelNegotiatedCapabilities, + limits: ChannelNegotiatedLimits, + link_mdu: u64, + }, + GreetingObserved { + encoded_bytes: u32, + }, + Stale, + Recovered, + Closed { + reason: ChannelSessionCloseReason, + link: Option, + duration_ms: Option, + }, +} + +pub struct ChannelsSessionActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub correlation_id: CorrelationId, + pub transition: ChannelSessionTransition, +} + +pub fn channels_session_activity( + input: ChannelsSessionActivity, +) -> Result { + let (kind, severity, direction, outcome, reason) = match input.transition { + ChannelSessionTransition::ConnectRequested => ( + kinds::CHANNELS_SESSION_CONNECT_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Started, + None, + ), + ChannelSessionTransition::Cancelled => ( + kinds::CHANNELS_SESSION_CANCELLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::PathRequested => ( + kinds::CHANNELS_SESSION_PATH_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + ), + ChannelSessionTransition::PathDiscovered { .. } => ( + kinds::CHANNELS_SESSION_PATH_DISCOVERED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::PathTimedOut => ( + kinds::CHANNELS_SESSION_PATH_TIMED_OUT, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + None, + ), + ChannelSessionTransition::LinkRequested => ( + kinds::CHANNELS_SESSION_LINK_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + ), + ChannelSessionTransition::LinkAuthenticated { .. } => ( + kinds::CHANNELS_SESSION_LINK_AUTHENTICATED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::LinkIdentificationSent { .. } => ( + kinds::CHANNELS_SESSION_LINK_IDENTIFICATION_SENT, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::HelloSent { .. } => ( + kinds::CHANNELS_SESSION_HELLO_SENT, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::WelcomeValidated { .. } => ( + kinds::CHANNELS_SESSION_WELCOME_VALIDATED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::WelcomeRejected { reason } => ( + kinds::CHANNELS_SESSION_WELCOME_REJECTED, + ActivitySeverity::Error, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + Some(reason.code()), + ), + ChannelSessionTransition::Failed { reason } => ( + kinds::CHANNELS_SESSION_FAILED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::Failed, + Some(reason.code()), + ), + ChannelSessionTransition::Negotiated { .. } => ( + kinds::CHANNELS_SESSION_NEGOTIATED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::GreetingObserved { .. } => ( + kinds::CHANNELS_SESSION_GREETING_OBSERVED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::Stale => ( + kinds::CHANNELS_SESSION_STALE, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + None, + ), + ChannelSessionTransition::Recovered => ( + kinds::CHANNELS_SESSION_RECOVERED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::Closed { + reason: ChannelSessionCloseReason::Local, + .. + } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + Some(ChannelSessionCloseReason::Local.code()), + ), + ChannelSessionTransition::Closed { + reason: ChannelSessionCloseReason::Timeout, + .. + } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + Some(ChannelSessionCloseReason::Timeout.code()), + ), + ChannelSessionTransition::Closed { + reason: ChannelSessionCloseReason::Remote, + .. + } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Error, + ActivityDirection::Inbound, + ActivityOutcome::Failed, + Some(ChannelSessionCloseReason::Remote.code()), + ), + ChannelSessionTransition::Closed { reason, .. } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::Failed, + Some(reason.code()), + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + match input.transition { + ChannelSessionTransition::PathDiscovered { hops } => { + draft = draft.exact( + ActivityAttributeKey::Hops, + ExactValue::Unsigned(u64::from(hops)), + ); + } + ChannelSessionTransition::LinkAuthenticated { link } + | ChannelSessionTransition::LinkIdentificationSent { link } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + if matches!( + input.transition, + ChannelSessionTransition::LinkIdentificationSent { .. } + ) { + draft = draft.operational_code(ActivityAttributeKey::State, "sent")?; + } + } + ChannelSessionTransition::HelloSent { encoded_bytes } + | ChannelSessionTransition::WelcomeValidated { encoded_bytes } + | ChannelSessionTransition::GreetingObserved { encoded_bytes } => { + draft = draft.exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(u64::from(encoded_bytes)), + ); + } + ChannelSessionTransition::Negotiated { + protocol_version, + capabilities, + limits, + link_mdu, + } => { + draft = draft + .exact( + ActivityAttributeKey::ProtocolVersion, + ExactValue::Unsigned(protocol_version), + ) + .exact(ActivityAttributeKey::Mdu, ExactValue::Unsigned(link_mdu)); + for (enabled, capability) in [ + (capabilities.actions, "action"), + (capabilities.direct_notices, "direct_notice"), + (capabilities.resource_envelopes, "resource_envelope"), + ] { + if enabled { + draft = draft.operational_code(ActivityAttributeKey::Capability, capability)?; + } + } + for (key, value) in [ + (ActivityAttributeKey::MaxNickBytes, limits.max_nick_bytes), + (ActivityAttributeKey::MaxRoomBytes, limits.max_room_bytes), + ( + ActivityAttributeKey::MaxMessageBytes, + limits.max_message_bytes, + ), + (ActivityAttributeKey::MaxRooms, limits.max_rooms), + (ActivityAttributeKey::RatePerMinute, limits.rate_per_minute), + ] { + if let Some(value) = value { + draft = draft.exact(key, ExactValue::Unsigned(value)); + } + } + } + ChannelSessionTransition::Closed { + link, duration_ms, .. + } => { + if let Some(link) = link { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + if let Some(duration_ms) = duration_ms { + draft = draft.exact( + ActivityAttributeKey::DurationMs, + ExactValue::Unsigned(duration_ms), + ); + } + } + ChannelSessionTransition::ConnectRequested + | ChannelSessionTransition::Cancelled + | ChannelSessionTransition::PathRequested + | ChannelSessionTransition::PathTimedOut + | ChannelSessionTransition::LinkRequested + | ChannelSessionTransition::WelcomeRejected { .. } + | ChannelSessionTransition::Failed { .. } + | ChannelSessionTransition::Stale + | ChannelSessionTransition::Recovered => {} + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelJoinEvidence { + JoinedRoster, + RrcdStatusNotice, +} + +impl ChannelJoinEvidence { + const fn code(self) -> &'static str { + match self { + Self::JoinedRoster => "joined_roster", + Self::RrcdStatusNotice => "rrcd_status_notice", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelRoomFailureReason { + HubRejected, + SendFailed, + SessionClosed, +} + +impl ChannelRoomFailureReason { + const fn code(self) -> &'static str { + match self { + Self::HubRejected => "hub_rejected", + Self::SendFailed => "send_failed", + Self::SessionClosed => "session_closed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelRoomTransition { + JoinRequested, + Joined { evidence: ChannelJoinEvidence }, + JoinRejected { reason: ChannelRoomFailureReason }, + JoinTimedOut, + JoinCancelled, + PartRequested, + Parted, + PartRejected { reason: ChannelRoomFailureReason }, + PartTimedOut, + PartCancelled, +} + +pub struct ChannelsRoomActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub room: ChannelRoomToken, + pub correlation_id: CorrelationId, + pub transition: ChannelRoomTransition, +} + +pub fn channels_room_activity( + input: ChannelsRoomActivity, +) -> Result { + let (kind, severity, direction, outcome, reason, evidence) = match input.transition { + ChannelRoomTransition::JoinRequested => ( + kinds::CHANNELS_ROOM_JOIN_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + None, + ), + ChannelRoomTransition::Joined { evidence } => ( + kinds::CHANNELS_ROOM_JOINED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + Some(evidence.code()), + ), + ChannelRoomTransition::JoinRejected { reason } => ( + kinds::CHANNELS_ROOM_JOIN_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + Some(reason.code()), + None, + ), + ChannelRoomTransition::JoinTimedOut => ( + kinds::CHANNELS_ROOM_JOIN_TIMED_OUT, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + None, + None, + ), + ChannelRoomTransition::JoinCancelled => ( + kinds::CHANNELS_ROOM_JOIN_CANCELLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + None, + ), + ChannelRoomTransition::PartRequested => ( + kinds::CHANNELS_ROOM_PART_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + None, + ), + ChannelRoomTransition::Parted => ( + kinds::CHANNELS_ROOM_PARTED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + None, + ), + ChannelRoomTransition::PartRejected { reason } => ( + kinds::CHANNELS_ROOM_PART_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + Some(reason.code()), + None, + ), + ChannelRoomTransition::PartTimedOut => ( + kinds::CHANNELS_ROOM_PART_TIMED_OUT, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + None, + None, + ), + ChannelRoomTransition::PartCancelled => ( + kinds::CHANNELS_ROOM_PART_CANCELLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + None, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)? + .protocol_identifier( + ActivityAttributeKey::Room, + IdentifierKind::Room, + &input.room.0, + )?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + if let Some(evidence) = evidence { + draft = draft.operational_code(ActivityAttributeKey::Validation, evidence)?; + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubServiceDegradation { + Announce, + EnvelopeOversize, + SendFailed, +} + +impl HubServiceDegradation { + const fn code(self) -> &'static str { + match self { + Self::Announce => "announce_failed", + Self::EnvelopeOversize => "envelope_oversize", + Self::SendFailed => "send_failed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubSessionRejection { + WelcomeUnsendable, +} + +impl HubSessionRejection { + const fn code(self) -> &'static str { + match self { + Self::WelcomeUnsendable => "welcome_unsendable", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubSessionCloseReason { + Remote, + PingTimeout, + HandshakeTimeout, + Kicked, + ServiceStopped, +} + +impl HubSessionCloseReason { + const fn code(self) -> &'static str { + match self { + Self::Remote => "remote", + Self::PingTimeout => "ping_timed_out", + Self::HandshakeTimeout => "handshake_timed_out", + Self::Kicked => "kicked", + Self::ServiceStopped => "service_stopped", + } + } +} + +/// Operator actions the hub took on a room. The verb is representable; the +/// room label, topic, key and kick reason are not, by construction. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubModerationAction { + Register, + Unregister, + Topic, + Mode, + Op, + Deop, + Voice, + Devoice, + Ban, + Unban, + Kick, + Invite, + Uninvite, +} + +impl HubModerationAction { + const fn code(self) -> &'static str { + match self { + Self::Register => "register", + Self::Unregister => "unregister", + Self::Topic => "topic", + Self::Mode => "mode", + Self::Op => "op", + Self::Deop => "deop", + Self::Voice => "voice", + Self::Devoice => "devoice", + Self::Ban => "ban", + Self::Unban => "unban", + Self::Kick => "kick", + Self::Invite => "invite", + Self::Uninvite => "uninvite", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubTrustChange { + KlineAdded, + KlineRemoved, +} + +impl HubTrustChange { + const fn code(self) -> &'static str { + match self { + Self::KlineAdded => "kline_added", + Self::KlineRemoved => "kline_removed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubTransition { + ServiceStarted, + ServiceStopped, + ServiceDegraded { + reason: HubServiceDegradation, + count: u64, + }, + SessionOpened { + link: LinkId, + peer: IdentityHash, + }, + SessionRejected { + link: LinkId, + reason: HubSessionRejection, + }, + SessionClosed { + link: LinkId, + reason: HubSessionCloseReason, + duration_ms: u64, + }, + RoomJoined { + link: LinkId, + room: ChannelRoomToken, + members: u64, + }, + RoomParted { + link: LinkId, + room: ChannelRoomToken, + members: u64, + }, + RoomModerated { + link: LinkId, + room: ChannelRoomToken, + action: HubModerationAction, + }, + TrustChanged { + link: LinkId, + change: HubTrustChange, + }, + RelayForwarded { + room: ChannelRoomToken, + method: ChannelEnvelopeKind, + encoded_bytes: u64, + recipients: u64, + }, + RelayThrottled { + rejected: u64, + dropped: u64, + span_ms: u64, + }, +} + +pub struct ChannelsHubActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub correlation_id: CorrelationId, + pub transition: HubTransition, +} + +/// Hub-side counterpart of the client Channels catalog. Every remote party is +/// an opaque identifier and every room is a random token, so nothing a peer +/// authored — nickname, room label, topic, body — has a representation here. +pub fn channels_hub_activity( + input: ChannelsHubActivity, +) -> Result { + let (kind, severity, direction, outcome, coalescing, reason) = match input.transition { + HubTransition::ServiceStarted => ( + kinds::CHANNELS_HUB_SERVICE_STARTED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Started, + CoalescingPolicy::Never, + None, + ), + HubTransition::ServiceStopped => ( + kinds::CHANNELS_HUB_SERVICE_STOPPED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::ServiceDegraded { reason, .. } => ( + kinds::CHANNELS_HUB_SERVICE_DEGRADED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + CoalescingPolicy::Never, + Some(reason.code()), + ), + HubTransition::SessionOpened { .. } => ( + kinds::CHANNELS_HUB_SESSION_OPENED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::SessionRejected { reason, .. } => ( + kinds::CHANNELS_HUB_SESSION_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + CoalescingPolicy::Never, + Some(reason.code()), + ), + HubTransition::SessionClosed { reason, .. } => { + let (severity, direction, outcome) = match reason { + HubSessionCloseReason::PingTimeout | HubSessionCloseReason::HandshakeTimeout => ( + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + ), + HubSessionCloseReason::Remote => ( + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + ), + HubSessionCloseReason::Kicked | HubSessionCloseReason::ServiceStopped => ( + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + ), + }; + ( + kinds::CHANNELS_HUB_SESSION_CLOSED, + severity, + direction, + outcome, + CoalescingPolicy::Never, + Some(reason.code()), + ) + } + HubTransition::RoomJoined { .. } => ( + kinds::CHANNELS_HUB_ROOM_JOINED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::RoomParted { .. } => ( + kinds::CHANNELS_HUB_ROOM_PARTED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::RoomModerated { action, .. } => ( + kinds::CHANNELS_HUB_ROOM_MODERATED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + Some(action.code()), + ), + HubTransition::TrustChanged { change, .. } => ( + kinds::CHANNELS_HUB_TRUST_CHANGED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + Some(change.code()), + ), + // The only ambient hub kind: one per relayed envelope, so it is + // Trace-only and coalesces. + HubTransition::RelayForwarded { .. } => ( + kinds::CHANNELS_HUB_RELAY_FORWARDED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::AdjacentEquivalent, + None, + ), + HubTransition::RelayThrottled { .. } => ( + kinds::CHANNELS_HUB_RELAY_THROTTLED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Dropped, + CoalescingPolicy::Never, + None, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + coalescing, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + match input.transition { + HubTransition::ServiceStarted | HubTransition::ServiceStopped => {} + HubTransition::ServiceDegraded { count, .. } => { + draft = draft.exact(ActivityAttributeKey::Count, ExactValue::Unsigned(count)); + } + HubTransition::SessionOpened { link, peer } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )?; + } + HubTransition::SessionRejected { link, .. } | HubTransition::TrustChanged { link, .. } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + HubTransition::SessionClosed { + link, duration_ms, .. + } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .exact( + ActivityAttributeKey::DurationMs, + ExactValue::Unsigned(duration_ms), + ); + } + HubTransition::RoomJoined { + link, + room, + members, + } + | HubTransition::RoomParted { + link, + room, + members, + } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)? + .exact(ActivityAttributeKey::Count, ExactValue::Unsigned(members)); + } + HubTransition::RoomModerated { link, room, .. } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)?; + } + HubTransition::RelayForwarded { + room, + method, + encoded_bytes, + recipients, + } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)? + .operational_code(ActivityAttributeKey::Method, method.code())? + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(encoded_bytes), + ) + .exact( + ActivityAttributeKey::Count, + ExactValue::Unsigned(recipients), + ); + } + HubTransition::RelayThrottled { + rejected, + dropped, + span_ms, + } => { + draft = draft + .exact( + ActivityAttributeKey::RejectedCount, + ExactValue::Unsigned(rejected), + ) + .exact( + ActivityAttributeKey::DroppedCount, + ExactValue::Unsigned(dropped), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(span_ms), + ); + } + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfDeliveryMethod { + Direct, + Opportunistic, + Paper, + Propagated, +} + +impl LxmfDeliveryMethod { + pub fn from_code(value: &str) -> Option { + match value { + "direct" => Some(Self::Direct), + "opportunistic" => Some(Self::Opportunistic), + "paper" => Some(Self::Paper), + "propagated" => Some(Self::Propagated), + _ => None, + } + } + + const fn code(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Opportunistic => "opportunistic", + Self::Paper => "paper", + Self::Propagated => "propagated", + } + } +} + +pub struct LxmfDeliveryQueued { + pub time: ObservationTime, + pub message: MessageId, + pub destination: DestinationHash, + pub method: LxmfDeliveryMethod, +} + +pub fn lxmf_delivery_queued( + input: LxmfDeliveryQueued, +) -> Result { + ActivityDraft::new( + kinds::LXMF_DELIVERY_QUEUED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message.0, + )? + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Method, input.method.code()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfSubmissionFailureReason { + RouterUnavailable, + PreparationFailed, +} + +impl LxmfSubmissionFailureReason { + const fn code(self) -> &'static str { + match self { + Self::RouterUnavailable => "router_unavailable", + Self::PreparationFailed => "preparation_failed", + } + } +} + +pub struct LxmfSubmissionFailed { + pub time: ObservationTime, + pub destination: DestinationHash, + pub reason: LxmfSubmissionFailureReason, +} + +pub fn lxmf_submission_failed( + input: LxmfSubmissionFailed, +) -> Result { + ActivityDraft::new( + kinds::LXMF_DELIVERY_SUBMISSION_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Reason, input.reason.code()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfDeliveryState { + Routing, + Propagating, + ReusingBackchannel, + SendingViaLink, + Sent, + Delivered, + Propagated, + Rejected, + Failed, +} + +pub struct LxmfDeliveryStateChanged { + pub time: ObservationTime, + pub message: MessageId, + pub state: LxmfDeliveryState, + pub method: Option, + pub rtt_ms: Option, + pub failure_reason: Option, +} + +pub fn lxmf_delivery_state_changed( + input: LxmfDeliveryStateChanged, +) -> Result { + let (kind, severity, outcome) = match input.state { + LxmfDeliveryState::Routing => ( + kinds::LXMF_DELIVERY_PATH_PENDING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::Propagating => ( + kinds::LXMF_PROPAGATION_STARTED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::ReusingBackchannel => ( + kinds::LXMF_DELIVERY_LINK_REUSED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::SendingViaLink => ( + // This persisted router state is overloaded: it also covers work + // queued behind a pending/busy reusable Link, before any packet or + // Resource has started. Keep the Activity fact deliberately + // coarse. Typed progress owns Resource-start facts; a packet-start + // fact remains deferred until a non-overloaded observer exists. + kinds::LXMF_DELIVERY_DIRECT_PENDING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::Sent => ( + kinds::LXMF_DELIVERY_AWAITING_PROOF, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::Delivered => ( + kinds::LXMF_DELIVERY_DELIVERED, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + LxmfDeliveryState::Propagated => ( + kinds::LXMF_PROPAGATION_SUCCEEDED, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + LxmfDeliveryState::Rejected => ( + kinds::LXMF_DELIVERY_REJECTED, + ActivitySeverity::Error, + ActivityOutcome::Rejected, + ), + LxmfDeliveryState::Failed + if matches!(input.method, Some(LxmfDeliveryMethod::Propagated)) => + { + ( + kinds::LXMF_PROPAGATION_FAILED, + ActivitySeverity::Error, + ActivityOutcome::Failed, + ) + } + LxmfDeliveryState::Failed => ( + kinds::LXMF_DELIVERY_FAILED, + ActivitySeverity::Error, + ActivityOutcome::Failed, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + ActivityDirection::Outbound, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message.0, + )?; + if let Some(method) = input.method { + draft = draft.operational_code(ActivityAttributeKey::Method, method.code())?; + } + if let Some(rtt_ms) = input.rtt_ms { + draft = draft.exact(ActivityAttributeKey::RttMs, ExactValue::Unsigned(rtt_ms)); + } + if let Some(reason) = input.failure_reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfProgressStep { + LinkEstablishing, + LinkReady, + DirectPending, + LinkReused, + ResourceStarted, + ResourceProgress, + AwaitingProof, +} + +pub struct LxmfDeliveryProgress { + pub time: ObservationTime, + pub message: MessageId, + pub destination: DestinationHash, + pub link: Option, + pub method: LxmfDeliveryMethod, + pub step: LxmfProgressStep, + pub percent: Option, + pub attempts: u32, +} + +pub fn lxmf_delivery_progress( + input: LxmfDeliveryProgress, +) -> Result { + let (kind, severity, outcome) = match input.step { + LxmfProgressStep::LinkEstablishing => ( + kinds::LXMF_DELIVERY_LINK_ESTABLISHING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::LinkReady => ( + kinds::LXMF_DELIVERY_LINK_READY, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::DirectPending => ( + kinds::LXMF_DELIVERY_DIRECT_PENDING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::LinkReused => ( + kinds::LXMF_DELIVERY_LINK_REUSED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::ResourceStarted => ( + kinds::LXMF_DELIVERY_RESOURCE_STARTED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::ResourceProgress => ( + kinds::LXMF_DELIVERY_PROGRESS, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::AwaitingProof => ( + kinds::LXMF_DELIVERY_AWAITING_PROOF, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + ActivityDirection::Outbound, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + if matches!(input.step, LxmfProgressStep::ResourceProgress) { + CoalescingPolicy::AdjacentEquivalent + } else { + CoalescingPolicy::Never + }, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message.0, + )? + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Method, input.method.code())? + .exact( + ActivityAttributeKey::Attempts, + ExactValue::Unsigned(u64::from(input.attempts)), + ); + if let Some(link) = input.link { + draft = + draft.protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)?; + } + if let Some(percent) = input.percent { + draft = draft.exact( + ActivityAttributeKey::Percent, + ExactValue::Unsigned(u64::from(percent.min(100))), + ); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InboundLxmfMethod { + Direct, + Opportunistic, + Propagated, +} + +impl InboundLxmfMethod { + const fn code(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Opportunistic => "opportunistic", + Self::Propagated => "propagated", + } + } +} + +pub struct LxmfInboundAccepted { + pub time: ObservationTime, + pub source: DestinationHash, + pub method: InboundLxmfMethod, + pub encoded_bytes: u32, +} + +pub fn lxmf_inbound_accepted( + input: LxmfInboundAccepted, +) -> Result { + let draft = ActivityDraft::new( + kinds::LXMF_INBOUND_ACCEPTED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.source.0, + )? + .operational_code(ActivityAttributeKey::Method, input.method.code())? + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(u64::from(input.encoded_bytes)), + ); + Ok(draft) +} + +pub struct LxmfDeliveryFailed { + pub time: ObservationTime, + pub message_id: MessageId, + pub destination: DestinationHash, + pub link_id: Option, + pub reason: DeliveryFailureReason, + pub correlation_id: CorrelationId, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DeliveryFailureReason { + LinkClosed, + PathUnavailable, + ProofTimedOut, + QueueRejected, + Rejected, + ResourceFailed, + RouterUnavailable, + TransportFailed, +} + +impl DeliveryFailureReason { + const fn code(self) -> &'static str { + match self { + Self::LinkClosed => "link_closed", + Self::PathUnavailable => "path_unavailable", + Self::ProofTimedOut => "proof_timed_out", + Self::QueueRejected => "queue_rejected", + Self::Rejected => "rejected", + Self::ResourceFailed => "resource_failed", + Self::RouterUnavailable => "router_unavailable", + Self::TransportFailed => "transport_failed", + } + } +} + +pub fn lxmf_delivery_failed( + input: LxmfDeliveryFailed, +) -> Result { + let mut draft = ActivityDraft::new( + kinds::LXMF_DELIVERY_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message_id.0, + )? + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Reason, input.reason.code())?; + if let Some(link_id) = input.link_id { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link_id.0, + )?; + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxstCallReason { + Busy, + Rejected, + Calling, + Available, + Ringing, + Connecting, + Established, + LinkFailed, + ServiceError, + MediaError, +} + +impl LxstCallReason { + const fn code(self) -> &'static str { + match self { + Self::Busy => "busy", + Self::Rejected => "rejected", + Self::Calling => "calling", + Self::Available => "available", + Self::Ringing => "ringing", + Self::Connecting => "connecting", + Self::Established => "established", + Self::LinkFailed => "link_failed", + Self::ServiceError => "service_error", + Self::MediaError => "media_error", + } + } +} + +pub enum LxstTransition { + ServiceStarted, + ServiceStopped, + ServiceFailed { + reason: LxstCallReason, + }, + IncomingRinging { + peer: IdentityHash, + link: LinkId, + }, + PathPending { + peer: IdentityHash, + }, + LinkRequested { + peer: IdentityHash, + link: LinkId, + }, + Ended { + link: LinkId, + }, + Rejected { + link: LinkId, + }, + Failed { + peer: Option, + link: Option, + reason: LxstCallReason, + }, + MediaWarning { + reason: LxstCallReason, + }, +} + +pub struct LxstActivity { + pub time: ObservationTime, + pub transition: LxstTransition, +} + +pub fn lxst_activity(input: LxstActivity) -> Result { + let (kind, severity, direction, outcome) = match &input.transition { + LxstTransition::ServiceStarted => ( + kinds::LXST_SERVICE_STARTED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Started, + ), + LxstTransition::ServiceStopped => ( + kinds::LXST_SERVICE_STOPPED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + ), + LxstTransition::ServiceFailed { .. } => ( + kinds::LXST_SERVICE_FAILED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::Failed, + ), + LxstTransition::IncomingRinging { .. } => ( + kinds::LXST_CALL_RINGING, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Started, + ), + LxstTransition::PathPending { .. } => ( + kinds::LXST_CALL_PATH_PENDING, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Progress, + ), + LxstTransition::LinkRequested { .. } => ( + kinds::LXST_CALL_LINK_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + ), + LxstTransition::Ended { .. } => ( + kinds::LXST_CALL_ENDED, + ActivitySeverity::Info, + ActivityDirection::None, + ActivityOutcome::Success, + ), + LxstTransition::Rejected { .. } => ( + kinds::LXST_CALL_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::None, + ActivityOutcome::Rejected, + ), + LxstTransition::Failed { .. } => ( + kinds::LXST_CALL_FAILED, + ActivitySeverity::Error, + ActivityDirection::None, + ActivityOutcome::Failed, + ), + LxstTransition::MediaWarning { .. } => ( + kinds::LXST_MEDIA_WARNING, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ); + match input.transition { + LxstTransition::IncomingRinging { peer, link } + | LxstTransition::LinkRequested { peer, link } => { + draft = draft + .protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )? + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)?; + } + LxstTransition::PathPending { peer } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )?; + } + LxstTransition::Ended { link } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + LxstTransition::Rejected { link } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .operational_code( + ActivityAttributeKey::Reason, + LxstCallReason::Rejected.code(), + )?; + } + LxstTransition::Failed { peer, link, reason } => { + if let Some(peer) = peer { + draft = draft.protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )?; + } + if let Some(link) = link { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + LxstTransition::MediaWarning { reason } => { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + LxstTransition::ServiceFailed { reason } => { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + LxstTransition::ServiceStarted | LxstTransition::ServiceStopped => {} + } + Ok(draft) +} + +pub(super) struct DiagnosticsSampled { + pub time: ObservationTime, + pub count: u64, + pub span_ms: u64, + pub source: RateDomain, +} + +pub(super) fn diagnostics_sampled( + input: DiagnosticsSampled, +) -> Result { + let draft = ActivityDraft::new( + kinds::DIAGNOSTICS_SAMPLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::None, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::SampledCount, + ExactValue::Unsigned(input.count), + ) + .operational_code(ActivityAttributeKey::SourceArea, input.source.code())? + .operational_code(ActivityAttributeKey::Reason, "sustained_rate_limit")?; + Ok(draft.exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + )) +} + +pub struct DiagnosticsDropped { + pub time: ObservationTime, + pub count: u64, + pub span_ms: u64, +} + +pub fn diagnostics_dropped(input: DiagnosticsDropped) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_DROPPED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Dropped, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::DroppedCount, + ExactValue::Unsigned(input.count), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + ) +} + +pub(super) struct DiagnosticsRejected { + pub time: ObservationTime, + pub count: u64, + pub span_ms: u64, +} + +pub(super) fn diagnostics_rejected(input: DiagnosticsRejected) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Rejected, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::RejectedCount, + ExactValue::Unsigned(input.count), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + ) +} + +pub(super) fn diagnostics_capture_started( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_CAPTURE_STARTED, time, profile) +} + +pub(super) fn diagnostics_capture_stopped( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_CAPTURE_STOPPED, time, profile) +} + +pub(super) fn diagnostics_capture_resumed(time: ObservationTime) -> ActivityDraft { + diagnostics_profile_boundary( + kinds::DIAGNOSTICS_CAPTURE_RESUMED, + time, + super::schema::CaptureProfile::Normal, + ) +} + +pub(super) fn diagnostics_capture_cleared( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_CAPTURE_CLEARED, time, profile) +} + +pub(super) fn diagnostics_profile_changed( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_PROFILE_CHANGED, time, profile) +} + +fn diagnostics_profile_boundary( + kind: super::schema::ActivityKindCode, + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + ActivityDraft::new( + kind, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + time.unix_ms, + time.elapsed_ms, + CoalescingPolicy::Never, + ) + .operational_code(ActivityAttributeKey::Profile, profile.code()) + .expect("capture profile codes are compile-time allowlisted") +} + +pub(super) struct DiagnosticsEvicted { + pub(super) time: ObservationTime, + pub(super) count: u64, + pub(super) bytes: u64, + pub(super) span_ms: u64, +} + +pub(super) fn diagnostics_evicted(input: DiagnosticsEvicted) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_EVICTED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Dropped, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::EvictedCount, + ExactValue::Unsigned(input.count), + ) + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(input.bytes), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + ) +} + +pub(super) fn diagnostics_worker_recovered(time: ObservationTime) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_WORKER_RECOVERED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + time.unix_ms, + time.elapsed_ms, + CoalescingPolicy::Never, + ) +} + +pub struct ChannelNavigationReference { + pub time: ObservationTime, + pub room: ChannelRoomToken, + pub navigation_token: NavigationToken, +} + +/// Test and future detail-action constructor demonstrating that an opaque +/// navigation reference is retained only in the raw vault and omitted from +/// every masked/copy projection. +pub fn channels_room_joined( + input: ChannelNavigationReference, +) -> Result { + ActivityDraft::new( + kinds::CHANNELS_ROOM_JOINED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Room, + IdentifierKind::Room, + &input.room.0, + )? + .opaque_reference( + ActivityAttributeKey::Session, + NavigationAction::Channel, + &input.navigation_token.0, + ) +} + +#[cfg(test)] +pub(super) fn test_network_event( + timestamp_unix_ms: u64, + elapsed_ms: u64, + destination: [u8; 16], + endpoint: &str, + coalescing: CoalescingPolicy, +) -> Result { + let endpoint = TcpEndpoint::new(endpoint.to_string())?; + Ok(ActivityDraft::new( + kinds::RNS_PATH_DISCOVERED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + timestamp_unix_ms, + elapsed_ms, + coalescing, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &destination, + )? + .sensitive_endpoint(ActivityAttributeKey::Endpoint, endpoint.0)) +} + +#[cfg(test)] +pub(super) fn test_large_error_event( + timestamp_unix_ms: u64, + elapsed_ms: u64, +) -> Result { + const LARGE_CODE: &str = concat!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let mut draft = ActivityDraft::new( + kinds::LXMF_DELIVERY_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + timestamp_unix_ms, + elapsed_ms, + CoalescingPolicy::Never, + ); + for key in [ + ActivityAttributeKey::Validation, + ActivityAttributeKey::Reason, + ActivityAttributeKey::State, + ActivityAttributeKey::Method, + ActivityAttributeKey::Capability, + ActivityAttributeKey::Profile, + ActivityAttributeKey::InterfaceClass, + ActivityAttributeKey::ProtocolVersion, + ActivityAttributeKey::Room, + ActivityAttributeKey::Hub, + ] { + draft = draft.operational_code(key, LARGE_CODE)?; + } + Ok(draft) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoints_are_validated_before_they_can_enter_a_catalog_input() { + assert!(TcpEndpoint::new("example.net:4242".to_string()).is_ok()); + assert!(matches!( + TcpEndpoint::new(" + + Ratspeak - Dashboard - - + + @@ -73,6 +74,11 @@ Messages + + + Channels + + Contacts @@ -114,20 +120,21 @@ - + - - + + - - - - - + + + + + + - + @@ -161,60 +168,64 @@ - + - + + + Contacts + + Identity - - + + Games - - + + Network - + - + Settings - + - + - + - Sort Peers + Sort Peers - Name - Status - Hops - Last Seen + Name + Status + Hops + Last Seen - + - + - Start Conversation + Start Conversation @@ -519,7 +530,32 @@ × - + + + + + + + + + + + + 0:00 + + + + + + + + + + + + + + @@ -527,9 +563,12 @@ - + - + + + + @@ -538,6 +577,128 @@ + + + + + + + + + + + + Channel + Joining + + Waiting for hub + + + + + + + + + + + + + + + + + Join a conversation + Connect to a trusted hub, then choose a channel. + Find a hub + + + + + + + + + + + + + + + + + @@ -741,9 +902,18 @@ - Network Activity + + + Activity + + + + + Network, messages, channels and calls + - Clear + Stop + Clear @@ -751,26 +921,31 @@ - Privacy mode is active - No activity is being collected or saved. Enable for this session to see network events in real time. - Enable For This Session + Activity is off + Start a private, on-device view of what Ratspeak is doing. + Start Activity - - Essential - Standard - Detailed - - - - + + + + + + + + Normal + Trace + - - - Listening for network events... + + + + + + Waiting for activity… @@ -779,7 +954,7 @@ - + Identity @@ -928,7 +1103,6 @@ - Identity Identity Management @@ -979,13 +1153,17 @@ General Theme, vibration, notifications, and blocks + + Channels + Hosting and channel preferences + Identity Active identity, status, backup, and recovery - + Privacy - Privacy related preferences + Activity identity protection and presence sharing Network @@ -1020,38 +1198,88 @@ Settings - Section General - Ratspeak General Theme, haptics, and app preferences. - General - + Theme - Choose light, dark, or match your system + Choose a color system for every part of Ratspeak. - - + + Theme family + + + + + + Color mode + Choose light, dark, or match your system. + + + - + - + + + + Text size + Choose a comfortable reading size without enlarging controls or touch targets. + + + + + + Aa + 100% + + + + + + Aa + 110% + + + + + + Aa + 120% + + + + + + Aa + 130% + + + + + + Aa + 140% + + + + Vibration @@ -1072,6 +1300,22 @@ + + + Hide known spam peers + Hide repeated bridge-style IDs unless you have saved or messaged the peer. + + + + + OFF + + + + ON + + + Block List @@ -1082,8 +1326,28 @@ + + + + + Channel hosting + Show hub controls in Channels and allow this device to host. + + + + + OFF + + + + ON + + + + + + - Identity @@ -1097,10 +1361,7 @@ Status Not set. - - Edit - Clear - + Set @@ -1119,16 +1380,31 @@ Hardware Key Auto-Lock - Lock a YubiKey identity after inactivity; PIN required to resume. Off relies on lock-on-quit. Applies on next unlock. + Lock a YubiKey identity after inactivity; PIN required to resume. When disabled, it locks only when you quit. Applies on next unlock. - Off + OFF - Privacy + + + Protect Activity identities + Hide peer names and addresses until you reveal an Activity event. + + + + + OFF + + + + ON + + + Announce Ratspeak usage @@ -1143,14 +1419,13 @@ - Network Transport Mode Relay packets for other nodes on the network - OFF + OFF @@ -1163,10 +1438,6 @@ - - Offline Inbox - - @@ -1180,9 +1451,7 @@ - System - System Developer Mode @@ -1191,11 +1460,31 @@ - Off + OFF - On + ON + + + + + + Window Decorations + Title bar drawn by the app. Auto hides it under tiling Wayland compositors (Sway, Hyprland, niri). + + + + + AUTO + + + + ON + + + + OFF @@ -1287,7 +1576,7 @@ - + Hub Info @@ -1335,7 +1624,7 @@ - + Connect to Network @@ -1389,6 +1678,10 @@ IFAC Passphrase + + IFAC Size (bytes) + + Connect @@ -1396,7 +1689,7 @@ - + Host Network @@ -1416,13 +1709,31 @@ Name (optional) + + + Use IFAC + + + + IFAC Network Name + + + + IFAC Passphrase + + + + IFAC Size (bytes) + + + Your firewall or router may need to allow this port. Start Hosting - + Host Backbone Server @@ -1442,13 +1753,31 @@ Name (optional) + + + Use IFAC + + + + IFAC Network Name + + + + IFAC Passphrase + + + + IFAC Size (bytes) + + + Use this for stable desktop or server nodes, not mobile networks. Start Hosting - + Add LoRa Device @@ -1504,8 +1833,6 @@ - - Name @@ -1577,6 +1904,35 @@ Use custom values only when every node on the link will use the same frequency, bandwidth, spreading factor, and coding rate. + + + + Display on public map + + + + + + + + + + Latitude + + + + Longitude + + + + + Use current location + + + + + + Back Add Radio @@ -1592,37 +1948,42 @@ - - - - - - - + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + +
(conn: &Connection, sql: &str, params: P) -> Vec +where + P: rusqlite::Params, +{ + let Ok(mut statement) = conn.prepare(sql) else { + return Vec::new(); + }; + let Ok(rows) = statement.query_map(params, |row| { + Ok(( + row.get::<_, String>(0).unwrap_or_default(), + row.get::<_, String>(1).unwrap_or_default(), + )) + }) else { + return Vec::new(); + }; + + let mut file_refs = Vec::new(); + for (attachment, image) in rows.flatten() { + if !attachment.is_empty() { + file_refs.push(attachment); + } + if !image.is_empty() { + file_refs.push(image); + } + } + file_refs +} + pub fn delete_conversation(pool: &DbPool, dest_hash: &str, identity_id: &str) -> Vec { let conn = match pool.get() { Ok(c) => c, Err(_) => return vec![], }; - let mut file_refs = Vec::new(); - if let Ok(mut stmt) = conn.prepare( - "SELECT attachment_stored_name, image_stored_name FROM messages WHERE (source = ?1 OR destination = ?1) AND identity_id = ?2" - ) - && let Ok(rows) = stmt.query_map(params![dest_hash, identity_id], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) - { - for r in rows.flatten() { - if !r.0.is_empty() { file_refs.push(r.0); } - if !r.1.is_empty() { file_refs.push(r.1); } - } - } + let file_refs = query_message_file_refs( + &conn, + "SELECT attachment_stored_name, image_stored_name FROM messages WHERE (source = ?1 OR destination = ?1) AND identity_id = ?2", + params![dest_hash, identity_id], + ); conn.execute( "DELETE FROM messages WHERE (source = ?1 OR destination = ?1) AND identity_id = ?2", @@ -2385,6 +3025,33 @@ pub fn get_setting(pool: &DbPool, key: &str) -> Option { .ok() } +/// Read a related set of settings from one SQLite snapshot. Missing keys are +/// omitted; database failures are surfaced instead of being confused with an +/// unset preference. +pub fn get_settings( + pool: &DbPool, + keys: &[&str], +) -> Result, String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let mut values = std::collections::HashMap::with_capacity(keys.len()); + for key in keys { + let value = transaction + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| error.to_string())?; + if let Some(value) = value { + values.insert((*key).to_string(), value); + } + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(values) +} + pub fn set_setting(pool: &DbPool, key: &str, value: &str) { let _ = try_set_setting(pool, key, value); } @@ -2399,6 +3066,3875 @@ pub fn try_set_setting(pool: &DbPool, key: &str, value: &str) -> Result<(), Stri Ok(()) } +/// Persist a coherent group of settings in one transaction. This is the +/// boundary for controls whose fields are edited and applied as one unit. +pub fn try_set_settings(pool: &DbPool, values: &[(String, String)]) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + for (key, value) in values { + transaction + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ) + .map_err(|error| error.to_string())?; + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod settings_tests { + use super::*; + use r2d2_sqlite::SqliteConnectionManager; + + fn test_pool() -> DbPool { + let manager = SqliteConnectionManager::memory(); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + pool + } + + #[test] + fn related_settings_round_trip_as_one_snapshot() { + let pool = test_pool(); + try_set_settings( + &pool, + &[ + ("hub_name".to_string(), "Mountain relay".to_string()), + ("hub_enabled".to_string(), "1".to_string()), + ], + ) + .unwrap(); + + let values = get_settings(&pool, &["hub_name", "hub_enabled", "missing"]).unwrap(); + assert_eq!( + values.get("hub_name").map(String::as_str), + Some("Mountain relay") + ); + assert_eq!(values.get("hub_enabled").map(String::as_str), Some("1")); + assert!(!values.contains_key("missing")); + } + + #[test] + fn a_failed_settings_batch_rolls_back_every_field() { + let pool = test_pool(); + pool.get() + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_test_setting + BEFORE INSERT ON settings + WHEN NEW.key = 'reject' + BEGIN SELECT RAISE(ABORT, 'rejected'); END;", + ) + .unwrap(); + + let result = try_set_settings( + &pool, + &[ + ("first".to_string(), "saved-too-early".to_string()), + ("reject".to_string(), "no".to_string()), + ], + ); + assert!(result.is_err()); + assert_eq!(get_setting(&pool, "first"), None); + } +} + +/// Local Channels history is intentionally finite. These ceilings bound disk +/// growth without asking a constrained hub to become a backlog service. +pub const CHANNEL_HISTORY_RETENTION_DAYS: u64 = 90; +pub const CHANNEL_HISTORY_MAX_EVENTS_PER_ROOM: usize = 5_000; +pub const CHANNEL_HISTORY_MAX_EVENTS_PER_IDENTITY: usize = 50_000; +pub const CHANNEL_HISTORY_MAX_EVENTS_GLOBAL: usize = 200_000; +pub const CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_ROOM: usize = 8 * 1024 * 1024; +pub const CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_IDENTITY: usize = 64 * 1024 * 1024; +pub const CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_GLOBAL: usize = 256 * 1024 * 1024; +pub const CHANNEL_HISTORY_DEFAULT_PAGE_SIZE: usize = 100; +pub const CHANNEL_HISTORY_MAX_PAGE_SIZE: usize = 200; +pub const CHANNEL_PARTICIPANT_MAX_RESULTS: usize = 200; +pub const CHANNEL_PARTICIPANT_MAX_TRANSIENT_PER_ROOM: usize = 100; +pub const CHANNEL_PARTICIPANT_HARD_MAX_PER_ROOM: usize = 500; +pub const CHANNEL_PARTICIPANT_MAX_OBSERVATION_BATCH: usize = 256; +pub const CHANNEL_HISTORY_MAX_APPEND_BATCH: usize = 256; + +pub const CHANNEL_HISTORY_MAX_ROOM_BYTES: usize = 256; +const CHANNEL_HISTORY_MAX_EVENT_ID_BYTES: usize = 128; +const CHANNEL_HISTORY_MAX_NICKNAME_BYTES: usize = 256; +const CHANNEL_HISTORY_MAX_TEXT_BYTES: usize = 64 * 1024; +const JAVASCRIPT_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const MILLIS_PER_DAY: i64 = 24 * 60 * 60 * 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelHistoryKind { + Message, + Notice, + Action, + Join, + Part, + Error, + System, +} + +impl ChannelHistoryKind { + fn as_storage(self) -> &'static str { + match self { + Self::Message => "message", + Self::Notice => "notice", + Self::Action => "action", + Self::Join => "join", + Self::Part => "part", + Self::Error => "error", + Self::System => "system", + } + } + + fn from_storage(value: &str) -> Option { + match value { + "message" => Some(Self::Message), + "notice" => Some(Self::Notice), + "action" => Some(Self::Action), + "join" => Some(Self::Join), + "part" => Some(Self::Part), + "error" => Some(Self::Error), + "system" => Some(Self::System), + _ => None, + } + } + + fn allows_mention(self) -> bool { + matches!(self, Self::Message | Self::Action) + } +} + +/// An accepted transcript observation waiting to enter the local append log. +/// +/// `timestamp_ms` is peer-provided display metadata. Retention uses the local +/// insertion clock, and ordering/pagination uses the SQLite sequence. +#[derive(Clone, PartialEq, Eq)] +pub struct NewChannelHistoryEvent { + pub hub_destination_hash: String, + pub room_name: String, + pub event_id: String, + pub kind: ChannelHistoryKind, + pub timestamp_ms: u64, + pub source_hash: Option, + pub nickname: Option, + pub text: String, + pub ours: bool, + /// Computed locally when the event is accepted. Never trust a remote + /// sender to classify its own message as a mention. + pub mentioned: bool, +} + +/// A cryptographically identified room participant observed through the +/// authenticated hub Link. This is durable identity metadata, not a claim +/// that the participant is currently online. +#[derive(Clone, PartialEq, Eq)] +pub struct NewChannelParticipantObservation { + pub hub_destination_hash: String, + pub room_name: String, + pub identity_hash: String, + pub nickname: Option, +} + +impl std::fmt::Debug for NewChannelParticipantObservation { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NewChannelParticipantObservation") + .field("hub_destination_hash", &self.hub_destination_hash) + .field("room_name", &self.room_name) + .field("identity_hash", &self.identity_hash) + .field("nickname_present", &self.nickname.is_some()) + .finish() + } +} + +// Transcript text and nicknames can be private. Keep them out of routine +// diagnostics even if a caller logs a failed batch. +impl std::fmt::Debug for NewChannelHistoryEvent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NewChannelHistoryEvent") + .field("hub_destination_hash", &self.hub_destination_hash) + .field("room_name", &self.room_name) + .field("event_id", &self.event_id) + .field("kind", &self.kind) + .field("timestamp_ms", &self.timestamp_ms) + .field("source_present", &self.source_hash.is_some()) + .field("text", &"") + .field("ours", &self.ours) + .field("mentioned", &self.mentioned) + .finish() + } +} + +/// One stored transcript item. The opaque decimal sequence is serialized as a +/// string so JavaScript cannot round a 64-bit SQLite cursor. +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelHistoryEvent { + pub sequence: String, + pub hub_destination_hash: String, + pub room_name: String, + pub event_id: String, + pub kind: ChannelHistoryKind, + pub timestamp_ms: u64, + pub recorded_at_ms: u64, + pub source_hash: Option, + /// Presentation-only LXMF destination derived by the command layer. It is + /// intentionally not duplicated in the local history table. + pub source_lxmf_hash: Option, + pub nickname: Option, + pub text: String, + pub ours: bool, + pub mentioned: bool, +} + +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelHistoryPage { + pub items: Vec, + pub next_before: Option, + /// Last sequence in this page. Clients can use it as an exclusive forward + /// cursor to catch up without reloading or trusting peer timestamps. + pub next_after: Option, + pub has_more: bool, +} + +/// One non-local participant observed in retained room history. +/// +/// This is deliberately not an online-presence claim. It powers a local +/// "Seen here" affordance when a peer is absent from the current hub roster. +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelParticipantSummary { + pub identity_hash: Option, + /// Presentation-only LXMF destination derived by the command layer. + pub lxmf_hash: Option, + pub nickname: Option, + /// Local receipt time of the newest retained event for this participant. + pub last_seen_at_ms: u64, +} + +#[derive(Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelParticipantPage { + pub participants: Vec, + pub omitted_count: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChannelHistoryAppendOutcome { + pub inserted: usize, + pub duplicates: usize, + pub pruned: usize, + pub latest_sequence: Option, + /// Exact batch positions committed by this transaction. This lets the + /// writer emit native notifications only after a new row exists, without + /// replaying alerts for deduplicated retries. + pub inserted_events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelHistoryInsertedEvent { + pub batch_index: usize, + pub sequence: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelRoomNotificationLevel { + All, + #[default] + Mentions, + Mute, +} + +impl ChannelRoomNotificationLevel { + pub fn as_storage(self) -> &'static str { + match self { + Self::All => "all", + Self::Mentions => "mentions", + Self::Mute => "mute", + } + } + + fn from_storage(value: &str) -> Option { + match value { + "all" => Some(Self::All), + "mentions" => Some(Self::Mentions), + "mute" => Some(Self::Mute), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelRoomReadState { + pub hub_destination_hash: String, + pub room_name: String, + pub last_read_sequence: String, + pub notification_level: ChannelRoomNotificationLevel, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChannelRoomUnread { + pub hub_destination_hash: String, + pub room_name: String, + pub unread_count: u64, + pub mention_count: u64, + pub notification_level: ChannelRoomNotificationLevel, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct ChannelUnreadSummary { + pub rooms: Vec, + /// All unread retained room traffic, including muted rooms. + pub unread_total: u64, + /// All unread exact mentions, including muted rooms. + pub mention_total: u64, + /// Events allowed to request attention by each room's policy. + pub attention_total: u64, +} + +#[derive(Clone, Copy)] +struct ChannelHistoryRetentionPolicy { + max_age_ms: i64, + max_events_per_room: usize, + max_events_per_identity: usize, + max_events_global: usize, + max_payload_bytes_per_room: usize, + max_payload_bytes_per_identity: usize, + max_payload_bytes_global: usize, +} + +const CHANNEL_HISTORY_RETENTION: ChannelHistoryRetentionPolicy = ChannelHistoryRetentionPolicy { + max_age_ms: CHANNEL_HISTORY_RETENTION_DAYS as i64 * MILLIS_PER_DAY, + max_events_per_room: CHANNEL_HISTORY_MAX_EVENTS_PER_ROOM, + max_events_per_identity: CHANNEL_HISTORY_MAX_EVENTS_PER_IDENTITY, + max_events_global: CHANNEL_HISTORY_MAX_EVENTS_GLOBAL, + max_payload_bytes_per_room: CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_ROOM, + max_payload_bytes_per_identity: CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_PER_IDENTITY, + max_payload_bytes_global: CHANNEL_HISTORY_MAX_PAYLOAD_BYTES_GLOBAL, +}; + +fn is_canonical_channel_hash(value: &str) -> bool { + value.len() == 32 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_channel_history_scope( + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result<(), String> { + if !is_canonical_channel_hash(identity_id) { + return Err("invalid Channels history identity".into()); + } + if !is_canonical_channel_hash(hub_destination_hash) { + return Err("invalid Channels history hub destination".into()); + } + if room_name.is_empty() + || room_name.len() > CHANNEL_HISTORY_MAX_ROOM_BYTES + || room_name.trim() != room_name + || room_name.to_lowercase() != room_name + { + return Err("invalid normalized Channels history room".into()); + } + Ok(()) +} + +pub fn validate_channel_history_event( + identity_id: &str, + event: &NewChannelHistoryEvent, +) -> Result<(), String> { + validate_channel_history_scope(identity_id, &event.hub_destination_hash, &event.room_name)?; + if event.event_id.is_empty() + || event.event_id.len() > CHANNEL_HISTORY_MAX_EVENT_ID_BYTES + || event.event_id.chars().any(char::is_control) + { + return Err("invalid Channels history event id".into()); + } + if event.timestamp_ms > JAVASCRIPT_MAX_SAFE_INTEGER { + return Err("Channels history timestamp exceeds the safe display range".into()); + } + if event + .source_hash + .as_deref() + .is_some_and(|source| !is_canonical_channel_hash(source)) + { + return Err("invalid Channels history source".into()); + } + if event + .nickname + .as_deref() + .is_some_and(|nickname| nickname.len() > CHANNEL_HISTORY_MAX_NICKNAME_BYTES) + { + return Err("Channels history nickname is too long".into()); + } + if event.text.len() > CHANNEL_HISTORY_MAX_TEXT_BYTES { + return Err("Channels history text is too long".into()); + } + if event.mentioned && (event.ours || !event.kind.allows_mention()) { + return Err("invalid Channels history mention classification".into()); + } + Ok(()) +} + +pub fn validate_channel_participant_observation( + identity_id: &str, + observation: &NewChannelParticipantObservation, +) -> Result<(), String> { + validate_channel_history_scope( + identity_id, + &observation.hub_destination_hash, + &observation.room_name, + )?; + if !is_canonical_channel_hash(&observation.identity_hash) + || observation.identity_hash == identity_id + { + return Err("invalid Channels participant identity".into()); + } + if observation.nickname.as_deref().is_some_and(|nickname| { + nickname.is_empty() + || nickname.trim() != nickname + || nickname.len() > CHANNEL_HISTORY_MAX_NICKNAME_BYTES + }) { + return Err("invalid Channels participant nickname".into()); + } + Ok(()) +} + +fn parse_channel_history_cursor(before: Option<&str>) -> Result, String> { + let Some(before) = before else { + return Ok(None); + }; + if before.is_empty() + || before == "0" + || before.starts_with('0') + || !before.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("invalid Channels history cursor".into()); + } + let sequence = before + .parse::() + .map_err(|_| "invalid Channels history cursor".to_string())?; + if sequence <= 0 { + return Err("invalid Channels history cursor".into()); + } + Ok(Some(sequence)) +} + +pub fn validate_channel_history_cursor(before: Option<&str>) -> Result<(), String> { + parse_channel_history_cursor(before).map(|_| ()) +} + +fn parse_channel_history_after_cursor(after: &str) -> Result { + if after.is_empty() + || (after.starts_with('0') && after != "0") + || !after.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("invalid Channels history forward cursor".into()); + } + let sequence = after + .parse::() + .map_err(|_| "invalid Channels history forward cursor".to_string())?; + if sequence < 0 { + return Err("invalid Channels history forward cursor".into()); + } + Ok(sequence) +} + +pub fn validate_channel_history_after_cursor(after: &str) -> Result<(), String> { + parse_channel_history_after_cursor(after).map(|_| ()) +} + +fn prune_expired_channel_history_at( + conn: &Connection, + now_ms: i64, + max_age_ms: i64, +) -> Result { + let cutoff = now_ms.saturating_sub(max_age_ms); + conn.execute( + "DELETE FROM channel_history WHERE recorded_at_ms < ?1", + params![cutoff], + ) + .map_err(|error| error.to_string()) +} + +fn prune_expired_channel_participant_observations_at( + conn: &Connection, + now_ms: i64, + max_age_ms: i64, +) -> Result { + let cutoff = now_ms.saturating_sub(max_age_ms); + conn.execute( + "DELETE FROM channel_participant_observations + WHERE last_observed_at_ms < ?1 + AND NOT EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_participant_observations.participant_identity_hash + )", + params![cutoff], + ) + .map_err(|error| error.to_string()) +} + +fn channel_participant_retention_ms(pool: &DbPool) -> Option { + get_prune_days(pool).map(|days| i64::from(days).saturating_mul(MILLIS_PER_DAY)) +} + +fn channel_participant_cutoff_ms(pool: &DbPool, now_ms: i64) -> Option { + channel_participant_retention_ms(pool).map(|max_age_ms| now_ms.saturating_sub(max_age_ms)) +} + +/// Remove age-expired rows across every identity. The runtime invokes this at +/// startup; append also performs the same pass so dormant identities are +/// eventually cleaned without server participation. +pub fn prune_expired_channel_history(pool: &DbPool) -> Result { + // Channel participants are identity metadata, so follow the same + // user-configurable lifetime as the known-identity cache (14 days by + // default). Read the setting before holding a pooled connection: test and + // embedded pools may intentionally have a single connection. + let participant_max_age_ms = channel_participant_retention_ms(pool); + let conn = pool.get().map_err(|error| error.to_string())?; + let now_ms = now_unix_ms(); + let history = + prune_expired_channel_history_at(&conn, now_ms, CHANNEL_HISTORY_RETENTION.max_age_ms)?; + let participants = match participant_max_age_ms { + Some(max_age_ms) => { + prune_expired_channel_participant_observations_at(&conn, now_ms, max_age_ms)? + } + None => 0, + }; + Ok(history.saturating_add(participants)) +} + +#[derive(Clone, Copy)] +enum ChannelHistoryRetentionScope<'a> { + Room { + identity_id: &'a str, + hub_destination_hash: &'a str, + room_name: &'a str, + }, + Identity(&'a str), + Global, +} + +fn channel_history_usage( + transaction: &rusqlite::Transaction<'_>, + scope: ChannelHistoryRetentionScope<'_>, +) -> Result<(i64, i64), String> { + match scope { + ChannelHistoryRetentionScope::Room { + identity_id, + hub_destination_hash, + room_name, + } => transaction + .query_row( + "SELECT event_count, payload_bytes + FROM channel_history_room_usage + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map(|usage| usage.unwrap_or((0, 0))) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Identity(identity_id) => transaction + .query_row( + "SELECT COALESCE(SUM(event_count), 0), + COALESCE(SUM(payload_bytes), 0) + FROM channel_history_room_usage + WHERE identity_id = ?1", + params![identity_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Global => transaction + .query_row( + "SELECT COALESCE(SUM(event_count), 0), + COALESCE(SUM(payload_bytes), 0) + FROM channel_history_room_usage", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| error.to_string()), + } +} + +fn channel_history_prune_query( + scope_clause: &str, + excess_count_parameter: usize, + excess_bytes_parameter: usize, +) -> String { + format!( + "DELETE FROM channel_history + WHERE sequence IN ( + SELECT sequence + FROM ( + SELECT + sequence, + payload_bytes, + ROW_NUMBER() OVER (ORDER BY sequence ASC) AS removal_count, + SUM(payload_bytes) OVER ( + ORDER BY sequence ASC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS removed_bytes + FROM ( + SELECT + sequence, + ( + 128 + + length(CAST(identity_id AS BLOB)) + + length(CAST(hub_destination_hash AS BLOB)) + + length(CAST(room_name AS BLOB)) + + length(CAST(event_id AS BLOB)) + + length(CAST(kind AS BLOB)) + + length(CAST(COALESCE(source_hash, '') AS BLOB)) + + length(CAST(COALESCE(nickname, '') AS BLOB)) + + length(CAST(text AS BLOB)) + ) AS payload_bytes + FROM channel_history + WHERE {scope_clause} + ) + ) + WHERE removal_count <= ?{excess_count_parameter} + OR removed_bytes - payload_bytes < ?{excess_bytes_parameter} + )" + ) +} + +/// Delete the smallest oldest prefix needed to satisfy both the row and +/// estimated-payload ceilings for one scope. Usage triggers keep the common +/// under-budget path O(number of rooms), not O(number of transcript rows). +fn prune_channel_history_scope( + transaction: &rusqlite::Transaction<'_>, + scope: ChannelHistoryRetentionScope<'_>, + max_events: usize, + max_payload_bytes: usize, +) -> Result { + let max_events = i64::try_from(max_events) + .map_err(|_| "Channels history event limit is too large".to_string())?; + let max_payload_bytes = i64::try_from(max_payload_bytes) + .map_err(|_| "Channels history payload limit is too large".to_string())?; + let (event_count, payload_bytes) = channel_history_usage(transaction, scope)?; + let excess_count = event_count.saturating_sub(max_events); + let excess_bytes = payload_bytes.saturating_sub(max_payload_bytes); + if excess_count == 0 && excess_bytes == 0 { + return Ok(0); + } + + match scope { + ChannelHistoryRetentionScope::Room { + identity_id, + hub_destination_hash, + room_name, + } => transaction + .execute( + &channel_history_prune_query( + "identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + 4, + 5, + ), + params![ + identity_id, + hub_destination_hash, + room_name, + excess_count, + excess_bytes + ], + ) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Identity(identity_id) => transaction + .execute( + &channel_history_prune_query("identity_id = ?1", 2, 3), + params![identity_id, excess_count, excess_bytes], + ) + .map_err(|error| error.to_string()), + ChannelHistoryRetentionScope::Global => transaction + .execute( + &channel_history_prune_query("1 = 1", 1, 2), + params![excess_count, excess_bytes], + ) + .map_err(|error| error.to_string()), + } +} + +pub fn append_channel_history_events( + pool: &DbPool, + identity_id: &str, + events: &[NewChannelHistoryEvent], +) -> Result { + append_channel_history_events_at( + pool, + identity_id, + events, + now_unix_ms(), + CHANNEL_HISTORY_RETENTION, + ) +} + +fn append_channel_history_events_at( + pool: &DbPool, + identity_id: &str, + events: &[NewChannelHistoryEvent], + recorded_at_ms: i64, + retention: ChannelHistoryRetentionPolicy, +) -> Result { + if events.len() > CHANNEL_HISTORY_MAX_APPEND_BATCH { + return Err(format!( + "Channels history batch exceeds {CHANNEL_HISTORY_MAX_APPEND_BATCH} events" + )); + } + if recorded_at_ms < 0 + || retention.max_age_ms < 0 + || retention.max_events_per_room == 0 + || retention.max_events_per_identity == 0 + || retention.max_events_global == 0 + || retention.max_payload_bytes_per_room == 0 + || retention.max_payload_bytes_per_identity == 0 + || retention.max_payload_bytes_global == 0 + { + return Err("invalid Channels history retention policy".into()); + } + if events.is_empty() { + return Ok(ChannelHistoryAppendOutcome::default()); + } + for event in events { + validate_channel_history_event(identity_id, event)?; + } + + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let mut inserted = 0usize; + let mut inserted_events = Vec::new(); + let mut touched_rooms = std::collections::BTreeSet::new(); + for (batch_index, event) in events.iter().enumerate() { + let inserted_row = transaction + .execute( + "INSERT INTO channel_history + (identity_id, hub_destination_hash, room_name, event_id, + kind, timestamp_ms, recorded_at_ms, source_hash, nickname, + text, ours, mentioned) + VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 + ) + ON CONFLICT( + identity_id, hub_destination_hash, room_name, event_id + ) DO NOTHING", + params![ + identity_id, + event.hub_destination_hash, + event.room_name, + event.event_id, + event.kind.as_storage(), + event.timestamp_ms as i64, + recorded_at_ms, + event.source_hash, + event.nickname, + event.text, + event.ours as i64, + event.mentioned as i64, + ], + ) + .map_err(|error| error.to_string())?; + inserted = inserted.saturating_add(inserted_row); + if inserted_row > 0 { + inserted_events.push(ChannelHistoryInsertedEvent { + batch_index, + sequence: transaction.last_insert_rowid().to_string(), + }); + } + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) VALUES (?1, ?2, ?3, 0, 'mentions', ?4) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO NOTHING", + params![ + identity_id, + event.hub_destination_hash, + event.room_name, + recorded_at_ms + ], + ) + .map_err(|error| error.to_string())?; + touched_rooms.insert(( + event.hub_destination_hash.as_str(), + event.room_name.as_str(), + )); + } + + let cutoff = recorded_at_ms.saturating_sub(retention.max_age_ms); + let mut pruned = transaction + .execute( + "DELETE FROM channel_history WHERE recorded_at_ms < ?1", + params![cutoff], + ) + .map_err(|error| error.to_string())?; + for (hub_destination_hash, room_name) in touched_rooms { + pruned = pruned.saturating_add(prune_channel_history_scope( + &transaction, + ChannelHistoryRetentionScope::Room { + identity_id, + hub_destination_hash, + room_name, + }, + retention.max_events_per_room, + retention.max_payload_bytes_per_room, + )?); + } + pruned = pruned.saturating_add(prune_channel_history_scope( + &transaction, + ChannelHistoryRetentionScope::Identity(identity_id), + retention.max_events_per_identity, + retention.max_payload_bytes_per_identity, + )?); + pruned = pruned.saturating_add(prune_channel_history_scope( + &transaction, + ChannelHistoryRetentionScope::Global, + retention.max_events_global, + retention.max_payload_bytes_global, + )?); + let latest_sequence = transaction + .query_row( + "SELECT MAX(sequence) FROM channel_history WHERE identity_id = ?1", + params![identity_id], + |row| row.get::<_, Option>(0), + ) + .map_err(|error| error.to_string())? + .map(|sequence| sequence.to_string()); + transaction.commit().map_err(|error| error.to_string())?; + + Ok(ChannelHistoryAppendOutcome { + inserted, + duplicates: events.len().saturating_sub(inserted), + pruned, + latest_sequence, + inserted_events, + }) +} + +/// Remember canonical participant identities independently of transcript +/// events. Initial RRC rosters may contain identities without generating an +/// individual JOIN row, so this bounded projection preserves an avatar the UI +/// has already been able to derive. +pub fn remember_channel_participants( + pool: &DbPool, + identity_id: &str, + observations: &[NewChannelParticipantObservation], +) -> Result { + remember_channel_participants_at(pool, identity_id, observations, now_unix_ms()) +} + +fn remember_channel_participants_at( + pool: &DbPool, + identity_id: &str, + observations: &[NewChannelParticipantObservation], + observed_at_ms: i64, +) -> Result { + if observations.len() > CHANNEL_PARTICIPANT_MAX_OBSERVATION_BATCH { + return Err(format!( + "Channels participant batch exceeds {CHANNEL_PARTICIPANT_MAX_OBSERVATION_BATCH} observations" + )); + } + if observed_at_ms < 0 { + return Err("invalid Channels participant observation time".into()); + } + if observations.is_empty() { + return Ok(0); + } + for observation in observations { + validate_channel_participant_observation(identity_id, observation)?; + } + + let participant_cutoff_ms = channel_participant_cutoff_ms(pool, observed_at_ms); + + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let mut touched_rooms = std::collections::BTreeSet::new(); + let mut remembered = 0usize; + for observation in observations { + remembered = remembered.saturating_add( + transaction + .execute( + "INSERT INTO channel_participant_observations ( + identity_id, hub_destination_hash, room_name, + participant_identity_hash, nickname, last_observed_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name, + participant_identity_hash + ) DO UPDATE SET + nickname = CASE + WHEN excluded.last_observed_at_ms >= + channel_participant_observations.last_observed_at_ms + AND excluded.nickname IS NOT NULL + AND trim(excluded.nickname) <> '' + THEN excluded.nickname + ELSE channel_participant_observations.nickname + END, + last_observed_at_ms = MAX( + channel_participant_observations.last_observed_at_ms, + excluded.last_observed_at_ms + )", + params![ + identity_id, + observation.hub_destination_hash, + observation.room_name, + observation.identity_hash, + observation.nickname, + observed_at_ms, + ], + ) + .map_err(|error| error.to_string())?, + ); + touched_rooms.insert(( + observation.hub_destination_hash.as_str(), + observation.room_name.as_str(), + )); + } + + if let Some(cutoff) = participant_cutoff_ms { + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE last_observed_at_ms < ?1 + AND NOT EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_participant_observations.participant_identity_hash + )", + params![cutoff], + ) + .map_err(|error| error.to_string())?; + } + let transient_limit = i64::try_from(CHANNEL_PARTICIPANT_MAX_TRANSIENT_PER_ROOM) + .map_err(|_| "Channels transient participant limit is too large".to_string())?; + let hard_limit = i64::try_from(CHANNEL_PARTICIPANT_HARD_MAX_PER_ROOM) + .map_err(|_| "Channels participant hard limit is too large".to_string())?; + for (hub_destination_hash, room_name) in touched_rooms { + // Keep a bounded recent tail for channel-only sightings. Identities + // still present in Ratspeak's normal peer graph are exempt so a saved + // contact or conversation does not lose its room association merely + // because a busy hub has supplied 100 newer names. + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE rowid IN ( + SELECT rowid + FROM channel_participant_observations AS candidate + WHERE candidate.identity_id = ?1 + AND candidate.hub_destination_hash = ?2 + AND candidate.room_name = ?3 + AND NOT EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + candidate.participant_identity_hash + ) + ORDER BY last_observed_at_ms DESC, + participant_identity_hash DESC + LIMIT -1 OFFSET ?4 + )", + params![ + identity_id, + hub_destination_hash, + room_name, + transient_limit + ], + ) + .map_err(|error| error.to_string())?; + // Even protected user data needs a defensive per-room ceiling against + // a hostile or badly behaved hub. This is intentionally far above the + // transient allowance and only evicts the oldest association. + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE rowid IN ( + SELECT rowid + FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + ORDER BY last_observed_at_ms DESC, + participant_identity_hash DESC + LIMIT -1 OFFSET ?4 + )", + params![identity_id, hub_destination_hash, room_name, hard_limit], + ) + .map_err(|error| error.to_string())?; + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(remembered) +} + +fn channel_history_row(row: &rusqlite::Row<'_>) -> Result { + let sequence = row.get::<_, i64>(0)?; + let kind = row.get::<_, String>(4)?; + let kind = ChannelHistoryKind::from_storage(&kind).ok_or(rusqlite::Error::InvalidQuery)?; + let timestamp_ms = u64::try_from(row.get::<_, i64>(5)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + let recorded_at_ms = u64::try_from(row.get::<_, i64>(6)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 6, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + Ok(ChannelHistoryEvent { + sequence: sequence.to_string(), + hub_destination_hash: row.get(1)?, + room_name: row.get(2)?, + event_id: row.get(3)?, + kind, + timestamp_ms, + recorded_at_ms, + source_hash: row.get(7)?, + source_lxmf_hash: None, + nickname: row.get(8)?, + text: row.get(9)?, + ours: row.get::<_, i64>(10)? != 0, + mentioned: row.get::<_, i64>(11)? != 0, + }) +} + +/// Return one room page in display order (oldest to newest). `before` is an +/// exclusive opaque cursor obtained from a prior page. +pub fn list_channel_history( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + before: Option<&str>, + limit: usize, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + if limit == 0 || limit > CHANNEL_HISTORY_MAX_PAGE_SIZE { + return Err(format!( + "Channels history page size must be between 1 and {CHANNEL_HISTORY_MAX_PAGE_SIZE}" + )); + } + let before = parse_channel_history_cursor(before)?; + let query_limit = i64::try_from(limit.saturating_add(1)) + .map_err(|_| "Channels history page size is too large".to_string())?; + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT sequence, hub_destination_hash, room_name, event_id, kind, + timestamp_ms, recorded_at_ms, source_hash, nickname, text, + ours, mentioned + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND (?4 IS NULL OR sequence < ?4) + ORDER BY sequence DESC + LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let mut items = statement + .query_map( + params![ + identity_id, + hub_destination_hash, + room_name, + before, + query_limit + ], + channel_history_row, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let has_more = items.len() > limit; + items.truncate(limit); + items.reverse(); + let next_before = has_more + .then(|| items.first().map(|item| item.sequence.clone())) + .flatten(); + let next_after = items.last().map(|item| item.sequence.clone()); + Ok(ChannelHistoryPage { + items, + next_before, + next_after, + has_more, + }) +} + +/// Return the newest retained observation for each non-local participant in +/// one room. Identified peers group by identity hash across nickname changes; +/// nickname-only RRC observations group conservatively by exact nickname. +pub fn list_channel_participants( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + list_channel_participants_at( + pool, + identity_id, + hub_destination_hash, + room_name, + now_unix_ms(), + ) +} + +fn list_channel_participants_at( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + now_ms: i64, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + if now_ms < 0 { + return Err("invalid Channels participant query time".into()); + } + let participant_cutoff_ms = channel_participant_cutoff_ms(pool, now_ms); + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "WITH observations AS ( + SELECT sequence AS observation_order, source_hash, nickname, + recorded_at_ms, 0 AS source_rank, + CASE + WHEN source_hash IS NOT NULL THEN 'identity:' || source_hash + ELSE 'nickname:' || nickname + END AS participant_key + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND ours = 0 + AND (source_hash IS NULL OR source_hash <> ?1) + AND ( + ?4 IS NULL OR recorded_at_ms >= ?4 OR + ( + source_hash IS NOT NULL AND EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_history.source_hash + ) + ) + ) + AND kind IN ('message', 'action', 'join', 'part') + AND ( + source_hash IS NOT NULL OR + (nickname IS NOT NULL AND trim(nickname) <> '') + ) + UNION ALL + SELECT 0 AS observation_order, + participant_identity_hash AS source_hash, + nickname, + last_observed_at_ms AS recorded_at_ms, + 1 AS source_rank, + 'identity:' || participant_identity_hash AS participant_key + FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND participant_identity_hash <> ?1 + AND ( + ?4 IS NULL OR last_observed_at_ms >= ?4 OR + EXISTS ( + SELECT 1 + FROM identity_activity + WHERE identity_activity.identity_hash = + channel_participant_observations.participant_identity_hash + ) + ) + ), ranked AS ( + SELECT observation_order, source_hash, nickname, + recorded_at_ms, source_rank, participant_key, + ROW_NUMBER() OVER ( + PARTITION BY participant_key + ORDER BY recorded_at_ms DESC, source_rank DESC, + observation_order DESC + ) AS participant_rank + FROM observations + ) + SELECT ranked.source_hash, + COALESCE( + ( + SELECT named.nickname + FROM observations AS named + WHERE named.participant_key = ranked.participant_key + AND named.nickname IS NOT NULL + AND trim(named.nickname) <> '' + ORDER BY named.recorded_at_ms DESC, + named.source_rank DESC, + named.observation_order DESC + LIMIT 1 + ), + ranked.nickname + ) AS nickname, + ranked.recorded_at_ms, + ( + SELECT COUNT(*) FROM ranked AS counted + WHERE counted.participant_rank = 1 + ) AS participant_count + FROM ranked + WHERE ranked.participant_rank = 1 + ORDER BY ranked.recorded_at_ms DESC, ranked.observation_order DESC, + ranked.participant_key ASC + LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let mut total_count = 0usize; + let mut participants = statement + .query_map( + params![ + identity_id, + hub_destination_hash, + room_name, + participant_cutoff_ms, + i64::try_from(CHANNEL_PARTICIPANT_MAX_RESULTS) + .map_err(|_| "Channels participant limit is too large".to_string())? + ], + |row| { + let last_seen_at_ms = u64::try_from(row.get::<_, i64>(2)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + let participant_count = + usize::try_from(row.get::<_, i64>(3)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + Ok(( + ChannelParticipantSummary { + identity_hash: row.get(0)?, + lxmf_hash: None, + nickname: row.get(1)?, + last_seen_at_ms, + }, + participant_count, + )) + }, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let participants = participants + .drain(..) + .map(|(participant, count)| { + total_count = count; + participant + }) + .collect::>(); + Ok(ChannelParticipantPage { + omitted_count: total_count.saturating_sub(participants.len()), + participants, + }) +} + +/// Catch up from an exclusive append-log cursor in receive order. Cursor `0` +/// starts at the identity's first retained row, which lets a client that +/// loaded an empty room avoid a latest-page gap when the first burst arrives. +pub fn list_channel_history_after( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + after: &str, + limit: usize, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + if limit == 0 || limit > CHANNEL_HISTORY_MAX_PAGE_SIZE { + return Err(format!( + "Channels history page size must be between 1 and {CHANNEL_HISTORY_MAX_PAGE_SIZE}" + )); + } + let after = parse_channel_history_after_cursor(after)?; + let query_limit = i64::try_from(limit.saturating_add(1)) + .map_err(|_| "Channels history page size is too large".to_string())?; + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT sequence, hub_destination_hash, room_name, event_id, kind, + timestamp_ms, recorded_at_ms, source_hash, nickname, text, + ours, mentioned + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND sequence > ?4 + ORDER BY sequence ASC + LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let mut items = statement + .query_map( + params![ + identity_id, + hub_destination_hash, + room_name, + after, + query_limit + ], + channel_history_row, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let has_more = items.len() > limit; + items.truncate(limit); + let next_after = items.last().map(|item| item.sequence.clone()); + Ok(ChannelHistoryPage { + items, + next_before: None, + next_after, + has_more, + }) +} + +fn channel_room_read_state( + hub_destination_hash: &str, + room_name: &str, + stored: Option<(i64, String)>, +) -> Result { + let (last_read_sequence, notification_level) = stored.unwrap_or(( + 0, + ChannelRoomNotificationLevel::default().as_storage().into(), + )); + let notification_level = ChannelRoomNotificationLevel::from_storage(¬ification_level) + .ok_or_else(|| "invalid stored Channels notification level".to_string())?; + Ok(ChannelRoomReadState { + hub_destination_hash: hub_destination_hash.into(), + room_name: room_name.into(), + last_read_sequence: last_read_sequence.to_string(), + notification_level, + }) +} + +fn query_channel_room_read_state( + conn: &Connection, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result, String> { + conn.query_row( + "SELECT last_read_sequence, notification_level + FROM channel_room_state + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string()) +} + +pub fn get_channel_room_read_state( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let conn = pool.get().map_err(|error| error.to_string())?; + let stored = + query_channel_room_read_state(&conn, identity_id, hub_destination_hash, room_name)?; + channel_room_read_state(hub_destination_hash, room_name, stored) +} + +/// Advance one room's read position to a sequence proven to belong to that +/// exact identity/hub/room scope. Cursors are monotonic and sequence `0` is an +/// idempotent no-op for an empty room. +pub fn mark_channel_room_read( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + through: &str, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let through = parse_channel_history_after_cursor(through)?; + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let stored = + query_channel_room_read_state(&transaction, identity_id, hub_destination_hash, room_name)?; + let current = stored.as_ref().map_or(0, |(sequence, _)| *sequence); + + if through > current { + let belongs_to_room = transaction + .query_row( + "SELECT 1 + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + AND sequence = ?4", + params![identity_id, hub_destination_hash, room_name, through], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|error| error.to_string())? + .is_some(); + if !belongs_to_room { + return Err("Channels read cursor does not belong to this room".into()); + } + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, 'mentions', ?5) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO UPDATE SET + last_read_sequence = excluded.last_read_sequence, + updated_at_ms = excluded.updated_at_ms + WHERE excluded.last_read_sequence > + channel_room_state.last_read_sequence", + params![ + identity_id, + hub_destination_hash, + room_name, + through, + now_unix_ms() + ], + ) + .map_err(|error| error.to_string())?; + } + + let stored = + query_channel_room_read_state(&transaction, identity_id, hub_destination_hash, room_name)?; + let state = channel_room_read_state(hub_destination_hash, room_name, stored)?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(state) +} + +pub fn set_channel_room_notification_level( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + notification_level: ChannelRoomNotificationLevel, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, updated_at_ms + ) VALUES ( + ?1, ?2, ?3, + COALESCE(( + SELECT MAX(sequence) + FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3 + ), 0), + ?4, ?5 + ) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO UPDATE SET + notification_level = excluded.notification_level, + updated_at_ms = excluded.updated_at_ms", + params![ + identity_id, + hub_destination_hash, + room_name, + notification_level.as_storage(), + now_unix_ms() + ], + ) + .map_err(|error| error.to_string())?; + let stored = + query_channel_room_read_state(&transaction, identity_id, hub_destination_hash, room_name)?; + let state = channel_room_read_state(hub_destination_hash, room_name, stored)?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(state) +} + +pub fn get_channel_unread_summary( + pool: &DbPool, + identity_id: &str, +) -> Result { + if !is_canonical_channel_hash(identity_id) { + return Err("invalid Channels unread identity".into()); + } + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT + state.hub_destination_hash, + state.room_name, + COUNT(history.sequence) AS unread_count, + COALESCE(SUM(history.mentioned), 0) AS mention_count, + state.notification_level + FROM channel_room_state AS state + LEFT JOIN channel_history AS history + ON history.identity_id = state.identity_id + AND history.hub_destination_hash = state.hub_destination_hash + AND history.room_name = state.room_name + AND history.ours = 0 + AND history.kind IN ('message', 'notice', 'action') + AND history.sequence > state.last_read_sequence + WHERE state.identity_id = ?1 + GROUP BY + state.hub_destination_hash, + state.room_name, + state.notification_level + ORDER BY + COALESCE(MAX(history.sequence), MAX(state.last_read_sequence)) DESC", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + let unread_count = row.get::<_, i64>(2)?; + let mention_count = row.get::<_, i64>(3)?; + let notification_level = row.get::<_, String>(4)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + unread_count, + mention_count, + notification_level, + )) + }) + .map_err(|error| error.to_string())?; + + let mut summary = ChannelUnreadSummary::default(); + for row in rows { + let (hub_destination_hash, room_name, unread_count, mention_count, notification_level) = + row.map_err(|error| error.to_string())?; + let unread_count = u64::try_from(unread_count) + .map_err(|_| "invalid stored Channels unread count".to_string())?; + let mention_count = u64::try_from(mention_count) + .map_err(|_| "invalid stored Channels mention count".to_string())?; + let notification_level = ChannelRoomNotificationLevel::from_storage(¬ification_level) + .ok_or_else(|| "invalid stored Channels notification level".to_string())?; + summary.unread_total = summary.unread_total.saturating_add(unread_count); + summary.mention_total = summary.mention_total.saturating_add(mention_count); + summary.attention_total = + summary + .attention_total + .saturating_add(match notification_level { + ChannelRoomNotificationLevel::All => unread_count, + ChannelRoomNotificationLevel::Mentions => mention_count, + ChannelRoomNotificationLevel::Mute => 0, + }); + summary.rooms.push(ChannelRoomUnread { + hub_destination_hash, + room_name, + unread_count, + mention_count, + notification_level, + }); + } + Ok(summary) +} + +/// Explicit history deletion is separate from bookmark removal. +pub fn clear_channel_room_history( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + validate_channel_history_scope(identity_id, hub_destination_hash, room_name)?; + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + transaction + .execute( + "DELETE FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + let deleted = transaction + .execute( + "DELETE FROM channel_history + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(deleted) +} + +pub fn clear_channel_history_for_identity( + pool: &DbPool, + identity_id: &str, +) -> Result { + if !is_canonical_channel_hash(identity_id) { + return Err("invalid Channels history identity".into()); + } + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + transaction + .execute( + "DELETE FROM channel_participant_observations WHERE identity_id = ?1", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + let deleted = transaction + .execute( + "DELETE FROM channel_history WHERE identity_id = ?1", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(deleted) +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct SavedChannelHub { + pub destination_hash: String, + pub label: String, + pub nickname: String, + pub added_at: f64, + pub last_connected: f64, + /// Durable scheduler intent, distinct from an observed live Link. + pub desired_connected: bool, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct SavedChannelRoom { + pub hub_destination_hash: String, + pub room_name: String, + pub added_at: f64, + pub last_joined: f64, + /// Durable scheduler intent, distinct from hub-confirmed membership. + pub desired_joined: bool, + /// Non-secret recovery hint. A desired protected room without ciphertext + /// must wait for user input instead of retrying a keyless JOIN forever. + pub join_key_required: bool, +} + +/// One room visible in the client-local Channels browser. This is the union of +/// bookmarks and retained history: forgetting a hub must not make its +/// separately retained transcript unreachable or imply that it was deleted. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct ChannelRoomIndexEntry { + pub hub_destination_hash: String, + pub room_name: String, + pub last_joined: f64, + pub latest_recorded_at_ms: Option, + pub saved: bool, + pub has_history: bool, + pub topic: Option, +} + +#[derive(Clone, PartialEq)] +pub struct StoredChannelRoomSecret { + pub hub_destination_hash: String, + pub room_name: String, + pub seal_scheme: String, + pub seal_version: u32, + pub ciphertext: Vec, + pub updated_at: f64, +} + +impl std::fmt::Debug for StoredChannelRoomSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredChannelRoomSecret") + .field("hub_destination_hash", &self.hub_destination_hash) + .field("room_name", &self.room_name) + .field("seal_scheme", &self.seal_scheme) + .field("seal_version", &self.seal_version) + .field("ciphertext", &"") + .field("updated_at", &self.updated_at) + .finish() + } +} + +pub fn list_saved_channel_hubs( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT destination_hash, label, nickname, added_at, last_connected, + desired_connected + FROM channel_hubs + WHERE identity_id = ?1 + ORDER BY last_connected DESC, label COLLATE NOCASE, destination_hash", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(SavedChannelHub { + destination_hash: row.get(0)?, + label: row.get(1)?, + nickname: row.get(2)?, + added_at: row.get(3)?, + last_connected: row.get(4)?, + desired_connected: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +pub fn save_channel_hub( + pool: &DbPool, + identity_id: &str, + destination_hash: &str, + label: &str, + nickname: &str, + connected: bool, +) -> Result<(), String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let now = now_ts(); + conn.execute( + "INSERT INTO channel_hubs + (identity_id, destination_hash, label, nickname, added_at, last_connected) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(identity_id, destination_hash) DO UPDATE SET + label = excluded.label, + nickname = excluded.nickname, + last_connected = CASE + WHEN excluded.last_connected > 0 THEN excluded.last_connected + ELSE channel_hubs.last_connected + END", + params![ + identity_id, + destination_hash, + label, + nickname, + now, + if connected { now } else { 0.0 } + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// Persist the one-hub scheduler target without conflating it with an +/// observed connection. Selecting a hub clears the previous winner in the +/// same transaction; the partial unique index is the final concurrency guard. +pub fn set_channel_hub_desired( + pool: &DbPool, + identity_id: &str, + destination_hash: &str, + nickname: &str, + desired: bool, +) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let tx = conn.transaction().map_err(|error| error.to_string())?; + if desired { + tx.execute( + "UPDATE channel_hubs SET desired_connected = 0 + WHERE identity_id = ?1 AND desired_connected != 0", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + } + let now = now_ts(); + tx.execute( + "INSERT INTO channel_hubs + (identity_id, destination_hash, label, nickname, added_at, + last_connected, desired_connected) + VALUES (?1, ?2, '', ?3, ?4, 0, ?5) + ON CONFLICT(identity_id, destination_hash) DO UPDATE SET + nickname = excluded.nickname, + desired_connected = excluded.desired_connected", + params![identity_id, destination_hash, nickname, now, desired as i64], + ) + .map_err(|error| error.to_string())?; + tx.commit().map_err(|error| error.to_string()) +} + +/// Rename an identity and retire the superseded name from its saved hub +/// bookmarks in one transaction. +/// +/// Bookmarks record whatever nickname the session connected as, so a rename +/// would otherwise keep offering — and broadcasting — the previous name. Only +/// bookmarks still holding the exact previous name are rewritten; a deliberate +/// per-hub alias differs from it and is left alone. +/// +/// The two writes must commit together: if the sweep were to fail after the +/// rename committed, a retry would read the already-updated name as the +/// "previous" one, skip the sweep on the equality guard, and strand the old +/// name in the bookmark permanently. +pub struct IdentityRenameOutcome { + /// The name this identity carried before the rename ("" if unset). + pub previous_name: String, + pub retired_bookmarks: usize, +} + +pub fn rename_identity_and_retire_alias( + pool: &DbPool, + identity_id: &str, + new_name: &str, +) -> Result { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let previous_name: String = transaction + .query_row( + "SELECT COALESCE(display_name, '') FROM identities WHERE hash = ?1", + params![identity_id], + |row| row.get(0), + ) + .unwrap_or_default(); + transaction + .execute( + "UPDATE identities SET display_name = ?1 WHERE hash = ?2", + params![new_name, identity_id], + ) + .map_err(|error| format!("display_name: {error}"))?; + let retired = if previous_name.is_empty() || previous_name == new_name { + 0 + } else { + transaction + .execute( + "UPDATE channel_hubs SET nickname = ?1 WHERE identity_id = ?2 AND nickname = ?3", + params![new_name, identity_id, previous_name], + ) + .map_err(|error| format!("hub_nickname: {error}"))? + }; + transaction.commit().map_err(|error| error.to_string())?; + Ok(IdentityRenameOutcome { + previous_name, + retired_bookmarks: retired, + }) +} + +/// One hosted room's durable policy. Grants ride along so a restore is a +/// single query pair rather than one query per room. +#[derive(Clone, PartialEq)] +pub struct HubRoomRow { + pub room_name: String, + pub topic: String, + pub key_salt: String, + pub key_mac: String, + pub key_pepper_id: String, + pub moderated: bool, + pub invite_only: bool, + pub topic_ops_only: bool, + pub no_outside_msgs: bool, + pub private: bool, + pub last_used: f64, + /// `(kind, subject hex, expires_at)`; kind is `op|voice|ban|invite`. + pub grants: Vec<(String, String, f64)>, +} + +/// Hand-written so a room key digest can never reach a log or a panic message. +impl std::fmt::Debug for HubRoomRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HubRoomRow") + .field("room_name", &self.room_name) + .field("keyed", &!self.key_mac.is_empty()) + .field("grants", &self.grants.len()) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone)] +pub enum HubRoomOp { + Upsert(Box), + Touched { room_name: String, last_used: f64 }, + Removed { room_name: String }, + ReplaceKlines(Vec), + GcInvites { before: f64 }, +} + +pub fn list_hub_rooms(pool: &DbPool, identity_id: &str) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut rooms: Vec = conn + .prepare( + "SELECT room_name, topic, key_salt, key_mac, key_pepper_id, moderated, + invite_only, topic_ops_only, no_outside_msgs, private, last_used + FROM channel_hub_rooms WHERE identity_id = ?1 ORDER BY room_name", + ) + .and_then(|mut stmt| { + stmt.query_map(params![identity_id], |row| { + Ok(HubRoomRow { + room_name: row.get(0)?, + topic: row.get(1)?, + key_salt: row.get(2)?, + key_mac: row.get(3)?, + key_pepper_id: row.get(4)?, + moderated: row.get::<_, i64>(5)? != 0, + invite_only: row.get::<_, i64>(6)? != 0, + topic_ops_only: row.get::<_, i64>(7)? != 0, + no_outside_msgs: row.get::<_, i64>(8)? != 0, + private: row.get::<_, i64>(9)? != 0, + last_used: row.get(10)?, + grants: Vec::new(), + }) + }) + .and_then(|rows| rows.collect::, _>>()) + }) + .map_err(|error| error.to_string())?; + + let mut grants: std::collections::HashMap> = + std::collections::HashMap::new(); + conn.prepare( + "SELECT room_name, kind, subject, expires_at + FROM channel_hub_grants WHERE identity_id = ?1", + ) + .and_then(|mut stmt| { + stmt.query_map(params![identity_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, f64>(3)?, + )) + }) + .and_then(|rows| rows.collect::, _>>()) + }) + .map_err(|error| error.to_string())? + .into_iter() + .for_each(|(room, kind, subject, expires)| { + grants + .entry(room) + .or_default() + .push((kind, subject, expires)); + }); + + for room in &mut rooms { + if let Some(found) = grants.remove(&room.room_name) { + room.grants = found; + } + } + Ok(rooms) +} + +pub fn list_hub_klines(pool: &DbPool, identity_id: &str) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + conn.prepare("SELECT subject FROM channel_hub_klines WHERE identity_id = ?1") + .and_then(|mut stmt| { + stmt.query_map(params![identity_id], |row| row.get::<_, String>(0)) + .and_then(|rows| rows.collect::, _>>()) + }) + .map_err(|error| error.to_string()) +} + +/// Apply a batch of registry writes in one transaction, in order. Ordering is +/// load-bearing: two writes to the same room must not reorder, so the caller +/// hands the whole batch over rather than spawning a task per op. +pub fn apply_hub_ops(pool: &DbPool, identity_id: &str, ops: &[HubRoomOp]) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let tx = conn.transaction().map_err(|error| error.to_string())?; + let now = now_ts(); + for op in ops { + match op { + HubRoomOp::Upsert(room) => { + tx.execute( + "INSERT INTO channel_hub_rooms + (identity_id, room_name, topic, key_salt, key_mac, key_pepper_id, + moderated, invite_only, topic_ops_only, no_outside_msgs, private, + created_at, last_used) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13) + ON CONFLICT(identity_id, room_name) DO UPDATE SET + topic = excluded.topic, + key_salt = excluded.key_salt, + key_mac = excluded.key_mac, + key_pepper_id = excluded.key_pepper_id, + moderated = excluded.moderated, + invite_only = excluded.invite_only, + topic_ops_only = excluded.topic_ops_only, + no_outside_msgs = excluded.no_outside_msgs, + private = excluded.private, + last_used = excluded.last_used", + params![ + identity_id, + room.room_name, + room.topic, + room.key_salt, + room.key_mac, + room.key_pepper_id, + room.moderated as i64, + room.invite_only as i64, + room.topic_ops_only as i64, + room.no_outside_msgs as i64, + room.private as i64, + now, + room.last_used + ], + ) + .map_err(|error| error.to_string())?; + // Grants are authoritative per room: replace wholesale so a + // revoked op or expired invite cannot survive as a stale row. + tx.execute( + "DELETE FROM channel_hub_grants WHERE identity_id = ?1 AND room_name = ?2", + params![identity_id, room.room_name], + ) + .map_err(|error| error.to_string())?; + for (kind, subject, expires_at) in &room.grants { + tx.execute( + "INSERT OR REPLACE INTO channel_hub_grants + (identity_id, room_name, kind, subject, granted_at, expires_at) + VALUES (?1,?2,?3,?4,?5,?6)", + params![identity_id, room.room_name, kind, subject, now, expires_at], + ) + .map_err(|error| error.to_string())?; + } + } + HubRoomOp::Touched { + room_name, + last_used, + } => { + tx.execute( + "UPDATE channel_hub_rooms SET last_used = ?1 + WHERE identity_id = ?2 AND room_name = ?3", + params![last_used, identity_id, room_name], + ) + .map_err(|error| error.to_string())?; + } + HubRoomOp::Removed { room_name } => { + tx.execute( + "DELETE FROM channel_hub_rooms WHERE identity_id = ?1 AND room_name = ?2", + params![identity_id, room_name], + ) + .map_err(|error| error.to_string())?; + } + HubRoomOp::ReplaceKlines(subjects) => { + tx.execute( + "DELETE FROM channel_hub_klines WHERE identity_id = ?1", + params![identity_id], + ) + .map_err(|error| error.to_string())?; + for subject in subjects { + tx.execute( + "INSERT OR REPLACE INTO channel_hub_klines + (identity_id, subject, banned_at) VALUES (?1,?2,?3)", + params![identity_id, subject, now], + ) + .map_err(|error| error.to_string())?; + } + } + HubRoomOp::GcInvites { before } => { + tx.execute( + "DELETE FROM channel_hub_grants + WHERE identity_id = ?1 AND kind = 'invite' AND expires_at <= ?2", + params![identity_id, before], + ) + .map_err(|error| error.to_string())?; + } + } + } + tx.commit().map_err(|error| error.to_string()) +} + +pub fn remove_channel_hub( + pool: &DbPool, + identity_id: &str, + destination_hash: &str, +) -> Result { + let conn = pool.get().map_err(|error| error.to_string())?; + conn.execute( + "DELETE FROM channel_hubs WHERE identity_id = ?1 AND destination_hash = ?2", + params![identity_id, destination_hash], + ) + .map(|changed| changed > 0) + .map_err(|error| error.to_string()) +} + +pub fn list_saved_channel_rooms( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT hub_destination_hash, room_name, added_at, last_joined, + desired_joined, join_key_required + FROM channel_rooms + WHERE identity_id = ?1 AND hub_destination_hash = ?2 + ORDER BY last_joined DESC, room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id, hub_destination_hash], |row| { + Ok(SavedChannelRoom { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + added_at: row.get(2)?, + last_joined: row.get(3)?, + desired_joined: row.get::<_, i64>(4)? != 0, + join_key_required: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// Load all remembered rooms for one identity in one query. The service-state +/// snapshot is hub-keyed, so doing one query per saved hub would make startup +/// cost grow quadratically with a user's community list. +pub fn list_saved_channel_rooms_for_identity( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT hub_destination_hash, room_name, added_at, last_joined, + desired_joined, join_key_required + FROM channel_rooms + WHERE identity_id = ?1 + ORDER BY hub_destination_hash, last_joined DESC, + room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(SavedChannelRoom { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + added_at: row.get(2)?, + last_joined: row.get(3)?, + desired_joined: row.get::<_, i64>(4)? != 0, + join_key_required: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// Load the local room browser in one query. Bookmarks and history have +/// intentionally independent lifetimes, so neither side is allowed to hide +/// the other. +pub fn list_channel_room_index( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "WITH room_index AS ( + SELECT + hub_destination_hash, + room_name, + last_joined, + NULL AS latest_recorded_at_ms, + 1 AS saved, + 0 AS has_history + FROM channel_rooms + WHERE identity_id = ?1 + + UNION ALL + + SELECT + hub_destination_hash, + room_name, + 0.0 AS last_joined, + MAX(recorded_at_ms) AS latest_recorded_at_ms, + 0 AS saved, + 1 AS has_history + FROM channel_history + WHERE identity_id = ?1 + GROUP BY hub_destination_hash, room_name + ), grouped_rooms AS ( + SELECT + hub_destination_hash, + room_name, + MAX(last_joined) AS last_joined, + MAX(latest_recorded_at_ms) AS latest_recorded_at_ms, + MAX(saved) AS saved, + MAX(has_history) AS has_history + FROM room_index + GROUP BY hub_destination_hash, room_name + ) + SELECT + rooms.hub_destination_hash, + rooms.room_name, + rooms.last_joined, + rooms.latest_recorded_at_ms, + rooms.saved, + rooms.has_history, + NULLIF(state.topic, '') + FROM grouped_rooms AS rooms + LEFT JOIN channel_room_state AS state + ON state.identity_id = ?1 + AND state.hub_destination_hash = rooms.hub_destination_hash + AND state.room_name = rooms.room_name + ORDER BY + COALESCE( + rooms.latest_recorded_at_ms, + CAST(rooms.last_joined * 1000 AS INTEGER), + 0 + ) DESC, + rooms.hub_destination_hash, + rooms.room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(ChannelRoomIndexEntry { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + last_joined: row.get(2)?, + latest_recorded_at_ms: row.get(3)?, + saved: row.get::<_, i64>(4)? != 0, + has_history: row.get::<_, i64>(5)? != 0, + topic: row.get(6)?, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +pub fn save_channel_room( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + joined: bool, + topic: Option<&str>, +) -> Result<(), String> { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let now = now_ts(); + transaction + .execute( + "INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, last_joined) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(identity_id, hub_destination_hash, room_name) DO UPDATE SET + last_joined = CASE + WHEN excluded.last_joined > 0 THEN excluded.last_joined + ELSE channel_rooms.last_joined + END", + params![ + identity_id, + hub_destination_hash, + room_name, + now, + if joined { now } else { 0.0 } + ], + ) + .map_err(|error| error.to_string())?; + if let Some(topic) = topic { + transaction + .execute( + "INSERT INTO channel_room_state ( + identity_id, hub_destination_hash, room_name, + last_read_sequence, notification_level, topic, updated_at_ms + ) VALUES (?1, ?2, ?3, 0, 'mentions', ?4, ?5) + ON CONFLICT ( + identity_id, hub_destination_hash, room_name + ) DO UPDATE SET + topic = excluded.topic, + updated_at_ms = excluded.updated_at_ms", + params![ + identity_id, + hub_destination_hash, + room_name, + topic, + now_unix_ms() + ], + ) + .map_err(|error| error.to_string())?; + } + transaction.commit().map_err(|error| error.to_string())?; + Ok(()) +} + +/// Persist desired room membership independently from the last JOIN observed. +/// A failed or disconnected session can therefore retain honest user intent +/// without claiming that the hub currently considers the identity a member. +pub fn set_channel_room_desired( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + desired: bool, +) -> Result<(), String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let now = now_ts(); + conn.execute( + "INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, + last_joined, desired_joined) + VALUES (?1, ?2, ?3, ?4, 0, ?5) + ON CONFLICT(identity_id, hub_destination_hash, room_name) DO UPDATE SET + desired_joined = excluded.desired_joined", + params![ + identity_id, + hub_destination_hash, + room_name, + now, + desired as i64 + ], + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +pub fn list_channel_room_secrets_for_identity( + pool: &DbPool, + identity_id: &str, +) -> Result, String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let mut statement = conn + .prepare( + "SELECT hub_destination_hash, room_name, seal_scheme, seal_version, + ciphertext, updated_at + FROM channel_room_secrets + WHERE identity_id = ?1 + ORDER BY hub_destination_hash, room_name COLLATE NOCASE", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![identity_id], |row| { + Ok(StoredChannelRoomSecret { + hub_destination_hash: row.get(0)?, + room_name: row.get(1)?, + seal_scheme: row.get(2)?, + seal_version: row.get(3)?, + ciphertext: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// Atomically store identity-sealed ciphertext and the non-secret requirement +/// hint. Callers must do this only after authenticated JOIN confirmation. +pub fn save_channel_room_secret( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + seal_scheme: &str, + seal_version: u32, + ciphertext: &[u8], +) -> Result<(), String> { + if seal_scheme.is_empty() || seal_version == 0 || ciphertext.is_empty() { + return Err("invalid sealed channel room secret".into()); + } + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let now = now_ts(); + let changed = transaction + .execute( + "UPDATE channel_rooms SET join_key_required = 1 + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err("channel room does not exist".into()); + } + transaction + .execute( + "INSERT INTO channel_room_secrets + (identity_id, hub_destination_hash, room_name, seal_scheme, + seal_version, ciphertext, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(identity_id, hub_destination_hash, room_name) DO UPDATE SET + seal_scheme = excluded.seal_scheme, + seal_version = excluded.seal_version, + ciphertext = excluded.ciphertext, + updated_at = excluded.updated_at", + params![ + identity_id, + hub_destination_hash, + room_name, + seal_scheme, + i64::from(seal_version), + ciphertext, + now + ], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string()) +} + +/// Persist one-way knowledge that this room requires a join key. This never +/// modifies recoverable ciphertext: a mistyped replacement must not destroy a +/// previously confirmed key. +pub fn mark_channel_room_key_required( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result<(), String> { + let conn = pool.get().map_err(|error| error.to_string())?; + let changed = conn + .execute( + "UPDATE channel_rooms SET join_key_required = 1 + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())?; + if changed == 1 { + Ok(()) + } else { + Err("channel room does not exist".into()) + } +} + +/// Forget recoverable ciphertext while preserving whether reconnect must wait +/// for a replacement key. Rejection/corruption uses `required = true`. +pub fn remove_channel_room_secret( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, + required: bool, +) -> Result { + let mut conn = pool.get().map_err(|error| error.to_string())?; + let transaction = conn.transaction().map_err(|error| error.to_string())?; + let removed = transaction + .execute( + "DELETE FROM channel_room_secrets + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map_err(|error| error.to_string())? + > 0; + transaction + .execute( + "UPDATE channel_rooms SET join_key_required = ?1 + WHERE identity_id = ?2 AND hub_destination_hash = ?3 AND room_name = ?4", + params![ + required as i64, + identity_id, + hub_destination_hash, + room_name + ], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(removed) +} + +pub fn remove_channel_room( + pool: &DbPool, + identity_id: &str, + hub_destination_hash: &str, + room_name: &str, +) -> Result { + let conn = pool.get().map_err(|error| error.to_string())?; + conn.execute( + "DELETE FROM channel_rooms + WHERE identity_id = ?1 AND hub_destination_hash = ?2 AND room_name = ?3", + params![identity_id, hub_destination_hash, room_name], + ) + .map(|changed| changed > 0) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod channel_history_tests { + use super::*; + use r2d2_sqlite::SqliteConnectionManager; + + const IDENTITY_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const IDENTITY_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const HUB_A: &str = "11111111111111111111111111111111"; + const HUB_B: &str = "22222222222222222222222222222222"; + + fn test_pool() -> DbPool { + let manager = SqliteConnectionManager::memory() + .with_init(|connection| connection.execute_batch("PRAGMA foreign_keys=ON;")); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + save_identity(&pool, IDENTITY_A, "", "A", "A"); + save_identity(&pool, IDENTITY_B, "", "B", "B"); + pool + } + + fn event(hub: &str, room: &str, id: &str) -> NewChannelHistoryEvent { + NewChannelHistoryEvent { + hub_destination_hash: hub.into(), + room_name: room.into(), + event_id: id.into(), + kind: ChannelHistoryKind::Message, + timestamp_ms: 1_700_000_000_000, + source_hash: Some(IDENTITY_B.into()), + nickname: Some("Field Rat".into()), + text: format!("message {id}"), + ours: false, + mentioned: false, + } + } + + fn ids(page: &ChannelHistoryPage) -> Vec<&str> { + page.items + .iter() + .map(|item| item.event_id.as_str()) + .collect() + } + + fn estimated_payload_bytes(identity_id: &str, event: &NewChannelHistoryEvent) -> usize { + 128 + identity_id.len() + + event.hub_destination_hash.len() + + event.room_name.len() + + event.event_id.len() + + event.kind.as_storage().len() + + event.source_hash.as_deref().map_or(0, str::len) + + event.nickname.as_deref().map_or(0, str::len) + + event.text.len() + } + + #[test] + fn history_is_deduplicated_identity_scoped_and_cursor_paginated() { + let pool = test_pool(); + save_channel_hub(&pool, IDENTITY_A, HUB_A, "Relay", "A", false).unwrap(); + save_channel_room( + &pool, + IDENTITY_A, + HUB_A, + "general", + false, + Some("General discussion"), + ) + .unwrap(); + + let events: Vec<_> = (1..=5) + .map(|index| event(HUB_A, "general", &format!("event-{index}"))) + .collect(); + let recorded_at_ms = now_unix_ms(); + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &events, + recorded_at_ms, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + assert_eq!(outcome.inserted, 5); + assert_eq!(outcome.duplicates, 0); + assert_eq!(outcome.pruned, 0); + assert!(outcome.latest_sequence.is_some()); + + let duplicate = event(HUB_A, "general", "event-3"); + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &[duplicate], + recorded_at_ms.saturating_add(1), + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + assert_eq!(outcome.inserted, 0); + assert_eq!(outcome.duplicates, 1); + + let newest = list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 2).unwrap(); + assert_eq!(ids(&newest), vec!["event-4", "event-5"]); + assert!(newest.has_more); + assert_eq!( + newest.next_after.as_deref(), + newest.items.last().map(|item| item.sequence.as_str()) + ); + let cursor = newest.next_before.as_deref().unwrap(); + assert!(cursor.bytes().all(|byte| byte.is_ascii_digit())); + + let middle = + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", Some(cursor), 2).unwrap(); + assert_eq!(ids(&middle), vec!["event-2", "event-3"]); + assert!(middle.has_more); + let oldest = list_channel_history( + &pool, + IDENTITY_A, + HUB_A, + "general", + middle.next_before.as_deref(), + 2, + ) + .unwrap(); + assert_eq!(ids(&oldest), vec!["event-1"]); + assert!(!oldest.has_more); + assert!(oldest.next_before.is_none()); + + let forward = + list_channel_history_after(&pool, IDENTITY_A, HUB_A, "general", "0", 2).unwrap(); + assert_eq!(ids(&forward), vec!["event-1", "event-2"]); + assert!(forward.has_more); + assert!(forward.next_before.is_none()); + let forward_cursor = forward.next_after.as_deref().unwrap(); + let forward = + list_channel_history_after(&pool, IDENTITY_A, HUB_A, "general", forward_cursor, 2) + .unwrap(); + assert_eq!(ids(&forward), vec!["event-3", "event-4"]); + assert!(forward.has_more); + let forward = list_channel_history_after( + &pool, + IDENTITY_A, + HUB_A, + "general", + forward.next_after.as_deref().unwrap(), + 2, + ) + .unwrap(); + assert_eq!(ids(&forward), vec!["event-5"]); + assert!(!forward.has_more); + + // The same event id is independent across identities, hubs, and rooms. + append_channel_history_events(&pool, IDENTITY_B, &[event(HUB_A, "general", "event-1")]) + .unwrap(); + append_channel_history_events(&pool, IDENTITY_A, &[event(HUB_B, "general", "event-1")]) + .unwrap(); + assert_eq!( + list_channel_history(&pool, IDENTITY_B, HUB_A, "general", None, 10) + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + list_channel_history(&pool, IDENTITY_A, HUB_B, "general", None, 10) + .unwrap() + .items + .len(), + 1 + ); + + // History is user data in its own right, not a child of a bookmark. + let indexed = list_channel_room_index(&pool, IDENTITY_A) + .unwrap() + .into_iter() + .find(|entry| entry.hub_destination_hash == HUB_A && entry.room_name == "general") + .expect("saved history room is indexed"); + assert!(indexed.saved); + assert!(indexed.has_history); + assert_eq!(indexed.topic.as_deref(), Some("General discussion")); + assert!(remove_channel_hub(&pool, IDENTITY_A, HUB_A).unwrap()); + assert_eq!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 10) + .unwrap() + .items + .len(), + 5 + ); + let index = list_channel_room_index(&pool, IDENTITY_A).unwrap(); + let retained = index + .iter() + .find(|entry| entry.hub_destination_hash == HUB_A && entry.room_name == "general") + .expect("forgotten bookmark history remains discoverable"); + assert!(!retained.saved); + assert!(retained.has_history); + assert_eq!(retained.topic.as_deref(), Some("General discussion")); + assert_eq!( + retained.latest_recorded_at_ms, + Some(u64::try_from(recorded_at_ms).unwrap()) + ); + } + + #[test] + fn participant_summaries_are_room_scoped_durable_and_not_presence_claims() { + let pool = test_pool(); + let mut identified_join = event(HUB_A, "general", "identified-join"); + identified_join.kind = ChannelHistoryKind::Join; + identified_join.nickname = Some("Ada".into()); + + let mut nickname_join = event(HUB_A, "general", "nickname-join"); + nickname_join.kind = ChannelHistoryKind::Join; + nickname_join.source_hash = None; + nickname_join.nickname = Some("Guest".into()); + + let mut nickname_part = event(HUB_A, "general", "nickname-part"); + nickname_part.kind = ChannelHistoryKind::Part; + nickname_part.source_hash = None; + nickname_part.nickname = Some("Guest".into()); + + let mut identified_part = event(HUB_A, "general", "identified-part"); + identified_part.kind = ChannelHistoryKind::Part; + identified_part.nickname = Some("Ada renamed".into()); + + let mut ours = event(HUB_A, "general", "ours"); + ours.source_hash = Some(IDENTITY_A.into()); + ours.nickname = Some("A".into()); + ours.ours = true; + + let mut notice = event(HUB_A, "general", "notice"); + notice.kind = ChannelHistoryKind::Notice; + notice.nickname = Some("Relay".into()); + + append_channel_history_events_at( + &pool, + IDENTITY_A, + &[ + identified_join, + nickname_join, + nickname_part, + identified_part, + ours, + notice, + ], + 1_700_000_123_456, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + append_channel_history_events_at( + &pool, + IDENTITY_A, + &[event(HUB_A, "other", "other-room")], + 1_700_000_123_456, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + + let page = + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "general", 1_700_000_123_456) + .unwrap(); + assert_eq!(page.omitted_count, 0); + let participants = page.participants; + assert_eq!(participants.len(), 2); + assert_eq!(participants[0].identity_hash.as_deref(), Some(IDENTITY_B)); + assert_eq!(participants[0].nickname.as_deref(), Some("Ada renamed")); + assert_eq!(participants[0].last_seen_at_ms, 1_700_000_123_456); + assert_eq!(participants[1].identity_hash, None); + assert_eq!(participants[1].nickname.as_deref(), Some("Guest")); + assert!(participants.iter().all(|participant| { + participant.nickname.as_deref() != Some("A") + && participant.nickname.as_deref() != Some("Relay") + })); + + let crowd = (0..=CHANNEL_PARTICIPANT_MAX_RESULTS) + .map(|index| { + let mut participant = event(HUB_A, "crowd", &format!("crowd-{index}")); + participant.source_hash = None; + participant.nickname = Some(format!("Guest {index}")); + participant + }) + .collect::>(); + append_channel_history_events_at( + &pool, + IDENTITY_A, + &crowd, + 1_700_000_123_457, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + let crowd_page = + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "crowd", 1_700_000_123_457) + .unwrap(); + assert_eq!( + crowd_page.participants.len(), + CHANNEL_PARTICIPANT_MAX_RESULTS + ); + assert_eq!(crowd_page.omitted_count, 1); + assert_eq!( + crowd_page.participants[0].nickname.as_deref(), + Some("Guest 200") + ); + } + + #[test] + fn roster_observations_preserve_identified_participants_without_transcript_rows() { + let pool = test_pool(); + let observation = NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "quiet".into(), + identity_hash: IDENTITY_B.into(), + nickname: Some("Ada".into()), + }; + assert_eq!( + remember_channel_participants_at( + &pool, + IDENTITY_A, + std::slice::from_ref(&observation), + 2_000, + ) + .unwrap(), + 1 + ); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "quiet", None, 10) + .unwrap() + .items + .is_empty() + ); + + // An older identity-only observation must not erase a nickname that + // was already associated with the canonical identity. + let mut identity_only = observation.clone(); + identity_only.nickname = None; + remember_channel_participants_at(&pool, IDENTITY_A, &[identity_only], 1_000).unwrap(); + let page = list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "quiet", 3_000).unwrap(); + assert_eq!(page.participants.len(), 1); + assert_eq!( + page.participants[0].identity_hash.as_deref(), + Some(IDENTITY_B) + ); + assert_eq!(page.participants[0].nickname.as_deref(), Some("Ada")); + assert_eq!(page.participants[0].last_seen_at_ms, 2_000); + + // The durable projection keeps a bounded channel-only tail while a + // peer still known elsewhere is exempt from that transient allowance. + assert_eq!( + touch_identity_activity_for_service( + &pool, + &[( + "dddddddddddddddddddddddddddddddd".into(), + 3.0, + Some("Ada".into()), + None, + )], + Some(IDENTITY_B), + PEER_SERVICE_LXMF_DELIVERY, + ), + 1 + ); + let known_peer = NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "observed-crowd".into(), + identity_hash: IDENTITY_B.into(), + nickname: Some("Ada".into()), + }; + remember_channel_participants_at(&pool, IDENTITY_A, &[known_peer], 2_500).unwrap(); + let crowd = (0..=CHANNEL_PARTICIPANT_MAX_RESULTS) + .map(|index| NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "observed-crowd".into(), + identity_hash: format!("{index:032x}"), + nickname: Some(format!("Peer {index}")), + }) + .collect::>(); + remember_channel_participants_at(&pool, IDENTITY_A, &crowd, 3_000).unwrap(); + let retained: i64 = pool + .get() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_participant_observations + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = 'observed-crowd'", + params![IDENTITY_A, HUB_A], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + retained, + i64::try_from(CHANNEL_PARTICIPANT_MAX_TRANSIENT_PER_ROOM + 1).unwrap() + ); + + assert_eq!( + clear_channel_room_history(&pool, IDENTITY_A, HUB_A, "quiet").unwrap(), + 0 + ); + assert!( + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "quiet", 3_000) + .unwrap() + .participants + .is_empty() + ); + } + + #[test] + fn participant_summaries_follow_the_known_identity_retention_setting() { + let pool = test_pool(); + let day_ms = MILLIS_PER_DAY; + let observed_at_ms = 20 * day_ms; + let query_at_ms = observed_at_ms + 15 * day_ms; + let mut historical = event(HUB_A, "retention", "historical-peer"); + historical.nickname = Some("Ada".into()); + append_channel_history_events_at( + &pool, + IDENTITY_A, + &[historical], + observed_at_ms, + CHANNEL_HISTORY_RETENTION, + ) + .unwrap(); + let roster_only = NewChannelParticipantObservation { + hub_destination_hash: HUB_A.into(), + room_name: "retention".into(), + identity_hash: "cccccccccccccccccccccccccccccccc".into(), + nickname: Some("Grace".into()), + }; + remember_channel_participants_at(&pool, IDENTITY_A, &[roster_only], observed_at_ms) + .unwrap(); + + assert!( + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "retention", query_at_ms,) + .unwrap() + .participants + .is_empty(), + "the default 14-day known-identity lifetime also bounds Seen here" + ); + + assert_eq!( + touch_identity_activity_for_service( + &pool, + &[( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".into(), + query_at_ms as f64 / 1_000.0, + Some("Ada".into()), + None, + )], + Some(IDENTITY_B), + PEER_SERVICE_LXMF_DELIVERY, + ), + 1 + ); + let protected = + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "retention", query_at_ms) + .unwrap(); + assert_eq!(protected.participants.len(), 1); + assert_eq!( + protected.participants[0].identity_hash.as_deref(), + Some(IDENTITY_B), + "a peer still known elsewhere keeps its channel association" + ); + + set_setting(&pool, "known_identities_prune_days", "0"); + assert_eq!( + list_channel_participants_at(&pool, IDENTITY_A, HUB_A, "retention", query_at_ms,) + .unwrap() + .participants + .len(), + 2, + "disabling identity-age pruning still leaves the per-room cap in force" + ); + } + + #[test] + fn unread_mentions_are_sequence_scoped_monotonic_and_policy_aware() { + let pool = test_pool(); + let plain = event(HUB_A, "general", "plain"); + let mut mention = event(HUB_A, "general", "mention"); + mention.kind = ChannelHistoryKind::Action; + mention.text = "@A checks the signal".into(); + mention.mentioned = true; + let mut notice = event(HUB_A, "general", "notice"); + notice.kind = ChannelHistoryKind::Notice; + let mut presence = event(HUB_A, "general", "join"); + presence.kind = ChannelHistoryKind::Join; + let mut ours = event(HUB_A, "general", "ours"); + ours.ours = true; + ours.source_hash = Some(IDENTITY_A.into()); + + let outcome = append_channel_history_events( + &pool, + IDENTITY_A, + &[plain, mention, notice, presence, ours], + ) + .unwrap(); + assert_eq!(outcome.inserted, 5); + assert_eq!( + outcome + .inserted_events + .iter() + .map(|inserted| inserted.batch_index) + .collect::>(), + vec![0, 1, 2, 3, 4] + ); + + let summary = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(summary.unread_total, 3); + assert_eq!(summary.mention_total, 1); + assert_eq!( + summary.attention_total, 1, + "the default mentions policy should not nag for every room message" + ); + assert_eq!(summary.rooms.len(), 1); + assert_eq!( + summary.rooms[0].notification_level, + ChannelRoomNotificationLevel::Mentions + ); + + let state = set_channel_room_notification_level( + &pool, + IDENTITY_A, + HUB_A, + "general", + ChannelRoomNotificationLevel::All, + ) + .unwrap(); + assert_eq!(state.last_read_sequence, "0"); + assert_eq!( + get_channel_unread_summary(&pool, IDENTITY_A) + .unwrap() + .attention_total, + 3 + ); + + let page = list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 10).unwrap(); + let mention_sequence = page + .items + .iter() + .find(|item| item.event_id == "mention") + .unwrap() + .sequence + .clone(); + let state = + mark_channel_room_read(&pool, IDENTITY_A, HUB_A, "general", &mention_sequence).unwrap(); + assert_eq!(state.last_read_sequence, mention_sequence); + assert_eq!( + state.notification_level, + ChannelRoomNotificationLevel::All, + "advancing read state must preserve delivery policy" + ); + let summary = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(summary.unread_total, 1); + assert_eq!(summary.mention_total, 0); + + let wrong_room = mark_channel_room_read( + &pool, + IDENTITY_A, + HUB_A, + "other", + &page.items.last().unwrap().sequence, + ); + assert!( + wrong_room.is_err(), + "a global sequence from another room must never mark this room read" + ); + + let tail = page.items.last().unwrap().sequence.clone(); + mark_channel_room_read(&pool, IDENTITY_A, HUB_A, "general", &tail).unwrap(); + let regressed = mark_channel_room_read(&pool, IDENTITY_A, HUB_A, "general", "1").unwrap(); + assert_eq!( + regressed.last_read_sequence, tail, + "read cursors are monotonic" + ); + let cleared = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(cleared.unread_total, 0); + assert_eq!(cleared.rooms.len(), 1); + assert_eq!( + cleared.rooms[0].notification_level, + ChannelRoomNotificationLevel::All, + "zero-unread rooms remain addressable for notification controls" + ); + + set_channel_room_notification_level( + &pool, + IDENTITY_A, + HUB_A, + "general", + ChannelRoomNotificationLevel::Mute, + ) + .unwrap(); + let mut later = event(HUB_A, "general", "later"); + later.mentioned = true; + append_channel_history_events(&pool, IDENTITY_A, &[later]).unwrap(); + let summary = get_channel_unread_summary(&pool, IDENTITY_A).unwrap(); + assert_eq!(summary.unread_total, 1); + assert_eq!(summary.mention_total, 1); + assert_eq!(summary.attention_total, 0); + } + + #[test] + fn retention_uses_local_time_and_bounds_rooms_and_identities() { + let pool = test_pool(); + let retention = ChannelHistoryRetentionPolicy { + max_age_ms: 100, + max_events_per_room: 3, + max_events_per_identity: 5, + max_events_global: 100, + max_payload_bytes_per_room: 1_000_000, + max_payload_bytes_per_identity: 1_000_000, + max_payload_bytes_global: 1_000_000, + }; + let alpha: Vec<_> = (1..=4) + .map(|index| event(HUB_A, "alpha", &format!("a-{index}"))) + .collect(); + let outcome = + append_channel_history_events_at(&pool, IDENTITY_A, &alpha, 1_000, retention).unwrap(); + assert_eq!(outcome.inserted, 4); + assert_eq!(outcome.pruned, 1); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10).unwrap()), + vec!["a-2", "a-3", "a-4"] + ); + + let beta: Vec<_> = (1..=3) + .map(|index| event(HUB_A, "beta", &format!("b-{index}"))) + .collect(); + let outcome = + append_channel_history_events_at(&pool, IDENTITY_A, &beta, 1_010, retention).unwrap(); + assert_eq!(outcome.pruned, 1, "identity ceiling removes the oldest row"); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10).unwrap()), + vec!["a-3", "a-4"] + ); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "beta", None, 10).unwrap()), + vec!["b-1", "b-2", "b-3"] + ); + + // A forged remote timestamp cannot extend retention. Advancing only + // the local recording clock expires all five prior rows. + let mut fresh = event(HUB_A, "gamma", "fresh"); + fresh.timestamp_ms = 1; + let outcome = + append_channel_history_events_at(&pool, IDENTITY_A, &[fresh], 1_111, retention) + .unwrap(); + assert_eq!(outcome.pruned, 5); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "gamma", None, 10).unwrap()), + vec!["fresh"] + ); + } + + #[test] + fn retention_bounds_estimated_payload_per_room_identity_and_install() { + let pool = test_pool(); + let first = event(HUB_A, "alpha", "same-1"); + let second = event(HUB_A, "alpha", "same-2"); + let one_event_bytes = estimated_payload_bytes(IDENTITY_A, &first); + assert_eq!( + one_event_bytes, + estimated_payload_bytes(IDENTITY_A, &second) + ); + let room_policy = ChannelHistoryRetentionPolicy { + max_age_ms: 10_000, + max_events_per_room: 100, + max_events_per_identity: 100, + max_events_global: 100, + max_payload_bytes_per_room: one_event_bytes, + max_payload_bytes_per_identity: 1_000_000, + max_payload_bytes_global: 1_000_000, + }; + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &[first, second], + 1_000, + room_policy, + ) + .unwrap(); + assert_eq!(outcome.pruned, 1); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10).unwrap()), + vec!["same-2"] + ); + + clear_channel_history_for_identity(&pool, IDENTITY_A).unwrap(); + let alpha = event(HUB_A, "alpha", "one-a"); + let beta = event(HUB_A, "bravo", "one-b"); + let identity_budget = estimated_payload_bytes(IDENTITY_A, &alpha) + .max(estimated_payload_bytes(IDENTITY_A, &beta)); + let identity_policy = ChannelHistoryRetentionPolicy { + max_payload_bytes_per_room: 1_000_000, + max_payload_bytes_per_identity: identity_budget, + ..room_policy + }; + let outcome = append_channel_history_events_at( + &pool, + IDENTITY_A, + &[alpha, beta], + 1_100, + identity_policy, + ) + .unwrap(); + assert_eq!(outcome.pruned, 1); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "alpha", None, 10) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_A, HUB_A, "bravo", None, 10).unwrap()), + vec!["one-b"] + ); + + clear_channel_history_for_identity(&pool, IDENTITY_A).unwrap(); + let old = event(HUB_A, "global", "old-one"); + let new = event(HUB_A, "global", "new-one"); + let global_budget = estimated_payload_bytes(IDENTITY_A, &old) + .max(estimated_payload_bytes(IDENTITY_B, &new)); + let global_policy = ChannelHistoryRetentionPolicy { + max_payload_bytes_per_identity: 1_000_000, + max_payload_bytes_global: global_budget, + ..identity_policy + }; + append_channel_history_events_at(&pool, IDENTITY_A, &[old], 1_200, global_policy).unwrap(); + let outcome = + append_channel_history_events_at(&pool, IDENTITY_B, &[new], 1_201, global_policy) + .unwrap(); + assert_eq!(outcome.pruned, 1); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "global", None, 10) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + ids(&list_channel_history(&pool, IDENTITY_B, HUB_A, "global", None, 10).unwrap()), + vec!["new-one"] + ); + } + + #[test] + fn explicit_clear_is_scoped_and_identity_delete_cascades() { + let pool = test_pool(); + append_channel_history_events( + &pool, + IDENTITY_A, + &[ + event(HUB_A, "general", "a-general"), + event(HUB_A, "other", "a-other"), + ], + ) + .unwrap(); + append_channel_history_events(&pool, IDENTITY_B, &[event(HUB_A, "general", "b-general")]) + .unwrap(); + + assert_eq!( + clear_channel_room_history(&pool, IDENTITY_A, HUB_A, "general").unwrap(), + 1 + ); + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 10) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "other", None, 10) + .unwrap() + .items + .len(), + 1 + ); + assert_eq!( + list_channel_history(&pool, IDENTITY_B, HUB_A, "general", None, 10) + .unwrap() + .items + .len(), + 1 + ); + + delete_identity(&pool, IDENTITY_A, true).unwrap(); + let remaining: i64 = pool + .get() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_history WHERE identity_id = ?1", + params![IDENTITY_A], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining, 0); + let remaining_usage: i64 = pool + .get() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_history_room_usage WHERE identity_id = ?1", + params![IDENTITY_A], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining_usage, 0); + } + + #[test] + fn history_rejects_ambiguous_cursors_and_unbounded_inputs() { + let pool = test_pool(); + let valid = event(HUB_A, "general", "secret-event"); + assert!(!format!("{valid:?}").contains("message secret-event")); + append_channel_history_events(&pool, IDENTITY_A, &[valid]).unwrap(); + + for cursor in ["", "0", "01", "-1", "abc", "9223372036854775808"] { + assert!( + list_channel_history(&pool, IDENTITY_A, HUB_A, "general", Some(cursor), 10) + .is_err(), + "cursor `{cursor}` must be rejected" + ); + } + assert!(list_channel_history(&pool, IDENTITY_A, HUB_A, "general", None, 0).is_err()); + for cursor in ["", "00", "01", "-1", "abc", "9223372036854775808"] { + assert!( + list_channel_history_after(&pool, IDENTITY_A, HUB_A, "general", cursor, 10) + .is_err(), + "forward cursor `{cursor}` must be rejected" + ); + } + assert!( + list_channel_history( + &pool, + IDENTITY_A, + HUB_A, + "general", + None, + CHANNEL_HISTORY_MAX_PAGE_SIZE + 1 + ) + .is_err() + ); + + let mut invalid = event(HUB_A, "General", "bad-room"); + assert!(append_channel_history_events(&pool, IDENTITY_A, &[invalid.clone()]).is_err()); + invalid.room_name = "general".into(); + invalid.hub_destination_hash = "ABCDEFABCDEFABCDEFABCDEFABCDEFAB".into(); + assert!(append_channel_history_events(&pool, IDENTITY_A, &[invalid]).is_err()); + + let oversized = vec![event(HUB_A, "general", "same"); CHANNEL_HISTORY_MAX_APPEND_BATCH + 1]; + assert!(append_channel_history_events(&pool, IDENTITY_A, &oversized).is_err()); + } +} + +#[cfg(test)] +mod channel_bookmark_tests { + use super::*; + use r2d2_sqlite::SqliteConnectionManager; + + fn test_pool() -> DbPool { + let manager = SqliteConnectionManager::memory() + .with_init(|connection| connection.execute_batch("PRAGMA foreign_keys=ON;")); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + pool + } + + #[test] + fn hubs_and_rooms_are_identity_scoped_and_hub_delete_cascades() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + + save_channel_hub( + &pool, + "identity-a", + "00112233445566778899aabbccddeeff", + "Mountain relay", + "Field Rat", + false, + ) + .unwrap(); + save_channel_room( + &pool, + "identity-a", + "00112233445566778899aabbccddeeff", + "field team", + true, + None, + ) + .unwrap(); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + assert_eq!(hubs.len(), 1); + assert_eq!(hubs[0].label, "Mountain relay"); + assert!( + list_saved_channel_hubs(&pool, "identity-b") + .unwrap() + .is_empty() + ); + let rooms = + list_saved_channel_rooms(&pool, "identity-a", "00112233445566778899aabbccddeeff") + .unwrap(); + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].room_name, "field team"); + assert!(rooms[0].last_joined > 0.0); + + assert!( + remove_channel_hub(&pool, "identity-a", "00112233445566778899aabbccddeeff").unwrap() + ); + assert!( + list_saved_channel_rooms(&pool, "identity-a", "00112233445566778899aabbccddeeff") + .unwrap() + .is_empty() + ); + } + + #[test] + fn desired_channel_state_is_single_hub_scoped_and_independent_of_recency() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + + set_channel_hub_desired(&pool, "identity-a", "aa", "alpha", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "general", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "quiet", false).unwrap(); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + assert_eq!(hubs.len(), 1); + assert!(hubs[0].desired_connected); + let rooms = list_saved_channel_rooms_for_identity(&pool, "identity-a").unwrap(); + assert_eq!(rooms.len(), 2); + assert!( + rooms + .iter() + .find(|room| room.room_name == "general") + .unwrap() + .desired_joined + ); + assert!( + !rooms + .iter() + .find(|room| room.room_name == "quiet") + .unwrap() + .desired_joined + ); + + // Selecting another hub atomically replaces the one scheduler winner + // but retains the first hub and its room intent for a later switch. + set_channel_hub_desired(&pool, "identity-a", "bb", "bravo", true).unwrap(); + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + assert_eq!(hubs.iter().filter(|hub| hub.desired_connected).count(), 1); + assert!( + hubs.iter() + .find(|hub| hub.destination_hash == "bb") + .unwrap() + .desired_connected + ); + assert!( + !hubs + .iter() + .find(|hub| hub.destination_hash == "aa") + .unwrap() + .desired_connected + ); + assert!( + list_saved_channel_rooms(&pool, "identity-a", "aa") + .unwrap() + .iter() + .any(|room| room.room_name == "general" && room.desired_joined) + ); + + // Updating recency and labels is orthogonal to scheduler intent. + save_channel_hub(&pool, "identity-a", "bb", "Relay B", "bravo", true).unwrap(); + save_channel_room(&pool, "identity-a", "bb", "ops", true, None).unwrap(); + assert!( + list_saved_channel_hubs(&pool, "identity-a") + .unwrap() + .iter() + .find(|hub| hub.destination_hash == "bb") + .unwrap() + .desired_connected + ); + assert!( + !list_saved_channel_rooms(&pool, "identity-a", "bb") + .unwrap() + .iter() + .find(|room| room.room_name == "ops") + .unwrap() + .desired_joined + ); + + set_channel_hub_desired(&pool, "identity-b", "cc", "charlie", true).unwrap(); + assert!( + list_saved_channel_hubs(&pool, "identity-b").unwrap()[0].desired_connected, + "the one-hub budget is identity-scoped" + ); + } + + #[test] + fn sealed_room_secrets_are_identity_scoped_redacted_and_forgettable() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + set_channel_hub_desired(&pool, "identity-a", "aa", "alpha", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "general", true).unwrap(); + set_channel_hub_desired(&pool, "identity-b", "bb", "bravo", true).unwrap(); + set_channel_room_desired(&pool, "identity-b", "bb", "general", true).unwrap(); + + let ciphertext = b"opaque-ciphertext-that-debug-must-hide"; + save_channel_room_secret( + &pool, + "identity-a", + "aa", + "general", + "rns_identity", + 1, + ciphertext, + ) + .unwrap(); + + let secrets = list_channel_room_secrets_for_identity(&pool, "identity-a").unwrap(); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0].ciphertext, ciphertext); + assert_eq!(secrets[0].seal_scheme, "rns_identity"); + assert_eq!(secrets[0].seal_version, 1); + let debug = format!("{:?}", secrets[0]); + assert!(debug.contains("")); + assert!(!debug.contains("opaque-ciphertext")); + assert!( + list_channel_room_secrets_for_identity(&pool, "identity-b") + .unwrap() + .is_empty() + ); + assert!(list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap()[0].join_key_required); + mark_channel_room_key_required(&pool, "identity-a", "aa", "general").unwrap(); + assert_eq!( + list_channel_room_secrets_for_identity(&pool, "identity-a").unwrap()[0].ciphertext, + ciphertext, + "learning that a key is required must not erase confirmed ciphertext" + ); + + set_channel_room_desired(&pool, "identity-a", "aa", "invited", true).unwrap(); + mark_channel_room_key_required(&pool, "identity-a", "aa", "invited").unwrap(); + let invited = list_saved_channel_rooms(&pool, "identity-a", "aa") + .unwrap() + .into_iter() + .find(|room| room.room_name == "invited") + .unwrap(); + assert!(invited.join_key_required); + assert_eq!( + list_channel_room_secrets_for_identity(&pool, "identity-a") + .unwrap() + .len(), + 1, + "key-required knowledge does not invent recoverable key material" + ); + + assert!(remove_channel_room_secret(&pool, "identity-a", "aa", "general", true).unwrap()); + assert!( + list_channel_room_secrets_for_identity(&pool, "identity-a") + .unwrap() + .is_empty() + ); + let room = &list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap()[0]; + assert!( + room.desired_joined, + "forgetting a key preserves room desire" + ); + assert!( + room.join_key_required, + "a rejected key must block keyless reconnect" + ); + } + + #[test] + fn removing_a_client_room_cascades_its_sealed_secret() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + set_channel_hub_desired(&pool, "identity-a", "aa", "alpha", true).unwrap(); + set_channel_room_desired(&pool, "identity-a", "aa", "general", true).unwrap(); + save_channel_room_secret( + &pool, + "identity-a", + "aa", + "general", + "rns_identity", + 1, + b"ciphertext", + ) + .unwrap(); + + assert!(remove_channel_room(&pool, "identity-a", "aa", "general").unwrap()); + assert!( + list_channel_room_secrets_for_identity(&pool, "identity-a") + .unwrap() + .is_empty() + ); + } + + #[test] + fn renaming_retires_the_old_name_but_keeps_deliberate_hub_aliases() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "Old Name"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + + // Bookmark carrying a copy of the identity name (auto-prefilled). + save_channel_hub(&pool, "identity-a", "aa", "Relay", "Old Name", true).unwrap(); + // Bookmark with a deliberate per-hub alias. + save_channel_hub(&pool, "identity-a", "bb", "Alias relay", "Radio Rat", true).unwrap(); + // Another identity that happens to use the same name. + save_channel_hub(&pool, "identity-b", "cc", "Other", "Old Name", true).unwrap(); + + let updated = rename_identity_and_retire_alias(&pool, "identity-a", "New Name").unwrap(); + assert_eq!( + updated.retired_bookmarks, 1, + "only the stale copy is rewritten" + ); + assert_eq!(updated.previous_name, "Old Name"); + assert_eq!( + get_identity(&pool, "identity-a") + .and_then(|identity| identity + .get("display_name") + .and_then(|value| value.as_str()) + .map(str::to_string)) + .unwrap_or_default(), + "New Name", + "the rename commits with the sweep" + ); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + let stale = hubs + .iter() + .find(|hub| hub.destination_hash == "aa") + .unwrap(); + let alias = hubs + .iter() + .find(|hub| hub.destination_hash == "bb") + .unwrap(); + assert_eq!( + stale.nickname, "New Name", + "superseded name must not survive" + ); + assert_eq!( + alias.nickname, "Radio Rat", + "a deliberate per-hub alias must keep working" + ); + + let other = list_saved_channel_hubs(&pool, "identity-b").unwrap(); + assert_eq!( + other[0].nickname, "Old Name", + "another identity's bookmarks are untouched" + ); + + // Renaming to the same name is a no-op sweep. + assert_eq!( + rename_identity_and_retire_alias(&pool, "identity-a", "New Name") + .unwrap() + .retired_bookmarks, + 0 + ); + } + + fn hub_room(name: &str) -> HubRoomRow { + HubRoomRow { + room_name: name.to_string(), + topic: "field ops".into(), + key_salt: "aabb".into(), + key_mac: "ccdd".into(), + key_pepper_id: "eeff".into(), + moderated: true, + invite_only: false, + topic_ops_only: true, + no_outside_msgs: true, + private: true, + last_used: 1234.0, + grants: vec![ + ("op".into(), "a".repeat(32), 0.0), + ("ban".into(), "b".repeat(32), 0.0), + ("invite".into(), "c".repeat(32), 9_000.0), + ], + } + } + + #[test] + fn hub_registry_round_trips_rooms_grants_and_klines() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + + apply_hub_ops( + &pool, + "identity-a", + &[ + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + HubRoomOp::ReplaceKlines(vec!["d".repeat(32)]), + ], + ) + .unwrap(); + + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + assert_eq!(rooms.len(), 1); + let room = &rooms[0]; + assert_eq!(room.room_name, "lobby"); + assert_eq!(room.topic, "field ops"); + assert_eq!(room.key_mac, "ccdd"); + // +p must survive a restart; the reference loses it. + assert!(room.private && room.moderated && room.topic_ops_only && room.no_outside_msgs); + assert_eq!(room.last_used, 1234.0); + let mut kinds: Vec<&str> = room.grants.iter().map(|(k, _, _)| k.as_str()).collect(); + kinds.sort(); + assert_eq!(kinds, vec!["ban", "invite", "op"]); + assert_eq!( + list_hub_klines(&pool, "identity-a").unwrap(), + vec!["d".repeat(32)] + ); + } + + #[test] + fn a_room_upsert_replaces_its_grants_wholesale() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Upsert(Box::new(hub_room("lobby")))], + ) + .unwrap(); + + // A revoked op must not survive as a stale row. + let mut room = hub_room("lobby"); + room.grants = vec![("voice".into(), "e".repeat(32), 0.0)]; + apply_hub_ops(&pool, "identity-a", &[HubRoomOp::Upsert(Box::new(room))]).unwrap(); + + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + assert_eq!(rooms[0].grants.len(), 1); + assert_eq!(rooms[0].grants[0].0, "voice"); + } + + #[test] + fn hub_registry_ops_apply_in_batch_order() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + // Touch-then-remove and remove-then-upsert must not reorder. + apply_hub_ops( + &pool, + "identity-a", + &[ + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + HubRoomOp::Touched { + room_name: "lobby".into(), + last_used: 4321.0, + }, + HubRoomOp::Removed { + room_name: "lobby".into(), + }, + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + ], + ) + .unwrap(); + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + assert_eq!(rooms.len(), 1); + assert_eq!(rooms[0].last_used, 1234.0, "the final upsert wins"); + } + + #[test] + fn removing_a_room_cascades_its_grants() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Upsert(Box::new(hub_room("lobby")))], + ) + .unwrap(); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Removed { + room_name: "lobby".into(), + }], + ) + .unwrap(); + + assert!(list_hub_rooms(&pool, "identity-a").unwrap().is_empty()); + let orphans: i64 = pool + .get() + .unwrap() + .query_row("SELECT COUNT(*) FROM channel_hub_grants", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(orphans, 0, "grants must not outlive their room"); + } + + #[test] + fn gc_invites_drops_only_expired_invite_grants() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::Upsert(Box::new(hub_room("lobby")))], + ) + .unwrap(); + + apply_hub_ops( + &pool, + "identity-a", + &[HubRoomOp::GcInvites { before: 10_000.0 }], + ) + .unwrap(); + let rooms = list_hub_rooms(&pool, "identity-a").unwrap(); + let kinds: Vec<&str> = rooms[0].grants.iter().map(|(k, _, _)| k.as_str()).collect(); + assert!(!kinds.contains(&"invite"), "the expired invite is gone"); + assert!( + kinds.contains(&"op") && kinds.contains(&"ban"), + "permanent grants (expires_at 0) must never be collected" + ); + } + + #[test] + fn hub_registry_is_identity_scoped_and_cascades_with_the_identity() { + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "A"); + save_identity(&pool, "identity-b", "lxmf-b", "B", "B"); + for id in ["identity-a", "identity-b"] { + apply_hub_ops( + &pool, + id, + &[ + HubRoomOp::Upsert(Box::new(hub_room("lobby"))), + HubRoomOp::ReplaceKlines(vec!["d".repeat(32)]), + ], + ) + .unwrap(); + } + + delete_identity(&pool, "identity-a", true).unwrap(); + assert!(list_hub_rooms(&pool, "identity-a").unwrap().is_empty()); + assert!(list_hub_klines(&pool, "identity-a").unwrap().is_empty()); + assert_eq!(list_hub_rooms(&pool, "identity-b").unwrap().len(), 1); + assert_eq!(list_hub_klines(&pool, "identity-b").unwrap().len(), 1); + } + + #[test] + fn a_hub_room_row_never_debug_prints_its_key() { + let rendered = format!("{:?}", hub_room("lobby")); + assert!(!rendered.contains("ccdd"), "the key MAC must not be logged"); + assert!( + !rendered.contains("aabb"), + "the key salt must not be logged" + ); + assert!(rendered.contains("keyed: true")); + } + + #[test] + fn a_rename_that_fails_leaves_the_old_name_recoverable() { + // The sweep must not commit ahead of the rename: if it did, a retry + // would read the new name as the "previous" one, skip the sweep on the + // equality guard, and strand the superseded name in the bookmark. + let pool = test_pool(); + save_identity(&pool, "identity-a", "lxmf-a", "A", "Old Name"); + save_channel_hub(&pool, "identity-a", "aa", "Relay", "Old Name", true).unwrap(); + + // Force the transaction to fail after the identities write by holding a + // schema-incompatible state: drop the table the sweep targets. + pool.get() + .unwrap() + .execute("DROP TABLE channel_hubs", []) + .unwrap(); + assert!(rename_identity_and_retire_alias(&pool, "identity-a", "New Name").is_err()); + + // The rename rolled back with it, so the retry still sees the old name + // as "previous" and can still retire it. + assert_eq!( + get_identity(&pool, "identity-a") + .and_then(|identity| identity + .get("display_name") + .and_then(|value| value.as_str()) + .map(str::to_string)) + .unwrap_or_default(), + "Old Name", + "a failed rename must not leave the new name committed" + ); + } +} + /// Overridable via `known_identities_prune_days` (0 disables). pub const DEFAULT_PRUNE_DAYS: u32 = 14; @@ -2437,6 +6973,14 @@ fn normalized_peer_services<'a>(services: impl IntoIterator) -> out } +fn normalized_lxmf_compression_support(value: &str) -> Option<&'static str> { + match value.trim() { + LXMF_COMPRESSION_SUPPORT_SUPPORTED => Some(LXMF_COMPRESSION_SUPPORT_SUPPORTED), + LXMF_COMPRESSION_SUPPORT_UNSUPPORTED => Some(LXMF_COMPRESSION_SUPPORT_UNSUPPORTED), + _ => None, + } +} + /// Same as `touch_identity_activity`, but records the service aspect that made /// the destination actionable for Ratspeak. pub fn touch_identity_activity_for_service( @@ -2458,6 +7002,7 @@ pub struct IdentityActivityUpdate { pub identity_hash: Option, pub services: Vec, pub clear_ratspeak_services: bool, + pub lxmf_compression_support: Option, } /// Same as `touch_identity_activity_for_service`, but merges multiple service @@ -2486,6 +7031,7 @@ pub fn touch_identity_activity_for_services( identity_hash: identity_hash.map(str::to_owned), services: services.clone(), clear_ratspeak_services, + lxmf_compression_support: None, }) .collect(); touch_identity_activity_updates(pool, &updates) @@ -2514,8 +7060,8 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit Err(_) => return 0, }; let mut stmt = match tx.prepare_cached( - "INSERT INTO identity_activity(dest_hash, identity_hash, last_seen, first_seen, announce_count, display_name, status, last_interface, services) - VALUES (?1, ?2, ?3, ?3, 1, COALESCE(?4, ''), COALESCE(?5, ''), COALESCE(?6, ''), ?7) + "INSERT INTO identity_activity(dest_hash, identity_hash, last_seen, first_seen, announce_count, display_name, status, last_interface, services, lxmf_compression_support) + VALUES (?1, ?2, ?3, ?3, 1, COALESCE(?4, ''), COALESCE(?5, ''), COALESCE(?6, ''), ?7, COALESCE(?8, '')) ON CONFLICT(dest_hash) DO UPDATE SET last_seen = MAX(excluded.last_seen, last_seen), announce_count = announce_count + 1, @@ -2535,7 +7081,11 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit WHEN excluded.last_interface != '' THEN excluded.last_interface ELSE last_interface END, - services = excluded.services", + services = excluded.services, + lxmf_compression_support = CASE + WHEN ?8 IS NOT NULL AND ?8 != '' THEN excluded.lxmf_compression_support + ELSE lxmf_compression_support + END", ) { Ok(s) => s, Err(_) => return 0, @@ -2561,6 +7111,10 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit } } let merged_services = merged.join(","); + let lxmf_compression_support = update + .lxmf_compression_support + .as_deref() + .and_then(normalized_lxmf_compression_support); let ok = stmt .execute(params![ update.dest_hash, @@ -2569,7 +7123,8 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit n, update.status.as_deref(), i, - merged_services + merged_services, + lxmf_compression_support, ]) .is_ok(); if ok { @@ -2581,6 +7136,38 @@ pub fn touch_identity_activity_updates(pool: &DbPool, updates: &[IdentityActivit touched } +pub fn get_identity_lxmf_compression_support(pool: &DbPool, dest_hash: &str) -> Option { + let conn = pool.get().ok()?; + let raw: String = conn + .query_row( + "SELECT COALESCE(lxmf_compression_support, '') FROM identity_activity WHERE dest_hash = ?1", + params![dest_hash], + |row| row.get(0), + ) + .ok()?; + normalized_lxmf_compression_support(&raw).map(str::to_owned) +} + +pub fn set_identity_lxmf_compression_support( + pool: &DbPool, + dest_hash: &str, + support: &str, +) -> bool { + let Some(support) = normalized_lxmf_compression_support(support) else { + return false; + }; + let conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + conn.execute( + "UPDATE identity_activity SET lxmf_compression_support = ?1 WHERE dest_hash = ?2", + params![support, dest_hash], + ) + .map(|rows| rows > 0) + .unwrap_or(false) +} + pub fn touch_identity_last_heard(pool: &DbPool, dest_hash: &str, timestamp: f64) -> bool { let conn = match pool.get() { Ok(c) => c, @@ -2650,8 +7237,11 @@ pub fn get_peers_by_hashes(pool: &DbPool, hashes: &[String], identity_id: &str) ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "get_peers_by_hashes: prepare failed"); + Err(_) => { + tracing::warn!( + reason = "prepare_failed", + "get_peers_by_hashes: prepare failed" + ); continue; } }; @@ -2749,8 +7339,11 @@ pub fn get_peers_snapshot(pool: &DbPool, cutoff_unix: f64, identity_id: &str) -> ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "get_peers_snapshot: prepare failed"); + Err(_) => { + tracing::warn!( + reason = "prepare_failed", + "get_peers_snapshot: prepare failed" + ); return vec![]; } }; @@ -2878,18 +7471,21 @@ pub fn delete_identity_activity(pool: &DbPool, hashes: &[String]) -> usize { .collect(); match tx.execute(&sql, params.as_slice()) { Ok(n) => deleted += n, - Err(e) => { + Err(_) => { // Continue on chunk failure; pruner retries next pass. tracing::warn!( - error = %e, chunk_len = chunk.len(), + reason = "delete_failed", "delete_identity_activity chunk failed; remaining chunks will still be attempted" ); } } } - if let Err(e) = tx.commit() { - tracing::error!(error = %e, "delete_identity_activity commit failed — deletions discarded"); + if tx.commit().is_err() { + tracing::error!( + reason = "commit_failed", + "delete_identity_activity commit failed — deletions discarded" + ); return 0; } deleted @@ -3146,45 +7742,19 @@ pub fn clear_all_messages(pool: &DbPool, identity_id: &str) -> Vec { Ok(c) => c, Err(_) => return vec![], }; - let mut file_refs = Vec::new(); - if identity_id.is_empty() { - if let Ok(mut stmt) = - conn.prepare("SELECT attachment_stored_name, image_stored_name FROM messages") - && let Ok(rows) = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) - { - for r in rows.flatten() { - if !r.0.is_empty() { - file_refs.push(r.0); - } - if !r.1.is_empty() { - file_refs.push(r.1); - } - } - } + let file_refs = if identity_id.is_empty() { + query_message_file_refs( + &conn, + "SELECT attachment_stored_name, image_stored_name FROM messages", + [], + ) } else { - if let Ok(mut stmt) = conn.prepare( + query_message_file_refs( + &conn, "SELECT attachment_stored_name, image_stored_name FROM messages WHERE identity_id = ?1", - ) && let Ok(rows) = stmt.query_map(params![identity_id], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) { - for r in rows.flatten() { - if !r.0.is_empty() { - file_refs.push(r.0); - } - if !r.1.is_empty() { - file_refs.push(r.1); - } - } - } - } + params![identity_id], + ) + }; if identity_id.is_empty() { conn.execute("DELETE FROM messages", []).ok(); } else { @@ -3205,25 +7775,11 @@ pub fn get_identity_file_refs(pool: &DbPool, identity_id: &str) -> Vec { if identity_id.is_empty() { return vec![]; } - let mut file_refs = Vec::new(); - if let Ok(mut stmt) = conn.prepare( + query_message_file_refs( + &conn, "SELECT attachment_stored_name, image_stored_name FROM messages WHERE identity_id = ?1", - ) && let Ok(rows) = stmt.query_map(params![identity_id], |row| { - Ok(( - row.get::<_, String>(0).unwrap_or_default(), - row.get::<_, String>(1).unwrap_or_default(), - )) - }) { - for r in rows.flatten() { - if !r.0.is_empty() { - file_refs.push(r.0); - } - if !r.1.is_empty() { - file_refs.push(r.1); - } - } - } - file_refs + params![identity_id], + ) } pub fn clear_all_contacts(pool: &DbPool, identity_id: &str) { @@ -3296,26 +7852,70 @@ pub fn backfill_identity_id(pool: &DbPool, identity_hash: &str) { params![identity_hash], ) .ok(); - tracing::info!( - "Backfilled identity_id={} on existing contacts/messages", - &identity_hash[..16.min(identity_hash.len())] - ); + tracing::info!("Backfilled identity_id on existing contacts/messages"); } -pub fn save_game_session(pool: &DbPool, session: &lrgp::session::Session) { +pub fn save_game_session(pool: &DbPool, session: &lrgp::session::Session) -> bool { let conn = match pool.get() { Ok(c) => c, - Err(_) => return, + Err(_) => return false, }; let metadata_json = serde_json::to_string(&session.metadata).unwrap_or_else(|_| "{}".into()); - conn.execute( - "INSERT OR REPLACE INTO app_sessions (session_id, identity_id, app_id, app_version, contact_hash, initiator, status, metadata, unread, created_at, updated_at, last_action_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + let written = conn.execute( + "INSERT INTO app_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(session_id, identity_id) DO UPDATE SET + app_id = excluded.app_id, + app_version = excluded.app_version, + contact_hash = CASE + WHEN app_sessions.contact_hash = '' THEN excluded.contact_hash + ELSE app_sessions.contact_hash + END, + initiator = CASE + WHEN app_sessions.initiator = '' THEN excluded.initiator + ELSE app_sessions.initiator + END, + status = excluded.status, + metadata = excluded.metadata, + unread = app_sessions.unread, + created_at = app_sessions.created_at, + updated_at = excluded.updated_at, + last_action_at = excluded.last_action_at + WHERE app_sessions.app_id = excluded.app_id + AND app_sessions.app_version = excluded.app_version + AND (app_sessions.contact_hash = '' OR app_sessions.contact_hash = excluded.contact_hash) + AND (app_sessions.initiator = '' OR app_sessions.initiator = excluded.initiator)", params![ - session.session_id, session.identity_id, session.app_id, session.app_version, - session.contact_hash, session.initiator, session.status, metadata_json, - session.unread, session.created_at, session.updated_at, session.last_action_at, + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + session.status, + metadata_json, + session.unread, + session.created_at, + session.updated_at, + session.last_action_at, ], - ).ok(); + ); + match written { + Ok(1) => true, + Ok(_) => { + tracing::warn!( + reason = "binding_conflict", + "Refusing to replace an established LRGP session binding" + ); + false + } + Err(_) => { + tracing::error!(reason = "storage_error", "Failed to persist LRGP session"); + false + } + } } pub fn get_game_session( @@ -3368,19 +7968,577 @@ pub fn list_game_sessions( .unwrap_or_default() } -pub fn save_game_action(pool: &DbPool, action: &lrgp::store::Action, envelope_mp: Option<&[u8]>) { +/// Load the durable LRGP session records exactly as the game engines expect +/// them. Unlike `list_game_sessions`, this intentionally returns the typed +/// storage model instead of the frontend projection so the runtime can +/// hydrate every local identity before accepting game traffic. +pub fn load_game_sessions(pool: &DbPool) -> Vec { let conn = match pool.get() { Ok(c) => c, - Err(_) => return, + Err(_) => return vec![], + }; + let mut stmt = match conn.prepare( + "SELECT session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at + FROM app_sessions", + ) { + Ok(s) => s, + Err(_) => return vec![], + }; + + stmt.query_map([], |row| { + let metadata_json: String = row.get(7)?; + let metadata = serde_json::from_str(&metadata_json).unwrap_or_default(); + Ok(lrgp::session::Session { + session_id: row.get(0)?, + identity_id: row.get(1)?, + app_id: row.get(2)?, + app_version: row.get::<_, i64>(3)?.try_into().unwrap_or(1), + contact_hash: row.get(4)?, + initiator: row.get(5)?, + status: row.get(6)?, + metadata, + unread: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + last_action_at: row.get(11)?, + }) + }) + .map(|rows| rows.filter_map(Result::ok).collect()) + .unwrap_or_default() +} + +pub fn save_game_action( + pool: &DbPool, + action: &lrgp::store::Action, + envelope_mp: Option<&[u8]>, +) -> bool { + let conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, }; conn.execute( - "INSERT OR REPLACE INTO app_actions (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO app_actions (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ action.session_id, action.identity_id, action.action_num, action.command, action.payload_json, action.sender, action.timestamp, envelope_mp, ], - ).ok(); + ).is_ok() +} + +/// Atomically allocate and append the next action number for a session. +/// +/// `COUNT(*)` followed by `INSERT OR REPLACE` can make two concurrent actions +/// choose the same number and silently overwrite one another. An immediate +/// transaction plus `MAX(action_num) + 1` serializes allocation and makes a +/// collision fail instead of replacing durable history. +#[allow(clippy::too_many_arguments)] +pub fn append_game_action( + pool: &DbPool, + session_id: &str, + identity_id: &str, + command: &str, + payload_json: &str, + sender: &str, + timestamp: f64, + envelope_mp: Option<&[u8]>, +) -> Option { + let mut conn = pool.get().ok()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .ok()?; + let action_num: i64 = tx + .query_row( + "SELECT COALESCE(MAX(action_num), -1) + 1 + FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) + .ok()?; + tx.execute( + "INSERT INTO app_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session_id, + identity_id, + action_num, + command, + payload_json, + sender, + timestamp, + envelope_mp, + ], + ) + .ok()?; + tx.commit().ok()?; + Some(action_num) +} + +/// Atomically persist a locally-applied LRGP state transition together with +/// the exact envelope needed to resume delivery after a process crash. +/// +/// This is the durable outbox boundary for games. Persisting the state without +/// the envelope can leave the local board ahead of the peer after a crash; +/// persisting the envelope without the state can make a resend impossible to +/// reconcile locally. Established app, participant, and initiator bindings are +/// immutable here even if a caller bypasses the router checks. +#[allow(clippy::too_many_arguments)] +pub fn persist_outbound_game_action( + pool: &DbPool, + session: &lrgp::session::Session, + command: &str, + payload_json: &str, + sender: &str, + timestamp: f64, + envelope_mp: &[u8], +) -> Option { + let envelope = lrgp::envelope::unpack_from_bytes(envelope_mp).ok()?; + let validated = lrgp::envelope::validate_envelope(&envelope).ok()?; + if validated.session_id != session.session_id + || validated.app_id != session.app_id + || validated.version != session.app_version + || validated.command != command + || sender != session.identity_id + { + return None; + } + + let mut conn = pool.get().ok()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .ok()?; + let existing: Option<(String, u32, String, String)> = tx + .query_row( + "SELECT app_id, app_version, contact_hash, initiator + FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session.session_id, session.identity_id], + |row| { + Ok(( + row.get(0)?, + row.get::<_, i64>(1)?.try_into().unwrap_or(0), + row.get(2)?, + row.get(3)?, + )) + }, + ) + .optional() + .ok()?; + if existing.is_some_and(|(app_id, version, contact_hash, initiator)| { + app_id != session.app_id + || version != session.app_version + || (!contact_hash.is_empty() && contact_hash != session.contact_hash) + || (!initiator.is_empty() && initiator != session.initiator) + }) { + return None; + } + + let nonce = validated.nonce; + let duplicate = { + let mut statement = tx + .prepare( + "SELECT envelope_mp FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND envelope_mp IS NOT NULL", + ) + .ok()?; + let rows = statement + .query_map(params![session.session_id, session.identity_id], |row| { + row.get::<_, Vec>(0) + }) + .ok()?; + rows.filter_map(Result::ok) + .any(|packed| packed_game_nonce(&packed).as_deref() == Some(nonce.as_slice())) + }; + if duplicate { + return None; + } + + let metadata = serde_json::to_string(&session.metadata).ok()?; + let session_written = tx + .execute( + "INSERT INTO app_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(session_id, identity_id) DO UPDATE SET + contact_hash = CASE + WHEN app_sessions.contact_hash = '' THEN excluded.contact_hash + ELSE app_sessions.contact_hash + END, + initiator = CASE + WHEN app_sessions.initiator = '' THEN excluded.initiator + ELSE app_sessions.initiator + END, + status = excluded.status, + metadata = excluded.metadata, + updated_at = excluded.updated_at, + last_action_at = excluded.last_action_at + WHERE app_sessions.app_id = excluded.app_id + AND app_sessions.app_version = excluded.app_version + AND (app_sessions.contact_hash = '' OR app_sessions.contact_hash = excluded.contact_hash) + AND (app_sessions.initiator = '' OR app_sessions.initiator = excluded.initiator)", + params![ + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + session.status, + metadata, + session.unread, + session.created_at, + session.updated_at, + session.last_action_at, + ], + ) + .ok()?; + if session_written != 1 { + return None; + } + + let action_num: i64 = tx + .query_row( + "SELECT COALESCE(MAX(action_num), -1) + 1 + FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session.session_id, session.identity_id], + |row| row.get(0), + ) + .ok()?; + tx.execute( + "INSERT INTO app_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session.session_id, + session.identity_id, + action_num, + command, + payload_json, + sender, + timestamp, + envelope_mp, + ], + ) + .ok()?; + tx.commit().ok()?; + Some(action_num) +} + +/// Reverse a not-submitted durable outbox entry and restore the matching +/// pre-dispatch session snapshot in one transaction. +pub fn rollback_outbound_game_action( + pool: &DbPool, + session_id: &str, + identity_id: &str, + action_num: i64, + snapshot: Option<&lrgp::session::Session>, +) -> bool { + if snapshot.is_some_and(|session| { + session.session_id != session_id || session.identity_id != identity_id + }) { + return false; + } + let mut conn = match pool.get() { + Ok(conn) => conn, + Err(_) => return false, + }; + let tx = match conn.transaction_with_behavior(TransactionBehavior::Immediate) { + Ok(tx) => tx, + Err(_) => return false, + }; + if tx + .execute( + "DELETE FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND action_num = ?3", + params![session_id, identity_id, action_num], + ) + .ok() + != Some(1) + { + return false; + } + + if let Some(session) = snapshot { + let metadata = match serde_json::to_string(&session.metadata) { + Ok(metadata) => metadata, + Err(_) => return false, + }; + if tx + .execute( + "UPDATE app_sessions SET + status = ?1, metadata = ?2, unread = ?3, + updated_at = ?4, last_action_at = ?5 + WHERE session_id = ?6 AND identity_id = ?7 + AND app_id = ?8 AND app_version = ?9 + AND contact_hash = ?10 AND initiator = ?11", + params![ + session.status, + metadata, + session.unread, + session.updated_at, + session.last_action_at, + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + ], + ) + .ok() + != Some(1) + { + return false; + } + } else { + let remaining_actions: i64 = match tx.query_row( + "SELECT COUNT(*) FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) { + Ok(count) => count, + Err(_) => return false, + }; + if remaining_actions != 0 + || tx + .execute( + "DELETE FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + ) + .ok() + != Some(1) + { + return false; + } + } + + tx.commit().is_ok() +} + +/// Persist an accepted inbound action, its session snapshot, and unread +/// transition as one transaction. The established contact is immutable: even +/// if a future caller bypasses LRGP participant authorization, storage refuses +/// to rebind a session to a different peer. +#[allow(clippy::too_many_arguments)] +pub fn persist_inbound_game_action( + pool: &DbPool, + session_id: &str, + identity_id: &str, + command: &str, + payload_json: &str, + sender: &str, + timestamp: f64, + envelope_mp: &[u8], + session: Option<&lrgp::session::Session>, +) -> Option { + let envelope = lrgp::envelope::unpack_from_bytes(envelope_mp).ok()?; + let validated = lrgp::envelope::validate_envelope(&envelope).ok()?; + if validated.session_id != session_id || validated.command != command { + return None; + } + if session.is_some_and(|next| { + next.session_id != session_id + || next.identity_id != identity_id + || next.app_id != validated.app_id + || next.app_version != validated.version + || next.contact_hash != sender + }) { + return None; + } + let incoming_nonce = validated.nonce; + let mut conn = pool.get().ok()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .ok()?; + let existing: Option<(i64, String, String, u32, String)> = tx + .query_row( + "SELECT unread, contact_hash, app_id, app_version, initiator FROM app_sessions + WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get::<_, i64>(3)?.try_into().unwrap_or(0), + row.get(4)?, + )) + }, + ) + .optional() + .ok()?; + + match &existing { + Some((_, contact_hash, app_id, app_version, _)) => { + if (!contact_hash.is_empty() && contact_hash != sender) + || app_id != &validated.app_id + || *app_version != validated.version + { + return None; + } + } + None if session.is_none() => return None, + None => {} + } + + let attempts_rebind = matches!( + (&existing, session), + ( + Some((_, established, established_app, established_version, established_initiator)), + Some(next), + ) if (!established.is_empty() && established != &next.contact_hash) + || established_app != &next.app_id + || *established_version != next.app_version + || (!established_initiator.is_empty() && established_initiator != &next.initiator) + ); + if attempts_rebind { + tracing::warn!( + session_id, + "Refusing to rebind an established LRGP session participant or app" + ); + return None; + } + let duplicate = { + let mut statement = tx + .prepare( + "SELECT envelope_mp FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND envelope_mp IS NOT NULL", + ) + .ok()?; + let packed = statement + .query_map(params![session_id, identity_id], |row| { + row.get::<_, Vec>(0) + }) + .ok()?; + packed.filter_map(Result::ok).any(|existing| { + packed_game_nonce(&existing).as_deref() == Some(incoming_nonce.as_slice()) + }) + }; + if duplicate { + return None; + } + + let action_num: i64 = tx + .query_row( + "SELECT COALESCE(MAX(action_num), -1) + 1 + FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) + .ok()?; + tx.execute( + "INSERT INTO app_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp, envelope_mp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + session_id, + identity_id, + action_num, + command, + payload_json, + sender, + timestamp, + envelope_mp, + ], + ) + .ok()?; + + let unread = existing + .as_ref() + .map(|(value, _, _, _, _)| value + 1) + .unwrap_or(1); + if let Some(session) = session { + let metadata = serde_json::to_string(&session.metadata).unwrap_or_else(|_| "{}".into()); + tx.execute( + "INSERT INTO app_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(session_id, identity_id) DO UPDATE SET + contact_hash = CASE + WHEN app_sessions.contact_hash = '' THEN excluded.contact_hash + ELSE app_sessions.contact_hash + END, + initiator = CASE + WHEN app_sessions.initiator = '' THEN excluded.initiator + ELSE app_sessions.initiator + END, + status = excluded.status, + metadata = excluded.metadata, + unread = excluded.unread, + updated_at = excluded.updated_at, + last_action_at = excluded.last_action_at + WHERE app_sessions.app_id = excluded.app_id + AND app_sessions.app_version = excluded.app_version + AND (app_sessions.contact_hash = '' OR app_sessions.contact_hash = excluded.contact_hash) + AND (app_sessions.initiator = '' OR app_sessions.initiator = excluded.initiator)", + params![ + session.session_id, + session.identity_id, + session.app_id, + session.app_version, + session.contact_hash, + session.initiator, + session.status, + metadata, + unread, + session.created_at, + session.updated_at, + session.last_action_at, + ], + ) + .ok() + .filter(|written| *written == 1)?; + } else if existing.is_some() { + tx.execute( + "UPDATE app_sessions SET unread = ?1, last_action_at = ?2 + WHERE session_id = ?3 AND identity_id = ?4", + params![unread, timestamp, session_id, identity_id], + ) + .ok()?; + } + + tx.commit().ok()?; + Some(existing.is_some()) +} + +fn packed_game_nonce(envelope_mp: &[u8]) -> Option> { + lrgp::envelope::unpack_from_bytes(envelope_mp) + .ok()? + .get(lrgp::constants::KEY_NONCE) + .and_then(|value| match value { + rmpv::Value::Binary(bytes) => Some(bytes.clone()), + _ => None, + }) +} + +/// Whether this LRGP nonce has already been durably accepted for the local +/// session. Comparing the nonce rather than the full envelope prevents a +/// replay from evading restart protection by changing payload bytes while +/// retaining the same protocol nonce. +pub fn has_game_nonce(pool: &DbPool, session_id: &str, identity_id: &str, nonce: &[u8]) -> bool { + let conn = match pool.get() { + Ok(c) => c, + Err(_) => return false, + }; + let mut statement = match conn.prepare( + "SELECT envelope_mp FROM app_actions + WHERE session_id = ?1 AND identity_id = ?2 AND envelope_mp IS NOT NULL", + ) { + Ok(statement) => statement, + Err(_) => return false, + }; + let packed = match statement.query_map(params![session_id, identity_id], |row| { + row.get::<_, Vec>(0) + }) { + Ok(rows) => rows, + Err(_) => return false, + }; + packed + .filter_map(Result::ok) + .any(|existing| packed_game_nonce(&existing).as_deref() == Some(nonce)) } /// Returns the packed LRGP envelope for the active identity's most recent @@ -3459,21 +8617,48 @@ pub fn mark_game_read(pool: &DbPool, session_id: &str, identity_id: &str) { .ok(); } -pub fn delete_game_session(pool: &DbPool, session_id: &str, identity_id: &str) { - let conn = match pool.get() { +pub fn delete_game_session(pool: &DbPool, session_id: &str, identity_id: &str) -> bool { + let mut conn = match pool.get() { Ok(c) => c, - Err(_) => return, + Err(_) => return false, }; - conn.execute( - "DELETE FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", - params![session_id, identity_id], - ) - .ok(); - conn.execute( - "DELETE FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", - params![session_id, identity_id], - ) - .ok(); + let Ok(tx) = conn.transaction_with_behavior(TransactionBehavior::Immediate) else { + return false; + }; + let status: Option = match tx + .query_row( + "SELECT status FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + |row| row.get(0), + ) + .optional() + { + Ok(status) => status, + Err(_) => return false, + }; + if !status.is_some_and(|status| matches!(status.as_str(), "completed" | "declined" | "expired")) + { + return false; + } + if tx + .execute( + "DELETE FROM app_actions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + ) + .is_err() + { + return false; + } + if tx + .execute( + "DELETE FROM app_sessions WHERE session_id = ?1 AND identity_id = ?2", + params![session_id, identity_id], + ) + .is_err() + { + return false; + } + tx.commit().is_ok() } pub fn get_failed_messages_for_contact( @@ -3615,9 +8800,7 @@ fn row_to_app_session(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result) -> rusqlite::Result DbPool { + let manager = SqliteConnectionManager::memory(); + let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap(); + init_schema(&pool).unwrap(); + pool + } + + fn session() -> lrgp::session::Session { + lrgp::session::Session { + session_id: "0123456789abcdef".into(), + identity_id: "11111111111111111111111111111111".into(), + app_id: "ttt".into(), + app_version: 1, + contact_hash: "22222222222222222222222222222222".into(), + initiator: "11111111111111111111111111111111".into(), + status: "active".into(), + metadata: HashMap::from([("board".into(), serde_json::json!("X________"))]), + unread: 2, + created_at: 10.0, + updated_at: 20.0, + last_action_at: 20.0, + } + } + + fn packed_envelope(nonce: [u8; lrgp::constants::NONCE_BYTES], command: &str) -> Vec { + let mut envelope = lrgp::envelope::Envelope::new(); + envelope.insert( + lrgp::constants::KEY_APP.into(), + rmpv::Value::String("ttt.1".into()), + ); + envelope.insert( + lrgp::constants::KEY_COMMAND.into(), + rmpv::Value::String(command.into()), + ); + envelope.insert( + lrgp::constants::KEY_SESSION.into(), + rmpv::Value::String("0123456789abcdef".into()), + ); + envelope.insert( + lrgp::constants::KEY_PAYLOAD.into(), + rmpv::Value::Map(Vec::new()), + ); + envelope.insert( + lrgp::constants::KEY_NONCE.into(), + rmpv::Value::Binary(nonce.to_vec()), + ); + lrgp::envelope::pack_to_bytes(&envelope).unwrap() + } + + #[test] + fn typed_sessions_round_trip_for_runtime_hydration() { + let pool = test_pool(); + let expected = session(); + assert!(save_game_session(&pool, &expected)); + + let loaded = load_game_sessions(&pool); + assert_eq!(loaded.len(), 1); + let actual = &loaded[0]; + assert_eq!(actual.session_id, expected.session_id); + assert_eq!(actual.identity_id, expected.identity_id); + assert_eq!(actual.contact_hash, expected.contact_hash); + assert_eq!(actual.metadata, expected.metadata); + assert_eq!(actual.unread, 2); + } + + #[test] + fn session_upsert_cannot_rebind_peer_or_initiator() { + let pool = test_pool(); + let established = session(); + assert!(save_game_session(&pool, &established)); + + let mut wrong_peer = established.clone(); + wrong_peer.contact_hash = "33333333333333333333333333333333".into(); + assert!(!save_game_session(&pool, &wrong_peer)); + + let mut wrong_initiator = established.clone(); + wrong_initiator.initiator = established.contact_hash.clone(); + assert!(!save_game_session(&pool, &wrong_initiator)); + + let stored = get_game_session(&pool, &established.session_id, &established.identity_id) + .expect("established session remains available"); + assert_eq!(stored["contact_hash"], established.contact_hash); + assert_eq!(stored["initiator"], established.initiator); + } + + #[test] + fn outbound_state_and_envelope_commit_and_roll_back_together() { + let pool = test_pool(); + let original = session(); + assert!(save_game_session(&pool, &original)); + + let mut advanced = original.clone(); + advanced + .metadata + .insert("board".into(), serde_json::json!("XO_______")); + advanced.updated_at = 30.0; + advanced.last_action_at = 30.0; + let envelope = packed_envelope([5; lrgp::constants::NONCE_BYTES], "move"); + let action_num = persist_outbound_game_action( + &pool, + &advanced, + "move", + "{}", + &advanced.identity_id, + 30.0, + &envelope, + ) + .expect("durable outbox commit"); + + assert_eq!(action_num, 0); + assert_eq!( + get_game_action_count(&pool, &advanced.session_id, &advanced.identity_id), + 1 + ); + assert_eq!( + get_last_outbound_envelope_for_session( + &pool, + &advanced.session_id, + &advanced.identity_id, + ), + Some(envelope) + ); + assert_eq!( + get_game_session(&pool, &advanced.session_id, &advanced.identity_id).unwrap()["state"], + "XO_______" + ); + + assert!(rollback_outbound_game_action( + &pool, + &advanced.session_id, + &advanced.identity_id, + action_num, + Some(&original), + )); + assert_eq!( + get_game_action_count(&pool, &advanced.session_id, &advanced.identity_id), + 0 + ); + assert_eq!( + get_game_session(&pool, &advanced.session_id, &advanced.identity_id).unwrap()["state"], + "X________" + ); + } + + #[test] + fn failed_new_challenge_removes_its_session_and_outbox_entry() { + let pool = test_pool(); + let mut challenge = session(); + challenge.status = "pending".into(); + let envelope = packed_envelope([6; lrgp::constants::NONCE_BYTES], "challenge"); + let action_num = persist_outbound_game_action( + &pool, + &challenge, + "challenge", + "{}", + &challenge.identity_id, + 10.0, + &envelope, + ) + .expect("durable challenge outbox commit"); + + assert!(rollback_outbound_game_action( + &pool, + &challenge.session_id, + &challenge.identity_id, + action_num, + None, + )); + assert!(get_game_session(&pool, &challenge.session_id, &challenge.identity_id).is_none()); + assert_eq!( + get_game_action_count(&pool, &challenge.session_id, &challenge.identity_id), + 0 + ); + } + + #[test] + fn append_allocates_without_replacing_and_tracks_nonces() { + let pool = test_pool(); + let s = session(); + let envelope_a = packed_envelope([1; lrgp::constants::NONCE_BYTES], "challenge"); + let envelope_b = packed_envelope([2; lrgp::constants::NONCE_BYTES], "accept"); + + let first = append_game_action( + &pool, + &s.session_id, + &s.identity_id, + "challenge", + "{}", + &s.identity_id, + 1.0, + Some(&envelope_a), + ); + let second = append_game_action( + &pool, + &s.session_id, + &s.identity_id, + "accept", + "{}", + &s.contact_hash, + 2.0, + Some(&envelope_b), + ); + + assert_eq!(first, Some(0)); + assert_eq!(second, Some(1)); + assert_eq!( + get_game_actions(&pool, &s.session_id, &s.identity_id).len(), + 2 + ); + assert!(has_game_nonce( + &pool, + &s.session_id, + &s.identity_id, + &[1; lrgp::constants::NONCE_BYTES] + )); + assert!(!has_game_nonce( + &pool, + &s.session_id, + &s.identity_id, + &[9; lrgp::constants::NONCE_BYTES] + )); + } + + #[test] + fn inbound_nonce_replay_is_rejected_without_partial_state() { + let pool = test_pool(); + let s = session(); + save_game_session(&pool, &s); + let first = packed_envelope([7; lrgp::constants::NONCE_BYTES], "move"); + let replay = packed_envelope([7; lrgp::constants::NONCE_BYTES], "resign"); + + assert_eq!( + persist_inbound_game_action( + &pool, + &s.session_id, + &s.identity_id, + "move", + "{}", + &s.contact_hash, + 21.0, + &first, + Some(&s), + ), + Some(true) + ); + assert_eq!( + persist_inbound_game_action( + &pool, + &s.session_id, + &s.identity_id, + "resign", + "{}", + &s.contact_hash, + 22.0, + &replay, + Some(&s), + ), + None + ); + assert_eq!( + get_game_actions(&pool, &s.session_id, &s.identity_id).len(), + 1 + ); + } + + #[test] + fn inbound_persistence_cannot_rebind_session_peer_or_app() { + let pool = test_pool(); + let established = session(); + save_game_session(&pool, &established); + + let mut wrong_peer = established.clone(); + wrong_peer.contact_hash = "33333333333333333333333333333333".into(); + let peer_envelope = packed_envelope([3; lrgp::constants::NONCE_BYTES], "move"); + assert_eq!( + persist_inbound_game_action( + &pool, + &established.session_id, + &established.identity_id, + "move", + "{}", + &wrong_peer.contact_hash, + 23.0, + &peer_envelope, + Some(&wrong_peer), + ), + None + ); + + let mut wrong_app = established.clone(); + wrong_app.app_id = "chess".into(); + let app_envelope = packed_envelope([4; lrgp::constants::NONCE_BYTES], "move"); + assert_eq!( + persist_inbound_game_action( + &pool, + &established.session_id, + &established.identity_id, + "move", + "{}", + &established.contact_hash, + 24.0, + &app_envelope, + Some(&wrong_app), + ), + None + ); + + let stored = get_game_session(&pool, &established.session_id, &established.identity_id) + .expect("established session remains available"); + assert_eq!(stored["app_id"], "ttt"); + assert_eq!(stored["contact_hash"], established.contact_hash); + assert!( + get_game_actions(&pool, &established.session_id, &established.identity_id).is_empty() + ); + } + + #[test] + fn inbound_persistence_requires_correlated_envelope_and_session_state() { + let pool = test_pool(); + let established = session(); + assert!(save_game_session(&pool, &established)); + let move_envelope = packed_envelope([8; lrgp::constants::NONCE_BYTES], "move"); + + // The command supplied to storage must be the command authenticated + // inside the exact packed envelope; callers cannot relabel an action. + assert_eq!( + persist_inbound_game_action( + &pool, + &established.session_id, + &established.identity_id, + "resign", + "{}", + &established.contact_hash, + 25.0, + &move_envelope, + Some(&established), + ), + None + ); + + // A state-less inbound record (the standard remote-error path) may + // update only an already established, participant-bound session. + let unknown_pool = test_pool(); + assert_eq!( + persist_inbound_game_action( + &unknown_pool, + &established.session_id, + &established.identity_id, + "move", + "{}", + &established.contact_hash, + 25.0, + &move_envelope, + None, + ), + None + ); + assert!( + get_game_actions( + &unknown_pool, + &established.session_id, + &established.identity_id, + ) + .is_empty() + ); + } + + #[test] + fn deleting_a_session_removes_actions_in_the_same_operation() { + let pool = test_pool(); + let mut s = session(); + s.status = "completed".into(); + save_game_session(&pool, &s); + append_game_action( + &pool, + &s.session_id, + &s.identity_id, + "move", + "{}", + &s.contact_hash, + 2.0, + None, + ); + + assert!(delete_game_session(&pool, &s.session_id, &s.identity_id)); + assert!(get_game_session(&pool, &s.session_id, &s.identity_id).is_none()); + assert!(get_game_actions(&pool, &s.session_id, &s.identity_id).is_empty()); + } + + #[test] + fn active_session_cannot_be_removed_as_history() { + let pool = test_pool(); + let s = session(); + assert!(save_game_session(&pool, &s)); + assert!(!delete_game_session(&pool, &s.session_id, &s.identity_id)); + assert!(get_game_session(&pool, &s.session_id, &s.identity_id).is_some()); + } +} + #[cfg(test)] mod unread_breakdown_tests { use super::*; @@ -4464,6 +10052,16 @@ mod migration_tests { "messages", "connection_history", "messages_fts", + "channel_hubs", + "channel_rooms", + "channel_room_secrets", + "channel_history", + "channel_history_room_usage", + "channel_room_state", + "channel_participant_observations", + "channel_hub_rooms", + "channel_hub_grants", + "channel_hub_klines", ] { let exists: i64 = conn .query_row( @@ -4480,6 +10078,15 @@ mod migration_tests { "idx_messages_identity_state", "idx_messages_source_identity", "idx_messages_dest_identity", + "idx_channel_hubs_identity_recent", + "idx_channel_rooms_identity_hub", + "idx_channel_history_room_sequence", + "idx_channel_history_identity_sequence", + "idx_channel_history_identity_unread", + "idx_channel_history_recorded_at", + "idx_channel_participant_observations_room_recent", + "idx_channel_participant_observations_age", + "idx_identity_activity_identity_hash", ] { let exists: i64 = conn .query_row( @@ -4490,6 +10097,19 @@ mod migration_tests { .unwrap(); assert!(exists > 0, "expected index `{index}` after init_schema"); } + + let activity_cols = get_column_names(&conn, "identity_activity").unwrap(); + assert!( + activity_cols + .iter() + .any(|c| c == "lxmf_compression_support"), + "fresh schema should include LXMF compression capability metadata" + ); + let room_state_cols = get_column_names(&conn, "channel_room_state").unwrap(); + assert!( + room_state_cols.iter().any(|column| column == "topic"), + "fresh schema should retain authenticated Channels room topics" + ); } #[test] @@ -4786,6 +10406,472 @@ mod migration_tests { .unwrap(); assert_eq!(kept, 1); } + + #[test] + fn migration_from_v33_adds_channel_bookmark_tables() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (33);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + for table in ["channel_hubs", "channel_rooms"] { + assert!(table_exists(&conn, table).unwrap()); + } + let version: i64 = conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn migration_from_v34_adds_channel_hub_registry_tables() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (34);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + for table in [ + "channel_hub_rooms", + "channel_hub_grants", + "channel_hub_klines", + ] { + assert!(table_exists(&conn, table).unwrap()); + } + let version: i64 = conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn migration_from_v35_adds_channel_desire_without_reclassifying_recents() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (35); + CREATE TABLE identities ( + hash TEXT PRIMARY KEY, + created_at REAL NOT NULL, + is_active INTEGER DEFAULT 0 + ); + INSERT INTO identities (hash, created_at) VALUES ('identity-a', 0); + CREATE TABLE channel_hubs ( + identity_id TEXT NOT NULL, + destination_hash TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + added_at REAL NOT NULL, + last_connected REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, destination_hash), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + CREATE TABLE channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash) + REFERENCES channel_hubs(identity_id, destination_hash) ON DELETE CASCADE + ); + INSERT INTO channel_hubs + (identity_id, destination_hash, label, nickname, added_at, last_connected) + VALUES ('identity-a', 'aa', 'Relay', 'rat', 1, 2); + INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, last_joined) + VALUES ('identity-a', 'aa', 'general', 1, 2);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let hubs = list_saved_channel_hubs(&pool, "identity-a").unwrap(); + let rooms = list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap(); + assert_eq!(hubs.len(), 1); + assert_eq!(rooms.len(), 1); + assert!( + !hubs[0].desired_connected && !rooms[0].desired_joined, + "past recency is not proof of current user intent" + ); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v36_adds_identity_sealed_room_key_storage() { + let pool = empty_pool(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "PRAGMA foreign_keys=ON; + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (36); + CREATE TABLE identities ( + hash TEXT PRIMARY KEY, + created_at REAL NOT NULL, + is_active INTEGER DEFAULT 0 + ); + INSERT INTO identities (hash, created_at) VALUES ('identity-a', 0); + CREATE TABLE channel_hubs ( + identity_id TEXT NOT NULL, + destination_hash TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + added_at REAL NOT NULL, + last_connected REAL NOT NULL DEFAULT 0, + desired_connected INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, destination_hash), + FOREIGN KEY (identity_id) REFERENCES identities(hash) ON DELETE CASCADE + ); + CREATE TABLE channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + desired_joined INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name), + FOREIGN KEY (identity_id, hub_destination_hash) + REFERENCES channel_hubs(identity_id, destination_hash) ON DELETE CASCADE + ); + INSERT INTO channel_hubs + (identity_id, destination_hash, added_at, desired_connected) + VALUES ('identity-a', 'aa', 1, 1); + INSERT INTO channel_rooms + (identity_id, hub_destination_hash, room_name, added_at, + desired_joined) + VALUES ('identity-a', 'aa', 'general', 1, 1);", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + assert!(table_exists(&conn, "channel_room_secrets").unwrap()); + assert!( + get_column_names(&conn, "channel_rooms") + .unwrap() + .iter() + .any(|column| column == "join_key_required") + ); + drop(conn); + let room = &list_saved_channel_rooms(&pool, "identity-a", "aa").unwrap()[0]; + assert!(room.desired_joined); + assert!( + !room.join_key_required, + "migration must not infer key policy from past membership" + ); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v37_adds_bookmark_independent_channel_history() { + let migrated = empty_pool(); + { + let conn = migrated.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (37);", + ) + .unwrap(); + } + init_schema(&migrated).unwrap(); + + let fresh = empty_pool(); + init_schema(&fresh).unwrap(); + assert_eq!( + get_column_names(&migrated.get().unwrap(), "channel_history").unwrap(), + get_column_names(&fresh.get().unwrap(), "channel_history").unwrap() + ); + + let conn = migrated.get().unwrap(); + let foreign_tables: Vec = conn + .prepare("PRAGMA foreign_key_list(channel_history)") + .unwrap() + .query_map([], |row| row.get(2)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + foreign_tables, + vec!["identities"], + "history must survive removal of channel hub and room bookmarks" + ); + for index in [ + "idx_channel_history_room_sequence", + "idx_channel_history_identity_sequence", + "idx_channel_history_recorded_at", + ] { + assert!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND name = ?1", + [index], + |row| row.get::<_, i64>(0), + ) + .unwrap() + > 0, + "missing migrated history index `{index}`" + ); + } + drop(conn); + assert_eq!(read_schema_version(&migrated), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v38_backfills_history_usage_and_installs_triggers() { + let pool = empty_pool(); + init_schema(&pool).unwrap(); + save_identity(&pool, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "A", "A"); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "DROP TRIGGER channel_history_usage_after_insert; + DROP TRIGGER channel_history_usage_after_delete; + DROP TABLE channel_history_room_usage; + UPDATE schema_version SET version = 38;", + ) + .unwrap(); + conn.execute( + "INSERT INTO channel_history ( + identity_id, hub_destination_hash, room_name, event_id, + kind, timestamp_ms, recorded_at_ms, source_hash, nickname, + text, ours + ) VALUES (?1, ?2, 'general', 'old', 'message', 1, 1, NULL, NULL, 'hello', 0)", + params![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "11111111111111111111111111111111" + ], + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + let conn = pool.get().unwrap(); + let (event_count, payload_bytes): (i64, i64) = conn + .query_row( + "SELECT event_count, payload_bytes + FROM channel_history_room_usage + WHERE identity_id = ?1 + AND hub_destination_hash = ?2 + AND room_name = 'general'", + params![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "11111111111111111111111111111111" + ], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(event_count, 1); + assert!(payload_bytes > 5); + conn.execute("DELETE FROM channel_history WHERE event_id = 'old'", []) + .unwrap(); + let usage_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM channel_history_room_usage", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(usage_rows, 0, "delete trigger should remove empty usage"); + drop(conn); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v39_marks_existing_history_read_and_adds_mentions() { + const IDENTITY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HUB: &str = "11111111111111111111111111111111"; + let pool = empty_pool(); + init_schema(&pool).unwrap(); + save_identity(&pool, IDENTITY, "", "A", "A"); + { + let conn = pool.get().unwrap(); + conn.execute( + "INSERT INTO channel_history ( + identity_id, hub_destination_hash, room_name, event_id, + kind, timestamp_ms, recorded_at_ms, source_hash, nickname, + text, ours + ) VALUES ( + ?1, ?2, 'general', 'old', 'message', 1, 1, NULL, NULL, + 'hello', 0 + )", + params![IDENTITY, HUB], + ) + .unwrap(); + conn.execute_batch( + "DROP TABLE channel_room_state; + ALTER TABLE channel_history DROP COLUMN mentioned; + UPDATE schema_version SET version = 39;", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + let columns = get_column_names(&pool.get().unwrap(), "channel_history").unwrap(); + assert!(columns.iter().any(|column| column == "mentioned")); + let state = get_channel_room_read_state(&pool, IDENTITY, HUB, "general").unwrap(); + assert_ne!(state.last_read_sequence, "0"); + assert_eq!( + state.notification_level, + ChannelRoomNotificationLevel::Mentions + ); + assert_eq!( + get_channel_unread_summary(&pool, IDENTITY) + .unwrap() + .unread_total, + 0, + "upgrades must not reinterpret old transcript rows as unread" + ); + + append_channel_history_events( + &pool, + IDENTITY, + &[NewChannelHistoryEvent { + hub_destination_hash: HUB.into(), + room_name: "general".into(), + event_id: "new".into(), + kind: ChannelHistoryKind::Message, + timestamp_ms: 2, + source_hash: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into()), + nickname: Some("B".into()), + text: "@A hello".into(), + ours: false, + mentioned: true, + }], + ) + .unwrap(); + let summary = get_channel_unread_summary(&pool, IDENTITY).unwrap(); + assert_eq!(summary.unread_total, 1); + assert_eq!(summary.mention_total, 1); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migration_from_v41_adds_durable_room_topics() { + let pool = empty_pool(); + init_schema(&pool).unwrap(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + "ALTER TABLE channel_room_state DROP COLUMN topic; + UPDATE schema_version SET version = 41;", + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + let conn = pool.get().unwrap(); + let columns = get_column_names(&conn, "channel_room_state").unwrap(); + assert!(columns.iter().any(|column| column == "topic")); + let default_topic: String = conn + .query_row( + "SELECT dflt_value + FROM pragma_table_info('channel_room_state') + WHERE name = 'topic'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(default_topic, "''"); + drop(conn); + assert_eq!(read_schema_version(&pool), SCHEMA_VERSION); + } + + #[test] + fn migrated_and_fresh_sealed_room_key_schemas_match() { + let migrated = empty_pool(); + { + let conn = migrated.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (36); + CREATE TABLE channel_rooms ( + identity_id TEXT NOT NULL, + hub_destination_hash TEXT NOT NULL, + room_name TEXT NOT NULL, + added_at REAL NOT NULL, + last_joined REAL NOT NULL DEFAULT 0, + desired_joined INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (identity_id, hub_destination_hash, room_name) + );", + ) + .unwrap(); + } + init_schema(&migrated).unwrap(); + let fresh = empty_pool(); + init_schema(&fresh).unwrap(); + + for table in ["channel_rooms", "channel_room_secrets"] { + let columns = |pool: &DbPool| { + let conn = pool.get().unwrap(); + get_column_names(&conn, table).unwrap() + }; + assert_eq!( + columns(&migrated), + columns(&fresh), + "migrated and fresh `{table}` columns diverged" + ); + } + } + + /// The migrated schema and the fresh schema must agree; the DDL is + /// duplicated between them by house convention, so drift is easy. + #[test] + fn migrated_and_fresh_hub_registry_schemas_match() { + let migrated = empty_pool(); + { + let conn = migrated.get().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (34);", + ) + .unwrap(); + } + init_schema(&migrated).unwrap(); + let fresh = empty_pool(); + init_schema(&fresh).unwrap(); + + for table in [ + "channel_hub_rooms", + "channel_hub_grants", + "channel_hub_klines", + ] { + let columns = |pool: &DbPool| -> Vec { + let conn = pool.get().unwrap(); + get_column_names(&conn, table).unwrap() + }; + assert_eq!( + columns(&migrated), + columns(&fresh), + "{table} drifted between the migration and the fresh schema" + ); + } + } } #[cfg(test)] @@ -4886,6 +10972,16 @@ mod peers_snapshot_tests { .unwrap() } + fn lxmf_compression_support_for(pool: &DbPool, hash: &str) -> String { + let conn = pool.get().unwrap(); + conn.query_row( + "SELECT lxmf_compression_support FROM identity_activity WHERE dest_hash = ?1", + params![hash], + |row| row.get::<_, String>(0), + ) + .unwrap() + } + #[test] fn touch_identity_activity_merges_multiple_services_once_and_clears_ratspeak() { let pool = test_pool(); @@ -4935,6 +11031,7 @@ mod peers_snapshot_tests { identity_hash: Some("11111111111111111111111111111111".into()), services: vec![PEER_SERVICE_LXMF_DELIVERY.into()], clear_ratspeak_services: true, + lxmf_compression_support: None, }, IdentityActivityUpdate { dest_hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), @@ -4945,6 +11042,7 @@ mod peers_snapshot_tests { identity_hash: Some("22222222222222222222222222222222".into()), services: vec![PEER_SERVICE_LXST_TELEPHONY.into()], clear_ratspeak_services: false, + lxmf_compression_support: None, }, ], ); @@ -4963,6 +11061,67 @@ mod peers_snapshot_tests { ); } + #[test] + fn touch_identity_activity_updates_merges_lxmf_compression_support() { + let pool = test_pool(); + let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + touch_identity_activity_updates( + &pool, + &[IdentityActivityUpdate { + dest_hash: hash.into(), + timestamp: 100.0, + display_name: Some("Alice".into()), + status: None, + last_interface: None, + identity_hash: None, + services: vec![PEER_SERVICE_LXMF_DELIVERY.into()], + clear_ratspeak_services: false, + lxmf_compression_support: Some(LXMF_COMPRESSION_SUPPORT_UNSUPPORTED.into()), + }], + ); + assert_eq!( + get_identity_lxmf_compression_support(&pool, hash).as_deref(), + Some(LXMF_COMPRESSION_SUPPORT_UNSUPPORTED) + ); + + touch_identity_activity_updates( + &pool, + &[IdentityActivityUpdate { + dest_hash: hash.into(), + timestamp: 101.0, + display_name: None, + status: None, + last_interface: None, + identity_hash: None, + services: vec![PEER_SERVICE_LXMF_DELIVERY.into()], + clear_ratspeak_services: false, + lxmf_compression_support: None, + }], + ); + assert_eq!( + lxmf_compression_support_for(&pool, hash), + LXMF_COMPRESSION_SUPPORT_UNSUPPORTED + ); + + assert!(set_identity_lxmf_compression_support( + &pool, + hash, + LXMF_COMPRESSION_SUPPORT_SUPPORTED + )); + assert_eq!( + get_identity_lxmf_compression_support(&pool, hash).as_deref(), + Some(LXMF_COMPRESSION_SUPPORT_SUPPORTED) + ); + assert!(!set_identity_lxmf_compression_support( + &pool, hash, "unknown" + )); + assert_eq!( + lxmf_compression_support_for(&pool, hash), + LXMF_COMPRESSION_SUPPORT_SUPPORTED + ); + } + fn add_contact(pool: &DbPool, hash: &str, display_name: &str) { add_contact_for(pool, "me", hash, display_name); } @@ -5448,4 +11607,78 @@ mod pending_blackhole_tests { ); assert_eq!(active.get("status").and_then(|v| v.as_str()), Some("")); } + + #[test] + fn migration_from_v32_adds_lxmf_compression_support_column() { + let mgr = SqliteConnectionManager::memory(); + let pool = r2d2::Pool::builder().max_size(1).build(mgr).unwrap(); + { + let conn = pool.get().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version (version) VALUES (32); + + CREATE TABLE identity_activity ( + dest_hash TEXT PRIMARY KEY, + identity_hash TEXT NOT NULL DEFAULT '', + last_seen REAL NOT NULL, + first_seen REAL NOT NULL, + announce_count INTEGER NOT NULL DEFAULT 1, + display_name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + last_interface TEXT NOT NULL DEFAULT '', + services TEXT NOT NULL DEFAULT '' + ); + INSERT INTO identity_activity + (dest_hash, identity_hash, last_seen, first_seen, announce_count, display_name, status, last_interface, services) + VALUES + ('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 10.0, + 5.0, + 3, + 'Peer', + 'Ready', + 'RNode', + 'lxmf.delivery'); + "#, + ) + .unwrap(); + } + + init_schema(&pool).unwrap(); + + let conn = pool.get().unwrap(); + let activity_cols = get_column_names(&conn, "identity_activity").unwrap(); + assert!( + activity_cols + .iter() + .any(|c| c == "lxmf_compression_support") + ); + let row: (String, String, String, String) = conn + .query_row( + "SELECT display_name, status, services, lxmf_compression_support + FROM identity_activity + WHERE dest_hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + row, + ( + "Peer".into(), + "Ready".into(), + "lxmf.delivery".into(), + "".into() + ) + ); + let version: i64 = conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + } } diff --git a/crates/ratspeak-db/src/static_nodes.rs b/crates/ratspeak-db/src/static_nodes.rs index bdf508b..b71cd71 100644 --- a/crates/ratspeak-db/src/static_nodes.rs +++ b/crates/ratspeak-db/src/static_nodes.rs @@ -62,9 +62,9 @@ pub fn node_for(hash: &[u8; 16]) -> Option<&'static StaticPropNode> { fn parse_nodes_json() -> Vec { let raw: Vec = match serde_json::from_str(NODES_JSON) { Ok(v) => v, - Err(e) => { + Err(_) => { tracing::warn!( - error = %e, + reason = "parse_failed", "static nodes.json failed to parse; bundled list is empty for this session" ); return Vec::new(); @@ -75,15 +75,18 @@ fn parse_nodes_json() -> Vec { .filter_map(|r| { let bytes = match hex::decode(&r.hash) { Ok(b) => b, - Err(e) => { - tracing::warn!(hash = %r.hash, error = %e, "static node hash is not valid hex; skipping"); + Err(_) => { + tracing::warn!( + reason = "invalid_hex", + "static node hash is not valid hex; skipping" + ); return None; } }; if bytes.len() != 16 { tracing::warn!( - hash = %r.hash, bytes = bytes.len(), + reason = "invalid_length", "static node hash is not 16 bytes; skipping" ); return None; diff --git a/crates/ratspeak-runtime/Cargo.toml b/crates/ratspeak-runtime/Cargo.toml index dd4a9ac..6952d1a 100644 --- a/crates/ratspeak-runtime/Cargo.toml +++ b/crates/ratspeak-runtime/Cargo.toml @@ -50,6 +50,7 @@ cpal = { workspace = true, optional = true } # Async + serialization tokio = { workspace = true } bytes = { workspace = true } +ciborium = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } rmpv = { workspace = true } @@ -57,6 +58,7 @@ thiserror = { workspace = true } tracing = { workspace = true } # Utilities +crossbeam-channel = "0.5" indexmap = "2" hex = { workspace = true } uuid = { version = "1", features = ["v4"] } diff --git a/crates/ratspeak-runtime/src/activity/admission.rs b/crates/ratspeak-runtime/src/activity/admission.rs new file mode 100644 index 0000000..764b7fd --- /dev/null +++ b/crates/ratspeak-runtime/src/activity/admission.rs @@ -0,0 +1,354 @@ +//! Lock-free pre-ingress rate admission and the FIFO's reserved-tail permit. + +use std::array; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Instant; + +use super::schema::{ActivitySeverity, CaptureProfile, RateDomain}; + +pub(super) const INGRESS_CAPACITY: usize = 1_024; +pub(super) const RESERVED_PRIORITY_SLOTS: usize = 64; +pub(super) const LOW_PRIORITY_LIMIT: usize = INGRESS_CAPACITY - RESERVED_PRIORITY_SLOTS; + +const NANOS_PER_SECOND: u64 = 1_000_000_000; +const NORMAL_RATE_PER_SECOND: u64 = 50; +const TRACE_RATE_PER_SECOND: u64 = 100; +const AMBIENT_RATE_PER_SECOND: u64 = 5; +// Stats polling delivers newly observed paths and announces in short batches. +// Keep the five-per-second sustained sampler while allowing one ordinary poll +// batch through without manufacturing loss from the polling boundary itself. +const AMBIENT_BURST_CAPACITY: u64 = 25; + +pub(super) trait MonotonicClock: Send + Sync { + fn now_tick(&self) -> u64; +} + +pub(super) struct ProcessClock { + origin: Instant, +} + +impl ProcessClock { + pub(super) fn new() -> Arc { + Arc::new(Self { + origin: Instant::now(), + }) + } +} + +impl MonotonicClock for ProcessClock { + fn now_tick(&self) -> u64 { + self.origin.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64 + } +} + +/// A lock-free Generic Cell Rate Algorithm bucket. Its burst tolerance is +/// token-bucket equivalent: `capacity` events may pass at one instant, then +/// one token replenishes per interval. +struct GcraBucket { + theoretical_arrival: AtomicU64, + interval: u64, + burst_window: u64, +} + +impl GcraBucket { + fn per_second(rate: u64, capacity: u64) -> Self { + debug_assert!(rate > 0); + debug_assert!(capacity > 0); + let interval = NANOS_PER_SECOND / rate; + Self { + theoretical_arrival: AtomicU64::new(0), + interval, + burst_window: interval.saturating_mul(capacity), + } + } + + fn reset(&self, now: u64) { + self.theoretical_arrival.store(now, Ordering::Relaxed); + } + + fn try_take(&self, now: u64) -> bool { + let mut observed = self.theoretical_arrival.load(Ordering::Relaxed); + loop { + let base = observed.max(now); + let Some(next) = base.checked_add(self.interval) else { + return false; + }; + let deadline = now.saturating_add(self.burst_window); + if next > deadline { + return false; + } + match self.theoretical_arrival.compare_exchange_weak( + observed, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return true, + Err(actual) => observed = actual, + } + } + } +} + +struct ProfileBuckets { + global: GcraBucket, + domains: [GcraBucket; RateDomain::COUNT], +} + +impl ProfileBuckets { + fn per_second(rate: u64) -> Self { + Self { + global: GcraBucket::per_second(rate, rate), + domains: array::from_fn(|_| GcraBucket::per_second(rate, rate)), + } + } + + fn reset(&self, now: u64) { + self.global.reset(now); + for domain in &self.domains { + domain.reset(now); + } + } + + fn try_take(&self, now: u64, domain: RateDomain) -> bool { + // A failed domain admission does not consume a global token. The + // reverse race can conservatively consume a domain token if another + // thread wins the global CAS; no unsafe refund is attempted. + self.domains[domain.index()].try_take(now) && self.global.try_take(now) + } +} + +pub(super) struct RateAdmission { + clock: Arc, + normal: ProfileBuckets, + trace: ProfileBuckets, + ambient: [GcraBucket; RateDomain::COUNT], +} + +impl RateAdmission { + pub(super) fn new(clock: Arc) -> Self { + Self { + clock, + normal: ProfileBuckets::per_second(NORMAL_RATE_PER_SECOND), + trace: ProfileBuckets::per_second(TRACE_RATE_PER_SECOND), + ambient: array::from_fn(|_| { + GcraBucket::per_second(AMBIENT_RATE_PER_SECOND, AMBIENT_BURST_CAPACITY) + }), + } + } + + pub(super) fn reset(&self, profile: CaptureProfile) { + let now = self.clock.now_tick(); + match profile { + CaptureProfile::Normal => self.normal.reset(now), + CaptureProfile::Trace => self.trace.reset(now), + } + for ambient in &self.ambient { + ambient.reset(now); + } + } + + pub(super) fn allow( + &self, + profile: CaptureProfile, + severity: ActivitySeverity, + domain: RateDomain, + ambient: bool, + ) -> bool { + if severity == ActivitySeverity::Error { + return true; + } + let now = self.clock.now_tick(); + if ambient && !self.ambient[domain.index()].try_take(now) { + return false; + } + match profile { + CaptureProfile::Normal => self.normal.try_take(now, domain), + CaptureProfile::Trace => self.trace.try_take(now, domain), + } + } +} + +/// At most 960 low-priority envelopes can hold one of these permits. The +/// permit moves through the channel with its draft and releases immediately +/// after receive or on any failed send/drop path. +pub(super) struct LowPermitPool { + in_use: AtomicUsize, +} + +impl LowPermitPool { + pub(super) fn new() -> Arc { + Arc::new(Self { + in_use: AtomicUsize::new(0), + }) + } + + pub(super) fn try_acquire(self: &Arc) -> Option { + let mut observed = self.in_use.load(Ordering::Relaxed); + loop { + if observed >= LOW_PRIORITY_LIMIT { + return None; + } + match self.in_use.compare_exchange_weak( + observed, + observed + 1, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + return Some(LowPermit { + pool: Arc::clone(self), + }); + } + Err(actual) => observed = actual, + } + } + } + + #[cfg(test)] + fn in_use(&self) -> usize { + self.in_use.load(Ordering::Relaxed) + } +} + +pub(super) struct LowPermit { + pool: Arc, +} + +impl Drop for LowPermit { + fn drop(&mut self) { + let previous = self.pool.in_use.fetch_sub(1, Ordering::Relaxed); + debug_assert!(previous > 0, "low-priority permit count underflow"); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Barrier; + use std::thread; + + use super::*; + + #[derive(Default)] + struct FakeClock(AtomicU64); + + impl FakeClock { + fn advance(&self, nanos: u64) { + self.0.fetch_add(nanos, Ordering::Relaxed); + } + } + + impl MonotonicClock for FakeClock { + fn now_tick(&self) -> u64 { + self.0.load(Ordering::Relaxed) + } + } + + #[test] + fn normal_and_trace_bursts_are_exact_and_replenish() { + let clock = Arc::new(FakeClock::default()); + let rate = RateAdmission::new(clock.clone()); + rate.reset(CaptureProfile::Normal); + for _ in 0..NORMAL_RATE_PER_SECOND { + assert!(rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Info, + RateDomain::Network, + false + )); + } + assert!(!rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Info, + RateDomain::Network, + false + )); + clock.advance(NANOS_PER_SECOND / NORMAL_RATE_PER_SECOND); + assert!(rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Info, + RateDomain::Network, + false + )); + + rate.reset(CaptureProfile::Trace); + for _ in 0..TRACE_RATE_PER_SECOND { + assert!(rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Warning, + RateDomain::Channels, + false + )); + } + assert!(!rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Warning, + RateDomain::Channels, + false + )); + } + + #[test] + fn ambient_bucket_allows_poll_bursts_but_sustains_five_per_second() { + let clock = Arc::new(FakeClock::default()); + let rate = RateAdmission::new(clock.clone()); + rate.reset(CaptureProfile::Trace); + for _ in 0..AMBIENT_BURST_CAPACITY { + assert!(rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Info, + RateDomain::Network, + true + )); + } + assert!(!rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Info, + RateDomain::Network, + true + )); + clock.advance(NANOS_PER_SECOND / AMBIENT_RATE_PER_SECOND); + assert!(rate.allow( + CaptureProfile::Trace, + ActivitySeverity::Info, + RateDomain::Network, + true + )); + for _ in 0..2_000 { + assert!(rate.allow( + CaptureProfile::Normal, + ActivitySeverity::Error, + RateDomain::Network, + true + )); + } + } + + #[test] + fn concurrent_low_permits_never_enter_the_reserved_tail() { + let pool = LowPermitPool::new(); + const WORKERS: usize = 32; + const PER_WORKER: usize = LOW_PRIORITY_LIMIT / WORKERS; + let start = Arc::new(Barrier::new(WORKERS + 1)); + let mut workers = Vec::with_capacity(WORKERS); + for _ in 0..WORKERS { + let pool = Arc::clone(&pool); + let start = Arc::clone(&start); + workers.push(thread::spawn(move || { + let permits: Vec<_> = (0..PER_WORKER) + .map(|_| pool.try_acquire().expect("first 960 must fit")) + .collect(); + start.wait(); + permits + })); + } + start.wait(); + assert_eq!(pool.in_use(), LOW_PRIORITY_LIMIT); + assert!(pool.try_acquire().is_none()); + for worker in workers { + drop(worker.join().expect("permit worker should finish")); + } + assert_eq!(pool.in_use(), 0); + } +} diff --git a/crates/ratspeak-runtime/src/activity/catalog.rs b/crates/ratspeak-runtime/src/activity/catalog.rs new file mode 100644 index 0000000..4839d20 --- /dev/null +++ b/crates/ratspeak-runtime/src/activity/catalog.rs @@ -0,0 +1,3345 @@ +//! Sealed, event-specific Activity constructors. +//! +//! Producer modules call functions in this catalog with concrete domain +//! inputs. They cannot select a classification, add an arbitrary attribute, +//! or supply a free-form event/summary code. + +#![allow( + dead_code, + reason = "the reviewed catalog includes variants reserved for later semantic coverage" +)] + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use super::classified::{ + ActivityDraft, ActivityRejectReason, ClassifiedEndpoint, CoalescingPolicy, CorrelationId, + ExactValue, NavigationAction, +}; +use super::schema::{ + ActivityAttributeKey, ActivityDirection, ActivityOutcome, ActivitySeverity, EndpointClass, + IdentifierKind, RateDomain, kinds, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ObservationTime { + unix_ms: u64, + elapsed_ms: u64, +} + +impl ObservationTime { + pub(super) const fn new(unix_ms: u64, elapsed_ms: u64) -> Self { + Self { + unix_ms, + elapsed_ms, + } + } + + pub(super) const fn unix_ms(self) -> u64 { + self.unix_ms + } + + pub(super) const fn elapsed_ms(self) -> u64 { + self.elapsed_ms + } + + pub(super) const fn unstamped() -> Self { + Self::new(0, 0) + } +} + +/// Recorder-owned wall/monotonic observation clock. `ObservationTime` never +/// leaves the private Activity implementation, so domain producers cannot +/// fabricate timestamps or choose another clock domain. +pub(super) trait ActivityClock: Send + Sync { + fn observe(&self) -> ObservationTime; +} + +pub(super) struct SystemActivityClock { + origin: Instant, +} + +impl SystemActivityClock { + pub(super) fn new() -> Self { + Self { + origin: Instant::now(), + } + } +} + +impl ActivityClock for SystemActivityClock { + fn observe(&self) -> ObservationTime { + let unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64; + let elapsed_ms = self.origin.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + ObservationTime::new(unix_ms, elapsed_ms) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct DestinationHash([u8; 16]); + +impl DestinationHash { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MessageId([u8; 32]); + +impl MessageId { + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct LinkId([u8; 16]); + +impl LinkId { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IdentityHash([u8; 16]); + +impl IdentityHash { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub fn from_hex(value: &str) -> Result { + decode_fixed_hex(value).map(Self) + } +} + +fn decode_fixed_hex(value: &str) -> Result<[u8; N], ActivityRejectReason> { + if value.len() != N.saturating_mul(2) { + return Err(ActivityRejectReason::InvalidIdentifier); + } + let bytes = hex::decode(value).map_err(|_| ActivityRejectReason::InvalidIdentifier)?; + bytes + .try_into() + .map_err(|_| ActivityRejectReason::InvalidIdentifier) +} + +/// Random opaque room-session token assigned by Channels outside Activity. It +/// must never be derived from the human-authored room label. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ChannelRoomToken([u8; 16]); + +impl ChannelRoomToken { + pub fn random() -> Self { + Self(rns_crypto::random::random_16()) + } + + #[cfg(test)] + pub(super) const fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } +} + +/// Random volatile token assigned to one RRC envelope identifier. The RRC +/// message id is only used as an in-memory lookup key by Channels; Activity +/// receives this unrelated 256-bit token. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ChannelMessageToken([u8; 32]); + +impl ChannelMessageToken { + pub fn random() -> Self { + Self(rns_crypto::random::random_32()) + } + + #[cfg(test)] + pub(super) const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +/// Random, session-local lookup key into navigation state owned outside +/// Activity. There is no constructor from labels, paths, or arbitrary bytes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct NavigationToken([u8; 16]); + +impl NavigationToken { + pub fn random() -> Self { + Self(rns_crypto::random::random_16()) + } + + #[cfg(test)] + pub(super) const fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } +} + +/// Validated private TCP endpoint input. It is zeroized when moved into a +/// draft and has no `Debug`, `Clone`, or serialization implementation. +pub struct TcpEndpoint(ClassifiedEndpoint); + +impl TcpEndpoint { + pub fn new(value: String) -> Result { + ClassifiedEndpoint::network(EndpointClass::Tcp, value).map(Self) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AppRuntimeTransition { + Started, + Ready, + Unavailable, + Stopped, +} + +pub fn app_runtime(time: ObservationTime, transition: AppRuntimeTransition) -> ActivityDraft { + let (kind, severity, outcome) = match transition { + AppRuntimeTransition::Started => ( + kinds::APP_RUNTIME_STARTED, + ActivitySeverity::Info, + ActivityOutcome::Started, + ), + AppRuntimeTransition::Ready => ( + kinds::APP_RUNTIME_READY, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + AppRuntimeTransition::Unavailable => ( + kinds::APP_RUNTIME_UNAVAILABLE, + ActivitySeverity::Error, + ActivityOutcome::Failed, + ), + AppRuntimeTransition::Stopped => ( + kinds::APP_RUNTIME_STOPPED, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + }; + ActivityDraft::new( + kind, + severity, + ActivityDirection::Local, + outcome, + time.unix_ms, + time.elapsed_ms, + CoalescingPolicy::Never, + ) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceClass { + Auto, + BackboneClient, + BackboneServer, + BluetoothPeer, + RNode, + TcpClient, + TcpServer, + Unknown, +} + +impl InterfaceClass { + const fn code(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::BackboneClient => "backbone_client", + Self::BackboneServer => "backbone_server", + Self::BluetoothPeer => "ble_peer", + Self::RNode => "rnode", + Self::TcpClient => "tcp_client", + Self::TcpServer => "tcp_server", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceDegradationReason { + CapabilityUnverified, + MulticastUnavailable, + PeripheralUnavailable, +} + +impl InterfaceDegradationReason { + const fn code(self) -> &'static str { + match self { + Self::CapabilityUnverified => "capability_unverified", + Self::MulticastUnavailable => "multicast_unavailable", + Self::PeripheralUnavailable => "peripheral_unavailable", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceTimeoutReason { + Setup, + Pairing, + Startup, +} + +impl InterfaceTimeoutReason { + const fn code(self) -> &'static str { + match self { + Self::Setup => "setup_timed_out", + Self::Pairing => "pairing_timed_out", + Self::Startup => "startup_timed_out", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceFailureReason { + CapabilityRejected, + Configure, + Connect, + Listen, + Remove, + Resume, + Runtime, + Update, +} + +impl InterfaceFailureReason { + const fn code(self) -> &'static str { + match self { + Self::CapabilityRejected => "capability_rejected", + Self::Configure => "configure_failed", + Self::Connect => "connect_failed", + Self::Listen => "listen_failed", + Self::Remove => "remove_failed", + Self::Resume => "resume_failed", + Self::Runtime => "runtime_failed", + Self::Update => "update_failed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InterfaceRollback { + ConfigRestored, + RestartFailed, + WriteFailed, +} + +impl InterfaceRollback { + const fn code(self) -> &'static str { + match self { + Self::ConfigRestored => "config_restored", + Self::RestartFailed => "restart_failed", + Self::WriteFailed => "write_failed", + } + } +} + +pub enum InterfaceTransition { + Configured, + Connecting, + Cancelled, + Online, + Offline, + Degraded { + reason: InterfaceDegradationReason, + }, + Paused, + Removed, + Failed { + reason: InterfaceFailureReason, + rollback: Option, + }, + TimedOut { + reason: InterfaceTimeoutReason, + }, +} + +pub struct InterfaceActivity { + pub time: ObservationTime, + pub class: InterfaceClass, + pub transition: InterfaceTransition, + pub endpoint: Option, +} + +pub fn interface_activity(input: InterfaceActivity) -> Result { + let (kind, severity, outcome, reason, rollback) = match input.transition { + InterfaceTransition::Configured => ( + kinds::INTERFACE_CONFIGURED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Connecting => ( + kinds::INTERFACE_CONNECTING, + ActivitySeverity::Info, + ActivityOutcome::Started, + None, + None, + ), + InterfaceTransition::Cancelled => ( + kinds::INTERFACE_CANCELLED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Online => ( + kinds::INTERFACE_ONLINE, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Offline => ( + kinds::INTERFACE_OFFLINE, + ActivitySeverity::Warning, + ActivityOutcome::Degraded, + None, + None, + ), + InterfaceTransition::Degraded { reason } => ( + kinds::INTERFACE_DEGRADED, + ActivitySeverity::Warning, + ActivityOutcome::Degraded, + Some(reason.code()), + None, + ), + InterfaceTransition::Paused => ( + kinds::INTERFACE_PAUSED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Removed => ( + kinds::INTERFACE_REMOVED, + ActivitySeverity::Info, + ActivityOutcome::Success, + None, + None, + ), + InterfaceTransition::Failed { reason, rollback } => ( + kinds::INTERFACE_FAILED, + ActivitySeverity::Error, + ActivityOutcome::Failed, + Some(reason.code()), + rollback, + ), + InterfaceTransition::TimedOut { reason } => ( + kinds::INTERFACE_TIMED_OUT, + ActivitySeverity::Error, + ActivityOutcome::TimedOut, + Some(reason.code()), + None, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + ActivityDirection::Local, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .operational_code(ActivityAttributeKey::InterfaceClass, input.class.code())?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + if let Some(rollback) = rollback { + draft = draft.operational_code(ActivityAttributeKey::State, rollback.code())?; + } + if let Some(endpoint) = input.endpoint { + draft = draft.sensitive_endpoint(ActivityAttributeKey::Endpoint, endpoint.0); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PathRequestMethod { + Automatic, + ContactRefresh, + Manual, +} + +impl PathRequestMethod { + const fn code(self) -> &'static str { + match self { + Self::Automatic => "automatic", + Self::ContactRefresh => "contact_refresh", + Self::Manual => "manual", + } + } +} + +pub struct RnsPathRequested { + pub time: ObservationTime, + pub destination: Option, + pub count: Option, + pub method: PathRequestMethod, +} + +pub fn rns_path_requested(input: RnsPathRequested) -> Result { + let mut draft = ActivityDraft::new( + kinds::RNS_PATH_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .operational_code(ActivityAttributeKey::Method, input.method.code())?; + if let Some(destination) = input.destination { + draft = draft.protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &destination.0, + )?; + } + if let Some(count) = input.count { + draft = draft.exact(ActivityAttributeKey::Count, ExactValue::Unsigned(count)); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnnounceMethod { + InterfaceOnline, + LxmfDelivery, + LxstService, + Manual, + Startup, + Transport, +} + +impl AnnounceMethod { + const fn code(self) -> &'static str { + match self { + Self::InterfaceOnline => "interface_online", + Self::LxmfDelivery => "lxmf_delivery", + Self::LxstService => "lxst_service", + Self::Manual => "manual", + Self::Startup => "startup", + Self::Transport => "transport", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnnounceFailureReason { + NoInterfaceTransmission, + NotReady, + QueueFailed, + TransportUnavailable, +} + +impl AnnounceFailureReason { + const fn code(self) -> &'static str { + match self { + Self::NoInterfaceTransmission => "no_interface_transmission", + Self::NotReady => "not_ready", + Self::QueueFailed => "queue_failed", + Self::TransportUnavailable => "transport_unavailable", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AnnounceSuppressionReason { + Cooldown, + InterfaceRestart, + RateLimit, +} + +impl AnnounceSuppressionReason { + const fn code(self) -> &'static str { + match self { + Self::Cooldown => "cooldown", + Self::InterfaceRestart => "interface_restart", + Self::RateLimit => "rate_limit", + } + } +} + +pub enum RnsAnnounceTransition { + Sent { + method: AnnounceMethod, + }, + Failed { + method: AnnounceMethod, + reason: AnnounceFailureReason, + }, + Held { + count: u64, + }, + IngressBurstStarted, + IngressBurstCleared, + Suppressed { + reason: AnnounceSuppressionReason, + }, + Observed { + destination: DestinationHash, + hops: u8, + }, +} + +pub struct RnsAnnounceActivity { + pub time: ObservationTime, + pub transition: RnsAnnounceTransition, + pub interface: Option, +} + +pub fn rns_announce_activity( + input: RnsAnnounceActivity, +) -> Result { + let (kind, severity, direction, outcome, coalescing) = match input.transition { + RnsAnnounceTransition::Sent { .. } => ( + kinds::RNS_ANNOUNCE_SENT, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Failed { .. } => ( + kinds::RNS_ANNOUNCE_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Held { .. } => ( + kinds::RNS_ANNOUNCE_HELD, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Progress, + CoalescingPolicy::AdjacentEquivalent, + ), + RnsAnnounceTransition::IngressBurstStarted => ( + kinds::RNS_ANNOUNCE_INGRESS_BURST_STARTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Degraded, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::IngressBurstCleared => ( + kinds::RNS_ANNOUNCE_INGRESS_BURST_CLEARED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Suppressed { .. } => ( + kinds::RNS_ANNOUNCE_SUPPRESSED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Dropped, + CoalescingPolicy::Never, + ), + RnsAnnounceTransition::Observed { .. } => ( + kinds::RNS_ANNOUNCE_OBSERVED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::AdjacentEquivalent, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + coalescing, + ); + match input.transition { + RnsAnnounceTransition::Sent { method } => { + draft = draft.operational_code(ActivityAttributeKey::Method, method.code())?; + } + RnsAnnounceTransition::Failed { method, reason } => { + draft = draft + .operational_code(ActivityAttributeKey::Method, method.code())? + .operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + RnsAnnounceTransition::Held { count } => { + draft = draft.exact( + ActivityAttributeKey::QueueCount, + ExactValue::Unsigned(count), + ); + } + RnsAnnounceTransition::Suppressed { reason } => { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + RnsAnnounceTransition::Observed { destination, hops } => { + draft = draft + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &destination.0, + )? + .exact( + ActivityAttributeKey::Hops, + ExactValue::Unsigned(u64::from(hops)), + ); + } + RnsAnnounceTransition::IngressBurstStarted | RnsAnnounceTransition::IngressBurstCleared => { + } + } + if let Some(interface) = input.interface { + draft = draft.operational_code(ActivityAttributeKey::InterfaceClass, interface.code())?; + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PathEvidence { + Announce, + Cached, + PathResponse, + Transport, +} + +impl PathEvidence { + const fn code(self) -> &'static str { + match self { + Self::Announce => "announce", + Self::Cached => "cached", + Self::PathResponse => "path_response", + Self::Transport => "transport", + } + } +} + +pub struct RnsPathDiscovered { + pub time: ObservationTime, + pub destination: DestinationHash, + pub hops: u8, + pub evidence: PathEvidence, + pub endpoint: Option, + pub correlation_id: Option, +} + +pub fn rns_path_discovered( + input: RnsPathDiscovered, +) -> Result { + rns_path_event(kinds::RNS_PATH_DISCOVERED, input) +} + +pub fn rns_path_observed(input: RnsPathDiscovered) -> Result { + rns_path_event(kinds::RNS_PATH_OBSERVED, input) +} + +fn rns_path_event( + kind: super::schema::ActivityKindCode, + input: RnsPathDiscovered, +) -> Result { + let mut draft = ActivityDraft::new( + kind, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::AdjacentEquivalent, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .exact( + ActivityAttributeKey::Hops, + ExactValue::Unsigned(u64::from(input.hops)), + ) + .operational_code(ActivityAttributeKey::Validation, input.evidence.code())?; + if let Some(endpoint) = input.endpoint { + draft = draft.sensitive_endpoint(ActivityAttributeKey::Endpoint, endpoint.0); + } + if let Some(correlation_id) = input.correlation_id { + draft = draft.with_correlation(correlation_id); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelEnvelopeKind { + Action, + Error, + Hello, + Join, + Joined, + Message, + Part, + Parted, + Ping, + Pong, + Notice, + Resource, + Welcome, +} + +impl ChannelEnvelopeKind { + const fn code(self) -> &'static str { + match self { + Self::Action => "action", + Self::Error => "error", + Self::Hello => "hello", + Self::Join => "join", + Self::Joined => "joined", + Self::Message => "message", + Self::Part => "part", + Self::Parted => "parted", + Self::Ping => "ping", + Self::Pong => "pong", + Self::Notice => "notice", + Self::Resource => "resource", + Self::Welcome => "welcome", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SourceValidation { + Accepted, + Duplicate, + Malformed, + NonHub, + Unsupported, + WrongSource, +} + +impl SourceValidation { + const fn code(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::Duplicate => "duplicate", + Self::Malformed => "malformed", + Self::NonHub => "non_hub", + Self::Unsupported => "unsupported", + Self::WrongSource => "wrong_source", + } + } +} + +pub struct ChannelsEnvelopeActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub room: Option, + pub message: Option, + pub envelope_kind: Option, + pub encoded_bytes: u32, + pub validation: SourceValidation, + pub correlation_id: CorrelationId, +} + +pub fn channels_envelope_sent( + input: ChannelsEnvelopeActivity, +) -> Result { + channels_envelope(input, ActivityDirection::Outbound) +} + +pub fn channels_envelope_received( + input: ChannelsEnvelopeActivity, +) -> Result { + channels_envelope(input, ActivityDirection::Inbound) +} + +fn channels_envelope( + input: ChannelsEnvelopeActivity, + direction: ActivityDirection, +) -> Result { + let (kind, severity, outcome, coalescing, duplicate) = match input.validation { + SourceValidation::Accepted => ( + if direction == ActivityDirection::Outbound { + kinds::CHANNELS_ENVELOPE_SENT + } else { + kinds::CHANNELS_ENVELOPE_RECEIVED + }, + ActivitySeverity::Info, + ActivityOutcome::Success, + CoalescingPolicy::AdjacentEquivalent, + false, + ), + SourceValidation::Duplicate | SourceValidation::Unsupported => ( + kinds::CHANNELS_ENVELOPE_RECEIVED, + ActivitySeverity::Info, + ActivityOutcome::Dropped, + CoalescingPolicy::Never, + matches!(input.validation, SourceValidation::Duplicate), + ), + SourceValidation::Malformed | SourceValidation::NonHub | SourceValidation::WrongSource => ( + kinds::CHANNELS_ENVELOPE_REJECTED, + ActivitySeverity::Warning, + ActivityOutcome::Rejected, + CoalescingPolicy::Never, + false, + ), + }; + let draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + coalescing, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)?; + let mut draft = draft + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(u64::from(input.encoded_bytes)), + ) + .exact( + ActivityAttributeKey::Duplicate, + ExactValue::Boolean(duplicate), + ) + .operational_code(ActivityAttributeKey::Validation, input.validation.code())?; + if let Some(room) = input.room { + draft = + draft.protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)?; + } + if let Some(message) = input.message { + draft = draft.protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &message.0, + )?; + } + if let Some(envelope_kind) = input.envelope_kind { + draft = draft.operational_code(ActivityAttributeKey::Method, envelope_kind.code())?; + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelSessionFailureReason { + AuthenticationFailed, + HubRejected, + IdentificationFailed, + InvalidAnnounce, + MalformedWelcome, + PathLookupFailed, + SendFailed, + TransportUnavailable, + UnsupportedVersion, + WelcomeTimedOut, + WrongSource, +} + +impl ChannelSessionFailureReason { + const fn code(self) -> &'static str { + match self { + Self::AuthenticationFailed => "authentication_failed", + Self::HubRejected => "hub_rejected", + Self::IdentificationFailed => "identification_failed", + Self::InvalidAnnounce => "invalid_announce", + Self::MalformedWelcome => "malformed_welcome", + Self::PathLookupFailed => "path_lookup_failed", + Self::SendFailed => "send_failed", + Self::TransportUnavailable => "transport_unavailable", + Self::UnsupportedVersion => "unsupported_version", + Self::WelcomeTimedOut => "welcome_timed_out", + Self::WrongSource => "wrong_source", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelSessionCloseReason { + Local, + Remote, + SendFailed, + StreamEnded, + Timeout, + TransportUnavailable, +} + +impl ChannelSessionCloseReason { + const fn code(self) -> &'static str { + match self { + Self::Local => "local", + Self::Remote => "remote", + Self::SendFailed => "send_failed", + Self::StreamEnded => "stream_ended", + Self::Timeout => "timeout", + Self::TransportUnavailable => "transport_unavailable", + } + } +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct ChannelNegotiatedCapabilities { + pub actions: bool, + pub direct_notices: bool, + pub resource_envelopes: bool, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct ChannelNegotiatedLimits { + pub max_nick_bytes: Option, + pub max_room_bytes: Option, + pub max_message_bytes: Option, + pub max_rooms: Option, + pub rate_per_minute: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelSessionTransition { + ConnectRequested, + Cancelled, + PathRequested, + PathDiscovered { + hops: u8, + }, + PathTimedOut, + LinkRequested, + LinkAuthenticated { + link: LinkId, + }, + LinkIdentificationSent { + link: LinkId, + }, + HelloSent { + encoded_bytes: u32, + }, + WelcomeValidated { + encoded_bytes: u32, + }, + WelcomeRejected { + reason: ChannelSessionFailureReason, + }, + Failed { + reason: ChannelSessionFailureReason, + }, + Negotiated { + protocol_version: u64, + capabilities: ChannelNegotiatedCapabilities, + limits: ChannelNegotiatedLimits, + link_mdu: u64, + }, + GreetingObserved { + encoded_bytes: u32, + }, + Stale, + Recovered, + Closed { + reason: ChannelSessionCloseReason, + link: Option, + duration_ms: Option, + }, +} + +pub struct ChannelsSessionActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub correlation_id: CorrelationId, + pub transition: ChannelSessionTransition, +} + +pub fn channels_session_activity( + input: ChannelsSessionActivity, +) -> Result { + let (kind, severity, direction, outcome, reason) = match input.transition { + ChannelSessionTransition::ConnectRequested => ( + kinds::CHANNELS_SESSION_CONNECT_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Started, + None, + ), + ChannelSessionTransition::Cancelled => ( + kinds::CHANNELS_SESSION_CANCELLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::PathRequested => ( + kinds::CHANNELS_SESSION_PATH_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + ), + ChannelSessionTransition::PathDiscovered { .. } => ( + kinds::CHANNELS_SESSION_PATH_DISCOVERED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::PathTimedOut => ( + kinds::CHANNELS_SESSION_PATH_TIMED_OUT, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + None, + ), + ChannelSessionTransition::LinkRequested => ( + kinds::CHANNELS_SESSION_LINK_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + ), + ChannelSessionTransition::LinkAuthenticated { .. } => ( + kinds::CHANNELS_SESSION_LINK_AUTHENTICATED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::LinkIdentificationSent { .. } => ( + kinds::CHANNELS_SESSION_LINK_IDENTIFICATION_SENT, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::HelloSent { .. } => ( + kinds::CHANNELS_SESSION_HELLO_SENT, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::WelcomeValidated { .. } => ( + kinds::CHANNELS_SESSION_WELCOME_VALIDATED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::WelcomeRejected { reason } => ( + kinds::CHANNELS_SESSION_WELCOME_REJECTED, + ActivitySeverity::Error, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + Some(reason.code()), + ), + ChannelSessionTransition::Failed { reason } => ( + kinds::CHANNELS_SESSION_FAILED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::Failed, + Some(reason.code()), + ), + ChannelSessionTransition::Negotiated { .. } => ( + kinds::CHANNELS_SESSION_NEGOTIATED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::GreetingObserved { .. } => ( + kinds::CHANNELS_SESSION_GREETING_OBSERVED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::Stale => ( + kinds::CHANNELS_SESSION_STALE, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + None, + ), + ChannelSessionTransition::Recovered => ( + kinds::CHANNELS_SESSION_RECOVERED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + ), + ChannelSessionTransition::Closed { + reason: ChannelSessionCloseReason::Local, + .. + } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + Some(ChannelSessionCloseReason::Local.code()), + ), + ChannelSessionTransition::Closed { + reason: ChannelSessionCloseReason::Timeout, + .. + } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + Some(ChannelSessionCloseReason::Timeout.code()), + ), + ChannelSessionTransition::Closed { + reason: ChannelSessionCloseReason::Remote, + .. + } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Error, + ActivityDirection::Inbound, + ActivityOutcome::Failed, + Some(ChannelSessionCloseReason::Remote.code()), + ), + ChannelSessionTransition::Closed { reason, .. } => ( + kinds::CHANNELS_SESSION_CLOSED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::Failed, + Some(reason.code()), + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + match input.transition { + ChannelSessionTransition::PathDiscovered { hops } => { + draft = draft.exact( + ActivityAttributeKey::Hops, + ExactValue::Unsigned(u64::from(hops)), + ); + } + ChannelSessionTransition::LinkAuthenticated { link } + | ChannelSessionTransition::LinkIdentificationSent { link } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + if matches!( + input.transition, + ChannelSessionTransition::LinkIdentificationSent { .. } + ) { + draft = draft.operational_code(ActivityAttributeKey::State, "sent")?; + } + } + ChannelSessionTransition::HelloSent { encoded_bytes } + | ChannelSessionTransition::WelcomeValidated { encoded_bytes } + | ChannelSessionTransition::GreetingObserved { encoded_bytes } => { + draft = draft.exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(u64::from(encoded_bytes)), + ); + } + ChannelSessionTransition::Negotiated { + protocol_version, + capabilities, + limits, + link_mdu, + } => { + draft = draft + .exact( + ActivityAttributeKey::ProtocolVersion, + ExactValue::Unsigned(protocol_version), + ) + .exact(ActivityAttributeKey::Mdu, ExactValue::Unsigned(link_mdu)); + for (enabled, capability) in [ + (capabilities.actions, "action"), + (capabilities.direct_notices, "direct_notice"), + (capabilities.resource_envelopes, "resource_envelope"), + ] { + if enabled { + draft = draft.operational_code(ActivityAttributeKey::Capability, capability)?; + } + } + for (key, value) in [ + (ActivityAttributeKey::MaxNickBytes, limits.max_nick_bytes), + (ActivityAttributeKey::MaxRoomBytes, limits.max_room_bytes), + ( + ActivityAttributeKey::MaxMessageBytes, + limits.max_message_bytes, + ), + (ActivityAttributeKey::MaxRooms, limits.max_rooms), + (ActivityAttributeKey::RatePerMinute, limits.rate_per_minute), + ] { + if let Some(value) = value { + draft = draft.exact(key, ExactValue::Unsigned(value)); + } + } + } + ChannelSessionTransition::Closed { + link, duration_ms, .. + } => { + if let Some(link) = link { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + if let Some(duration_ms) = duration_ms { + draft = draft.exact( + ActivityAttributeKey::DurationMs, + ExactValue::Unsigned(duration_ms), + ); + } + } + ChannelSessionTransition::ConnectRequested + | ChannelSessionTransition::Cancelled + | ChannelSessionTransition::PathRequested + | ChannelSessionTransition::PathTimedOut + | ChannelSessionTransition::LinkRequested + | ChannelSessionTransition::WelcomeRejected { .. } + | ChannelSessionTransition::Failed { .. } + | ChannelSessionTransition::Stale + | ChannelSessionTransition::Recovered => {} + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelJoinEvidence { + JoinedRoster, + RrcdStatusNotice, +} + +impl ChannelJoinEvidence { + const fn code(self) -> &'static str { + match self { + Self::JoinedRoster => "joined_roster", + Self::RrcdStatusNotice => "rrcd_status_notice", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelRoomFailureReason { + HubRejected, + SendFailed, + SessionClosed, +} + +impl ChannelRoomFailureReason { + const fn code(self) -> &'static str { + match self { + Self::HubRejected => "hub_rejected", + Self::SendFailed => "send_failed", + Self::SessionClosed => "session_closed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChannelRoomTransition { + JoinRequested, + Joined { evidence: ChannelJoinEvidence }, + JoinRejected { reason: ChannelRoomFailureReason }, + JoinTimedOut, + JoinCancelled, + PartRequested, + Parted, + PartRejected { reason: ChannelRoomFailureReason }, + PartTimedOut, + PartCancelled, +} + +pub struct ChannelsRoomActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub room: ChannelRoomToken, + pub correlation_id: CorrelationId, + pub transition: ChannelRoomTransition, +} + +pub fn channels_room_activity( + input: ChannelsRoomActivity, +) -> Result { + let (kind, severity, direction, outcome, reason, evidence) = match input.transition { + ChannelRoomTransition::JoinRequested => ( + kinds::CHANNELS_ROOM_JOIN_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + None, + ), + ChannelRoomTransition::Joined { evidence } => ( + kinds::CHANNELS_ROOM_JOINED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + Some(evidence.code()), + ), + ChannelRoomTransition::JoinRejected { reason } => ( + kinds::CHANNELS_ROOM_JOIN_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + Some(reason.code()), + None, + ), + ChannelRoomTransition::JoinTimedOut => ( + kinds::CHANNELS_ROOM_JOIN_TIMED_OUT, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + None, + None, + ), + ChannelRoomTransition::JoinCancelled => ( + kinds::CHANNELS_ROOM_JOIN_CANCELLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + None, + ), + ChannelRoomTransition::PartRequested => ( + kinds::CHANNELS_ROOM_PART_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + None, + None, + ), + ChannelRoomTransition::Parted => ( + kinds::CHANNELS_ROOM_PARTED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + None, + None, + ), + ChannelRoomTransition::PartRejected { reason } => ( + kinds::CHANNELS_ROOM_PART_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + Some(reason.code()), + None, + ), + ChannelRoomTransition::PartTimedOut => ( + kinds::CHANNELS_ROOM_PART_TIMED_OUT, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + None, + None, + ), + ChannelRoomTransition::PartCancelled => ( + kinds::CHANNELS_ROOM_PART_CANCELLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + None, + None, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)? + .protocol_identifier( + ActivityAttributeKey::Room, + IdentifierKind::Room, + &input.room.0, + )?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + if let Some(evidence) = evidence { + draft = draft.operational_code(ActivityAttributeKey::Validation, evidence)?; + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubServiceDegradation { + Announce, + EnvelopeOversize, + SendFailed, +} + +impl HubServiceDegradation { + const fn code(self) -> &'static str { + match self { + Self::Announce => "announce_failed", + Self::EnvelopeOversize => "envelope_oversize", + Self::SendFailed => "send_failed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubSessionRejection { + WelcomeUnsendable, +} + +impl HubSessionRejection { + const fn code(self) -> &'static str { + match self { + Self::WelcomeUnsendable => "welcome_unsendable", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubSessionCloseReason { + Remote, + PingTimeout, + HandshakeTimeout, + Kicked, + ServiceStopped, +} + +impl HubSessionCloseReason { + const fn code(self) -> &'static str { + match self { + Self::Remote => "remote", + Self::PingTimeout => "ping_timed_out", + Self::HandshakeTimeout => "handshake_timed_out", + Self::Kicked => "kicked", + Self::ServiceStopped => "service_stopped", + } + } +} + +/// Operator actions the hub took on a room. The verb is representable; the +/// room label, topic, key and kick reason are not, by construction. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubModerationAction { + Register, + Unregister, + Topic, + Mode, + Op, + Deop, + Voice, + Devoice, + Ban, + Unban, + Kick, + Invite, + Uninvite, +} + +impl HubModerationAction { + const fn code(self) -> &'static str { + match self { + Self::Register => "register", + Self::Unregister => "unregister", + Self::Topic => "topic", + Self::Mode => "mode", + Self::Op => "op", + Self::Deop => "deop", + Self::Voice => "voice", + Self::Devoice => "devoice", + Self::Ban => "ban", + Self::Unban => "unban", + Self::Kick => "kick", + Self::Invite => "invite", + Self::Uninvite => "uninvite", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubTrustChange { + KlineAdded, + KlineRemoved, +} + +impl HubTrustChange { + const fn code(self) -> &'static str { + match self { + Self::KlineAdded => "kline_added", + Self::KlineRemoved => "kline_removed", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HubTransition { + ServiceStarted, + ServiceStopped, + ServiceDegraded { + reason: HubServiceDegradation, + count: u64, + }, + SessionOpened { + link: LinkId, + peer: IdentityHash, + }, + SessionRejected { + link: LinkId, + reason: HubSessionRejection, + }, + SessionClosed { + link: LinkId, + reason: HubSessionCloseReason, + duration_ms: u64, + }, + RoomJoined { + link: LinkId, + room: ChannelRoomToken, + members: u64, + }, + RoomParted { + link: LinkId, + room: ChannelRoomToken, + members: u64, + }, + RoomModerated { + link: LinkId, + room: ChannelRoomToken, + action: HubModerationAction, + }, + TrustChanged { + link: LinkId, + change: HubTrustChange, + }, + RelayForwarded { + room: ChannelRoomToken, + method: ChannelEnvelopeKind, + encoded_bytes: u64, + recipients: u64, + }, + RelayThrottled { + rejected: u64, + dropped: u64, + span_ms: u64, + }, +} + +pub struct ChannelsHubActivity { + pub time: ObservationTime, + pub hub: DestinationHash, + pub correlation_id: CorrelationId, + pub transition: HubTransition, +} + +/// Hub-side counterpart of the client Channels catalog. Every remote party is +/// an opaque identifier and every room is a random token, so nothing a peer +/// authored — nickname, room label, topic, body — has a representation here. +pub fn channels_hub_activity( + input: ChannelsHubActivity, +) -> Result { + let (kind, severity, direction, outcome, coalescing, reason) = match input.transition { + HubTransition::ServiceStarted => ( + kinds::CHANNELS_HUB_SERVICE_STARTED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Started, + CoalescingPolicy::Never, + None, + ), + HubTransition::ServiceStopped => ( + kinds::CHANNELS_HUB_SERVICE_STOPPED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::ServiceDegraded { reason, .. } => ( + kinds::CHANNELS_HUB_SERVICE_DEGRADED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + CoalescingPolicy::Never, + Some(reason.code()), + ), + HubTransition::SessionOpened { .. } => ( + kinds::CHANNELS_HUB_SESSION_OPENED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::SessionRejected { reason, .. } => ( + kinds::CHANNELS_HUB_SESSION_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Rejected, + CoalescingPolicy::Never, + Some(reason.code()), + ), + HubTransition::SessionClosed { reason, .. } => { + let (severity, direction, outcome) = match reason { + HubSessionCloseReason::PingTimeout | HubSessionCloseReason::HandshakeTimeout => ( + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::TimedOut, + ), + HubSessionCloseReason::Remote => ( + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + ), + HubSessionCloseReason::Kicked | HubSessionCloseReason::ServiceStopped => ( + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + ), + }; + ( + kinds::CHANNELS_HUB_SESSION_CLOSED, + severity, + direction, + outcome, + CoalescingPolicy::Never, + Some(reason.code()), + ) + } + HubTransition::RoomJoined { .. } => ( + kinds::CHANNELS_HUB_ROOM_JOINED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::RoomParted { .. } => ( + kinds::CHANNELS_HUB_ROOM_PARTED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::Never, + None, + ), + HubTransition::RoomModerated { action, .. } => ( + kinds::CHANNELS_HUB_ROOM_MODERATED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + Some(action.code()), + ), + HubTransition::TrustChanged { change, .. } => ( + kinds::CHANNELS_HUB_TRUST_CHANGED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Success, + CoalescingPolicy::Never, + Some(change.code()), + ), + // The only ambient hub kind: one per relayed envelope, so it is + // Trace-only and coalesces. + HubTransition::RelayForwarded { .. } => ( + kinds::CHANNELS_HUB_RELAY_FORWARDED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + CoalescingPolicy::AdjacentEquivalent, + None, + ), + HubTransition::RelayThrottled { .. } => ( + kinds::CHANNELS_HUB_RELAY_THROTTLED, + ActivitySeverity::Warning, + ActivityDirection::Inbound, + ActivityOutcome::Dropped, + CoalescingPolicy::Never, + None, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + coalescing, + ) + .protocol_identifier(ActivityAttributeKey::Hub, IdentifierKind::Hub, &input.hub.0)?; + if let Some(reason) = reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason)?; + } + match input.transition { + HubTransition::ServiceStarted | HubTransition::ServiceStopped => {} + HubTransition::ServiceDegraded { count, .. } => { + draft = draft.exact(ActivityAttributeKey::Count, ExactValue::Unsigned(count)); + } + HubTransition::SessionOpened { link, peer } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )?; + } + HubTransition::SessionRejected { link, .. } | HubTransition::TrustChanged { link, .. } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + HubTransition::SessionClosed { + link, duration_ms, .. + } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .exact( + ActivityAttributeKey::DurationMs, + ExactValue::Unsigned(duration_ms), + ); + } + HubTransition::RoomJoined { + link, + room, + members, + } + | HubTransition::RoomParted { + link, + room, + members, + } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)? + .exact(ActivityAttributeKey::Count, ExactValue::Unsigned(members)); + } + HubTransition::RoomModerated { link, room, .. } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)?; + } + HubTransition::RelayForwarded { + room, + method, + encoded_bytes, + recipients, + } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Room, IdentifierKind::Room, &room.0)? + .operational_code(ActivityAttributeKey::Method, method.code())? + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(encoded_bytes), + ) + .exact( + ActivityAttributeKey::Count, + ExactValue::Unsigned(recipients), + ); + } + HubTransition::RelayThrottled { + rejected, + dropped, + span_ms, + } => { + draft = draft + .exact( + ActivityAttributeKey::RejectedCount, + ExactValue::Unsigned(rejected), + ) + .exact( + ActivityAttributeKey::DroppedCount, + ExactValue::Unsigned(dropped), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(span_ms), + ); + } + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfDeliveryMethod { + Direct, + Opportunistic, + Paper, + Propagated, +} + +impl LxmfDeliveryMethod { + pub fn from_code(value: &str) -> Option { + match value { + "direct" => Some(Self::Direct), + "opportunistic" => Some(Self::Opportunistic), + "paper" => Some(Self::Paper), + "propagated" => Some(Self::Propagated), + _ => None, + } + } + + const fn code(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Opportunistic => "opportunistic", + Self::Paper => "paper", + Self::Propagated => "propagated", + } + } +} + +pub struct LxmfDeliveryQueued { + pub time: ObservationTime, + pub message: MessageId, + pub destination: DestinationHash, + pub method: LxmfDeliveryMethod, +} + +pub fn lxmf_delivery_queued( + input: LxmfDeliveryQueued, +) -> Result { + ActivityDraft::new( + kinds::LXMF_DELIVERY_QUEUED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message.0, + )? + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Method, input.method.code()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfSubmissionFailureReason { + RouterUnavailable, + PreparationFailed, +} + +impl LxmfSubmissionFailureReason { + const fn code(self) -> &'static str { + match self { + Self::RouterUnavailable => "router_unavailable", + Self::PreparationFailed => "preparation_failed", + } + } +} + +pub struct LxmfSubmissionFailed { + pub time: ObservationTime, + pub destination: DestinationHash, + pub reason: LxmfSubmissionFailureReason, +} + +pub fn lxmf_submission_failed( + input: LxmfSubmissionFailed, +) -> Result { + ActivityDraft::new( + kinds::LXMF_DELIVERY_SUBMISSION_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Reason, input.reason.code()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfDeliveryState { + Routing, + Propagating, + ReusingBackchannel, + SendingViaLink, + Sent, + Delivered, + Propagated, + Rejected, + Failed, +} + +pub struct LxmfDeliveryStateChanged { + pub time: ObservationTime, + pub message: MessageId, + pub state: LxmfDeliveryState, + pub method: Option, + pub rtt_ms: Option, + pub failure_reason: Option, +} + +pub fn lxmf_delivery_state_changed( + input: LxmfDeliveryStateChanged, +) -> Result { + let (kind, severity, outcome) = match input.state { + LxmfDeliveryState::Routing => ( + kinds::LXMF_DELIVERY_PATH_PENDING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::Propagating => ( + kinds::LXMF_PROPAGATION_STARTED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::ReusingBackchannel => ( + kinds::LXMF_DELIVERY_LINK_REUSED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::SendingViaLink => ( + // This persisted router state is overloaded: it also covers work + // queued behind a pending/busy reusable Link, before any packet or + // Resource has started. Keep the Activity fact deliberately + // coarse. Typed progress owns Resource-start facts; a packet-start + // fact remains deferred until a non-overloaded observer exists. + kinds::LXMF_DELIVERY_DIRECT_PENDING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::Sent => ( + kinds::LXMF_DELIVERY_AWAITING_PROOF, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfDeliveryState::Delivered => ( + kinds::LXMF_DELIVERY_DELIVERED, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + LxmfDeliveryState::Propagated => ( + kinds::LXMF_PROPAGATION_SUCCEEDED, + ActivitySeverity::Info, + ActivityOutcome::Success, + ), + LxmfDeliveryState::Rejected => ( + kinds::LXMF_DELIVERY_REJECTED, + ActivitySeverity::Error, + ActivityOutcome::Rejected, + ), + LxmfDeliveryState::Failed + if matches!(input.method, Some(LxmfDeliveryMethod::Propagated)) => + { + ( + kinds::LXMF_PROPAGATION_FAILED, + ActivitySeverity::Error, + ActivityOutcome::Failed, + ) + } + LxmfDeliveryState::Failed => ( + kinds::LXMF_DELIVERY_FAILED, + ActivitySeverity::Error, + ActivityOutcome::Failed, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + ActivityDirection::Outbound, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message.0, + )?; + if let Some(method) = input.method { + draft = draft.operational_code(ActivityAttributeKey::Method, method.code())?; + } + if let Some(rtt_ms) = input.rtt_ms { + draft = draft.exact(ActivityAttributeKey::RttMs, ExactValue::Unsigned(rtt_ms)); + } + if let Some(reason) = input.failure_reason { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxmfProgressStep { + LinkEstablishing, + LinkReady, + DirectPending, + LinkReused, + ResourceStarted, + ResourceProgress, + AwaitingProof, +} + +pub struct LxmfDeliveryProgress { + pub time: ObservationTime, + pub message: MessageId, + pub destination: DestinationHash, + pub link: Option, + pub method: LxmfDeliveryMethod, + pub step: LxmfProgressStep, + pub percent: Option, + pub attempts: u32, +} + +pub fn lxmf_delivery_progress( + input: LxmfDeliveryProgress, +) -> Result { + let (kind, severity, outcome) = match input.step { + LxmfProgressStep::LinkEstablishing => ( + kinds::LXMF_DELIVERY_LINK_ESTABLISHING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::LinkReady => ( + kinds::LXMF_DELIVERY_LINK_READY, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::DirectPending => ( + kinds::LXMF_DELIVERY_DIRECT_PENDING, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::LinkReused => ( + kinds::LXMF_DELIVERY_LINK_REUSED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::ResourceStarted => ( + kinds::LXMF_DELIVERY_RESOURCE_STARTED, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::ResourceProgress => ( + kinds::LXMF_DELIVERY_PROGRESS, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + LxmfProgressStep::AwaitingProof => ( + kinds::LXMF_DELIVERY_AWAITING_PROOF, + ActivitySeverity::Info, + ActivityOutcome::Progress, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + ActivityDirection::Outbound, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + if matches!(input.step, LxmfProgressStep::ResourceProgress) { + CoalescingPolicy::AdjacentEquivalent + } else { + CoalescingPolicy::Never + }, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message.0, + )? + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Method, input.method.code())? + .exact( + ActivityAttributeKey::Attempts, + ExactValue::Unsigned(u64::from(input.attempts)), + ); + if let Some(link) = input.link { + draft = + draft.protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)?; + } + if let Some(percent) = input.percent { + draft = draft.exact( + ActivityAttributeKey::Percent, + ExactValue::Unsigned(u64::from(percent.min(100))), + ); + } + Ok(draft) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum InboundLxmfMethod { + Direct, + Opportunistic, + Propagated, +} + +impl InboundLxmfMethod { + const fn code(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Opportunistic => "opportunistic", + Self::Propagated => "propagated", + } + } +} + +pub struct LxmfInboundAccepted { + pub time: ObservationTime, + pub source: DestinationHash, + pub method: InboundLxmfMethod, + pub encoded_bytes: u32, +} + +pub fn lxmf_inbound_accepted( + input: LxmfInboundAccepted, +) -> Result { + let draft = ActivityDraft::new( + kinds::LXMF_INBOUND_ACCEPTED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.source.0, + )? + .operational_code(ActivityAttributeKey::Method, input.method.code())? + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(u64::from(input.encoded_bytes)), + ); + Ok(draft) +} + +pub struct LxmfDeliveryFailed { + pub time: ObservationTime, + pub message_id: MessageId, + pub destination: DestinationHash, + pub link_id: Option, + pub reason: DeliveryFailureReason, + pub correlation_id: CorrelationId, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DeliveryFailureReason { + LinkClosed, + PathUnavailable, + ProofTimedOut, + QueueRejected, + Rejected, + ResourceFailed, + RouterUnavailable, + TransportFailed, +} + +impl DeliveryFailureReason { + const fn code(self) -> &'static str { + match self { + Self::LinkClosed => "link_closed", + Self::PathUnavailable => "path_unavailable", + Self::ProofTimedOut => "proof_timed_out", + Self::QueueRejected => "queue_rejected", + Self::Rejected => "rejected", + Self::ResourceFailed => "resource_failed", + Self::RouterUnavailable => "router_unavailable", + Self::TransportFailed => "transport_failed", + } + } +} + +pub fn lxmf_delivery_failed( + input: LxmfDeliveryFailed, +) -> Result { + let mut draft = ActivityDraft::new( + kinds::LXMF_DELIVERY_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Message, + IdentifierKind::Message, + &input.message_id.0, + )? + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &input.destination.0, + )? + .operational_code(ActivityAttributeKey::Reason, input.reason.code())?; + if let Some(link_id) = input.link_id { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link_id.0, + )?; + } + Ok(draft.with_correlation(input.correlation_id)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LxstCallReason { + Busy, + Rejected, + Calling, + Available, + Ringing, + Connecting, + Established, + LinkFailed, + ServiceError, + MediaError, +} + +impl LxstCallReason { + const fn code(self) -> &'static str { + match self { + Self::Busy => "busy", + Self::Rejected => "rejected", + Self::Calling => "calling", + Self::Available => "available", + Self::Ringing => "ringing", + Self::Connecting => "connecting", + Self::Established => "established", + Self::LinkFailed => "link_failed", + Self::ServiceError => "service_error", + Self::MediaError => "media_error", + } + } +} + +pub enum LxstTransition { + ServiceStarted, + ServiceStopped, + ServiceFailed { + reason: LxstCallReason, + }, + IncomingRinging { + peer: IdentityHash, + link: LinkId, + }, + PathPending { + peer: IdentityHash, + }, + LinkRequested { + peer: IdentityHash, + link: LinkId, + }, + Ended { + link: LinkId, + }, + Rejected { + link: LinkId, + }, + Failed { + peer: Option, + link: Option, + reason: LxstCallReason, + }, + MediaWarning { + reason: LxstCallReason, + }, +} + +pub struct LxstActivity { + pub time: ObservationTime, + pub transition: LxstTransition, +} + +pub fn lxst_activity(input: LxstActivity) -> Result { + let (kind, severity, direction, outcome) = match &input.transition { + LxstTransition::ServiceStarted => ( + kinds::LXST_SERVICE_STARTED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Started, + ), + LxstTransition::ServiceStopped => ( + kinds::LXST_SERVICE_STOPPED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + ), + LxstTransition::ServiceFailed { .. } => ( + kinds::LXST_SERVICE_FAILED, + ActivitySeverity::Error, + ActivityDirection::Local, + ActivityOutcome::Failed, + ), + LxstTransition::IncomingRinging { .. } => ( + kinds::LXST_CALL_RINGING, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Started, + ), + LxstTransition::PathPending { .. } => ( + kinds::LXST_CALL_PATH_PENDING, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Progress, + ), + LxstTransition::LinkRequested { .. } => ( + kinds::LXST_CALL_LINK_REQUESTED, + ActivitySeverity::Info, + ActivityDirection::Outbound, + ActivityOutcome::Started, + ), + LxstTransition::Ended { .. } => ( + kinds::LXST_CALL_ENDED, + ActivitySeverity::Info, + ActivityDirection::None, + ActivityOutcome::Success, + ), + LxstTransition::Rejected { .. } => ( + kinds::LXST_CALL_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::None, + ActivityOutcome::Rejected, + ), + LxstTransition::Failed { .. } => ( + kinds::LXST_CALL_FAILED, + ActivitySeverity::Error, + ActivityDirection::None, + ActivityOutcome::Failed, + ), + LxstTransition::MediaWarning { .. } => ( + kinds::LXST_MEDIA_WARNING, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + ), + }; + let mut draft = ActivityDraft::new( + kind, + severity, + direction, + outcome, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ); + match input.transition { + LxstTransition::IncomingRinging { peer, link } + | LxstTransition::LinkRequested { peer, link } => { + draft = draft + .protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )? + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)?; + } + LxstTransition::PathPending { peer } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )?; + } + LxstTransition::Ended { link } => { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + LxstTransition::Rejected { link } => { + draft = draft + .protocol_identifier(ActivityAttributeKey::Link, IdentifierKind::Link, &link.0)? + .operational_code( + ActivityAttributeKey::Reason, + LxstCallReason::Rejected.code(), + )?; + } + LxstTransition::Failed { peer, link, reason } => { + if let Some(peer) = peer { + draft = draft.protocol_identifier( + ActivityAttributeKey::Identity, + IdentifierKind::Peer, + &peer.0, + )?; + } + if let Some(link) = link { + draft = draft.protocol_identifier( + ActivityAttributeKey::Link, + IdentifierKind::Link, + &link.0, + )?; + } + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + LxstTransition::MediaWarning { reason } => { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + LxstTransition::ServiceFailed { reason } => { + draft = draft.operational_code(ActivityAttributeKey::Reason, reason.code())?; + } + LxstTransition::ServiceStarted | LxstTransition::ServiceStopped => {} + } + Ok(draft) +} + +pub(super) struct DiagnosticsSampled { + pub time: ObservationTime, + pub count: u64, + pub span_ms: u64, + pub source: RateDomain, +} + +pub(super) fn diagnostics_sampled( + input: DiagnosticsSampled, +) -> Result { + let draft = ActivityDraft::new( + kinds::DIAGNOSTICS_SAMPLED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::None, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::SampledCount, + ExactValue::Unsigned(input.count), + ) + .operational_code(ActivityAttributeKey::SourceArea, input.source.code())? + .operational_code(ActivityAttributeKey::Reason, "sustained_rate_limit")?; + Ok(draft.exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + )) +} + +pub struct DiagnosticsDropped { + pub time: ObservationTime, + pub count: u64, + pub span_ms: u64, +} + +pub fn diagnostics_dropped(input: DiagnosticsDropped) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_DROPPED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Dropped, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::DroppedCount, + ExactValue::Unsigned(input.count), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + ) +} + +pub(super) struct DiagnosticsRejected { + pub time: ObservationTime, + pub count: u64, + pub span_ms: u64, +} + +pub(super) fn diagnostics_rejected(input: DiagnosticsRejected) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_REJECTED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Rejected, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::RejectedCount, + ExactValue::Unsigned(input.count), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + ) +} + +pub(super) fn diagnostics_capture_started( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_CAPTURE_STARTED, time, profile) +} + +pub(super) fn diagnostics_capture_stopped( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_CAPTURE_STOPPED, time, profile) +} + +pub(super) fn diagnostics_capture_resumed(time: ObservationTime) -> ActivityDraft { + diagnostics_profile_boundary( + kinds::DIAGNOSTICS_CAPTURE_RESUMED, + time, + super::schema::CaptureProfile::Normal, + ) +} + +pub(super) fn diagnostics_capture_cleared( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_CAPTURE_CLEARED, time, profile) +} + +pub(super) fn diagnostics_profile_changed( + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + diagnostics_profile_boundary(kinds::DIAGNOSTICS_PROFILE_CHANGED, time, profile) +} + +fn diagnostics_profile_boundary( + kind: super::schema::ActivityKindCode, + time: ObservationTime, + profile: super::schema::CaptureProfile, +) -> ActivityDraft { + ActivityDraft::new( + kind, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + time.unix_ms, + time.elapsed_ms, + CoalescingPolicy::Never, + ) + .operational_code(ActivityAttributeKey::Profile, profile.code()) + .expect("capture profile codes are compile-time allowlisted") +} + +pub(super) struct DiagnosticsEvicted { + pub(super) time: ObservationTime, + pub(super) count: u64, + pub(super) bytes: u64, + pub(super) span_ms: u64, +} + +pub(super) fn diagnostics_evicted(input: DiagnosticsEvicted) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_EVICTED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Dropped, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .exact( + ActivityAttributeKey::EvictedCount, + ExactValue::Unsigned(input.count), + ) + .exact( + ActivityAttributeKey::ByteLength, + ExactValue::Unsigned(input.bytes), + ) + .exact( + ActivityAttributeKey::TimeSpanMs, + ExactValue::Unsigned(input.span_ms), + ) +} + +pub(super) fn diagnostics_worker_recovered(time: ObservationTime) -> ActivityDraft { + ActivityDraft::new( + kinds::DIAGNOSTICS_WORKER_RECOVERED, + ActivitySeverity::Warning, + ActivityDirection::Local, + ActivityOutcome::Degraded, + time.unix_ms, + time.elapsed_ms, + CoalescingPolicy::Never, + ) +} + +pub struct ChannelNavigationReference { + pub time: ObservationTime, + pub room: ChannelRoomToken, + pub navigation_token: NavigationToken, +} + +/// Test and future detail-action constructor demonstrating that an opaque +/// navigation reference is retained only in the raw vault and omitted from +/// every masked/copy projection. +pub fn channels_room_joined( + input: ChannelNavigationReference, +) -> Result { + ActivityDraft::new( + kinds::CHANNELS_ROOM_JOINED, + ActivitySeverity::Info, + ActivityDirection::Local, + ActivityOutcome::Success, + input.time.unix_ms, + input.time.elapsed_ms, + CoalescingPolicy::Never, + ) + .protocol_identifier( + ActivityAttributeKey::Room, + IdentifierKind::Room, + &input.room.0, + )? + .opaque_reference( + ActivityAttributeKey::Session, + NavigationAction::Channel, + &input.navigation_token.0, + ) +} + +#[cfg(test)] +pub(super) fn test_network_event( + timestamp_unix_ms: u64, + elapsed_ms: u64, + destination: [u8; 16], + endpoint: &str, + coalescing: CoalescingPolicy, +) -> Result { + let endpoint = TcpEndpoint::new(endpoint.to_string())?; + Ok(ActivityDraft::new( + kinds::RNS_PATH_DISCOVERED, + ActivitySeverity::Info, + ActivityDirection::Inbound, + ActivityOutcome::Success, + timestamp_unix_ms, + elapsed_ms, + coalescing, + ) + .protocol_identifier( + ActivityAttributeKey::Destination, + IdentifierKind::Destination, + &destination, + )? + .sensitive_endpoint(ActivityAttributeKey::Endpoint, endpoint.0)) +} + +#[cfg(test)] +pub(super) fn test_large_error_event( + timestamp_unix_ms: u64, + elapsed_ms: u64, +) -> Result { + const LARGE_CODE: &str = concat!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let mut draft = ActivityDraft::new( + kinds::LXMF_DELIVERY_FAILED, + ActivitySeverity::Error, + ActivityDirection::Outbound, + ActivityOutcome::Failed, + timestamp_unix_ms, + elapsed_ms, + CoalescingPolicy::Never, + ); + for key in [ + ActivityAttributeKey::Validation, + ActivityAttributeKey::Reason, + ActivityAttributeKey::State, + ActivityAttributeKey::Method, + ActivityAttributeKey::Capability, + ActivityAttributeKey::Profile, + ActivityAttributeKey::InterfaceClass, + ActivityAttributeKey::ProtocolVersion, + ActivityAttributeKey::Room, + ActivityAttributeKey::Hub, + ] { + draft = draft.operational_code(key, LARGE_CODE)?; + } + Ok(draft) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoints_are_validated_before_they_can_enter_a_catalog_input() { + assert!(TcpEndpoint::new("example.net:4242".to_string()).is_ok()); + assert!(matches!( + TcpEndpoint::new(" + + Ratspeak - Dashboard - - + + @@ -73,6 +74,11 @@ Messages + + + Channels + + Contacts @@ -114,20 +120,21 @@ - + - - + + - - - - - + + + + + + - + @@ -161,60 +168,64 @@ - + - + + + Contacts + + Identity - - + + Games - - + + Network - + - + Settings - + - + - + - Sort Peers + Sort Peers - Name - Status - Hops - Last Seen + Name + Status + Hops + Last Seen - + - + - Start Conversation + Start Conversation @@ -519,7 +530,32 @@ × - + + + + + + + + + + + + 0:00 + + + + + + + + + + + + + + @@ -527,9 +563,12 @@ - + - + + + + @@ -538,6 +577,128 @@ + + + + + + + + + + + + Channel + Joining + + Waiting for hub + + + + + + + + + + + + + + + + + Join a conversation + Connect to a trusted hub, then choose a channel. + Find a hub + + + + + + + + + + + + + + + + + @@ -741,9 +902,18 @@ - Network Activity + + + Activity + + + + + Network, messages, channels and calls + - Clear + Stop + Clear @@ -751,26 +921,31 @@ - Privacy mode is active - No activity is being collected or saved. Enable for this session to see network events in real time. - Enable For This Session + Activity is off + Start a private, on-device view of what Ratspeak is doing. + Start Activity - - Essential - Standard - Detailed - - - - + + + + + + + + Normal + Trace + - - - Listening for network events... + + + + + + Waiting for activity… @@ -779,7 +954,7 @@ - + Identity @@ -928,7 +1103,6 @@ - Identity Identity Management @@ -979,13 +1153,17 @@ General Theme, vibration, notifications, and blocks + + Channels + Hosting and channel preferences + Identity Active identity, status, backup, and recovery - + Privacy - Privacy related preferences + Activity identity protection and presence sharing Network @@ -1020,38 +1198,88 @@ Settings - Section General - Ratspeak General Theme, haptics, and app preferences. - General - + Theme - Choose light, dark, or match your system + Choose a color system for every part of Ratspeak. - - + + Theme family + + + + + + Color mode + Choose light, dark, or match your system. + + + - + - + + + + Text size + Choose a comfortable reading size without enlarging controls or touch targets. + + + + + + Aa + 100% + + + + + + Aa + 110% + + + + + + Aa + 120% + + + + + + Aa + 130% + + + + + + Aa + 140% + + + + Vibration @@ -1072,6 +1300,22 @@ + + + Hide known spam peers + Hide repeated bridge-style IDs unless you have saved or messaged the peer. + + + + + OFF + + + + ON + + + Block List @@ -1082,8 +1326,28 @@ + + + + + Channel hosting + Show hub controls in Channels and allow this device to host. + + + + + OFF + + + + ON + + + + + + - Identity @@ -1097,10 +1361,7 @@ Status Not set. - - Edit - Clear - + Set @@ -1119,16 +1380,31 @@ Hardware Key Auto-Lock - Lock a YubiKey identity after inactivity; PIN required to resume. Off relies on lock-on-quit. Applies on next unlock. + Lock a YubiKey identity after inactivity; PIN required to resume. When disabled, it locks only when you quit. Applies on next unlock. - Off + OFF - Privacy + + + Protect Activity identities + Hide peer names and addresses until you reveal an Activity event. + + + + + OFF + + + + ON + + + Announce Ratspeak usage @@ -1143,14 +1419,13 @@ - Network Transport Mode Relay packets for other nodes on the network - OFF + OFF @@ -1163,10 +1438,6 @@ - - Offline Inbox - - @@ -1180,9 +1451,7 @@ - System - System Developer Mode @@ -1191,11 +1460,31 @@ - Off + OFF - On + ON + + + + + + Window Decorations + Title bar drawn by the app. Auto hides it under tiling Wayland compositors (Sway, Hyprland, niri). + + + + + AUTO + + + + ON + + + + OFF @@ -1287,7 +1576,7 @@ - + Hub Info @@ -1335,7 +1624,7 @@ - + Connect to Network @@ -1389,6 +1678,10 @@ IFAC Passphrase + + IFAC Size (bytes) + + Connect @@ -1396,7 +1689,7 @@ - + Host Network @@ -1416,13 +1709,31 @@ Name (optional) + + + Use IFAC + + + + IFAC Network Name + + + + IFAC Passphrase + + + + IFAC Size (bytes) + + + Your firewall or router may need to allow this port. Start Hosting - + Host Backbone Server @@ -1442,13 +1753,31 @@ Name (optional) + + + Use IFAC + + + + IFAC Network Name + + + + IFAC Passphrase + + + + IFAC Size (bytes) + + + Use this for stable desktop or server nodes, not mobile networks. Start Hosting - + Add LoRa Device @@ -1504,8 +1833,6 @@ - - Name @@ -1577,6 +1904,35 @@ Use custom values only when every node on the link will use the same frequency, bandwidth, spreading factor, and coding rate. + + + + Display on public map + + + + + + + + + + Latitude + + + + Longitude + + + + + Use current location + + + + + + Back Add Radio @@ -1592,37 +1948,42 @@ - - - - - - - + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + +
Connect to a trusted hub, then choose a channel.
Theme, haptics, and app preferences.
Your firewall or router may need to allow this port.
Use this for stable desktop or server nodes, not mobile networks.